1use crate::error::SubtitleError;
6use crate::model::{Format, Subtitle, SubtitleFile};
7use crate::types::AnyResult;
8use crate::utils::parse_timestamp;
9#[cfg(not(target_arch = "wasm32"))]
10use tokio::io::AsyncWriteExt;
11
12pub fn parse_content(content: &str) -> AnyResult<SubtitleFile> {
14 let mut subtitles: Vec<Subtitle> = Vec::with_capacity((content.len() / 40).max(16));
15
16 for line in content.lines() {
17 let trimmed = line.trim();
18 if trimmed.is_empty() {
19 continue;
20 }
21
22 let parts: Vec<&str> = trimmed.splitn(3, ',').collect();
24 if parts.len() == 3 {
25 let (t1, t2, text) = (parts[0], parts[1], parts[2]);
26 let t1_fixed = if t1.chars().filter(|&c| c == ':').count() == 1 {
28 format!("00:{}", t1)
29 } else {
30 t1.to_string()
31 };
32 let t2_fixed = if t2.chars().filter(|&c| c == ':').count() == 1 {
33 format!("00:{}", t2)
34 } else {
35 t2.to_string()
36 };
37
38 if let (Ok(start), Ok(end)) = (
39 parse_timestamp(&t1_fixed, Format::Sbv),
40 parse_timestamp(&t2_fixed, Format::Sbv),
41 ) {
42 subtitles.push(Subtitle::new(start, end, text.trim()));
43 }
44 }
45 }
46
47 Ok(SubtitleFile::Sbv(subtitles))
48}
49
50pub fn parse_bytes(data: &[u8]) -> AnyResult<SubtitleFile> {
52 let text = crate::encoding::decode_to_string(data)?;
53 parse_content(&text)
54}
55
56#[cfg(not(target_arch = "wasm32"))]
58pub async fn parse_file(path: impl AsRef<std::path::Path>) -> AnyResult<SubtitleFile> {
59 let text = tokio::fs::read_to_string(path).await?;
60 parse_content(&text)
61}
62
63#[cfg(feature = "http")]
65pub async fn parse_url(url: &str) -> AnyResult<SubtitleFile> {
66 let response = reqwest::get(url).await?;
67 let content = response.text().await?;
68 parse_content(&content)
69}
70
71pub fn detect_format(data: &[u8]) -> Option<crate::model::Format> {
74 let text = crate::encoding::try_decode_for_detection(data)?;
75 let has_sbv = text.lines().any(|l| {
76 let t = l.trim();
77 if t.is_empty() || !t.starts_with(|c: char| c.is_ascii_digit()) {
78 return false;
79 }
80 let parts: Vec<&str> = t.splitn(3, ',').collect();
82 if parts.len() < 3 {
83 return false;
84 }
85 parts[0].contains(':')
87 && parts[0].contains('.')
88 && parts[1].contains(':')
89 && parts[1].contains('.')
90 });
91 if has_sbv {
92 return Some(crate::model::Format::Sbv);
93 }
94 None
95}
96
97#[cfg(not(target_arch = "wasm32"))]
101pub async fn generate(
102 subtitles: &[Subtitle],
103 file_path: impl AsRef<std::path::Path>,
104 policy: Option<crate::model::WritePolicy>,
105) -> AnyResult<String> {
106 let content = to_string(subtitles);
107 let path = file_path.as_ref();
108 crate::io::write_with_policy(path, content.as_bytes(), policy).await?;
109 Ok(path.to_string_lossy().into_owned())
110}
111
112pub fn to_string(subtitles: &[Subtitle]) -> String {
114 let mut buf = String::new();
115 for sub in subtitles {
116 let start = format_sbv_time(sub.start);
117 let end = format_sbv_time(sub.end);
118 buf.push_str(&format!("{},{},{}\n", start, end, sub.text));
119 }
120 buf
121}
122
123fn format_sbv_time(ms: u64) -> String {
124 let total_seconds = ms / 1000;
125 let millis = ms % 1000;
126 let hours = total_seconds / 3600;
127 let minutes = (total_seconds % 3600) / 60;
128 let seconds = total_seconds % 60;
129 format!("{}:{:02}:{:02}.{:03}", hours, minutes, seconds, millis)
130}
131
132pub fn parse_stream<'a>(content: &'a str) -> SbVStream<'a> {
135 SbVStream::new(content)
136}
137
138pub struct SbVStream<'a> {
139 lines: std::str::Lines<'a>,
140}
141
142impl<'a> SbVStream<'a> {
143 pub fn new(content: &'a str) -> Self {
144 SbVStream {
145 lines: content.lines(),
146 }
147 }
148}
149
150impl<'a> Iterator for SbVStream<'a> {
151 type Item = AnyResult<Subtitle>;
152 fn next(&mut self) -> Option<Self::Item> {
153 for line in self.lines.by_ref() {
154 let trimmed = line.trim();
155 if trimmed.is_empty() {
156 continue;
157 }
158 return Some(parse_sbv_line(trimmed).map_err(Into::into));
159 }
160 None
161 }
162}
163
164impl<'a> crate::model::StreamingParser for SbVStream<'a> {}
165
166fn parse_sbv_line(line: &str) -> Result<Subtitle, SubtitleError> {
167 let parts: Vec<&str> = line.splitn(3, ',').collect();
168 if parts.len() < 3 {
169 return Err(SubtitleError::InvalidLine {
170 format: Format::Sbv,
171 line: line.to_string(),
172 });
173 }
174 let (t1, t2, text) = (parts[0], parts[1], parts[2]);
175 let t1_fixed = if t1.chars().filter(|&c| c == ':').count() == 1 {
176 format!("00:{}", t1)
177 } else {
178 t1.to_string()
179 };
180 let t2_fixed = if t2.chars().filter(|&c| c == ':').count() == 1 {
181 format!("00:{}", t2)
182 } else {
183 t2.to_string()
184 };
185 let start = crate::utils::parse_timestamp(&t1_fixed, Format::Sbv)?;
186 let end = crate::utils::parse_timestamp(&t2_fixed, Format::Sbv)?;
187 Ok(Subtitle::new(start, end, text.trim()))
188}
189
190#[cfg(not(target_arch = "wasm32"))]
192pub async fn write_stream<W: tokio::io::AsyncWrite + Unpin>(
193 subtitles: &[Subtitle],
194 writer: &mut W,
195) -> AnyResult<()> {
196 for sub in subtitles {
197 let start = format_sbv_time(sub.start);
198 let end = format_sbv_time(sub.end);
199 writer
200 .write_all(format!("{},{},{}\n", start, end, sub.text).as_bytes())
201 .await?;
202 }
203 writer.flush().await?;
204 Ok(())
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use crate::model::SubtitleFormat;
211
212 #[test]
213 fn test_parse_basic() {
214 let content = "0:00:01.000,0:00:03.500,Hello World\n0:00:04.000,0:00:06.500,Line two\n";
215 let file = parse_content(content).unwrap();
216 let subs = file.subtitles();
217 assert_eq!(subs.len(), 2);
218 assert_eq!(subs[0].start, 1000);
219 assert_eq!(subs[0].end, 3500);
220 assert_eq!(subs[0].text, "Hello World");
221 }
222
223 #[test]
224 fn test_round_trip() {
225 let content = "0:00:01.000,0:00:03.500,Hello\n";
226 let file = parse_content(content).unwrap();
227 let subs = file.subtitles();
228 let output = to_string(subs);
229 assert!(output.contains("Hello"));
230 let reparsed = parse_content(&output).unwrap();
231 assert_eq!(subs.len(), reparsed.subtitles().len());
232 }
233
234 #[test]
235 fn test_detect() {
236 assert!(detect_format(b"0:00:01.000,0:00:03.500,test").is_some());
237 assert!(detect_format(b"WEBVTT").is_none());
238 }
239}