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    /// A channel locator (`@handle`, `/c/Name`, `/user/Name`, …) could not be
69    /// resolved to a `UC…` channel ID. Either the input is not a recognised
70    /// channel reference or the scraped channel page did not carry the
71    /// expected `channelId` token (the page format drifts, like
72    /// [`Self::MissingVisitorData`]).
73    #[error("could not resolve a YouTube channel ID from `{input}`")]
74    ChannelNotFound {
75        /// The channel locator the caller supplied.
76        input: String,
77    },
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn invalid_locator_display() {
86        let err = TranscriptError::InvalidLocator("not a youtube url".to_string());
87        let msg = err.to_string();
88        assert!(msg.contains("invalid transcript locator"));
89        assert!(msg.contains("not a youtube url"));
90    }
91
92    #[test]
93    fn parse_error_display() {
94        let err = TranscriptError::ParseError("missing field `videoId`".to_string());
95        assert!(err.to_string().contains("missing field"));
96    }
97
98    #[test]
99    fn language_not_found_with_available() {
100        let err = TranscriptError::LanguageNotFound {
101            requested: "fr".to_string(),
102            available: vec!["en".to_string(), "es".to_string()],
103        };
104        let msg = err.to_string();
105        assert!(msg.contains("`fr`"));
106        assert!(msg.contains("en, es"));
107    }
108
109    #[test]
110    fn language_not_found_with_empty_available() {
111        let err = TranscriptError::LanguageNotFound {
112            requested: "en".to_string(),
113            available: vec![],
114        };
115        let msg = err.to_string();
116        assert!(msg.contains("`en`"));
117        assert!(msg.contains("(none)"));
118    }
119
120    #[test]
121    fn auto_captions_require_opt_in_display() {
122        let err = TranscriptError::AutoCaptionsRequireOptIn("en".to_string());
123        let msg = err.to_string();
124        assert!(msg.contains("auto-generated"));
125        assert!(msg.contains("--auto"));
126    }
127
128    #[test]
129    fn playability_refused_with_reason() {
130        let err = TranscriptError::PlayabilityRefused {
131            status: "LOGIN_REQUIRED".to_string(),
132            reason: Some("Sign in to confirm your age".to_string()),
133        };
134        let msg = err.to_string();
135        assert!(msg.contains("LOGIN_REQUIRED"));
136        assert!(msg.contains("Sign in to confirm your age"));
137    }
138
139    #[test]
140    fn playability_refused_without_reason() {
141        let err = TranscriptError::PlayabilityRefused {
142            status: "UNPLAYABLE".to_string(),
143            reason: None,
144        };
145        let msg = err.to_string();
146        assert!(msg.contains("UNPLAYABLE"));
147        assert!(!msg.contains("()"));
148    }
149
150    #[test]
151    fn io_error_from_conversion() {
152        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
153        let err: TranscriptError = io_err.into();
154        assert!(matches!(err, TranscriptError::Io(_)));
155        assert!(err.to_string().contains("I/O error"));
156    }
157
158    #[test]
159    fn debug_impl_present() {
160        let err = TranscriptError::InvalidLocator("x".to_string());
161        let dbg = format!("{err:?}");
162        assert!(dbg.contains("InvalidLocator"));
163    }
164
165    #[test]
166    fn missing_visitor_data_display() {
167        let err = TranscriptError::MissingVisitorData {
168            url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ".to_string(),
169        };
170        let msg = err.to_string();
171        assert!(msg.contains("watch page"));
172        assert!(msg.contains("https://www.youtube.com/watch?v=dQw4w9WgXcQ"));
173    }
174}