use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::transcript::cue::Cue;
use crate::transcript::error::Result;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchOpts {
pub language: String,
pub allow_auto: bool,
pub translate_to: Option<String>,
}
impl FetchOpts {
pub fn new(language: impl Into<String>) -> Self {
Self {
language: language.into(),
allow_auto: false,
translate_to: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrackKind {
Manual,
Auto,
Translated,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transcript {
pub source: String,
pub locator_id: String,
pub language: String,
pub kind: TrackKind,
pub cues: Vec<Cue>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LanguageInfo {
pub code: String,
pub name: String,
pub kind: TrackKind,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MediaInfo {
pub source: String,
pub locator_id: String,
pub title: String,
pub author: Option<String>,
pub duration_ms: Option<u64>,
pub languages: Vec<LanguageInfo>,
}
#[async_trait]
pub trait TranscriptSource: Send + Sync {
fn name(&self) -> &'static str;
fn matches(url: &str) -> bool
where
Self: Sized;
async fn fetch(&self, locator: &str, opts: &FetchOpts) -> Result<Transcript>;
async fn list_languages(&self, locator: &str) -> Result<Vec<LanguageInfo>>;
async fn info(&self, locator: &str) -> Result<MediaInfo>;
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::transcript::error::TranscriptError;
#[test]
fn fetch_opts_new_defaults() {
let opts = FetchOpts::new("en");
assert_eq!(opts.language, "en");
assert!(!opts.allow_auto);
assert_eq!(opts.translate_to, None);
}
#[test]
fn fetch_opts_clone_eq() {
let a = FetchOpts {
language: "fr".into(),
allow_auto: true,
translate_to: Some("en".into()),
};
let b = a.clone();
assert_eq!(a, b);
}
#[test]
fn track_kind_serde_lowercase() {
let json = serde_json::to_string(&TrackKind::Manual).unwrap();
assert_eq!(json, "\"manual\"");
let json = serde_json::to_string(&TrackKind::Auto).unwrap();
assert_eq!(json, "\"auto\"");
let json = serde_json::to_string(&TrackKind::Translated).unwrap();
assert_eq!(json, "\"translated\"");
let back: TrackKind = serde_json::from_str("\"auto\"").unwrap();
assert_eq!(back, TrackKind::Auto);
}
#[test]
fn transcript_serde_round_trip() {
let t = Transcript {
source: "youtube".into(),
locator_id: "dQw4w9WgXcQ".into(),
language: "en".into(),
kind: TrackKind::Manual,
cues: vec![Cue::new(0, 1000, "hi")],
};
let json = serde_json::to_string(&t).unwrap();
let back: Transcript = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
}
#[test]
fn language_info_serde_round_trip() {
let li = LanguageInfo {
code: "en-US".into(),
name: "English (United States)".into(),
kind: TrackKind::Auto,
};
let json = serde_json::to_string(&li).unwrap();
let back: LanguageInfo = serde_json::from_str(&json).unwrap();
assert_eq!(li, back);
}
#[test]
fn media_info_serde_with_optional_fields() {
let mi = MediaInfo {
source: "youtube".into(),
locator_id: "abc".into(),
title: "T".into(),
author: None,
duration_ms: None,
languages: vec![],
};
let json = serde_json::to_string(&mi).unwrap();
let back: MediaInfo = serde_json::from_str(&json).unwrap();
assert_eq!(mi, back);
}
struct MockSource;
#[async_trait]
impl TranscriptSource for MockSource {
fn name(&self) -> &'static str {
"mock"
}
fn matches(url: &str) -> bool {
url.starts_with("mock://")
}
async fn fetch(&self, locator: &str, opts: &FetchOpts) -> Result<Transcript> {
if locator.is_empty() {
return Err(TranscriptError::InvalidLocator("empty".into()));
}
Ok(Transcript {
source: self.name().into(),
locator_id: locator.into(),
language: opts.language.clone(),
kind: TrackKind::Manual,
cues: vec![Cue::new(0, 1000, "hello")],
})
}
async fn list_languages(&self, _locator: &str) -> Result<Vec<LanguageInfo>> {
Ok(vec![LanguageInfo {
code: "en".into(),
name: "English".into(),
kind: TrackKind::Manual,
}])
}
async fn info(&self, locator: &str) -> Result<MediaInfo> {
Ok(MediaInfo {
source: self.name().into(),
locator_id: locator.into(),
title: "Mock".into(),
author: Some("Tester".into()),
duration_ms: Some(5000),
languages: vec![],
})
}
}
#[test]
fn matches_static_dispatch() {
assert!(MockSource::matches("mock://foo"));
assert!(!MockSource::matches("https://youtube.com/watch?v=x"));
}
#[tokio::test]
async fn mock_source_fetch_succeeds() {
let src = MockSource;
let opts = FetchOpts::new("en");
let t = src.fetch("vid", &opts).await.unwrap();
assert_eq!(t.source, "mock");
assert_eq!(t.locator_id, "vid");
assert_eq!(t.language, "en");
assert_eq!(t.cues.len(), 1);
}
#[tokio::test]
async fn mock_source_fetch_propagates_error() {
let src = MockSource;
let opts = FetchOpts::new("en");
let err = src.fetch("", &opts).await.unwrap_err();
assert!(matches!(err, TranscriptError::InvalidLocator(_)));
}
#[tokio::test]
async fn mock_source_via_dyn_box() {
let src: Box<dyn TranscriptSource> = Box::new(MockSource);
assert_eq!(src.name(), "mock");
let langs = src.list_languages("anything").await.unwrap();
assert_eq!(langs.len(), 1);
let info = src.info("anything").await.unwrap();
assert_eq!(info.title, "Mock");
}
}