use std::{error::Error as StdError, io, io::ErrorKind, num::TryFromIntError};
use kithara_bufpool::BudgetExhausted;
use kithara_stream::{AudioCodec, ContainerFormat, PendingReason, VariantChangeError};
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum DecodeError {
#[error("IO error: {0}")]
Io(io::Error),
#[error("Unsupported codec: {0:?}")]
UnsupportedCodec(AudioCodec),
#[error("Unsupported container: {0:?}")]
UnsupportedContainer(ContainerFormat),
#[error("Invalid data: {0}")]
InvalidData(String),
#[error("Seek failed: {0}")]
SeekFailed(String),
#[error("Seek target out of range: {0}")]
SeekOutOfRange(String),
#[error("Probe failed: could not detect codec")]
ProbeFailed,
#[error("Backend unavailable: {backend}")]
BackendUnavailable { backend: &'static str },
#[error("Interrupted by seek")]
Interrupted,
#[error("Decoder error: {0}")]
Backend(#[source] Box<dyn StdError + Send + Sync>),
}
fn is_seek_pending_io(err: &io::Error) -> bool {
err.kind() == ErrorKind::Interrupted
|| err
.get_ref()
.and_then(|src| src.downcast_ref::<PendingReason>())
.is_some_and(|reason| matches!(reason, PendingReason::SeekPending))
}
fn is_variant_change_io(err: &io::Error) -> bool {
err.get_ref()
.and_then(|source| source.downcast_ref::<VariantChangeError>())
.is_some()
}
fn walk_error_chain<I, L>(err: &(dyn StdError + 'static), check_io: &I, check_leaf: &L) -> bool
where
I: Fn(&io::Error) -> bool,
L: Fn(&(dyn StdError + 'static)) -> bool,
{
let io_hit = err.downcast_ref::<io::Error>().map(check_io);
#[cfg(feature = "symphonia")]
let symphonia_hit = crate::symphonia::echain::inspect(err, check_io, check_leaf);
#[cfg(not(feature = "symphonia"))]
let symphonia_hit: Option<bool> = None;
let leaf_hit = check_leaf(err);
match (io_hit, symphonia_hit, leaf_hit) {
(Some(hit), _, _) | (None, Some(hit), _) => hit,
(None, None, true) => true,
(None, None, false) => err
.source()
.is_some_and(|source| walk_error_chain(source, check_io, check_leaf)),
}
}
fn error_chain_is_interrupted(err: &(dyn StdError + 'static)) -> bool {
walk_error_chain(err, &is_seek_pending_io, &|leaf| {
leaf.downcast_ref::<PendingReason>()
.is_some_and(|reason| matches!(reason, PendingReason::SeekPending))
})
}
fn error_chain_is_variant_change(err: &(dyn StdError + 'static)) -> bool {
walk_error_chain(err, &is_variant_change_io, &|leaf| {
leaf.downcast_ref::<VariantChangeError>().is_some()
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ErrorClass {
Interrupted,
VariantChange,
Other,
}
impl DecodeError {
#[must_use]
pub fn backend<E>(err: E) -> Self
where
E: Into<Box<dyn StdError + Send + Sync>>,
{
Self::Backend(err.into())
}
#[must_use]
pub fn backend_msg<S>(msg: S) -> Self
where
S: Into<String>,
{
Self::Backend(Box::new(io::Error::other(msg.into())))
}
#[must_use]
#[inline]
pub fn classify(&self) -> ErrorClass {
match self {
Self::Interrupted => ErrorClass::Interrupted,
Self::Io(err) => {
if is_variant_change_io(err) {
ErrorClass::VariantChange
} else if is_seek_pending_io(err) {
ErrorClass::Interrupted
} else {
ErrorClass::Other
}
}
Self::Backend(err) => {
let leaf = err.as_ref();
if error_chain_is_variant_change(leaf) {
ErrorClass::VariantChange
} else if error_chain_is_interrupted(leaf) {
ErrorClass::Interrupted
} else {
ErrorClass::Other
}
}
_ => ErrorClass::Other,
}
}
#[must_use]
pub fn is_interrupted(&self) -> bool {
matches!(self.classify(), ErrorClass::Interrupted)
}
}
impl From<io::Error> for DecodeError {
fn from(err: io::Error) -> Self {
if err.kind() == ErrorKind::Interrupted {
Self::Interrupted
} else {
Self::Io(err)
}
}
}
impl From<TryFromIntError> for DecodeError {
fn from(err: TryFromIntError) -> Self {
Self::backend(err)
}
}
impl From<BudgetExhausted> for DecodeError {
fn from(err: BudgetExhausted) -> Self {
Self::backend(err)
}
}
pub type DecodeResult<T> = Result<T, DecodeError>;
#[cfg(test)]
mod tests {
use std::io::{Error as IoError, ErrorKind};
use kithara_test_utils::kithara;
use super::*;
#[kithara::test]
#[case::invalid_data(DecodeError::InvalidData("bad frame".into()), "Invalid data: bad frame")]
#[case::seek_failed(DecodeError::SeekFailed("timestamp out of range".into()), "Seek failed: timestamp out of range")]
#[case::probe_failed(DecodeError::ProbeFailed, "Probe failed: could not detect codec")]
#[case::unsupported_codec(
DecodeError::UnsupportedCodec(AudioCodec::AacLc),
"Unsupported codec: AacLc"
)]
#[case::unsupported_container(
DecodeError::UnsupportedContainer(ContainerFormat::Fmp4),
"Unsupported container: Fmp4"
)]
fn test_error_display(#[case] error: DecodeError, #[case] expected: &str) {
assert_eq!(error.to_string(), expected);
}
#[derive(Debug, Clone, Copy)]
enum ExpectedKind {
Io,
Interrupted,
}
#[kithara::test]
#[case::not_found_becomes_io(ErrorKind::NotFound, "file not found", ExpectedKind::Io)]
#[case::interrupted_becomes_interrupted(
ErrorKind::Interrupted,
"seek pending",
ExpectedKind::Interrupted
)]
fn test_decode_error_from_io(
#[case] kind: ErrorKind,
#[case] msg: &str,
#[case] expected: ExpectedKind,
) {
let io_err = IoError::new(kind, msg);
let decode_err: DecodeError = io_err.into();
match expected {
ExpectedKind::Io => assert!(matches!(decode_err, DecodeError::Io(_))),
ExpectedKind::Interrupted => assert!(matches!(decode_err, DecodeError::Interrupted)),
}
}
#[kithara::test]
fn test_decode_error_backend_wraps_any_error() {
let err = DecodeError::backend(IoError::other("symphonia error"));
assert!(err.to_string().contains("Decoder error"));
}
#[kithara::test]
fn test_decode_error_backend_msg_wraps_a_display() {
let err = DecodeError::backend_msg(format!("oss status {}", 42));
assert!(err.to_string().contains("oss status 42"));
}
#[kithara::test]
fn test_decode_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<DecodeError>();
}
#[kithara::test]
#[case::seek_pending_counts_as_interrupted(
DecodeError::backend(IoError::other(PendingReason::SeekPending)),
true
)]
#[case::other_io_is_not_interrupted(
DecodeError::backend(IoError::other("other backend error")),
false
)]
fn test_backend_is_interrupted(#[case] decode_err: DecodeError, #[case] expected: bool) {
assert_eq!(decode_err.is_interrupted(), expected);
}
#[kithara::test]
fn test_io_variant_change_is_detected() {
let decode_err = DecodeError::Io(IoError::other(VariantChangeError));
assert_eq!(decode_err.classify(), ErrorClass::VariantChange);
assert!(!decode_err.is_interrupted());
}
}