bilibili_extractor_lib/
subtitle.rs

1use crate::{
2    error::{Error, Result},
3    metadata::{EpisodeMetadata, NormalEpisodeMetadata, SpecialEpisodeMetadata},
4};
5use rsubs_lib::{
6    srt::{SRTFile, SRTLine},
7    ssa::{SSAEvent, SSAFile, SSAStyle},
8    util::{
9        color::{
10            Alignment, Color,
11            ColorType::{self, SSAColor},
12        },
13        time::Time,
14    },
15    vtt::{VTTFile, VTTLine, VTTStyle},
16    Subtitle,
17};
18use serde::{Deserialize, Serialize};
19use std::{collections::HashMap, fs, path::Path};
20
21/// Contains information inside a Bilibili JSON subtitle.
22///
23/// # Convert to other subtitle format
24///
25/// ```
26/// use bilibili_extractor_lib::subtitle::{JsonSubtitle, JsonSubtitleBody};
27///
28/// let json_subtitle = JsonSubtitle {
29///     body: vec![JsonSubtitleBody {
30///         from: 0.,
31///         to: 1.,
32///         content: "Subtitle".into(),
33///     }],
34/// };
35///
36/// println!("{}", json_subtitle.to_ssa().to_string())
37/// ```
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
39pub struct JsonSubtitle {
40    pub body: Vec<JsonSubtitleBody>,
41}
42
43/// Lines inside a Bilibili JSON subtitle.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
45pub struct JsonSubtitleBody {
46    pub from: f32,
47    pub to: f32,
48    pub content: String,
49}
50
51/// Either a softsub or hardsub.
52#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
53pub enum SubtitleType {
54    Hard,
55    #[default]
56    Soft,
57}
58
59/// Format of the subtitle. Though Bilibili only uses SSA and JSON.
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
61pub enum SubtitleFormat {
62    #[default]
63    Json,
64    Ssa,
65    Srt,
66    Vtt,
67}
68
69impl JsonSubtitle {
70    /// Fetch the json subtitle of an episode.
71    pub fn new_from_episode(
72        episode: &EpisodeMetadata<impl AsRef<Path>>,
73        subtitle_language: &str,
74    ) -> Result<Self> {
75        match episode {
76            EpisodeMetadata::Normal(e) => Self::new_from_normal_episode(e, subtitle_language),
77            EpisodeMetadata::Special(e) => Self::new_from_special_episode(e, subtitle_language),
78        }
79    }
80
81    /// Fetch the json subtitle of a normal episode.
82    pub fn new_from_normal_episode(
83        episode: &NormalEpisodeMetadata<impl AsRef<Path>>,
84        subtitle_language: &str,
85    ) -> Result<Self> {
86        let subtitle_path = episode
87            .path
88            .as_ref()
89            .ok_or(format!(
90                "Episode {} of {} doesn't have a path.",
91                episode.episode, episode.title
92            ))?
93            .as_ref()
94            .join(subtitle_language)
95            .read_dir()?
96            .next()
97            .ok_or("Subtitle directory is empty.")??
98            .path();
99
100        Self::new_from_path(subtitle_path)
101    }
102
103    /// Fetch the json subtitle of a special episode.
104    pub fn new_from_special_episode(
105        episode: &SpecialEpisodeMetadata<impl AsRef<Path>>,
106        subtitle_language: &str,
107    ) -> Result<Self> {
108        let subtitle_path = episode
109            .path
110            .as_ref()
111            .ok_or(format!(
112                "{} of {} doesn't have a path.",
113                episode.episode_name, episode.title
114            ))?
115            .as_ref()
116            .join(subtitle_language)
117            .read_dir()?
118            .next()
119            .ok_or("Subtitle directory is empty.")??
120            .path();
121
122        Self::new_from_path(subtitle_path)
123    }
124
125    /// Create a `JsonSubtitle` from path.
126    pub fn new_from_path(path: impl AsRef<Path>) -> Result<Self> {
127        let json_string = fs::read_to_string(path)?;
128
129        Ok(serde_json::from_str(&json_string)?)
130    }
131
132    /// Convert to `Subtitle`.
133    pub fn to_subtitle(self) -> Subtitle {
134        self.into()
135    }
136
137    /// Convert to `SSAFile`.
138    pub fn to_ssa(self) -> SSAFile {
139        self.into()
140    }
141
142    /// Convert to `SRTFile`.
143    pub fn to_srt(self) -> SRTFile {
144        self.into()
145    }
146
147    /// Convert to `VTTFile`.
148    pub fn to_vtt(self) -> VTTFile {
149        self.into()
150    }
151}
152
153impl From<JsonSubtitle> for Subtitle {
154    fn from(value: JsonSubtitle) -> Self {
155        let mut ass_info = HashMap::new();
156        ass_info.insert("Title".into(), "Bilibili Subtitle".into());
157        ass_info.insert("ScriptType".into(), "v4.00+".into());
158        ass_info.insert("WrapStyle".into(), "0".into());
159        ass_info.insert("ScaledBorderAndShadow".into(), "yes".into());
160        ass_info.insert("YCbCr Matrix".into(), "TV.601".into());
161        ass_info.insert("PlayResX".into(), "1920".into());
162        ass_info.insert("PlayResY".into(), "1080".into());
163
164        let ass_styles = SSAStyle {
165            name: "Default".into(),
166            fontname: "Noto Sans".into(),
167            fontsize: 100.,
168            firstcolor: SSAColor(Color {
169                a: 0,
170                r: 255,
171                g: 255,
172                b: 255,
173            }),
174            secondcolor: SSAColor(Color {
175                r: 0,
176                g: 255,
177                b: 255,
178                a: 0,
179            }),
180            outlinecolor: SSAColor(Color {
181                r: 0,
182                g: 0,
183                b: 0,
184                a: 0,
185            }),
186            backgroundcolor: SSAColor(Color {
187                r: 0,
188                g: 0,
189                b: 0,
190                a: 0,
191            }),
192            bold: false,
193            italic: true,
194            unerline: true,
195            strikeout: true,
196            scalex: 100.,
197            scaley: 100.,
198            spacing: 0.,
199            angle: 0.,
200            borderstyle: 1,
201            outline: 3.,
202            shadow: 0.,
203            alignment: Alignment::BottomCenter,
204            lmargin: 96,
205            rmargin: 96,
206            vmargin: 65,
207            encoding: 1,
208            ..Default::default()
209        };
210
211        let mut ass_event = vec![];
212
213        value.body.iter().for_each(|b| {
214            ass_event.push(SSAEvent {
215                style: "Default".into(),
216                line_start: Time {
217                    ms: (b.from * 1000.) as u32,
218                    ..Default::default()
219                },
220                line_end: Time {
221                    ms: (b.to * 1000.) as u32,
222                    ..Default::default()
223                },
224                line_text: b.content.clone(),
225                ..Default::default()
226            })
227        });
228
229        Subtitle::SSA(Some(SSAFile {
230            events: ass_event,
231            styles: vec![ass_styles],
232            info: ass_info,
233            format: ".ass".into(),
234        }))
235    }
236}
237
238impl From<JsonSubtitle> for SSAFile {
239    fn from(value: JsonSubtitle) -> Self {
240        let mut ass_info = HashMap::new();
241        ass_info.insert("Title".into(), "Bilibili Subtitle".into());
242        ass_info.insert("ScriptType".into(), "v4.00+".into());
243        ass_info.insert("WrapStyle".into(), "0".into());
244        ass_info.insert("ScaledBorderAndShadow".into(), "yes".into());
245        ass_info.insert("YCbCr Matrix".into(), "TV.601".into());
246        ass_info.insert("PlayResX".into(), "1920".into());
247        ass_info.insert("PlayResY".into(), "1080".into());
248
249        let ass_styles = SSAStyle {
250            name: "Default".into(),
251            fontname: "Noto Sans".into(),
252            fontsize: 100.,
253            firstcolor: SSAColor(Color {
254                a: 0,
255                r: 255,
256                g: 255,
257                b: 255,
258            }),
259            secondcolor: SSAColor(Color {
260                r: 0,
261                g: 255,
262                b: 255,
263                a: 0,
264            }),
265            outlinecolor: SSAColor(Color {
266                r: 0,
267                g: 0,
268                b: 0,
269                a: 0,
270            }),
271            backgroundcolor: SSAColor(Color {
272                r: 0,
273                g: 0,
274                b: 0,
275                a: 0,
276            }),
277            bold: false,
278            italic: true,
279            unerline: true,
280            strikeout: true,
281            scalex: 100.,
282            scaley: 100.,
283            spacing: 0.,
284            angle: 0.,
285            borderstyle: 1,
286            outline: 3.,
287            shadow: 0.,
288            alignment: Alignment::BottomCenter,
289            lmargin: 96,
290            rmargin: 96,
291            vmargin: 65,
292            encoding: 1,
293            ..Default::default()
294        };
295
296        let mut ass_event = vec![];
297
298        value.body.iter().for_each(|b| {
299            ass_event.push(SSAEvent {
300                style: "Default".into(),
301                line_start: Time {
302                    ms: (b.from * 1000.) as u32,
303                    ..Default::default()
304                },
305                line_end: Time {
306                    ms: (b.to * 1000.) as u32,
307                    ..Default::default()
308                },
309                line_text: b.content.clone(),
310                ..Default::default()
311            })
312        });
313
314        SSAFile {
315            events: ass_event,
316            styles: vec![ass_styles],
317            info: ass_info,
318            format: ".ass".into(),
319        }
320    }
321}
322
323impl From<JsonSubtitle> for SRTFile {
324    fn from(value: JsonSubtitle) -> Self {
325        let mut srt_line = vec![];
326
327        for (i, b) in value.body.iter().enumerate() {
328            srt_line.push(SRTLine {
329                line_number: (i + 1) as i32,
330                line_text: b.content.clone(),
331                line_start: Time {
332                    ms: (b.from * 1000.) as u32,
333                    ..Default::default()
334                },
335                line_end: Time {
336                    ms: (b.to * 1000.) as u32,
337                    ..Default::default()
338                },
339            });
340        }
341
342        SRTFile { lines: srt_line }
343    }
344}
345
346impl From<JsonSubtitle> for VTTFile {
347    fn from(value: JsonSubtitle) -> Self {
348        let vtt_style = VTTStyle {
349            name: Some("Default".into()),
350            font_family: "Noto Sans".into(),
351            font_size: "100".into(),
352            color: ColorType::VTTColor(Color {
353                r: 255,
354                g: 255,
355                b: 255,
356                a: 0,
357            }),
358            background_color: ColorType::VTTColor(Color {
359                r: 0,
360                g: 0,
361                b: 0,
362                a: 127,
363            }),
364            ..Default::default()
365        };
366
367        let mut vtt_lines = vec![];
368
369        for (i, b) in value.body.iter().enumerate() {
370            vtt_lines.push(VTTLine {
371                line_number: i.to_string(),
372                style: Some("Default".into()),
373                line_start: Time {
374                    ms: (b.from * 1000.) as u32,
375                    ..Default::default()
376                },
377                line_end: Time {
378                    ms: (b.to * 1000.) as u32,
379                    ..Default::default()
380                },
381                position: None,
382                line_text: b.content.clone(),
383            })
384        }
385
386        VTTFile {
387            styles: vec![vtt_style],
388            lines: vtt_lines,
389        }
390    }
391}
392
393impl SubtitleFormat {
394    /// Get the subtitle format of an episode.
395    pub fn get_episode_subtitle_type(
396        episode: &EpisodeMetadata<impl AsRef<Path>>,
397        subtitle_language: &str,
398    ) -> Result<Self> {
399        match episode {
400            EpisodeMetadata::Normal(e) => {
401                Self::get_normal_episode_subtitle_type(e, subtitle_language)
402            }
403            EpisodeMetadata::Special(e) => {
404                Self::get_special_episode_subtitle_type(e, subtitle_language)
405            }
406        }
407    }
408
409    /// Get the subtitle format of a normal episode.
410    pub fn get_normal_episode_subtitle_type(
411        episode: &NormalEpisodeMetadata<impl AsRef<Path>>,
412        subtitle_language: &str,
413    ) -> Result<Self> {
414        let subtitle_path = episode
415            .path
416            .as_ref()
417            .ok_or(format!(
418                "Episode {} of {} doesn't have a path.",
419                episode.episode, episode.title
420            ))?
421            .as_ref()
422            .join(subtitle_language)
423            .read_dir()?
424            .next()
425            .ok_or("Subtitle directory is empty")??
426            .path();
427        let extension = subtitle_path.extension().ok_or(format!(
428            "Subtitle {} has no extension.",
429            subtitle_path.display()
430        ))?;
431
432        match extension
433            .to_str()
434            .ok_or("OsStr doesn't yeild valid Unicode.")?
435        {
436            "json" => Ok(Self::Json),
437            "ass" | "ssa" => Ok(Self::Ssa),
438            "srt" => Ok(Self::Srt),
439            "vtt" => Ok(Self::Vtt),
440            _ => Err(Error::FromString(format!(
441                "Invalid extension: {}",
442                subtitle_path.display()
443            ))),
444        }
445    }
446
447    /// Get the subtitle format of a special episode.
448    pub fn get_special_episode_subtitle_type(
449        episode: &SpecialEpisodeMetadata<impl AsRef<Path>>,
450        subtitle_language: &str,
451    ) -> Result<Self> {
452        let subtitle_path = episode
453            .path
454            .as_ref()
455            .ok_or(format!(
456                "{} of {} doesn't have a path.",
457                episode.episode_name, episode.title
458            ))?
459            .as_ref()
460            .join(subtitle_language)
461            .read_dir()?
462            .next()
463            .ok_or("Subtitle directory is empty.")??
464            .path();
465        let extension = subtitle_path.extension().ok_or(format!(
466            "Subtitle {} has no extension.",
467            subtitle_path.display()
468        ))?;
469
470        match extension
471            .to_str()
472            .ok_or("OsStr doesn't yeild valid Unicode.")?
473        {
474            "json" => Ok(Self::Json),
475            "ass" | "ssa" => Ok(Self::Ssa),
476            "srt" => Ok(Self::Srt),
477            "vtt" => Ok(Self::Vtt),
478            _ => Err(Error::FromString(format!(
479                "Invalid extension: {}",
480                subtitle_path.display()
481            ))),
482        }
483    }
484}