Skip to main content

omni_dev/transcript/
error.rs

1//! Error types for transcript operations.
2
3use thiserror::Error;
4
5/// Result type alias for transcript operations.
6pub type Result<T> = std::result::Result<T, TranscriptError>;
7
8/// Errors that can occur during transcript fetching, parsing, or formatting.
9#[derive(Error, Debug)]
10pub enum TranscriptError {
11    /// The supplied locator (URL, ID) could not be parsed by any source.
12    #[error("invalid transcript locator: {0}")]
13    InvalidLocator(String),
14
15    /// The media platform returned a response that did not parse as expected.
16    #[error("failed to parse response from media platform: {0}")]
17    ParseError(String),
18
19    /// No caption track matched the requested language.
20    #[error(
21        "no caption track for language `{requested}`; available: {}",
22        if available.is_empty() { "(none)".to_string() } else { available.join(", ") }
23    )]
24    LanguageNotFound {
25        /// The language code the caller asked for.
26        requested: String,
27        /// Language codes that *are* available on the media item.
28        available: Vec<String>,
29    },
30
31    /// An auto-generated (`asr`) track was the only match but the caller did
32    /// not opt in via `allow_auto`.
33    #[error("only auto-generated captions are available for `{0}`; pass --auto to accept them")]
34    AutoCaptionsRequireOptIn(String),
35
36    /// The media platform refused playback (e.g. age-gated, region-locked,
37    /// removed, or login-required). Carries the platform's status string so
38    /// callers can react to the specific reason rather than a generic HTTP
39    /// failure.
40    #[error("media platform refused playback: status={status}{}", reason.as_deref().map(|r| format!(" ({r})")).unwrap_or_default())]
41    PlayabilityRefused {
42        /// Platform-specific status code (e.g. YouTube `LOGIN_REQUIRED`,
43        /// `AGE_VERIFICATION_REQUIRED`, `UNPLAYABLE`).
44        status: String,
45        /// Optional human-readable reason from the platform.
46        reason: Option<String>,
47    },
48
49    /// An I/O error occurred (e.g. writing transcript to a file).
50    #[error("I/O error: {0}")]
51    Io(#[from] std::io::Error),
52
53    /// An HTTP transport or non-2xx response error.
54    #[error("HTTP error: {0}")]
55    Http(#[from] reqwest::Error),
56
57    /// The bootstrap GET to a media platform's watch page returned a body
58    /// without the expected session-bootstrap token. The page format drifts
59    /// every few months; this variant signals the scrape needs to be
60    /// retuned and is distinct from a generic [`Self::ParseError`] so
61    /// callers can react to it specifically (e.g. a clearer CLI message).
62    #[error("watch page at {url} did not contain the expected session token")]
63    MissingVisitorData {
64        /// The URL whose response body was scraped.
65        url: String,
66    },
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn invalid_locator_display() {
75        let err = TranscriptError::InvalidLocator("not a youtube url".to_string());
76        let msg = err.to_string();
77        assert!(msg.contains("invalid transcript locator"));
78        assert!(msg.contains("not a youtube url"));
79    }
80
81    #[test]
82    fn parse_error_display() {
83        let err = TranscriptError::ParseError("missing field `videoId`".to_string());
84        assert!(err.to_string().contains("missing field"));
85    }
86
87    #[test]
88    fn language_not_found_with_available() {
89        let err = TranscriptError::LanguageNotFound {
90            requested: "fr".to_string(),
91            available: vec!["en".to_string(), "es".to_string()],
92        };
93        let msg = err.to_string();
94        assert!(msg.contains("`fr`"));
95        assert!(msg.contains("en, es"));
96    }
97
98    #[test]
99    fn language_not_found_with_empty_available() {
100        let err = TranscriptError::LanguageNotFound {
101            requested: "en".to_string(),
102            available: vec![],
103        };
104        let msg = err.to_string();
105        assert!(msg.contains("`en`"));
106        assert!(msg.contains("(none)"));
107    }
108
109    #[test]
110    fn auto_captions_require_opt_in_display() {
111        let err = TranscriptError::AutoCaptionsRequireOptIn("en".to_string());
112        let msg = err.to_string();
113        assert!(msg.contains("auto-generated"));
114        assert!(msg.contains("--auto"));
115    }
116
117    #[test]
118    fn playability_refused_with_reason() {
119        let err = TranscriptError::PlayabilityRefused {
120            status: "LOGIN_REQUIRED".to_string(),
121            reason: Some("Sign in to confirm your age".to_string()),
122        };
123        let msg = err.to_string();
124        assert!(msg.contains("LOGIN_REQUIRED"));
125        assert!(msg.contains("Sign in to confirm your age"));
126    }
127
128    #[test]
129    fn playability_refused_without_reason() {
130        let err = TranscriptError::PlayabilityRefused {
131            status: "UNPLAYABLE".to_string(),
132            reason: None,
133        };
134        let msg = err.to_string();
135        assert!(msg.contains("UNPLAYABLE"));
136        assert!(!msg.contains("()"));
137    }
138
139    #[test]
140    fn io_error_from_conversion() {
141        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
142        let err: TranscriptError = io_err.into();
143        assert!(matches!(err, TranscriptError::Io(_)));
144        assert!(err.to_string().contains("I/O error"));
145    }
146
147    #[test]
148    fn debug_impl_present() {
149        let err = TranscriptError::InvalidLocator("x".to_string());
150        let dbg = format!("{err:?}");
151        assert!(dbg.contains("InvalidLocator"));
152    }
153
154    #[test]
155    fn missing_visitor_data_display() {
156        let err = TranscriptError::MissingVisitorData {
157            url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string(),
158        };
159        let msg = err.to_string();
160        assert!(msg.contains("watch page"));
161        assert!(msg.contains("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
162    }
163}