biblius 10 місяців тому
батько
коміт
29bd72e635
9 змінених файлів з 617 додано та 352 видалено
  1. 71 13
      Cargo.lock
  2. 7 1
      Cargo.toml
  3. 19 2
      assets/styles.css
  4. 7 1
      src/error.rs
  5. 45 10
      src/ffmpeg.rs
  6. 319 0
      src/htmx.rs
  7. 12 15
      src/main.rs
  8. 98 2
      src/ost/data.rs
  9. 39 308
      src/routes.rs

+ 71 - 13
Cargo.lock

@@ -40,7 +40,7 @@ checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
 ]
 
 [[package]]
@@ -368,6 +368,29 @@ version = "0.3.3"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7"
 
+[[package]]
+name = "htmxpress"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d0e35dbc14b62ae1d5404b700db6e40081d2bf45a6b59b3cbd013578fa5fbb"
+dependencies = [
+ "htmxpress_macros",
+ "http 1.0.0",
+ "urlencoding",
+]
+
+[[package]]
+name = "htmxpress_macros"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47cad7a03d1ebb26cfd82c81abb7ef740b98d932a08225a168ab3da74949d2a8"
+dependencies = [
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.48",
+]
+
 [[package]]
 name = "http"
 version = "0.2.11"
@@ -686,6 +709,7 @@ dependencies = [
  "axum",
  "chrono",
  "dotenv",
+ "htmxpress",
  "lazy_static",
  "minijinja",
  "rand",
@@ -767,7 +791,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
 ]
 
 [[package]]
@@ -827,7 +851,7 @@ checksum = "4359fd9c9171ec6e8c62926d6faaf553a8dc3f64e1507e76da7911b4f6a04405"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
 ]
 
 [[package]]
@@ -854,11 +878,35 @@ version = "0.2.17"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
 
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
 [[package]]
 name = "proc-macro2"
-version = "1.0.74"
+version = "1.0.76"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2de98502f212cfcea8d0bb305bd0f49d7ebdd75b64ba0a68f937d888f4e0d6db"
+checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c"
 dependencies = [
  "unicode-ident",
 ]
@@ -1029,7 +1077,7 @@ checksum = "a3385e45322e8f9931410f01b3031ec534c3947d0e94c18049af4d9f9907d4e0"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
 ]
 
 [[package]]
@@ -1110,9 +1158,19 @@ dependencies = [
 
 [[package]]
 name = "syn"
-version = "2.0.46"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.48"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89456b690ff72fddcecf231caedbe615c59480c93358a93dfae7fc29e3ebbf0e"
+checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -1176,7 +1234,7 @@ checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
 ]
 
 [[package]]
@@ -1230,7 +1288,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
 ]
 
 [[package]]
@@ -1330,7 +1388,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
 ]
 
 [[package]]
@@ -1475,7 +1533,7 @@ dependencies = [
  "once_cell",
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
  "wasm-bindgen-shared",
 ]
 
@@ -1509,7 +1567,7 @@ checksum = "f0eb82fcb7930ae6219a7ecfd55b217f5f0893484b7a13022ebb2b2bf20b5283"
 dependencies = [
  "proc-macro2",
  "quote",
- "syn",
+ "syn 2.0.48",
  "wasm-bindgen-backend",
  "wasm-bindgen-shared",
 ]

+ 7 - 1
Cargo.toml

@@ -16,11 +16,17 @@ reqwest = { version = "0.11.23", features = ["json"] }
 serde = { version = "1.0.194", features = ["derive"] }
 serde_json = "1.0.110"
 thiserror = "1.0.56"
-tokio = { version = "1.35.1", features = ["macros", "rt-multi-thread", "process"] }
+tokio = { version = "1.35.1", features = [
+    "macros",
+    "rt-multi-thread",
+    "process",
+] }
 tower-http = { version = "0.5.0", features = ["fs"] }
 tracing = "0.1.40"
 tracing-subscriber = "0.3.18"
 urlencoding = "2.1.3"
+htmxpress = "0.1.0"
+# htmxpress = { path = "../htmxpress/htmxpress" }
 
 [features]
 debug = []

+ 19 - 2
assets/styles.css

@@ -46,9 +46,22 @@ main {
   align-items: start;
 }
 
-.file-entry h2 {
-  width: 100%;
+.file-entry .file-header {
   display: flex;
+  flex-wrap: nowrap;
+  width: 100%;
+}
+
+.file-entry .file-header h2 {
+  width: 100%;
+  min-width: fit-content;
+}
+
+.file-entry .file-header .has-subs {
+  width: 100%;
+  margin-top: 1.5rem;
+  margin-left: 1rem;
+  width: 100%;
 }
 
 .file-entry .meta {
@@ -94,6 +107,10 @@ main {
   justify-content: center;
 }
 
+.sub-meta-inner {
+  border-bottom: 1px dotted gray;
+}
+
 .info {
   width: 100%;
   padding: 0.3rem;

+ 7 - 1
src/error.rs

@@ -1,4 +1,4 @@
-use std::string::FromUtf8Error;
+use std::{num::ParseIntError, string::FromUtf8Error};
 
 use axum::{http::StatusCode, response::IntoResponse};
 use thiserror::Error;
@@ -19,6 +19,9 @@ pub enum NtitledError {
 
     #[error("{0}")]
     Utf8(#[from] FromUtf8Error),
+
+    #[error("{0}")]
+    Parse(#[from] ParseIntError),
 }
 
 impl IntoResponse for NtitledError {
@@ -33,6 +36,9 @@ impl IntoResponse for NtitledError {
             NtitledError::Reqwest(e) => {
                 (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
             }
+            NtitledError::Parse(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 - 10
src/ffmpeg.rs

@@ -1,5 +1,6 @@
 use std::{collections::HashMap, process::Stdio};
 
+use htmxpress::Element;
 use serde::{de::Unexpected, Deserialize, Deserializer, Serialize};
 use tracing::warn;
 
@@ -48,24 +49,39 @@ pub enum Stream {
     Sub(SubtitleStream),
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, Element)]
 pub struct VideoStream {
     pub index: usize,
+
+    #[element("p")]
+    #[format("Codec: {}")]
     #[serde(rename = "codec_name")]
     pub codec: String,
-    #[serde(rename = "codec_long_name")]
-    pub codec_long: String,
-    pub profile: Option<String>,
-    pub codec_type: String,
+
+    #[element("p")]
+    #[format("W: {}")]
     pub width: u16,
+
+    #[element("p")]
+    #[format("H: {}")]
     pub height: u16,
-    pub coded_width: u16,
-    pub coded_height: u16,
 
+    #[element("p")]
+    #[format("Captions: {}")]
     #[serde(deserialize_with = "bool_from_int")]
     pub closed_captions: bool,
 
+    #[element("p")]
+    #[format("Framerate: {}")]
     pub avg_frame_rate: String,
+
+    #[serde(rename = "codec_long_name")]
+    pub codec_long: String,
+    pub profile: Option<String>,
+    pub codec_type: String,
+    pub coded_width: u16,
+    pub coded_height: u16,
+
     pub start_time: String,
     pub disposition: Disposition,
     pub tags: Option<HashMap<String, String>>,
@@ -83,21 +99,40 @@ pub struct AudioStream {
     pub sample_rate: String,
 }
 
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Debug, Serialize, Deserialize, Element)]
 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")]
     pub codec_long: String,
+
     pub codec_type: String,
     pub avg_frame_rate: String,
     pub time_base: String,
     pub start_time: String,
     pub duration_ts: Option<usize>,
-    pub duration: Option<String>,
     pub disposition: Disposition,
-    pub tags: HashMap<String, String>,
+}
+
+#[derive(Debug, Serialize, Deserialize, Element)]
+pub struct Tags {
+    #[element("p")]
+    #[format("Language: {}")]
+    #[default("unspecified")]
+    language: Option<String>,
 }
 
 #[derive(Debug, Serialize, Deserialize)]

+ 319 - 0
src/htmx.rs

@@ -0,0 +1,319 @@
+use htmxpress::Element;
+
+use crate::{
+    ffmpeg::{SubtitleStream, Tags, VideoStream},
+    ost::data::{SubtitleFile, SubtitleResponse},
+    Directory, File,
+};
+
+#[derive(Debug, Element)]
+#[element("a")]
+#[attrs(class = "file-entry dir")]
+#[attr("href" = "/dir/{}", path)]
+#[urlencode]
+pub struct DirHtmx {
+    path: String,
+
+    #[element("h2")]
+    #[format("{} &#128193;")]
+    name: String,
+}
+
+impl From<Directory> for DirHtmx {
+    fn from(value: Directory) -> Self {
+        let Directory { name, path } = value;
+        Self { path, name }
+    }
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "file-entry file")]
+pub struct FileHtmx {
+    #[nest]
+    header: FileHeaderHtmx,
+
+    #[element("div")]
+    #[before("<h4>Video</h4>")]
+    #[attrs(class = "meta video-meta")]
+    #[list(nest)]
+    video_meta: Vec<VStreamHtmx>,
+
+    #[element("div")]
+    #[before("<h4>Subtitles (Embedded)</h4>")]
+    #[attrs(class = "meta sub-meta")]
+    #[list(nest)]
+    sub_meta: Vec<SStreamHtmx>,
+
+    #[element("div")]
+    #[attrs(class = "subtitles")]
+    #[nest]
+    sub_list: SubtitleListHtmx,
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "file-header")]
+struct FileHeaderHtmx {
+    id: usize,
+
+    #[element("h2")]
+    #[hx_get("/subtitles?name={}&path={}", name, path)]
+    #[attr("hx-target"= "#_sub_{}", id)]
+    #[map(name => name.rsplit_once('.').map(|n|n.0).unwrap_or(name))]
+    #[urlencode]
+    name: String,
+
+    path: String,
+
+    /// `true` if a same file exists with a `.srt` extension found
+    /// in the same directory or if the file has embedded subs
+    #[element("p")]
+    #[attrs(class = "has-subs")]
+    #[map(el => if *el { "&#10004;" } else { "&#10060;" })]
+    has_subs: bool,
+}
+
+#[derive(Debug, Element)]
+struct SubtitleListHtmx {
+    id: usize,
+
+    #[element("div")]
+    #[attrs(class = "subtitles-inner")]
+    #[attr("id" = "_sub_{}", id)]
+    inner: String,
+}
+
+impl FileHtmx {
+    pub fn new(id: usize, value: File) -> Self {
+        let File {
+            name,
+            path,
+            has_subs,
+            video_meta,
+            sub_meta,
+        } = value;
+
+        Self {
+            header: FileHeaderHtmx {
+                id,
+                name,
+                path,
+                has_subs,
+            },
+            video_meta: video_meta.into_iter().map(VStreamHtmx::from).collect(),
+            sub_meta: sub_meta.into_iter().map(SStreamHtmx::from).collect(),
+            sub_list: SubtitleListHtmx {
+                id,
+                inner: String::new(),
+            },
+        }
+    }
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "video-meta-inner")]
+pub struct VStreamHtmx {
+    #[element("p")]
+    #[format("Codec: {}")]
+    pub codec: String,
+
+    #[element("p")]
+    #[format("W: {}")]
+    pub width: u16,
+
+    #[element("p")]
+    #[format("H: {}")]
+    pub height: u16,
+
+    #[element("p")]
+    #[format("Captions: {}")]
+    pub closed_captions: bool,
+
+    #[element("p")]
+    #[format("Framerate: {}")]
+    pub avg_frame_rate: String,
+}
+
+impl From<VideoStream> for VStreamHtmx {
+    fn from(value: VideoStream) -> Self {
+        let VideoStream {
+            codec,
+            width,
+            height,
+            closed_captions,
+            avg_frame_rate,
+            ..
+        } = value;
+        Self {
+            codec,
+            width,
+            height,
+            closed_captions,
+            avg_frame_rate,
+        }
+    }
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "sub-meta-inner")]
+pub struct SStreamHtmx {
+    #[element("p")]
+    #[format("Codec: {}")]
+    pub codec: String,
+
+    #[element("p")]
+    #[format("Duration: {}")]
+    #[default("unknown")]
+    pub duration: Option<String>,
+
+    #[nest]
+    pub tags: Tags,
+}
+
+impl From<SubtitleStream> for SStreamHtmx {
+    fn from(value: SubtitleStream) -> Self {
+        let SubtitleStream {
+            codec,
+            duration,
+            tags,
+            ..
+        } = value;
+        Self {
+            codec,
+            duration,
+            tags,
+        }
+    }
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "sub-container")]
+pub struct SubtitleResponseHtmx {
+    #[nest]
+    header: SubContainerTitleHtmx,
+
+    #[nest]
+    info: SubContainerInfoHtmx,
+}
+
+impl SubtitleResponseHtmx {
+    pub fn new(path: String, value: SubtitleResponse) -> Self {
+        let SubtitleResponse {
+            title,
+            original_title,
+            year,
+            download_count,
+            parent_title,
+            episode_number,
+            season_number,
+            files,
+            language,
+            hashmatch,
+        } = value;
+
+        let hashmatch = if hashmatch { "🎯" } else { "" };
+
+        let original_title = original_title
+            .map(|title| format!("({title}) "))
+            .unwrap_or_default();
+
+        let parent_title = parent_title
+            .map(|title| format!("{title} -"))
+            .unwrap_or_default();
+
+        let episode_id = match (episode_number, season_number) {
+            (Some(ep), Some(season)) => format!("S{season}E{ep}"),
+            (_, _) => String::new(),
+        };
+
+        let files = files
+            .into_iter()
+            .map(|el| SubFileHtmx::new(path.clone(), el))
+            .collect();
+
+        Self {
+            header: SubContainerTitleHtmx {
+                parent_title,
+                title,
+                original_title,
+                episode_id,
+                hashmatch: hashmatch.to_string(),
+            },
+            info: SubContainerInfoHtmx {
+                language,
+                downloads: download_count,
+                year,
+                files,
+            },
+        }
+    }
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "sub-container-title")]
+pub struct SubContainerTitleHtmx {
+    #[element("h3")]
+    #[format("{} {} {}{} {}", title, original_title, episode_id, hashmatch)]
+    parent_title: String,
+    title: String,
+    original_title: String,
+    episode_id: String,
+    hashmatch: String,
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "sub-container-info")]
+pub struct SubContainerInfoHtmx {
+    #[element("p")]
+    #[attrs(class = "info")]
+    #[format("Language: {}")]
+    language: Option<String>,
+
+    #[element("p")]
+    #[attrs(class = "info")]
+    #[format("Downloads: {}")]
+    downloads: usize,
+
+    #[element("p")]
+    #[attrs(class = "info")]
+    #[format("Year: {}")]
+    year: usize,
+
+    #[list(nest)]
+    files: Vec<SubFileHtmx>,
+}
+
+#[derive(Debug, Element)]
+#[element("div")]
+#[attrs(class = "sub-files")]
+pub struct SubFileHtmx {
+    file_id: String,
+    path: String,
+
+    #[element("button")]
+    #[attrs(class = "download-button")]
+    #[hx(
+        "target" = "closest-div",
+        "swap" = "innerHtml"
+    )]
+    #[hx_get("/download?file_id={}&full_path={}", file_id, path)]
+    #[urlencode]
+    button: &'static str,
+}
+
+impl SubFileHtmx {
+    fn new(path: String, value: SubtitleFile) -> Self {
+        let SubtitleFile { file_id, .. } = value;
+        Self {
+            file_id: file_id.to_string(),
+            path,
+            button: "Download",
+        }
+    }
+}

+ 12 - 15
src/main.rs

@@ -20,6 +20,7 @@ use crate::ffmpeg::{FFProbeOutput, SubtitleStream};
 
 pub mod error;
 pub mod ffmpeg;
+pub mod htmx;
 pub mod ost;
 pub mod routes;
 
@@ -46,14 +47,16 @@ lazy_static::lazy_static! {
 
 #[tokio::main]
 async fn main() {
-    dotenv::dotenv().expect("could not load env");
-
     tracing_subscriber::fmt()
         .with_max_level(Level::DEBUG)
         .init();
 
     info!("Starting ntitled");
 
+    dotenv::dotenv().expect("could not load env");
+
+    info!("Successfully loaded env");
+
     info!("Max threads: {}", *MAX_THREADS);
 
     let client = OSClient::init().await;
@@ -216,13 +219,10 @@ fn extract_entry(subs: &[String], entry: &DirEntry) -> Option<FileType> {
 
         let subs_embedded = !sub_meta.is_empty();
 
-        debug!("File: {name}; Subs: {}", subs_embedded || subs_file);
-
         Some(FileType::File(File {
             name: entry.file_name().to_str()?.to_string(),
             path: entry.path().to_str()?.to_string(),
-            subs_file,
-            subs_embedded,
+            has_subs: subs_embedded || subs_file,
             video_meta,
             sub_meta,
         }))
@@ -240,21 +240,18 @@ pub struct File {
     name: String,
     path: String,
 
+    /// `true` if a same file exists with a `.srt` extension found
+    /// in the same directory or if the file has embedded subs
+    has_subs: bool,
+
     video_meta: Vec<VideoStream>,
 
     sub_meta: Vec<SubtitleStream>,
-
-    /// `true` if a same file exists with a `.srt` extension found
-    /// in the same directory
-    subs_file: bool,
-
-    /// `true` if the file has embedded subtitles
-    subs_embedded: bool,
 }
 
 impl PartialEq for File {
     fn eq(&self, other: &Self) -> bool {
-        self.name == other.name && self.path == other.path && self.subs_file == other.subs_file
+        self.name == other.name && self.path == other.path && self.has_subs == other.has_subs
     }
 }
 
@@ -276,7 +273,7 @@ impl Ord for File {
             core::cmp::Ordering::Equal => {}
             ord => return ord,
         }
-        self.subs_file.cmp(&other.subs_file)
+        self.has_subs.cmp(&other.has_subs)
     }
 }
 

+ 98 - 2
src/ost/data.rs

@@ -44,11 +44,11 @@ pub struct SubtitleAttrs {
     pub url: String,
     pub moviehash_match: Option<bool>,
     pub points: Option<usize>,
-    pub files: Vec<File>,
+    pub files: Vec<SubtitleFile>,
 }
 
 #[derive(Debug, Deserialize, Serialize)]
-pub struct File {
+pub struct SubtitleFile {
     pub file_id: usize,
     pub cd_number: usize,
     pub file_name: String,
@@ -175,3 +175,99 @@ pub struct SubtitleFileInfo {
     pub reset_time: String,
     pub reset_time_utc: DateTime<Utc>,
 }
+
+#[derive(Debug, Deserialize)]
+pub struct SubtitleResponse {
+    pub hashmatch: bool,
+    pub title: String,
+    pub original_title: Option<String>,
+    pub year: usize,
+    pub download_count: usize,
+    pub parent_title: Option<String>,
+    pub episode_number: Option<usize>,
+    pub season_number: Option<usize>,
+    pub files: Vec<SubtitleFile>,
+    pub language: Option<String>,
+}
+
+impl From<Subtitle> for SubtitleResponse {
+    fn from(value: Subtitle) -> Self {
+        let SubtitleAttrs {
+            feature_details,
+            download_count,
+            files,
+            language,
+            moviehash_match,
+            ..
+        } = value.attributes;
+
+        match feature_details {
+            crate::ost::data::Feature::TvShow(item) => {
+                let TvShow {
+                    title,
+                    original_title,
+                    year,
+                    ..
+                } = item;
+
+                Self {
+                    title,
+                    original_title: Some(original_title),
+                    year,
+                    download_count,
+                    parent_title: None,
+                    episode_number: None,
+                    season_number: None,
+                    files,
+                    language,
+                    hashmatch: moviehash_match.is_some_and(|is| is),
+                }
+            }
+            crate::ost::data::Feature::Episode(item) => {
+                let Episode {
+                    year,
+                    title,
+                    parent_title,
+                    episode_number,
+                    season_number,
+                    ..
+                } = item;
+                Self {
+                    title,
+                    original_title: None,
+                    year,
+                    download_count,
+                    parent_title,
+                    episode_number,
+                    season_number,
+                    files,
+                    language,
+                    hashmatch: moviehash_match.is_some_and(|is| is),
+                }
+            }
+            crate::ost::data::Feature::Movie(item) => {
+                let Movie {
+                    title,
+                    original_title,
+                    year,
+                    parent_title,
+                    episode_number,
+                    season_number,
+                    ..
+                } = item;
+                Self {
+                    title,
+                    original_title: Some(original_title),
+                    year,
+                    download_count,
+                    parent_title: Some(parent_title),
+                    episode_number,
+                    season_number,
+                    files,
+                    language,
+                    hashmatch: moviehash_match.is_some_and(|is| is),
+                }
+            }
+        }
+    }
+}

+ 39 - 308
src/routes.rs

@@ -1,19 +1,19 @@
 use crate::{
     error::NtitledError,
-    ffmpeg::{SubtitleStream, VideoStream},
+    htmx::{DirHtmx, FileHtmx, SubtitleResponseHtmx},
     ost::{
-        data::{Episode, File, GuessIt, Movie, Subtitle, SubtitleAttrs, TvShow},
+        data::{GuessIt, SubtitleResponse},
         hash::compute_hash,
     },
-    scan_dir_contents, Directory, FileType, State,
+    scan_dir_contents, FileType, State,
 };
 use axum::{
     extract::Query,
     http::{header, StatusCode},
     response::IntoResponse,
 };
+use htmxpress::HtmxElement;
 use minijinja::context;
-use rand::{distributions, Rng};
 use serde::Deserialize;
 use std::{ffi::OsStr, fmt::Write, path::Path};
 use tracing::info;
@@ -28,6 +28,7 @@ pub async fn get_directory(
         state.base_path.clone()
     } else {
         let path = urlencoding::decode(path)?;
+        let path = &path[1..];
         format!("{}/{path}", state.base_path)
     };
 
@@ -37,7 +38,29 @@ pub async fn get_directory(
 
     let response = files
         .into_iter()
-        .fold(String::new(), |acc, el| write_dir_html(&state, acc, el));
+        .enumerate()
+        .fold(String::new(), |mut acc, (i, entry)| {
+            match entry {
+                FileType::Directory(mut dir) => {
+                    let Some((_, path)) = dir.path.split_once(&state.base_path) else {
+                        return acc;
+                    };
+                    dir.path = path.to_string();
+                    let dir: DirHtmx = dir.into();
+                    let _ = write!(acc, "{}", dir.to_htmx());
+                }
+                FileType::File(mut file) => {
+                    let Some((_, path)) = file.path.split_once(&state.base_path) else {
+                        return acc;
+                    };
+                    file.path = path.to_string();
+                    let file = FileHtmx::new(i, file);
+                    let _ = write!(acc, "{}", file.to_htmx());
+                }
+            };
+
+            acc
+        });
 
     let template = state.env.get_template("index").unwrap();
     let template = template.render(context! {divs => response}).unwrap();
@@ -62,80 +85,19 @@ pub async fn search_subtitles(
     state: axum::extract::State<State>,
     query: axum::extract::Query<SubtitleSearch>,
 ) -> Result<String, NtitledError> {
-    let path = format!("{}/{}", state.base_path, query.path);
+    let path = format!("{}{}", state.base_path, &query.path);
+
+    info!("Computing hash for {}", &path);
+
     let hash = compute_hash(&path)?;
 
+    info!("Searching subtitles for {}", &query.name);
     let search = state.client.search(&query.name, Some(&hash)).await?;
 
     let mut response = search.data.into_iter().fold(String::new(), |mut acc, el| {
-
-        let hashmatch = el.attributes.moviehash_match
-            .is_some_and(|is| is)
-            .then_some("🎯")
-            .unwrap_or_default();
-
-        let response = SubtitleResponse::from(el);
-
-        let SubtitleResponse {
-            title,
-            original_title,
-            year,
-            download_count,
-            parent_title,
-            episode_number,
-            season_number,
-            files,
-            language,
-        } = response;
-
-        if language.as_ref().is_some_and(|lang| lang != "en") {
-            return acc;
-        }
-
-        let original_title = original_title
-            .map(|title| format!("({title}) "))
-            .unwrap_or_default();
-
-        let parent_title = parent_title.map(|title| format!("{title} -")).unwrap_or_default();
-
-        let episode_id = match (episode_number, season_number) {
-            (Some(ep), Some(season)) => format!("S{season}E{ep}"),
-            (_, _) => String::new(),
-        };
-
-        let mut files = files.into_iter().fold(String::from("<div class=\"sub-files\">"),|mut acc, file| {
-            let File { file_id, ..} = file;
-            let path = urlencoding::encode(&path);
-            write!(acc, r#"
-                <button class="download-button" hx-target="closest div" hx-swap="innerHTML" hx-get="/download?file_id={file_id}&full_path={path}">Download</button>"#).unwrap();
-            acc
-        });
-
-        write!(files, "</div>").unwrap();
-
-        let language = language.unwrap_or("?".to_string());
-
-        write!(
-            acc,
-            r##"
-            <div class="sub-container">
-
-                <div class="sub-container-title">
-                    <h3>{parent_title} {title} {original_title}{episode_id} {hashmatch}</h3>
-                </div>
-
-                <div class="sub-container-info">
-                    <p class="info">Language: {language}</p> 
-                    <p class="info">Downloads: {download_count}</p>
-                    <p class="info">Year: {year}</p>
-                    {files}
-                </div>
-
-            </div>
-            "##
-        )
-        .unwrap();
-
+        let el = SubtitleResponse::from(el);
+        let el = SubtitleResponseHtmx::new(path.clone(), el).to_htmx();
+        let _ = write!(acc, "{el}");
         acc
     });
 
@@ -147,7 +109,7 @@ pub async fn search_subtitles(
 }
 #[derive(Debug, Deserialize)]
 pub struct DownloadQuery {
-    file_id: usize,
+    file_id: String,
     full_path: String,
 }
 
@@ -155,9 +117,9 @@ pub async fn download_subtitles(
     state: axum::extract::State<State>,
     query: axum::extract::Query<DownloadQuery>,
 ) -> Result<String, NtitledError> {
-    let file_id = query.file_id;
-
+    let file_id = urlencoding::decode(&query.file_id)?.parse()?;
     let full_path = urlencoding::decode(&query.full_path)?.to_string();
+
     let file = full_path.split('/').last();
     if let Some(file) = file {
         info!("Downloading subtitles for {file}");
@@ -207,234 +169,3 @@ pub async fn guess_it(
 
     Ok(response)
 }
-
-/// Write contents of a directory as HTML
-fn write_dir_html(state: &State, mut acc: String, file: FileType) -> String {
-    match file {
-        FileType::Directory(Directory { name, path }) => {
-            let Some((_, path)) = path.split_once(&state.base_path) else {
-                return acc;
-            };
-
-            let path_enc = urlencoding::encode(&path[1..]);
-            // Used by frontend
-            let ty = "dir";
-
-            write!(
-                acc,
-                r#"
-            <a href="/dir/{path_enc}" class="file-entry {ty}">
-                <h2>{name} &#128193;</h2>
-            </a>
-            "#
-            )
-            .unwrap();
-        }
-        FileType::File(crate::File {
-            ref name,
-            path,
-            subs_file,
-            subs_embedded,
-            video_meta,
-            sub_meta,
-        }) => {
-            let Some((_, path)) = path.split_once(&state.base_path) else {
-                return acc;
-            };
-
-            let name = if let Some((name, _)) = name.rsplit_once('.') {
-                name
-            } else {
-                name
-            };
-
-            // Used by frontend
-            let ty = "file";
-
-            let get = format!(
-                "hx-get=/subtitles?name={}&path={}",
-                urlencoding::encode(name),
-                urlencoding::encode(&path[1..])
-            );
-
-            let id = gen_el_id();
-            let guess_id = gen_el_id();
-
-            // Checkmark
-            let has_subs = if subs_file || subs_embedded {
-                " &#10004;"
-            } else {
-                ""
-            };
-
-            let video_meta = video_meta.into_iter().fold(String::new(), |mut acc, el| {
-                let VideoStream {
-                    codec,
-                    width,
-                    height,
-                    closed_captions,
-                    avg_frame_rate,
-                    ..
-                } = el;
-                write!(
-                    acc,
-                    r#"
-                        <p>Codec: {codec}</p>
-                        <p>W: {width} H: {height}</p>
-                        <p>Framerate: {avg_frame_rate}</p>
-                        <p>Captions: {closed_captions}</p>
-                    "#
-                )
-                .unwrap();
-                acc
-            });
-
-            let sub_meta = sub_meta.into_iter().fold(String::new(), |mut acc, el| {
-                let SubtitleStream {
-                    codec,
-                    duration,
-                    tags,
-                    ..
-                } = el;
-                let language = if let Some(l) = tags.get("language") {
-                    l.to_string()
-                } else {
-                    "none".to_string()
-                };
-                let duration = if let Some(l) = duration {
-                    l.to_string()
-                } else {
-                    "none".to_string()
-                };
-                write!(
-                    acc,
-                    r#"
-                        <p>Language: {language:?}</p>
-                        <p>Codec: {codec}</p>
-                        <p>Duration: {duration}</p>
-                        <br>
-                    "#
-                )
-                .unwrap();
-                acc
-            });
-
-            let template = state.env.get_template("file_container").unwrap();
-            let template = template
-                .render(context! {
-                    ty => ty,
-                    id => id,
-                    name => name,
-                    has_subs => has_subs,
-                    guess_id => guess_id,
-                    get => get,
-                    video_meta => video_meta,
-                    sub_meta => sub_meta
-                })
-                .unwrap();
-
-            write!(acc, "{template}").unwrap();
-        }
-    };
-
-    acc
-}
-
-#[derive(Debug, Deserialize)]
-pub struct SubtitleResponse {
-    title: String,
-    original_title: Option<String>,
-    year: usize,
-    download_count: usize,
-    parent_title: Option<String>,
-    episode_number: Option<usize>,
-    season_number: Option<usize>,
-    files: Vec<File>,
-    language: Option<String>,
-}
-
-impl From<Subtitle> for SubtitleResponse {
-    fn from(value: Subtitle) -> Self {
-        let SubtitleAttrs {
-            feature_details,
-            download_count,
-            files,
-            language,
-            ..
-        } = value.attributes;
-
-        match feature_details {
-            crate::ost::data::Feature::TvShow(item) => {
-                let TvShow {
-                    title,
-                    original_title,
-                    year,
-                    ..
-                } = item;
-
-                Self {
-                    title,
-                    original_title: Some(original_title),
-                    year,
-                    download_count,
-                    parent_title: None,
-                    episode_number: None,
-                    season_number: None,
-                    files,
-                    language,
-                }
-            }
-            crate::ost::data::Feature::Episode(item) => {
-                let Episode {
-                    year,
-                    title,
-                    parent_title,
-                    episode_number,
-                    season_number,
-                    ..
-                } = item;
-                Self {
-                    title,
-                    original_title: None,
-                    year,
-                    download_count,
-                    parent_title,
-                    episode_number,
-                    season_number,
-                    files,
-                    language,
-                }
-            }
-            crate::ost::data::Feature::Movie(item) => {
-                let Movie {
-                    title,
-                    original_title,
-                    year,
-                    parent_title,
-                    episode_number,
-                    season_number,
-                    ..
-                } = item;
-                Self {
-                    title,
-                    original_title: Some(original_title),
-                    year,
-                    download_count,
-                    parent_title: Some(parent_title),
-                    episode_number,
-                    season_number,
-                    files,
-                    language,
-                }
-            }
-        }
-    }
-}
-
-fn gen_el_id() -> String {
-    rand::thread_rng()
-        .sample_iter(distributions::Alphanumeric)
-        .take(16)
-        .map(char::from)
-        .collect::<String>()
-}