1use std::path::PathBuf;
7use std::time::Duration;
8
9use ff_format::{ErrorSeverity, MediaError};
10use thiserror::Error;
11
12use crate::HardwareAccel;
13
14#[derive(Error, Debug)]
29pub enum DecodeError {
30 #[error("File not found: {path}")]
34 FileNotFound {
35 path: PathBuf,
37 },
38
39 #[error("No video stream found in: {path}")]
44 NoVideoStream {
45 path: PathBuf,
47 },
48
49 #[error("No audio stream found in: {path}")]
54 NoAudioStream {
55 path: PathBuf,
57 },
58
59 #[error("Codec not supported: {codec}")]
64 UnsupportedCodec {
65 codec: String,
67 },
68
69 #[error("Decoder unavailable: {codec} — {hint}")]
75 DecoderUnavailable {
76 codec: String,
78 hint: String,
80 },
81
82 #[error("Decoding failed at {timestamp:?}: {reason}")]
87 DecodingFailed {
88 timestamp: Option<Duration>,
90 reason: String,
92 },
93
94 #[error("Seek failed to {target:?}: {reason}")]
99 SeekFailed {
100 target: Duration,
102 reason: String,
104 },
105
106 #[error("Hardware acceleration unavailable: {accel:?}")]
112 HwAccelUnavailable {
113 accel: HardwareAccel,
115 },
116
117 #[error("Invalid output dimensions: {width}x{height} (must be > 0 and even)")]
124 InvalidOutputDimensions {
125 width: u32,
127 height: u32,
129 },
130
131 #[error("ffmpeg error: {message} (code={code})")]
136 Ffmpeg {
137 code: i32,
139 message: String,
141 },
142
143 #[error("IO error: {0}")]
148 Io(#[from] std::io::Error),
149
150 #[error("network timeout: endpoint={endpoint} — {message} (code={code})")]
156 NetworkTimeout {
157 code: i32,
159 endpoint: String,
161 message: String,
163 },
164
165 #[error("connection failed: endpoint={endpoint} — {message} (code={code})")]
172 ConnectionFailed {
173 code: i32,
175 endpoint: String,
177 message: String,
179 },
180
181 #[error("stream interrupted: endpoint={endpoint} — {message} (code={code})")]
187 StreamInterrupted {
188 code: i32,
190 endpoint: String,
192 message: String,
194 },
195
196 #[error("seek is not supported on live streams")]
201 SeekNotSupported,
202
203 #[error("unsupported resolution {width}x{height}: exceeds 32768 in one or both axes")]
205 UnsupportedResolution {
206 width: u32,
208 height: u32,
210 },
211
212 #[error(
214 "stream corrupted: {consecutive_invalid_packets} consecutive invalid packets without recovery"
215 )]
216 StreamCorrupted {
217 consecutive_invalid_packets: u32,
219 },
220
221 #[error("no frame found at timestamp: {timestamp:?}")]
226 NoFrameAtTimestamp {
227 timestamp: Duration,
229 },
230
231 #[error("extraction failed: {reason}")]
237 ExtractionFailed {
238 reason: String,
240 },
241}
242
243impl DecodeError {
244 #[must_use]
260 pub fn decoding_failed(reason: impl Into<String>) -> Self {
261 Self::DecodingFailed {
262 timestamp: None,
263 reason: reason.into(),
264 }
265 }
266
267 #[must_use]
288 pub fn decoding_failed_at(timestamp: Duration, reason: impl Into<String>) -> Self {
289 Self::DecodingFailed {
290 timestamp: Some(timestamp),
291 reason: reason.into(),
292 }
293 }
294
295 #[must_use]
316 pub fn seek_failed(target: Duration, reason: impl Into<String>) -> Self {
317 Self::SeekFailed {
318 target,
319 reason: reason.into(),
320 }
321 }
322
323 #[must_use]
330 pub fn decoder_unavailable(codec: impl Into<String>, hint: impl Into<String>) -> Self {
331 Self::DecoderUnavailable {
332 codec: codec.into(),
333 hint: hint.into(),
334 }
335 }
336
337 #[must_use]
355 pub fn ffmpeg(code: i32, message: impl Into<String>) -> Self {
356 Self::Ffmpeg {
357 code,
358 message: message.into(),
359 }
360 }
361}
362
363impl MediaError for DecodeError {
364 fn severity(&self) -> ErrorSeverity {
367 match self {
368 Self::DecodingFailed { .. }
369 | Self::SeekFailed { .. }
370 | Self::NetworkTimeout { .. }
371 | Self::StreamInterrupted { .. } => ErrorSeverity::Recoverable,
372 Self::FileNotFound { .. }
373 | Self::NoVideoStream { .. }
374 | Self::NoAudioStream { .. }
375 | Self::UnsupportedCodec { .. }
376 | Self::DecoderUnavailable { .. }
377 | Self::HwAccelUnavailable { .. }
378 | Self::InvalidOutputDimensions { .. }
379 | Self::ConnectionFailed { .. }
380 | Self::Io(_)
381 | Self::StreamCorrupted { .. }
382 | Self::ExtractionFailed { .. } => ErrorSeverity::Fatal,
383 Self::Ffmpeg { .. }
384 | Self::SeekNotSupported
385 | Self::UnsupportedResolution { .. }
386 | Self::NoFrameAtTimestamp { .. } => ErrorSeverity::Other,
387 }
388 }
389}
390
391#[cfg(test)]
392#[allow(clippy::panic)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_decode_error_display() {
398 let error = DecodeError::FileNotFound {
399 path: PathBuf::from("/path/to/video.mp4"),
400 };
401 assert!(error.to_string().contains("File not found"));
402 assert!(error.to_string().contains("/path/to/video.mp4"));
403
404 let error = DecodeError::NoVideoStream {
405 path: PathBuf::from("/path/to/audio.mp3"),
406 };
407 assert!(error.to_string().contains("No video stream"));
408
409 let error = DecodeError::UnsupportedCodec {
410 codec: "unknown_codec".to_string(),
411 };
412 assert!(error.to_string().contains("Codec not supported"));
413 assert!(error.to_string().contains("unknown_codec"));
414 }
415
416 #[test]
417 fn test_decoding_failed_constructor() {
418 let error = DecodeError::decoding_failed("Corrupted frame data");
419 match error {
420 DecodeError::DecodingFailed { timestamp, reason } => {
421 assert!(timestamp.is_none());
422 assert_eq!(reason, "Corrupted frame data");
423 }
424 _ => panic!("Wrong error type"),
425 }
426 }
427
428 #[test]
429 fn test_decoding_failed_at_constructor() {
430 let error = DecodeError::decoding_failed_at(Duration::from_secs(30), "Invalid packet size");
431 match error {
432 DecodeError::DecodingFailed { timestamp, reason } => {
433 assert_eq!(timestamp, Some(Duration::from_secs(30)));
434 assert_eq!(reason, "Invalid packet size");
435 }
436 _ => panic!("Wrong error type"),
437 }
438 }
439
440 #[test]
441 fn test_seek_failed_constructor() {
442 let error = DecodeError::seek_failed(Duration::from_secs(60), "Index not found");
443 match error {
444 DecodeError::SeekFailed { target, reason } => {
445 assert_eq!(target, Duration::from_secs(60));
446 assert_eq!(reason, "Index not found");
447 }
448 _ => panic!("Wrong error type"),
449 }
450 }
451
452 #[test]
453 fn test_ffmpeg_constructor() {
454 let error = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
455 match error {
456 DecodeError::Ffmpeg { code, message } => {
457 assert_eq!(code, -22);
458 assert_eq!(message, "AVERROR_INVALIDDATA");
459 }
460 _ => panic!("Wrong error type"),
461 }
462 }
463
464 #[test]
465 fn ffmpeg_should_format_with_code_and_message() {
466 let error = DecodeError::ffmpeg(-22, "Invalid data");
467 assert!(error.to_string().contains("code=-22"));
468 assert!(error.to_string().contains("Invalid data"));
469 }
470
471 #[test]
472 fn ffmpeg_with_zero_code_should_be_constructible() {
473 let error = DecodeError::ffmpeg(0, "allocation failed");
474 assert!(matches!(error, DecodeError::Ffmpeg { code: 0, .. }));
475 }
476
477 #[test]
478 fn decoder_unavailable_should_include_codec_and_hint() {
479 let e = DecodeError::decoder_unavailable(
480 "exr",
481 "Requires FFmpeg built with EXR support (--enable-decoder=exr)",
482 );
483 assert!(e.to_string().contains("exr"));
484 assert!(e.to_string().contains("Requires FFmpeg"));
485 }
486
487 #[test]
488 fn decoder_unavailable_should_be_fatal() {
489 let e = DecodeError::decoder_unavailable("exr", "hint");
490 assert!(e.is_fatal());
491 assert!(!e.is_recoverable());
492 }
493
494 #[test]
495 fn test_is_recoverable() {
496 assert!(DecodeError::decoding_failed("test").is_recoverable());
497 assert!(DecodeError::seek_failed(Duration::from_secs(1), "test").is_recoverable());
498 assert!(
499 !DecodeError::FileNotFound {
500 path: PathBuf::new()
501 }
502 .is_recoverable()
503 );
504 }
505
506 #[test]
507 fn test_is_fatal() {
508 assert!(
509 DecodeError::FileNotFound {
510 path: PathBuf::new()
511 }
512 .is_fatal()
513 );
514 assert!(
515 DecodeError::NoVideoStream {
516 path: PathBuf::new()
517 }
518 .is_fatal()
519 );
520 assert!(
521 DecodeError::NoAudioStream {
522 path: PathBuf::new()
523 }
524 .is_fatal()
525 );
526 assert!(
527 DecodeError::UnsupportedCodec {
528 codec: "test".to_string()
529 }
530 .is_fatal()
531 );
532 assert!(!DecodeError::decoding_failed("test").is_fatal());
533 }
534
535 #[test]
536 fn test_io_error_conversion() {
537 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
538 let decode_error: DecodeError = io_error.into();
539 assert!(matches!(decode_error, DecodeError::Io(_)));
540 }
541
542 #[test]
543 fn test_hw_accel_unavailable() {
544 let error = DecodeError::HwAccelUnavailable {
545 accel: HardwareAccel::Nvdec,
546 };
547 assert!(
548 error
549 .to_string()
550 .contains("Hardware acceleration unavailable")
551 );
552 assert!(error.to_string().contains("Nvdec"));
553 }
554
555 #[test]
558 fn file_not_found_should_be_fatal_and_not_recoverable() {
559 let e = DecodeError::FileNotFound {
560 path: PathBuf::new(),
561 };
562 assert!(e.is_fatal());
563 assert!(!e.is_recoverable());
564 }
565
566 #[test]
567 fn no_video_stream_should_be_fatal_and_not_recoverable() {
568 let e = DecodeError::NoVideoStream {
569 path: PathBuf::new(),
570 };
571 assert!(e.is_fatal());
572 assert!(!e.is_recoverable());
573 }
574
575 #[test]
576 fn no_audio_stream_should_be_fatal_and_not_recoverable() {
577 let e = DecodeError::NoAudioStream {
578 path: PathBuf::new(),
579 };
580 assert!(e.is_fatal());
581 assert!(!e.is_recoverable());
582 }
583
584 #[test]
585 fn unsupported_codec_should_be_fatal_and_not_recoverable() {
586 let e = DecodeError::UnsupportedCodec {
587 codec: "test".to_string(),
588 };
589 assert!(e.is_fatal());
590 assert!(!e.is_recoverable());
591 }
592
593 #[test]
594 fn decoder_unavailable_should_be_fatal_and_not_recoverable() {
595 let e = DecodeError::decoder_unavailable("exr", "hint");
596 assert!(e.is_fatal());
597 assert!(!e.is_recoverable());
598 }
599
600 #[test]
601 fn decoding_failed_should_be_recoverable_and_not_fatal() {
602 let e = DecodeError::decoding_failed("corrupt frame");
603 assert!(e.is_recoverable());
604 assert!(!e.is_fatal());
605 }
606
607 #[test]
608 fn seek_failed_should_be_recoverable_and_not_fatal() {
609 let e = DecodeError::seek_failed(Duration::from_secs(5), "index not found");
610 assert!(e.is_recoverable());
611 assert!(!e.is_fatal());
612 }
613
614 #[test]
615 fn hw_accel_unavailable_should_be_fatal_and_not_recoverable() {
616 let e = DecodeError::HwAccelUnavailable {
617 accel: HardwareAccel::Nvdec,
618 };
619 assert!(e.is_fatal());
620 assert!(!e.is_recoverable());
621 }
622
623 #[test]
624 fn invalid_output_dimensions_should_be_fatal_and_not_recoverable() {
625 let e = DecodeError::InvalidOutputDimensions {
626 width: 0,
627 height: 0,
628 };
629 assert!(e.is_fatal());
630 assert!(!e.is_recoverable());
631 }
632
633 #[test]
634 fn ffmpeg_error_should_be_neither_fatal_nor_recoverable() {
635 let e = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
636 assert!(!e.is_fatal());
637 assert!(!e.is_recoverable());
638 }
639
640 #[test]
641 fn io_error_should_be_fatal_and_not_recoverable() {
642 let e: DecodeError =
643 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied").into();
644 assert!(e.is_fatal());
645 assert!(!e.is_recoverable());
646 }
647
648 #[test]
649 fn network_timeout_should_be_recoverable_and_not_fatal() {
650 let e = DecodeError::NetworkTimeout {
651 code: -110,
652 endpoint: "rtmp://example.com/live".to_string(),
653 message: "timed out".to_string(),
654 };
655 assert!(e.is_recoverable());
656 assert!(!e.is_fatal());
657 }
658
659 #[test]
660 fn connection_failed_should_be_fatal_and_not_recoverable() {
661 let e = DecodeError::ConnectionFailed {
662 code: -111,
663 endpoint: "rtmp://example.com/live".to_string(),
664 message: "connection refused".to_string(),
665 };
666 assert!(e.is_fatal());
667 assert!(!e.is_recoverable());
668 }
669
670 #[test]
671 fn stream_interrupted_should_be_recoverable_and_not_fatal() {
672 let e = DecodeError::StreamInterrupted {
673 code: -5,
674 endpoint: "rtmp://example.com/live".to_string(),
675 message: "I/O error".to_string(),
676 };
677 assert!(e.is_recoverable());
678 assert!(!e.is_fatal());
679 }
680
681 #[test]
682 fn seek_not_supported_should_be_neither_fatal_nor_recoverable() {
683 let e = DecodeError::SeekNotSupported;
684 assert!(!e.is_fatal());
685 assert!(!e.is_recoverable());
686 }
687
688 #[test]
689 fn unsupported_resolution_display_should_contain_width_x_height() {
690 let e = DecodeError::UnsupportedResolution {
691 width: 40000,
692 height: 480,
693 };
694 let msg = e.to_string();
695 assert!(msg.contains("40000x480"), "expected '40000x480' in '{msg}'");
696 }
697
698 #[test]
699 fn unsupported_resolution_display_should_contain_axes_hint() {
700 let e = DecodeError::UnsupportedResolution {
701 width: 640,
702 height: 40000,
703 };
704 let msg = e.to_string();
705 assert!(msg.contains("32768"), "expected '32768' limit in '{msg}'");
706 }
707
708 #[test]
709 fn unsupported_resolution_should_be_neither_fatal_nor_recoverable() {
710 let e = DecodeError::UnsupportedResolution {
711 width: 40000,
712 height: 40000,
713 };
714 assert!(!e.is_fatal());
715 assert!(!e.is_recoverable());
716 }
717
718 #[test]
719 fn stream_corrupted_display_should_contain_packet_count() {
720 let e = DecodeError::StreamCorrupted {
721 consecutive_invalid_packets: 32,
722 };
723 let msg = e.to_string();
724 assert!(msg.contains("32"), "expected '32' in '{msg}'");
725 }
726
727 #[test]
728 fn stream_corrupted_display_should_mention_consecutive() {
729 let e = DecodeError::StreamCorrupted {
730 consecutive_invalid_packets: 32,
731 };
732 let msg = e.to_string();
733 assert!(
734 msg.contains("consecutive"),
735 "expected 'consecutive' in '{msg}'"
736 );
737 }
738
739 #[test]
740 fn stream_corrupted_should_be_fatal_and_not_recoverable() {
741 let e = DecodeError::StreamCorrupted {
742 consecutive_invalid_packets: 32,
743 };
744 assert!(e.is_fatal());
745 assert!(!e.is_recoverable());
746 }
747
748 #[test]
749 fn decode_error_no_frame_at_timestamp_should_display_correctly() {
750 let e = DecodeError::NoFrameAtTimestamp {
751 timestamp: Duration::from_secs(5),
752 };
753 let msg = e.to_string();
754 assert!(
755 msg.contains("no frame found at timestamp"),
756 "unexpected message: {msg}"
757 );
758 assert!(msg.contains("5s"), "expected timestamp in message: {msg}");
759 }
760
761 #[test]
762 fn decode_error_extraction_failed_should_display_correctly() {
763 let e = DecodeError::ExtractionFailed {
764 reason: "interval must be positive".to_string(),
765 };
766 let msg = e.to_string();
767 assert!(
768 msg.contains("extraction failed"),
769 "unexpected message: {msg}"
770 );
771 assert!(
772 msg.contains("interval must be positive"),
773 "expected reason in message: {msg}"
774 );
775 }
776
777 #[test]
778 fn no_frame_at_timestamp_should_be_neither_fatal_nor_recoverable() {
779 let e = DecodeError::NoFrameAtTimestamp {
780 timestamp: Duration::from_secs(10),
781 };
782 assert!(!e.is_fatal());
783 assert!(!e.is_recoverable());
784 }
785
786 #[test]
787 fn extraction_failed_should_be_fatal_and_not_recoverable() {
788 let e = DecodeError::ExtractionFailed {
789 reason: "no suitable frame".to_string(),
790 };
791 assert!(e.is_fatal());
792 assert!(!e.is_recoverable());
793 }
794}