Sfoglia il codice sorgente

add ffmpeg and metadata

biblius 10 mesi fa
parent
commit
fb03824786
6 ha cambiato i file con 538 aggiunte e 123 eliminazioni
  1. 10 5
      assets/filecontainer.html
  2. 38 4
      assets/styles.css
  3. 4 0
      deploy.sh
  4. 159 0
      src/ffmpeg.rs
  5. 167 32
      src/main.rs
  6. 160 82
      src/routes.rs

+ 10 - 5
assets/filecontainer.html

@@ -2,13 +2,18 @@
 
     <h2 hx-target="#_{{ id }}" {{ get }}>{{ name }}{{ has_subs }}</h2>
 
-    <div class="guess">
-        <h4>Guess</h4>
-        <button hx-get="/guess?filename={{ name }}" hx-target="#_{{ guess_id }}">Guess</button>
-        <div id="_{{ guess_id }}"></div>
+    <div class="meta video-meta">
+        <h4> Video </h4>
+        {{ video_meta }}
+    </div>
+
+    <div class="meta sub-meta">
+        <h4> Subtitles (Embedded) </h4>
+        {{ sub_meta }}
     </div>
 
     <div class="subtitles">
-        <div id="_{{ id }}" class="subtitles-entry"></div>
+        <h4> Subtitles </h4>
+        <div class="subtitles-inner" id="_{{ id }}"></div>
     </div>
 </div>

+ 38 - 4
assets/styles.css

@@ -1,11 +1,12 @@
 html * {
   font-family: Arial;
+  color: white;
+  background-color: black;
 }
 
 a {
   font-family: Arial;
   text-decoration: none;
-  color: black;
 }
 
 .hashmatch {
@@ -31,10 +32,24 @@ main {
 .file-entry {
   padding: 0.2rem 0.5rem;
   width: 100%;
-  border: 1px solid black;
   display: flex;
   flex-wrap: wrap;
-  align-items: center;
+  align-items: start;
+}
+
+.file-entry h2 {
+  width: 100%;
+  display: flex;
+}
+
+.file-entry .meta {
+  width: 50%;
+  border-box: content;
+  max-height: 16rem;
+}
+
+.file-entry .sub-meta {
+  overflow-y: scroll;
 }
 
 .file-entry h2:hover {
@@ -64,15 +79,34 @@ main {
 
 .sub-container-info {
   width: 50%;
+  display: flex;
+  flex-wrap: wrap;
+  flex-gap: 0.1rem;
+  justify-content: center;
 }
 
-.sub-files {
+.info {
+  width: 100%;
+  padding: 0.3rem;
+  margin: 0.1rem;
+  text-align: center;
 }
 
 .guess {
   width: 100%;
 }
 
+.download-button {
+  font-size: 16px;
+  width: 16rem;
+  height: 3rem;
+  margin: 0.5rem 0rem;
+}
+
+.download-button:hover {
+  cursor: pointer;
+}
+
 .dir:hover {
   cursor: pointer;
 }

+ 4 - 0
deploy.sh

@@ -0,0 +1,4 @@
+cargo build --release --target=aarch64-unknown-linux-gnu
+
+scp -r assets root@192.168.0.69:/opt/ntitled
+scp target/aarch64-unknown-linux-gnu/release/ntitled root@192.168.0.69:/opt/ntitled

+ 159 - 0
src/ffmpeg.rs

@@ -0,0 +1,159 @@
+use std::{collections::HashMap, process::Stdio};
+
+use serde::{
+    de::{DeserializeOwned, Unexpected},
+    Deserialize, Deserializer, Serialize,
+};
+
+use crate::error::NtitledError;
+
+#[derive(Debug, Deserialize)]
+pub struct FFProbeOutput<T> {
+    pub streams: Option<Vec<T>>,
+}
+
+impl<T> FFProbeOutput<T>
+where
+    T: DeserializeOwned + FFProbeStream,
+{
+    pub fn probe(path: &str) -> Result<Self, NtitledError> {
+        let stream = T::id();
+
+        let child = std::process::Command::new("ffprobe")
+            .args([
+                "-loglevel",
+                "error",
+                "-select_streams",
+                stream,
+                "-show_entries",
+                "stream:stream_tags=language",
+                "-of",
+                "json",
+                path,
+            ])
+            .stdout(Stdio::piped())
+            .spawn()?
+            .wait_with_output()?;
+
+        let out = String::from_utf8(child.stdout)?;
+
+        serde_json::from_str::<Self>(&out).map_err(NtitledError::from)
+    }
+}
+
+#[derive(Debug, Deserialize)]
+pub struct Codec {
+    #[serde(rename = "index")]
+    pub stream_idx: usize,
+
+    pub codec_type: String,
+}
+
+impl Codec {
+    pub fn probe(path: &str) -> Result<Option<Vec<Self>>, NtitledError> {
+        let child = std::process::Command::new("ffprobe")
+            .args([
+                "-loglevel",
+                "error",
+                "-show_entries",
+                "stream=index,codec_type",
+                "-of",
+                "json",
+                path,
+            ])
+            .stdout(Stdio::piped())
+            .spawn()?
+            .wait_with_output()?;
+
+        let out = String::from_utf8(child.stdout)?;
+
+        let out = serde_json::from_str::<FFProbeOutput<Self>>(&out)?;
+
+        Ok(out.streams)
+    }
+}
+
+pub trait FFProbeStream {
+    fn id() -> &'static str;
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct VideoMeta {
+    pub index: usize,
+    #[serde(rename = "codec_name")]
+    pub codec: String,
+    #[serde(rename = "codec_long_name")]
+    pub codec_long: String,
+    pub profile: Option<String>,
+    pub codec_type: String,
+    pub width: u16,
+    pub height: u16,
+    pub coded_width: u16,
+    pub coded_height: u16,
+
+    #[serde(deserialize_with = "bool_from_int")]
+    pub closed_captions: bool,
+
+    pub avg_frame_rate: String,
+    pub start_time: String,
+    pub disposition: Disposition,
+    pub tags: Option<HashMap<String, String>>,
+}
+
+impl FFProbeStream for VideoMeta {
+    fn id() -> &'static str {
+        "v"
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SubtitleMeta {
+    pub index: usize,
+    #[serde(rename = "codec_name")]
+    pub codec: String,
+    #[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>,
+}
+
+impl FFProbeStream for SubtitleMeta {
+    fn id() -> &'static str {
+        "s"
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Disposition {
+    #[serde(deserialize_with = "bool_from_int")]
+    pub default: bool,
+
+    #[serde(deserialize_with = "bool_from_int")]
+    pub dub: bool,
+
+    #[serde(deserialize_with = "bool_from_int")]
+    pub original: bool,
+
+    #[serde(deserialize_with = "bool_from_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",
+        )),
+    }
+}

+ 167 - 32
src/main.rs

@@ -1,5 +1,3 @@
-use std::{fs, path::Path};
-
 use axum::{
     response::Redirect,
     routing::{get, get_service},
@@ -8,13 +6,23 @@ use axum::{
 use minijinja::Environment;
 use ost::client::OSClient;
 use serde::Serialize;
+use std::{
+    fs::{self, DirEntry},
+    path::Path,
+    thread,
+};
 use tower_http::services::ServeDir;
-use tracing::info;
+use tracing::{debug, info, Level};
+
+use crate::ffmpeg::{Codec, FFProbeOutput, SubtitleMeta, VideoMeta};
 
 pub mod error;
+pub mod ffmpeg;
 pub mod ost;
 pub mod routes;
 
+const MAX_THREADS: usize = 4;
+
 #[derive(Debug, Clone)]
 pub struct State {
     client: OSClient,
@@ -36,11 +44,13 @@ lazy_static::lazy_static! {
 async fn main() {
     dotenv::dotenv().expect("could not load env");
 
-    tracing_subscriber::fmt().init();
+    tracing_subscriber::fmt()
+        .with_max_level(Level::DEBUG)
+        .init();
 
     let client = OSClient::init().await;
 
-    info!("Successfully loaded client");
+    info!("Successfully OST loaded client");
 
     let base_path = std::env::var("BASE_PATH").expect("base path not configured");
     let port = std::env::var("PORT").unwrap_or(String::from("3001"));
@@ -73,6 +83,8 @@ async fn main() {
         .await
         .unwrap();
 
+    info!("Starting server on 0.0.0.0:{port}");
+
     axum::serve(listener, router.merge(router_static))
         .await
         .unwrap();
@@ -93,52 +105,175 @@ pub fn scan_dir_contents(path: impl AsRef<Path>) -> std::io::Result<Vec<FileType
         })
         .collect::<Vec<_>>();
 
-    let mut files: Vec<_> = entries
-        .iter()
-        .filter_map(|entry| {
-            if entry.path().is_dir() {
-                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 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")
-            }) {
-                None
-            } else {
-                let path = entry.path();
-                let ext = path.extension()?.to_str()?;
-                let (name, _) = path.file_name()?.to_str()?.split_once(ext)?;
-                Some(FileType::File(File {
-                    name: entry.file_name().to_str().unwrap_or_default().to_string(),
-                    path: entry.path().to_str().unwrap_or_default().to_string(),
-                    has_subs: subs.contains(&name.to_string()),
-                }))
+    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
+            });
+
+        let subs = &subs;
+
+        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:?}")
+                }
             }
-        })
-        .collect();
+        }
+
+        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 codecs = Codec::probe(path_str).ok()?;
+
+        if codecs.is_none() || codecs.is_some_and(|codec| codec.is_empty()) {
+            return None;
+        }
+
+        let video_meta = match FFProbeOutput::<VideoMeta>::probe(path_str) {
+            Ok(video_meta) => video_meta,
+            Err(e) => {
+                debug!("File {path_str} is not a video: {e}");
+                return None;
+            }
+        };
+
+        let Some(video_meta) = video_meta.streams else {
+            return None;
+        };
+
+        let sub_meta = match FFProbeOutput::<SubtitleMeta>::probe(path_str) {
+            Ok(meta) => meta.streams,
+            Err(e) => {
+                debug!("File {path_str} has no subs: {e}");
+                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 subs_embedded = sub_meta.as_ref().is_some_and(|s| !s.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,
+            video_meta,
+            sub_meta,
+        }))
+    }
+}
+
 #[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
 pub enum FileType {
     Directory(Directory),
     File(File),
 }
 
-#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
+#[derive(Debug, Serialize)]
 pub struct File {
     name: String,
     path: String,
 
+    video_meta: Vec<VideoMeta>,
+
+    sub_meta: Option<Vec<SubtitleMeta>>,
+
     /// `true` if a same file exists with a `.srt` extension found
     /// in the same directory
-    has_subs: bool,
+    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
+    }
+}
+
+impl Eq for File {}
+
+impl PartialOrd for File {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl Ord for File {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        match self.name.cmp(&other.name) {
+            core::cmp::Ordering::Equal => {}
+            ord => return ord,
+        }
+        match self.path.cmp(&other.path) {
+            core::cmp::Ordering::Equal => {}
+            ord => return ord,
+        }
+        self.subs_file.cmp(&other.subs_file)
+    }
 }
 
 #[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]

+ 160 - 82
src/routes.rs

@@ -1,5 +1,6 @@
 use crate::{
     error::NtitledError,
+    ffmpeg::{SubtitleMeta, VideoMeta},
     ost::{
         data::{Episode, File, GuessIt, Movie, Subtitle, SubtitleAttrs, TvShow},
         hash::compute_hash,
@@ -34,74 +35,9 @@ pub async fn get_directory(
 
     let files = scan_dir_contents(path)?;
 
-    let response = files.into_iter().fold(String::new(), |mut acc, el| {
-        match el {
-            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,
-                has_subs,
-            }) => {
-                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();
-
-                let has_subs = if has_subs { " &#10004;" } else { "" };
-
-                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
-                    })
-                    .unwrap();
-
-                write!(acc, "{template}").unwrap();
-            }
-        };
-
-        acc
-    });
+    let response = files
+        .into_iter()
+        .fold(String::new(), |acc, el| write_dir_html(&state, acc, el));
 
     let template = state.env.get_template("index").unwrap();
     let template = template.render(context! {divs => response}).unwrap();
@@ -131,7 +67,7 @@ pub async fn search_subtitles(
 
     let search = state.client.search(&query.name, Some(&hash)).await?;
 
-    let response = search.data.into_iter().fold(String::new(), |mut acc, el| {
+    let mut response = search.data.into_iter().fold(String::new(), |mut acc, el| {
 
         let hashmatch = el.attributes.moviehash_match
             .is_some_and(|is| is)
@@ -143,15 +79,19 @@ pub async fn search_subtitles(
         let SubtitleResponse {
             title,
             original_title,
-            imdb_id,
             year,
             download_count,
             parent_title,
             episode_number,
             season_number,
-            files
+            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();
@@ -167,13 +107,14 @@ pub async fn search_subtitles(
             let File { file_id, ..} = file;
             let path = urlencoding::encode(&path);
             write!(acc, r#"
-                File ID: {file_id}
-                <button hx-target="closest div" hx-swap="innerHTML" hx-get="/download?file_id={file_id}&full_path={path}">Download</button>"#).unwrap();
+                <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##"
@@ -184,7 +125,9 @@ pub async fn search_subtitles(
                 </div>
 
                 <div class="sub-container-info">
-                    <p>IMDB: {imdb_id}, Year: {year}, Downloads: {download_count}</p>
+                    <p class="info">Language: {language}</p> 
+                    <p class="info">Downloads: {download_count}</p>
+                    <p class="info">Year: {year}</p>
                     {files}
                 </div>
 
@@ -196,6 +139,10 @@ pub async fn search_subtitles(
         acc
     });
 
+    if response.is_empty() {
+        response = "No subtitles found :(".to_string();
+    }
+
     Ok(response)
 }
 #[derive(Debug, Deserialize)]
@@ -220,7 +167,7 @@ pub async fn download_subtitles(
 
     let path = ext
         .map(|ext| query.full_path.replace(&format!(".{ext}"), ".srt"))
-        .unwrap_or(format!("{}.srt", full_path));
+        .unwrap_or(format!("{full_path}.srt"));
 
     state.client.download_subtitles(file_id, &path).await?;
     Ok(String::from("Successfully downloaded subtitles"))
@@ -261,17 +208,150 @@ 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 VideoMeta {
+                    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.map(|subs| {
+                subs.into_iter().fold(String::new(), |mut acc, el| {
+                    let SubtitleMeta {
+                        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>,
-    imdb_id: usize,
     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 {
@@ -280,6 +360,7 @@ impl From<Subtitle> for SubtitleResponse {
             feature_details,
             download_count,
             files,
+            language,
             ..
         } = value.attributes;
 
@@ -289,27 +370,25 @@ impl From<Subtitle> for SubtitleResponse {
                     title,
                     original_title,
                     year,
-                    imdb_id,
                     ..
                 } = item;
 
                 Self {
                     title,
                     original_title: Some(original_title),
-                    imdb_id,
                     year,
                     download_count,
                     parent_title: None,
                     episode_number: None,
                     season_number: None,
                     files,
+                    language,
                 }
             }
             crate::ost::data::Feature::Episode(item) => {
                 let Episode {
                     year,
                     title,
-                    imdb_id,
                     parent_title,
                     episode_number,
                     season_number,
@@ -318,13 +397,13 @@ impl From<Subtitle> for SubtitleResponse {
                 Self {
                     title,
                     original_title: None,
-                    imdb_id,
                     year,
                     download_count,
                     parent_title,
                     episode_number,
                     season_number,
                     files,
+                    language,
                 }
             }
             crate::ost::data::Feature::Movie(item) => {
@@ -332,7 +411,6 @@ impl From<Subtitle> for SubtitleResponse {
                     title,
                     original_title,
                     year,
-                    imdb_id,
                     parent_title,
                     episode_number,
                     season_number,
@@ -341,13 +419,13 @@ impl From<Subtitle> for SubtitleResponse {
                 Self {
                     title,
                     original_title: Some(original_title),
-                    imdb_id,
                     year,
                     download_count,
                     parent_title: Some(parent_title),
                     episode_number,
                     season_number,
                     files,
+                    language,
                 }
             }
         }