1use std::{error::Error as StdError, io, io::ErrorKind, num::TryFromIntError};
2
3use kithara_bufpool::BudgetExhausted;
4use kithara_stream::{AudioCodec, ContainerFormat, PendingReason, VariantChangeError};
5use thiserror::Error;
6
7#[derive(Debug, Error)]
12#[non_exhaustive]
13pub enum DecodeError {
14 #[error("IO error: {0}")]
15 Io(io::Error),
16
17 #[error("Unsupported codec: {0:?}")]
18 UnsupportedCodec(AudioCodec),
19
20 #[error("Unsupported container: {0:?}")]
21 UnsupportedContainer(ContainerFormat),
22
23 #[error("Invalid data: {0}")]
24 InvalidData(String),
25
26 #[error("Seek failed: {0}")]
27 SeekFailed(String),
28
29 #[error("Seek target out of range: {0}")]
38 SeekOutOfRange(String),
39
40 #[error("Probe failed: could not detect codec")]
41 ProbeFailed,
42
43 #[error("Backend unavailable: {backend}")]
50 BackendUnavailable { backend: &'static str },
51
52 #[error("Interrupted by seek")]
55 Interrupted,
56
57 #[error("Decoder error: {0}")]
58 Backend(#[source] Box<dyn StdError + Send + Sync>),
59}
60
61fn is_seek_pending_io(err: &io::Error) -> bool {
62 err.kind() == ErrorKind::Interrupted
63 || err
64 .get_ref()
65 .and_then(|src| src.downcast_ref::<PendingReason>())
66 .is_some_and(|reason| matches!(reason, PendingReason::SeekPending))
67}
68
69fn is_variant_change_io(err: &io::Error) -> bool {
70 err.get_ref()
71 .and_then(|source| source.downcast_ref::<VariantChangeError>())
72 .is_some()
73}
74
75fn walk_error_chain<I, L>(err: &(dyn StdError + 'static), check_io: &I, check_leaf: &L) -> bool
76where
77 I: Fn(&io::Error) -> bool,
78 L: Fn(&(dyn StdError + 'static)) -> bool,
79{
80 let io_hit = err.downcast_ref::<io::Error>().map(check_io);
81 #[cfg(feature = "symphonia")]
82 let symphonia_hit = crate::symphonia::echain::inspect(err, check_io, check_leaf);
83 #[cfg(not(feature = "symphonia"))]
84 let symphonia_hit: Option<bool> = None;
85 let leaf_hit = check_leaf(err);
86 match (io_hit, symphonia_hit, leaf_hit) {
87 (Some(hit), _, _) | (None, Some(hit), _) => hit,
88 (None, None, true) => true,
89 (None, None, false) => err
90 .source()
91 .is_some_and(|source| walk_error_chain(source, check_io, check_leaf)),
92 }
93}
94
95fn error_chain_is_interrupted(err: &(dyn StdError + 'static)) -> bool {
96 walk_error_chain(err, &is_seek_pending_io, &|leaf| {
97 leaf.downcast_ref::<PendingReason>()
98 .is_some_and(|reason| matches!(reason, PendingReason::SeekPending))
99 })
100}
101
102fn error_chain_is_variant_change(err: &(dyn StdError + 'static)) -> bool {
103 walk_error_chain(err, &is_variant_change_io, &|leaf| {
104 leaf.downcast_ref::<VariantChangeError>().is_some()
105 })
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115#[non_exhaustive]
116pub enum ErrorClass {
117 Interrupted,
119 VariantChange,
121 Other,
123}
124
125impl DecodeError {
126 #[must_use]
130 pub fn backend<E>(err: E) -> Self
131 where
132 E: Into<Box<dyn StdError + Send + Sync>>,
133 {
134 Self::Backend(err.into())
135 }
136
137 #[must_use]
141 pub fn backend_msg<S>(msg: S) -> Self
142 where
143 S: Into<String>,
144 {
145 Self::Backend(Box::new(io::Error::other(msg.into())))
146 }
147
148 #[must_use]
152 #[inline]
153 pub fn classify(&self) -> ErrorClass {
155 match self {
156 Self::Interrupted => ErrorClass::Interrupted,
157 Self::Io(err) => {
158 if is_variant_change_io(err) {
159 ErrorClass::VariantChange
160 } else if is_seek_pending_io(err) {
161 ErrorClass::Interrupted
162 } else {
163 ErrorClass::Other
164 }
165 }
166 Self::Backend(err) => {
167 let leaf = err.as_ref();
168 if error_chain_is_variant_change(leaf) {
169 ErrorClass::VariantChange
170 } else if error_chain_is_interrupted(leaf) {
171 ErrorClass::Interrupted
172 } else {
173 ErrorClass::Other
174 }
175 }
176 _ => ErrorClass::Other,
177 }
178 }
179
180 #[must_use]
182 pub fn is_interrupted(&self) -> bool {
183 matches!(self.classify(), ErrorClass::Interrupted)
184 }
185}
186
187impl From<io::Error> for DecodeError {
188 fn from(err: io::Error) -> Self {
189 if err.kind() == ErrorKind::Interrupted {
190 Self::Interrupted
191 } else {
192 Self::Io(err)
193 }
194 }
195}
196
197impl From<TryFromIntError> for DecodeError {
198 fn from(err: TryFromIntError) -> Self {
199 Self::backend(err)
200 }
201}
202
203impl From<BudgetExhausted> for DecodeError {
204 fn from(err: BudgetExhausted) -> Self {
205 Self::backend(err)
206 }
207}
208
209pub type DecodeResult<T> = Result<T, DecodeError>;
211
212#[cfg(test)]
213mod tests {
214 use std::io::{Error as IoError, ErrorKind};
215
216 use kithara_test_utils::kithara;
217
218 use super::*;
219
220 #[kithara::test]
221 #[case::invalid_data(DecodeError::InvalidData("bad frame".into()), "Invalid data: bad frame")]
222 #[case::seek_failed(DecodeError::SeekFailed("timestamp out of range".into()), "Seek failed: timestamp out of range")]
223 #[case::probe_failed(DecodeError::ProbeFailed, "Probe failed: could not detect codec")]
224 #[case::unsupported_codec(
225 DecodeError::UnsupportedCodec(AudioCodec::AacLc),
226 "Unsupported codec: AacLc"
227 )]
228 #[case::unsupported_container(
229 DecodeError::UnsupportedContainer(ContainerFormat::Fmp4),
230 "Unsupported container: Fmp4"
231 )]
232 fn test_error_display(#[case] error: DecodeError, #[case] expected: &str) {
233 assert_eq!(error.to_string(), expected);
234 }
235
236 #[derive(Debug, Clone, Copy)]
237 enum ExpectedKind {
238 Io,
239 Interrupted,
240 }
241
242 #[kithara::test]
243 #[case::not_found_becomes_io(ErrorKind::NotFound, "file not found", ExpectedKind::Io)]
244 #[case::interrupted_becomes_interrupted(
245 ErrorKind::Interrupted,
246 "seek pending",
247 ExpectedKind::Interrupted
248 )]
249 fn test_decode_error_from_io(
250 #[case] kind: ErrorKind,
251 #[case] msg: &str,
252 #[case] expected: ExpectedKind,
253 ) {
254 let io_err = IoError::new(kind, msg);
255 let decode_err: DecodeError = io_err.into();
256 match expected {
257 ExpectedKind::Io => assert!(matches!(decode_err, DecodeError::Io(_))),
258 ExpectedKind::Interrupted => assert!(matches!(decode_err, DecodeError::Interrupted)),
259 }
260 }
261
262 #[kithara::test]
263 fn test_decode_error_backend_wraps_any_error() {
264 let err = DecodeError::backend(IoError::other("symphonia error"));
265 assert!(err.to_string().contains("Decoder error"));
266 }
267
268 #[kithara::test]
269 fn test_decode_error_backend_msg_wraps_a_display() {
270 let err = DecodeError::backend_msg(format!("oss status {}", 42));
271 assert!(err.to_string().contains("oss status 42"));
272 }
273
274 #[kithara::test]
275 fn test_decode_error_is_send_sync() {
276 fn assert_send_sync<T: Send + Sync>() {}
277 assert_send_sync::<DecodeError>();
278 }
279
280 #[kithara::test]
281 #[case::seek_pending_counts_as_interrupted(
282 DecodeError::backend(IoError::other(PendingReason::SeekPending)),
283 true
284 )]
285 #[case::other_io_is_not_interrupted(
286 DecodeError::backend(IoError::other("other backend error")),
287 false
288 )]
289 fn test_backend_is_interrupted(#[case] decode_err: DecodeError, #[case] expected: bool) {
290 assert_eq!(decode_err.is_interrupted(), expected);
291 }
292
293 #[kithara::test]
294 fn test_io_variant_change_is_detected() {
295 let decode_err = DecodeError::Io(IoError::other(VariantChangeError));
296 assert_eq!(decode_err.classify(), ErrorClass::VariantChange);
297 assert!(!decode_err.is_interrupted());
298 }
299}