bilibili_extractor_lib/
subtitle.rs

1use crate::{error::Result, metadata::EpisodeMetadata};
2use rsubs_lib::{
3    srt::{SRTFile, SRTLine},
4    ssa::{SSAEvent, SSAFile, SSAStyle},
5    util::{
6        color::{Alignment, Color, ColorType},
7        time::Time,
8    },
9    vtt::{VTTFile, VTTLine, VTTStyle},
10    Subtitle,
11};
12use serde::{Deserialize, Serialize};
13use std::{collections::HashMap, fs, path::Path};
14
15macro_rules! new_ssa_subtitile {
16    ($value: expr) => {{
17        let mut ass_info = HashMap::new();
18        ass_info.insert("Title".into(), "Bilibili Subtitle".into());
19        ass_info.insert("ScriptType".into(), "v4.00+".into());
20        ass_info.insert("WrapStyle".into(), "0".into());
21        ass_info.insert("ScaledBorderAndShadow".into(), "yes".into());
22        ass_info.insert("YCbCr Matrix".into(), "TV.601".into());
23        ass_info.insert("PlayResX".into(), "1920".into());
24        ass_info.insert("PlayResY".into(), "1080".into());
25
26        let ass_styles = SSAStyle {
27            name: "Default".into(),
28            fontsize: 70.,
29            bold: false, // For some reason `rsubs_lib` binds `false` with `-1` and true with `0`,
30            // but SSA format uses `-1` for `true` and `0` with false.
31            borderstyle: 1,
32            outline: 5.,
33            alignment: Alignment::BottomCenter,
34            vmargin: 30,
35            ..Default::default()
36        };
37
38        let mut ass_event = vec![];
39
40        $value.body.iter().for_each(|b| {
41            ass_event.push(SSAEvent {
42                style: "Default".into(),
43                line_start: Time {
44                    ms: (b.from * 1000.) as u32,
45                    ..Default::default()
46                },
47                line_end: Time {
48                    ms: (b.to * 1000.) as u32,
49                    ..Default::default()
50                },
51                line_text: b.content.clone().replace("\n", "\\N"),
52                ..Default::default()
53            })
54        });
55
56        SSAFile {
57            events: ass_event,
58            styles: vec![ass_styles],
59            info: ass_info,
60            format: ".ass".into(),
61        }
62    }};
63}
64
65/// Contains information inside a Bilibili JSON subtitle.
66///
67/// # Convert to other subtitle format
68///
69/// ```
70/// use bilibili_extractor_lib::subtitle::{JsonSubtitle, JsonSubtitleBody};
71///
72/// let json_subtitle = JsonSubtitle {
73///     body: vec![JsonSubtitleBody {
74///         from: 0.,
75///         to: 1.,
76///         content: "Subtitle".into(),
77///     }],
78/// };
79///
80/// println!("{}", json_subtitle.to_ssa().to_string())
81/// ```
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
83pub struct JsonSubtitle {
84    pub body: Vec<JsonSubtitleBody>,
85}
86
87/// Lines inside a Bilibili JSON subtitle.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, PartialOrd)]
89pub struct JsonSubtitleBody {
90    pub from: f32,
91    pub to: f32,
92    pub content: String,
93}
94
95/// Either a softsub or hardsub.
96#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
97pub enum SubtitleType {
98    Hard,
99    #[default]
100    Soft,
101}
102
103/// Format of the subtitle. Though Bilibili only uses SSA and JSON.
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
105pub enum SubtitleFormat {
106    #[default]
107    Json,
108    Ssa,
109    Srt,
110    Vtt,
111}
112
113impl JsonSubtitle {
114    /// Fetch the json subtitle of an episode.
115    pub fn new_from_episode(episode: &EpisodeMetadata, subtitle_language: &str) -> Result<Self> {
116        let subtitle_path = episode
117            .path
118            .join(subtitle_language)
119            .read_dir()?
120            .next()
121            .ok_or("Subtitle directory is empty.")??
122            .path();
123
124        Self::new_from_path(subtitle_path)
125    }
126
127    /// Create a `JsonSubtitle` from path.
128    pub fn new_from_path(path: impl AsRef<Path>) -> Result<Self> {
129        let json_string = fs::read_to_string(path)?;
130
131        Ok(serde_json::from_str(&json_string)?)
132    }
133
134    /// Convert to `Subtitle`.
135    pub fn to_subtitle(self) -> Subtitle {
136        self.into()
137    }
138
139    /// Convert to `SSAFile`.
140    pub fn to_ssa(self) -> SSAFile {
141        self.into()
142    }
143
144    /// Convert to `SRTFile`.
145    pub fn to_srt(self) -> SRTFile {
146        self.into()
147    }
148
149    /// Convert to `VTTFile`.
150    pub fn to_vtt(self) -> VTTFile {
151        self.into()
152    }
153}
154
155impl From<JsonSubtitle> for Subtitle {
156    fn from(value: JsonSubtitle) -> Self {
157        let ssa_subtitle = new_ssa_subtitile!(value);
158
159        Subtitle::SSA(Some(ssa_subtitle))
160    }
161}
162
163impl From<JsonSubtitle> for SSAFile {
164    fn from(value: JsonSubtitle) -> Self {
165        new_ssa_subtitile!(value)
166    }
167}
168
169impl From<JsonSubtitle> for SRTFile {
170    fn from(value: JsonSubtitle) -> Self {
171        let mut srt_line = vec![];
172
173        for (i, b) in value.body.iter().enumerate() {
174            srt_line.push(SRTLine {
175                line_number: (i + 1) as i32,
176                line_text: b.content.clone(),
177                line_start: Time {
178                    ms: (b.from * 1000.) as u32,
179                    ..Default::default()
180                },
181                line_end: Time {
182                    ms: (b.to * 1000.) as u32,
183                    ..Default::default()
184                },
185            });
186        }
187
188        SRTFile { lines: srt_line }
189    }
190}
191
192impl From<JsonSubtitle> for VTTFile {
193    fn from(value: JsonSubtitle) -> Self {
194        let vtt_style = VTTStyle {
195            name: Some("Default".into()),
196            font_family: "Noto Sans".into(),
197            font_size: "100".into(),
198            color: ColorType::VTTColor(Color {
199                r: 255,
200                g: 255,
201                b: 255,
202                a: 0,
203            }),
204            background_color: ColorType::VTTColor(Color {
205                r: 0,
206                g: 0,
207                b: 0,
208                a: 127,
209            }),
210            ..Default::default()
211        };
212
213        let mut vtt_lines = vec![];
214
215        for (i, b) in value.body.iter().enumerate() {
216            vtt_lines.push(VTTLine {
217                line_number: i.to_string(),
218                style: Some("Default".into()),
219                line_start: Time {
220                    ms: (b.from * 1000.) as u32,
221                    ..Default::default()
222                },
223                line_end: Time {
224                    ms: (b.to * 1000.) as u32,
225                    ..Default::default()
226                },
227                position: None,
228                line_text: b.content.clone(),
229            })
230        }
231
232        VTTFile {
233            styles: vec![vtt_style],
234            lines: vtt_lines,
235        }
236    }
237}
238
239impl SubtitleFormat {
240    /// Get the subtitle format of an episode.
241    pub fn get_episode_subtitle_type(
242        episode: &EpisodeMetadata,
243        subtitle_language: &str,
244    ) -> Result<Self> {
245        let subtitle_path = episode
246            .path
247            .join(subtitle_language)
248            .read_dir()?
249            .next()
250            .ok_or("Subtitle directory is empty")??
251            .path();
252        let extension = subtitle_path.extension().ok_or(format!(
253            "Subtitle {} has no extension.",
254            subtitle_path.display()
255        ))?;
256
257        match extension
258            .to_str()
259            .ok_or("OsStr doesn't yeild valid Unicode.")?
260        {
261            "json" => Ok(Self::Json),
262            "ass" | "ssa" => Ok(Self::Ssa),
263            "srt" => Ok(Self::Srt),
264            "vtt" => Ok(Self::Vtt),
265            _ => Err(format!("Invalid extension: {}", extension.to_string_lossy()).into()),
266        }
267    }
268}