Skip to main content

subtitler/
sbv.rs

1//! SBV (YouTube subtitle format) parser and generator.
2//!
3//! Format: `time1,time2,text`  where time is `[hours:]minutes:seconds.milliseconds`.
4
5use 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
12/// Parse SBV content into a SubtitleFile.
13pub 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    // Format: time1,time2,text  (three comma-separated parts)
23    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      // Convert SBV time format (hh:mm:ss.mmm or mm:ss.mmm) to SRT-like
27      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
50/// Parse SBV from a byte slice.
51pub fn parse_bytes(data: &[u8]) -> AnyResult<SubtitleFile> {
52  let text = crate::encoding::decode_to_string(data)?;
53  parse_content(&text)
54}
55
56/// Parse an SBV file asynchronously.
57#[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/// Parse an SBV file from a URL (requires `http` feature).
64#[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
71/// Detect if data looks like SBV (YouTube subtitle format).
72/// SBV lines have the pattern: `H:MM:SS.mmm,H:MM:SS.mmm,text`
73pub 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    // Must have at least 2 commas (time1,time2,text)
81    let parts: Vec<&str> = t.splitn(3, ',').collect();
82    if parts.len() < 3 {
83      return false;
84    }
85    // Both time fields must contain ':' and '.' (SBV time format)
86    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/// Write subtitles to a file in SBV format.
98///
99/// `policy` controls overwrite behavior (None = default Overwrite).
100#[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
112/// Serialize subtitles to SBV format.
113pub 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
132/// Streaming parser entry point — yields subtitles one at a time
133/// without allocating a full `Vec`.
134pub 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/// Write SBV subtitles to an async writer streamingly.
191#[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}