biblius преди 10 месеца
родител
ревизия
053ec3313b
променени са 11 файла, в които са добавени 483 реда и са изтрити 244 реда
  1. 2 1
      .env.example
  2. 139 3
      Cargo.lock
  3. 2 0
      Cargo.toml
  4. 1 0
      assets/styles.css
  5. 38 0
      src/cache.rs
  6. 7 0
      src/error.rs
  7. 45 37
      src/ffmpeg.rs
  8. 1 0
      src/htmx.rs
  9. 243 141
      src/main.rs
  10. 1 19
      src/ost/client.rs
  11. 4 43
      src/routes.rs

+ 2 - 1
.env.example

@@ -3,4 +3,5 @@ API_KEY =
 USERNAME = 
 PASSWORD = 
 BASE_PATH = 
-PORT = 
+PORT = 
+REDIS_URL = 

+ 139 - 3
Cargo.lock

@@ -32,6 +32,54 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "anstream"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
+dependencies = [
+ "anstyle",
+ "windows-sys 0.52.0",
+]
+
 [[package]]
 name = "async-trait"
 version = "0.1.77"
@@ -179,6 +227,49 @@ dependencies = [
  "windows-targets 0.48.5",
 ]
 
+[[package]]
+name = "clap"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c"
+dependencies = [
+ "clap_builder",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "combine"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35ed6e9d84f0b51a7f52daf1c7d71dd136fd7a3f41a8462b8cdb8c78d920fad4"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
 [[package]]
 name = "core-foundation"
 version = "0.9.4"
@@ -482,7 +573,7 @@ dependencies = [
  "httpdate",
  "itoa",
  "pin-project-lite",
- "socket2",
+ "socket2 0.5.5",
  "tokio",
  "tower-service",
  "tracing",
@@ -534,7 +625,7 @@ dependencies = [
  "http-body 1.0.0",
  "hyper 1.1.0",
  "pin-project-lite",
- "socket2",
+ "socket2 0.5.5",
  "tokio",
  "tracing",
 ]
@@ -708,11 +799,13 @@ version = "0.1.0"
 dependencies = [
  "axum",
  "chrono",
+ "clap",
  "dotenv",
  "htmxpress",
  "lazy_static",
  "minijinja",
  "rand",
+ "redis",
  "reqwest",
  "serde",
  "serde_json",
@@ -950,6 +1043,21 @@ dependencies = [
  "getrandom",
 ]
 
+[[package]]
+name = "redis"
+version = "0.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c580d9cbbe1d1b479e8d67cf9daf6a62c957e6846048408b80b43ac3f6af84cd"
+dependencies = [
+ "combine",
+ "itoa",
+ "percent-encoding",
+ "ryu",
+ "sha1_smol",
+ "socket2 0.4.10",
+ "url",
+]
+
 [[package]]
 name = "redox_syscall"
 version = "0.4.1"
@@ -1113,6 +1221,12 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "sha1_smol"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae1a47186c03a32177042e55dbc5fd5aee900b8e0069a8d70fba96a9375cd012"
+
 [[package]]
 name = "sharded-slab"
 version = "0.1.7"
@@ -1146,6 +1260,16 @@ version = "1.11.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "4dccd0940a2dcdf68d092b8cbab7dc0ad8fa938bf95787e1b916b0e3d0e8e970"
 
+[[package]]
+name = "socket2"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d"
+dependencies = [
+ "libc",
+ "winapi",
+]
+
 [[package]]
 name = "socket2"
 version = "0.5.5"
@@ -1156,6 +1280,12 @@ dependencies = [
  "windows-sys 0.48.0",
 ]
 
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
 [[package]]
 name = "syn"
 version = "1.0.109"
@@ -1275,7 +1405,7 @@ dependencies = [
  "num_cpus",
  "pin-project-lite",
  "signal-hook-registry",
- "socket2",
+ "socket2 0.5.5",
  "tokio-macros",
  "windows-sys 0.48.0",
 ]
@@ -1479,6 +1609,12 @@ version = "2.1.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
 
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
 [[package]]
 name = "valuable"
 version = "0.1.0"

+ 2 - 0
Cargo.toml

@@ -26,6 +26,8 @@ tracing = "0.1.40"
 tracing-subscriber = "0.3.18"
 urlencoding = "2.1.3"
 htmxpress = "0.1.0"
+redis = "0.24.0"
+clap = "4.4.18"
 # htmxpress = { path = "../htmxpress/htmxpress" }
 
 [features]

+ 1 - 0
assets/styles.css

@@ -50,6 +50,7 @@ main {
   display: flex;
   flex-wrap: nowrap;
   width: 100%;
+  border-bottom: 1px dotted gray;
 }
 
 .file-entry .file-header h2 {

+ 38 - 0
src/cache.rs

@@ -0,0 +1,38 @@
+use redis::Commands;
+use tracing::debug;
+
+use crate::{error::NtitledError, File};
+
+const DEFAULT_EXPIRE: u64 = 60 * 60 * 24 * 3;
+pub trait Cache {
+    fn set_file_meta(&mut self, file: &File) -> Result<(), NtitledError>;
+
+    fn get_file_meta(&mut self, file_name: &str) -> Result<Option<File>, NtitledError>;
+
+    fn del_file_meta(&mut self, file_name: &str) -> Result<(), NtitledError>;
+}
+
+impl Cache for redis::Client {
+    fn set_file_meta(&mut self, file: &File) -> Result<(), NtitledError> {
+        debug!("Caching: {}", file.name);
+        let key = &file.name;
+        let val = serde_json::to_string(file)?;
+        self.set_ex(key, val, DEFAULT_EXPIRE)?;
+        Ok(())
+    }
+
+    fn get_file_meta(&mut self, file_name: &str) -> Result<Option<File>, NtitledError> {
+        let val: Option<String> = self.get(file_name)?;
+        let Some(val) = val.as_ref() else {
+            return Ok(None);
+        };
+        let val = serde_json::from_str(val)?;
+        Ok(Some(val))
+    }
+
+    fn del_file_meta(&mut self, file_name: &str) -> Result<(), NtitledError> {
+        debug!("Clearing: {}", file_name);
+        self.del(file_name)?;
+        Ok(())
+    }
+}

+ 7 - 0
src/error.rs

@@ -1,6 +1,7 @@
 use std::{num::ParseIntError, string::FromUtf8Error};
 
 use axum::{http::StatusCode, response::IntoResponse};
+use redis::RedisError;
 use thiserror::Error;
 
 #[derive(Debug, Error)]
@@ -22,6 +23,9 @@ pub enum NtitledError {
 
     #[error("{0}")]
     Parse(#[from] ParseIntError),
+
+    #[error("{0}")]
+    Redis(#[from] RedisError),
 }
 
 impl IntoResponse for NtitledError {
@@ -39,6 +43,9 @@ impl IntoResponse for NtitledError {
             NtitledError::Parse(e) => {
                 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
             }
+            NtitledError::Redis(e) => {
+                (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
+            }
             NtitledError::Utf8(e) => (StatusCode::BAD_REQUEST, e.to_string()).into_response(),
             NtitledError::Message(m) => (StatusCode::BAD_REQUEST, m).into_response(),
         }

+ 45 - 37
src/ffmpeg.rs

@@ -1,8 +1,8 @@
-use std::{collections::HashMap, process::Stdio};
+use std::{collections::HashMap, process::Stdio, time::Instant};
 
 use htmxpress::Element;
-use serde::{de::Unexpected, Deserialize, Deserializer, Serialize};
-use tracing::warn;
+use serde::{Deserialize, Serialize};
+use tracing::{debug, warn};
 
 use crate::error::NtitledError;
 
@@ -13,6 +13,10 @@ pub struct FFProbeOutput {
 
 impl FFProbeOutput {
     pub fn probe(path: &str) -> Result<Self, NtitledError> {
+        debug!("Probing {path}");
+
+        let start = Instant::now();
+
         let child = std::process::Command::new("ffprobe")
             .args([
                 "-loglevel",
@@ -29,6 +33,12 @@ impl FFProbeOutput {
             .spawn()?
             .wait_with_output()?;
 
+        debug!(
+            "({}ms) Probe complete {}",
+            Instant::now().duration_since(start).as_millis(),
+            path,
+        );
+
         let out = String::from_utf8(child.stdout)?;
         let err = String::from_utf8(child.stderr)?;
         let err = err.trim();
@@ -49,30 +59,20 @@ pub enum Stream {
     Sub(SubtitleStream),
 }
 
-#[derive(Debug, Serialize, Deserialize, Element)]
+#[derive(Debug, Serialize, Deserialize)]
 pub struct VideoStream {
     pub index: usize,
 
-    #[element("p")]
-    #[format("Codec: {}")]
     #[serde(rename = "codec_name")]
     pub codec: String,
 
-    #[element("p")]
-    #[format("W: {}")]
     pub width: u16,
 
-    #[element("p")]
-    #[format("H: {}")]
     pub height: u16,
 
-    #[element("p")]
-    #[format("Captions: {}")]
-    #[serde(deserialize_with = "bool_from_int")]
+    #[serde(with = "bool_int")]
     pub closed_captions: bool,
 
-    #[element("p")]
-    #[format("Framerate: {}")]
     pub avg_frame_rate: String,
 
     #[serde(rename = "codec_long_name")]
@@ -99,21 +99,15 @@ pub struct AudioStream {
     pub sample_rate: String,
 }
 
-#[derive(Debug, Serialize, Deserialize, Element)]
+#[derive(Debug, Serialize, Deserialize)]
 pub struct SubtitleStream {
     pub index: usize,
 
-    #[element("p")]
-    #[format("Codec: {}")]
     #[serde(rename = "codec_name")]
     pub codec: String,
 
-    #[element("p")]
-    #[format("Duration: {}")]
-    #[default("unknown")]
     pub duration: Option<String>,
 
-    #[nest]
     pub tags: Tags,
 
     #[serde(rename = "codec_long_name")]
@@ -137,29 +131,43 @@ pub struct Tags {
 
 #[derive(Debug, Serialize, Deserialize)]
 pub struct Disposition {
-    #[serde(deserialize_with = "bool_from_int")]
+    #[serde(with = "bool_int")]
     pub default: bool,
 
-    #[serde(deserialize_with = "bool_from_int")]
+    #[serde(with = "bool_int")]
     pub dub: bool,
 
-    #[serde(deserialize_with = "bool_from_int")]
+    #[serde(with = "bool_int")]
     pub original: bool,
 
-    #[serde(deserialize_with = "bool_from_int")]
+    #[serde(with = "bool_int")]
     pub comment: bool,
 }
 
-fn bool_from_int<'de, D>(deserializer: D) -> Result<bool, D::Error>
-where
-    D: Deserializer<'de>,
-{
-    match u8::deserialize(deserializer)? {
-        0 => Ok(false),
-        1 => Ok(true),
-        other => Err(serde::de::Error::invalid_value(
-            Unexpected::Unsigned(other as u64),
-            &"zero or one",
-        )),
+mod bool_int {
+    use serde::{de::Unexpected, Deserialize, Deserializer, Serializer};
+
+    pub fn serialize<S>(value: &bool, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: Serializer,
+    {
+        match value {
+            true => serializer.serialize_u8(1),
+            false => serializer.serialize_u8(0),
+        }
+    }
+
+    pub fn deserialize<'de, D>(deserializer: D) -> Result<bool, D::Error>
+    where
+        D: Deserializer<'de>,
+    {
+        match u8::deserialize(deserializer)? {
+            0 => Ok(false),
+            1 => Ok(true),
+            other => Err(serde::de::Error::invalid_value(
+                Unexpected::Unsigned(other as u64),
+                &"zero or one",
+            )),
+        }
     }
 }

+ 1 - 0
src/htmx.rs

@@ -92,6 +92,7 @@ impl FileHtmx {
             has_subs,
             video_meta,
             sub_meta,
+            ..
         } = value;
 
         Self {

+ 243 - 141
src/main.rs

@@ -3,10 +3,13 @@ use axum::{
     routing::{get, get_service},
     Router,
 };
+use cache::Cache;
+use error::NtitledError;
 use ffmpeg::VideoStream;
 use minijinja::Environment;
 use ost::client::OSClient;
-use serde::Serialize;
+use redis::ConnectionInfo;
+use serde::{Deserialize, Serialize};
 use std::{
     fs::{self, DirEntry},
     num::NonZeroUsize,
@@ -14,10 +17,21 @@ use std::{
     thread,
 };
 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};
 
+pub mod cache;
 pub mod error;
 pub mod ffmpeg;
 pub mod htmx;
@@ -29,16 +43,185 @@ pub struct State {
     client: OSClient,
     env: Environment<'static>,
     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! {
-    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! {
@@ -59,6 +242,55 @@ async fn main() {
 
     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;
 
     info!("Successfully loaded OST client");
@@ -68,23 +300,20 @@ async fn main() {
 
     let mut env = Environment::new();
 
-    env.add_template("file_container", &FILE_CONTAINER)
-        .expect("unable to add template");
-
     env.add_template("index", &INDEX)
-        .expect("unable to add template");
+        .expect("unable to add index template");
 
     let state = State {
         client,
         env,
         base_path,
+        cache,
     };
 
     let router = axum::Router::new()
         .route("/", get(|| async { Redirect::permanent("/dir/root") }))
         .route("/dir/*key", get(routes::get_directory))
         .route("/subtitles", get(routes::search_subtitles))
-        .route("/guess", get(routes::guess_it))
         .route("/download", get(routes::download_subtitles))
         .with_state(state);
 
@@ -101,143 +330,16 @@ async fn main() {
         .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)]
 pub enum FileType {
     Directory(Directory),
     File(File),
 }
 
-#[derive(Debug, Serialize)]
+#[derive(Debug, Serialize, Deserialize)]
 pub struct File {
     name: String,
+    ext: String,
     path: String,
 
     /// `true` if a same file exists with a `.srt` extension found

+ 1 - 19
src/ost/client.rs

@@ -4,7 +4,7 @@ use tracing::info;
 
 use crate::{error::NtitledError, ost::data::DownloadResponse};
 
-use super::data::{GuessIt, Login, LoginResponse, SearchResponse};
+use super::data::{Login, LoginResponse, SearchResponse};
 
 const BASE_URL: &str = "https://api.opensubtitles.com/api/v1";
 
@@ -187,22 +187,4 @@ impl OSClient {
 
         Ok(())
     }
-
-    pub async fn guess_it(&self, name: &str) -> Result<GuessIt, NtitledError> {
-        let req = self
-            .client
-            .get(format!("{BASE_URL}/utilities/guessit"))
-            .query(&[("filename", name)]);
-
-        let req = req.build().expect("invalid request");
-
-        let res: Value = self.client.execute(req).await?.json().await?;
-
-        #[cfg(feature = "debug")]
-        {
-            dbg!(&res);
-        }
-
-        Ok(serde_json::from_value(res)?)
-    }
 }

+ 4 - 43
src/routes.rs

@@ -1,14 +1,10 @@
 use crate::{
     error::NtitledError,
     htmx::{DirHtmx, FileHtmx, SubtitleResponseHtmx},
-    ost::{
-        data::{GuessIt, SubtitleResponse},
-        hash::compute_hash,
-    },
-    scan_dir_contents, FileType, State,
+    ost::{data::SubtitleResponse, hash::compute_hash},
+    FileType, State,
 };
 use axum::{
-    extract::Query,
     http::{header, StatusCode},
     response::IntoResponse,
 };
@@ -19,7 +15,7 @@ use std::{ffi::OsStr, fmt::Write, path::Path};
 use tracing::info;
 
 pub async fn get_directory(
-    state: axum::extract::State<State>,
+    mut state: axum::extract::State<State>,
     req: axum::extract::Request,
 ) -> Result<impl IntoResponse, NtitledError> {
     // Strip /dir/
@@ -33,7 +29,7 @@ pub async fn get_directory(
 
     info!("Listing {path}");
 
-    let files = scan_dir_contents(path)?;
+    let files = state.0.scan_dir_contents(path)?;
 
     let response = files
         .into_iter()
@@ -133,38 +129,3 @@ pub async fn download_subtitles(
     state.client.download_subtitles(file_id, &path).await?;
     Ok(String::from("Successfully downloaded subtitles"))
 }
-
-#[derive(Debug, Deserialize)]
-pub struct GuessRequest {
-    filename: String,
-}
-
-pub async fn guess_it(
-    state: axum::extract::State<State>,
-    query: Query<GuessRequest>,
-) -> Result<String, NtitledError> {
-    let GuessIt {
-        title,
-        audio_codec,
-        video_codec,
-        release_group,
-        r#type,
-        episode,
-        episode_title,
-        season,
-    } = state.client.guess_it(&query.filename).await?;
-
-    let mut response = String::new();
-
-    write!(
-        response,
-        r##"
-        <h5>Title: {title}</h5>
-        <p>Type: {:?}, S: {season:?}, E: {episode:?} ({episode_title:?}), A: {audio_codec:?}, V: {video_codec:?}, {release_group:?}</p>
-    "##,
-    r#type
-    )
-    .unwrap();
-
-    Ok(response)
-}