omni-dev 0.31.0

AI-powered git commit rewriter, PR generator, and MCP server for Jira, Confluence, and Datadog.
Documentation
//! The [`TranscriptSource`] trait and its supporting value types.
//!
//! A *source* is a media platform (YouTube, Vimeo, podcast feed, generic
//! captions URL, …) capable of resolving a locator to a transcript. Concrete
//! implementations live under [`crate::transcript::sources`].

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::transcript::cue::Cue;
use crate::transcript::error::Result;

/// Caller-supplied options that influence track selection during
/// [`TranscriptSource::fetch`].
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FetchOpts {
    /// Preferred caption language code (e.g. `"en"`, `"en-US"`). Sources
    /// should apply prefix fallback (`"en"` matches `"en-US"`).
    pub language: String,
    /// If `true`, allow falling through to auto-generated (ASR) tracks when
    /// no manual track matches. If `false`, exhausting manual tracks should
    /// surface [`crate::transcript::TranscriptError::AutoCaptionsRequireOptIn`].
    pub allow_auto: bool,
    /// If set and no native track matches `language`, request a translated
    /// track in this target language. Sources that cannot translate should
    /// ignore this field.
    pub translate_to: Option<String>,
}

impl FetchOpts {
    /// Construct options requesting `language` with no auto-captions and no
    /// translation. Callers building richer requests should mutate the
    /// returned struct.
    pub fn new(language: impl Into<String>) -> Self {
        Self {
            language: language.into(),
            allow_auto: false,
            translate_to: None,
        }
    }
}

/// Whether a track was authored by humans, generated by ASR, or synthesised
/// by the platform's machine translation.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrackKind {
    /// Human-authored captions.
    Manual,
    /// Auto-generated (ASR) captions.
    Auto,
    /// Machine-translated from another track.
    Translated,
}

/// A fetched transcript: the cues plus the metadata needed to interpret them.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Transcript {
    /// Name of the source that produced this transcript (e.g. `"youtube"`).
    pub source: String,
    /// Source-specific identifier for the media item (e.g. YouTube video ID).
    pub locator_id: String,
    /// Language code of the returned cues.
    pub language: String,
    /// Whether the track is manual, auto-generated, or translated.
    pub kind: TrackKind,
    /// The timed cues, in chronological order.
    pub cues: Vec<Cue>,
}

/// Metadata describing a single available caption track on a media item.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LanguageInfo {
    /// Language code (e.g. `"en"`, `"en-US"`).
    pub code: String,
    /// Human-readable language name (e.g. `"English"`).
    pub name: String,
    /// Whether the track is manual, auto-generated, or translated.
    pub kind: TrackKind,
}

/// Top-level metadata about a media item.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct MediaInfo {
    /// Source name that produced this metadata.
    pub source: String,
    /// Source-specific identifier.
    pub locator_id: String,
    /// Title of the media item.
    pub title: String,
    /// Author / channel / uploader.
    pub author: Option<String>,
    /// Duration of the media item in milliseconds, if known.
    pub duration_ms: Option<u64>,
    /// All caption tracks available on this media item.
    pub languages: Vec<LanguageInfo>,
}

/// A source capable of fetching transcripts from a media platform.
///
/// Implementations are expected to be cheap to construct; per-request state
/// (HTTP client, auth, etc.) lives inside the implementor.
///
/// The trait is `Send + Sync` so implementations can be used behind
/// `Box<dyn TranscriptSource>` for runtime dispatch (e.g. once
/// `omni-dev transcript fetch <url>` auto-detection lands in a follow-up).
#[async_trait]
pub trait TranscriptSource: Send + Sync {
    /// Stable, lowercase identifier for the source (e.g. `"youtube"`). Used
    /// in error messages, the [`Transcript::source`] field, and as the
    /// CLI subcommand name.
    fn name(&self) -> &'static str;

    /// Whether this source recognises `url` as one of its locators. Used by
    /// future auto-detection (`omni-dev transcript fetch <url>`).
    ///
    /// `where Self: Sized` keeps this method out of the dyn vtable so
    /// `dyn TranscriptSource` remains object-safe.
    fn matches(url: &str) -> bool
    where
        Self: Sized;

    /// Resolve `locator` to a transcript matching `opts`.
    async fn fetch(&self, locator: &str, opts: &FetchOpts) -> Result<Transcript>;

    /// List the caption tracks available on `locator`.
    async fn list_languages(&self, locator: &str) -> Result<Vec<LanguageInfo>>;

    /// Fetch top-level metadata about `locator`.
    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);
    }

    /// Mock source — exercises the trait shape and proves it is object-safe.
    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");
    }
}