Browse Source

restructure to htmxpress

biblius 10 tháng trước cách đây
mục cha
commit
6eab05c42c
10 tập tin đã thay đổi với 646 bổ sung352 xóa
  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. 38 308
      src/routes.rs
  10. 30 0
      test.html

+ 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),
+                }
+            }
+        }
+    }
+}

+ 38 - 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;
@@ -37,7 +37,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[1..].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[1..].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 +84,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 +108,7 @@ pub async fn search_subtitles(
 }
 #[derive(Debug, Deserialize)]
 pub struct DownloadQuery {
-    file_id: usize,
+    file_id: String,
     full_path: String,
 }
 
@@ -155,9 +116,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 +168,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>()
-}

+ 30 - 0
test.html

@@ -0,0 +1,30 @@
+<div class="sub-container">
+    <div class="sub-container-title">
+        <h3>The Venture Bros. - Dia de Los Dangerous! S1E1 🎯</h3>
+    </div>
+    <div class="sub-container-info">
+        <p class="info">Language: en</p>
+        <p class="info">Downloads: 8638</p>
+        <p class="info">Year: 2003</p>
+        <div class="sub-files">
+            <button
+                hx-get="/download?file_id=7873840&full_path=%2Fmedia%2Fbiblius%2FMaxtor%2FFilmovi%20i%20serije%2FThe%20Venture%20Bros%20S01-S06%20%282003-%29%2FThe%20Venture%20Bros%20S01%20%28360p%20re-dvdrip%29%2FThe%20Venture%20Bros%20S01E00%20The%20Terrible%20Secret%20of%20Turtle%20Bay.mp4"
+                class="download-button" hx-target="closest-div" hx-swap="innerHtml">
+                Download
+            </button>
+        </div>
+    </div>
+</div>
+<div class="sub-container">
+    <div class="sub-container-title">
+        <h3>The Venture Bros. - Dia de Los Dangerous! S1E1 🎯</h3>
+    </div>
+    <div class="sub-container-info">
+        <p class="info">Language: es</p>
+        <p class="info">Downloads: 535</p>
+        <p class="info">Year: 2004</p>
+        <div class="sub-files"><button
+                hx-get="/download?file_id=2653995&full_path=%2Fmedia%2Fbiblius%2FMaxtor%2FFilmovi%20i%20serije%2FThe%20Venture%20Bros%20S01-S06%20%282003-%29%2FThe%20Venture%20Bros%20S01%20%28360p%20re-dvdrip%29%2FThe%20Venture%20Bros%20S01E00%20The%20Terrible%20Secret%20of%20Turtle%20Bay.mp4"
+                class="download-button" hx-target="closest div" hx-swap="innerHtml">Download</button></div>
+    </div>
+</div>