1use std::path::PathBuf;
7use std::time::Duration;
8
9use thiserror::Error;
10
11use crate::HardwareAccel;
12
13#[derive(Error, Debug)]
28pub enum DecodeError {
29 #[error("File not found: {path}")]
33 FileNotFound {
34 path: PathBuf,
36 },
37
38 #[error("No video stream found in: {path}")]
43 NoVideoStream {
44 path: PathBuf,
46 },
47
48 #[error("No audio stream found in: {path}")]
53 NoAudioStream {
54 path: PathBuf,
56 },
57
58 #[error("Codec not supported: {codec}")]
63 UnsupportedCodec {
64 codec: String,
66 },
67
68 #[error("Decoder unavailable: {codec} — {hint}")]
74 DecoderUnavailable {
75 codec: String,
77 hint: String,
79 },
80
81 #[error("Decoding failed at {timestamp:?}: {reason}")]
86 DecodingFailed {
87 timestamp: Option<Duration>,
89 reason: String,
91 },
92
93 #[error("Seek failed to {target:?}: {reason}")]
98 SeekFailed {
99 target: Duration,
101 reason: String,
103 },
104
105 #[error("Hardware acceleration unavailable: {accel:?}")]
111 HwAccelUnavailable {
112 accel: HardwareAccel,
114 },
115
116 #[error("Invalid output dimensions: {width}x{height} (must be > 0 and even)")]
123 InvalidOutputDimensions {
124 width: u32,
126 height: u32,
128 },
129
130 #[error("ffmpeg error: {message} (code={code})")]
135 Ffmpeg {
136 code: i32,
138 message: String,
140 },
141
142 #[error("IO error: {0}")]
147 Io(#[from] std::io::Error),
148
149 #[error("network timeout: endpoint={endpoint} — {message} (code={code})")]
155 NetworkTimeout {
156 code: i32,
158 endpoint: String,
160 message: String,
162 },
163
164 #[error("connection failed: endpoint={endpoint} — {message} (code={code})")]
171 ConnectionFailed {
172 code: i32,
174 endpoint: String,
176 message: String,
178 },
179
180 #[error("stream interrupted: endpoint={endpoint} — {message} (code={code})")]
186 StreamInterrupted {
187 code: i32,
189 endpoint: String,
191 message: String,
193 },
194
195 #[error("seek is not supported on live streams")]
200 SeekNotSupported,
201
202 #[error("unsupported resolution {width}x{height}: exceeds 32768 in one or both axes")]
204 UnsupportedResolution {
205 width: u32,
207 height: u32,
209 },
210
211 #[error(
213 "stream corrupted: {consecutive_invalid_packets} consecutive invalid packets without recovery"
214 )]
215 StreamCorrupted {
216 consecutive_invalid_packets: u32,
218 },
219
220 #[error("no frame found at timestamp: {timestamp:?}")]
225 NoFrameAtTimestamp {
226 timestamp: Duration,
228 },
229
230 #[error("analysis failed: {reason}")]
235 AnalysisFailed {
236 reason: String,
238 },
239}
240
241impl DecodeError {
242 #[must_use]
258 pub fn decoding_failed(reason: impl Into<String>) -> Self {
259 Self::DecodingFailed {
260 timestamp: None,
261 reason: reason.into(),
262 }
263 }
264
265 #[must_use]
286 pub fn decoding_failed_at(timestamp: Duration, reason: impl Into<String>) -> Self {
287 Self::DecodingFailed {
288 timestamp: Some(timestamp),
289 reason: reason.into(),
290 }
291 }
292
293 #[must_use]
314 pub fn seek_failed(target: Duration, reason: impl Into<String>) -> Self {
315 Self::SeekFailed {
316 target,
317 reason: reason.into(),
318 }
319 }
320
321 #[must_use]
328 pub fn decoder_unavailable(codec: impl Into<String>, hint: impl Into<String>) -> Self {
329 Self::DecoderUnavailable {
330 codec: codec.into(),
331 hint: hint.into(),
332 }
333 }
334
335 #[must_use]
353 pub fn ffmpeg(code: i32, message: impl Into<String>) -> Self {
354 Self::Ffmpeg {
355 code,
356 message: message.into(),
357 }
358 }
359
360 #[must_use]
388 pub fn is_recoverable(&self) -> bool {
389 match self {
390 Self::DecodingFailed { .. }
391 | Self::SeekFailed { .. }
392 | Self::NetworkTimeout { .. }
393 | Self::StreamInterrupted { .. } => true,
394 Self::FileNotFound { .. }
395 | Self::NoVideoStream { .. }
396 | Self::NoAudioStream { .. }
397 | Self::UnsupportedCodec { .. }
398 | Self::DecoderUnavailable { .. }
399 | Self::HwAccelUnavailable { .. }
400 | Self::InvalidOutputDimensions { .. }
401 | Self::ConnectionFailed { .. }
402 | Self::Io(_)
403 | Self::Ffmpeg { .. }
404 | Self::SeekNotSupported
405 | Self::UnsupportedResolution { .. }
406 | Self::StreamCorrupted { .. }
407 | Self::NoFrameAtTimestamp { .. }
408 | Self::AnalysisFailed { .. } => false,
409 }
410 }
411
412 #[must_use]
444 pub fn is_fatal(&self) -> bool {
445 match self {
446 Self::FileNotFound { .. }
447 | Self::NoVideoStream { .. }
448 | Self::NoAudioStream { .. }
449 | Self::UnsupportedCodec { .. }
450 | Self::DecoderUnavailable { .. }
451 | Self::HwAccelUnavailable { .. }
452 | Self::InvalidOutputDimensions { .. }
453 | Self::ConnectionFailed { .. }
454 | Self::Io(_)
455 | Self::StreamCorrupted { .. }
456 | Self::AnalysisFailed { .. } => true,
457 Self::DecodingFailed { .. }
458 | Self::SeekFailed { .. }
459 | Self::NetworkTimeout { .. }
460 | Self::StreamInterrupted { .. }
461 | Self::Ffmpeg { .. }
462 | Self::SeekNotSupported
463 | Self::UnsupportedResolution { .. }
464 | Self::NoFrameAtTimestamp { .. } => false,
465 }
466 }
467}
468
469#[cfg(test)]
470#[allow(clippy::panic)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn test_decode_error_display() {
476 let error = DecodeError::FileNotFound {
477 path: PathBuf::from("/path/to/video.mp4"),
478 };
479 assert!(error.to_string().contains("File not found"));
480 assert!(error.to_string().contains("/path/to/video.mp4"));
481
482 let error = DecodeError::NoVideoStream {
483 path: PathBuf::from("/path/to/audio.mp3"),
484 };
485 assert!(error.to_string().contains("No video stream"));
486
487 let error = DecodeError::UnsupportedCodec {
488 codec: "unknown_codec".to_string(),
489 };
490 assert!(error.to_string().contains("Codec not supported"));
491 assert!(error.to_string().contains("unknown_codec"));
492 }
493
494 #[test]
495 fn test_decoding_failed_constructor() {
496 let error = DecodeError::decoding_failed("Corrupted frame data");
497 match error {
498 DecodeError::DecodingFailed { timestamp, reason } => {
499 assert!(timestamp.is_none());
500 assert_eq!(reason, "Corrupted frame data");
501 }
502 _ => panic!("Wrong error type"),
503 }
504 }
505
506 #[test]
507 fn test_decoding_failed_at_constructor() {
508 let error = DecodeError::decoding_failed_at(Duration::from_secs(30), "Invalid packet size");
509 match error {
510 DecodeError::DecodingFailed { timestamp, reason } => {
511 assert_eq!(timestamp, Some(Duration::from_secs(30)));
512 assert_eq!(reason, "Invalid packet size");
513 }
514 _ => panic!("Wrong error type"),
515 }
516 }
517
518 #[test]
519 fn test_seek_failed_constructor() {
520 let error = DecodeError::seek_failed(Duration::from_secs(60), "Index not found");
521 match error {
522 DecodeError::SeekFailed { target, reason } => {
523 assert_eq!(target, Duration::from_secs(60));
524 assert_eq!(reason, "Index not found");
525 }
526 _ => panic!("Wrong error type"),
527 }
528 }
529
530 #[test]
531 fn test_ffmpeg_constructor() {
532 let error = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
533 match error {
534 DecodeError::Ffmpeg { code, message } => {
535 assert_eq!(code, -22);
536 assert_eq!(message, "AVERROR_INVALIDDATA");
537 }
538 _ => panic!("Wrong error type"),
539 }
540 }
541
542 #[test]
543 fn ffmpeg_should_format_with_code_and_message() {
544 let error = DecodeError::ffmpeg(-22, "Invalid data");
545 assert!(error.to_string().contains("code=-22"));
546 assert!(error.to_string().contains("Invalid data"));
547 }
548
549 #[test]
550 fn ffmpeg_with_zero_code_should_be_constructible() {
551 let error = DecodeError::ffmpeg(0, "allocation failed");
552 assert!(matches!(error, DecodeError::Ffmpeg { code: 0, .. }));
553 }
554
555 #[test]
556 fn decoder_unavailable_should_include_codec_and_hint() {
557 let e = DecodeError::decoder_unavailable(
558 "exr",
559 "Requires FFmpeg built with EXR support (--enable-decoder=exr)",
560 );
561 assert!(e.to_string().contains("exr"));
562 assert!(e.to_string().contains("Requires FFmpeg"));
563 }
564
565 #[test]
566 fn decoder_unavailable_should_be_fatal() {
567 let e = DecodeError::decoder_unavailable("exr", "hint");
568 assert!(e.is_fatal());
569 assert!(!e.is_recoverable());
570 }
571
572 #[test]
573 fn test_is_recoverable() {
574 assert!(DecodeError::decoding_failed("test").is_recoverable());
575 assert!(DecodeError::seek_failed(Duration::from_secs(1), "test").is_recoverable());
576 assert!(
577 !DecodeError::FileNotFound {
578 path: PathBuf::new()
579 }
580 .is_recoverable()
581 );
582 }
583
584 #[test]
585 fn test_is_fatal() {
586 assert!(
587 DecodeError::FileNotFound {
588 path: PathBuf::new()
589 }
590 .is_fatal()
591 );
592 assert!(
593 DecodeError::NoVideoStream {
594 path: PathBuf::new()
595 }
596 .is_fatal()
597 );
598 assert!(
599 DecodeError::NoAudioStream {
600 path: PathBuf::new()
601 }
602 .is_fatal()
603 );
604 assert!(
605 DecodeError::UnsupportedCodec {
606 codec: "test".to_string()
607 }
608 .is_fatal()
609 );
610 assert!(!DecodeError::decoding_failed("test").is_fatal());
611 }
612
613 #[test]
614 fn test_io_error_conversion() {
615 let io_error = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
616 let decode_error: DecodeError = io_error.into();
617 assert!(matches!(decode_error, DecodeError::Io(_)));
618 }
619
620 #[test]
621 fn test_hw_accel_unavailable() {
622 let error = DecodeError::HwAccelUnavailable {
623 accel: HardwareAccel::Nvdec,
624 };
625 assert!(
626 error
627 .to_string()
628 .contains("Hardware acceleration unavailable")
629 );
630 assert!(error.to_string().contains("Nvdec"));
631 }
632
633 #[test]
636 fn file_not_found_should_be_fatal_and_not_recoverable() {
637 let e = DecodeError::FileNotFound {
638 path: PathBuf::new(),
639 };
640 assert!(e.is_fatal());
641 assert!(!e.is_recoverable());
642 }
643
644 #[test]
645 fn no_video_stream_should_be_fatal_and_not_recoverable() {
646 let e = DecodeError::NoVideoStream {
647 path: PathBuf::new(),
648 };
649 assert!(e.is_fatal());
650 assert!(!e.is_recoverable());
651 }
652
653 #[test]
654 fn no_audio_stream_should_be_fatal_and_not_recoverable() {
655 let e = DecodeError::NoAudioStream {
656 path: PathBuf::new(),
657 };
658 assert!(e.is_fatal());
659 assert!(!e.is_recoverable());
660 }
661
662 #[test]
663 fn unsupported_codec_should_be_fatal_and_not_recoverable() {
664 let e = DecodeError::UnsupportedCodec {
665 codec: "test".to_string(),
666 };
667 assert!(e.is_fatal());
668 assert!(!e.is_recoverable());
669 }
670
671 #[test]
672 fn decoder_unavailable_should_be_fatal_and_not_recoverable() {
673 let e = DecodeError::decoder_unavailable("exr", "hint");
674 assert!(e.is_fatal());
675 assert!(!e.is_recoverable());
676 }
677
678 #[test]
679 fn decoding_failed_should_be_recoverable_and_not_fatal() {
680 let e = DecodeError::decoding_failed("corrupt frame");
681 assert!(e.is_recoverable());
682 assert!(!e.is_fatal());
683 }
684
685 #[test]
686 fn seek_failed_should_be_recoverable_and_not_fatal() {
687 let e = DecodeError::seek_failed(Duration::from_secs(5), "index not found");
688 assert!(e.is_recoverable());
689 assert!(!e.is_fatal());
690 }
691
692 #[test]
693 fn hw_accel_unavailable_should_be_fatal_and_not_recoverable() {
694 let e = DecodeError::HwAccelUnavailable {
695 accel: HardwareAccel::Nvdec,
696 };
697 assert!(e.is_fatal());
698 assert!(!e.is_recoverable());
699 }
700
701 #[test]
702 fn invalid_output_dimensions_should_be_fatal_and_not_recoverable() {
703 let e = DecodeError::InvalidOutputDimensions {
704 width: 0,
705 height: 0,
706 };
707 assert!(e.is_fatal());
708 assert!(!e.is_recoverable());
709 }
710
711 #[test]
712 fn ffmpeg_error_should_be_neither_fatal_nor_recoverable() {
713 let e = DecodeError::ffmpeg(-22, "AVERROR_INVALIDDATA");
714 assert!(!e.is_fatal());
715 assert!(!e.is_recoverable());
716 }
717
718 #[test]
719 fn io_error_should_be_fatal_and_not_recoverable() {
720 let e: DecodeError =
721 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied").into();
722 assert!(e.is_fatal());
723 assert!(!e.is_recoverable());
724 }
725
726 #[test]
727 fn network_timeout_should_be_recoverable_and_not_fatal() {
728 let e = DecodeError::NetworkTimeout {
729 code: -110,
730 endpoint: "rtmp://example.com/live".to_string(),
731 message: "timed out".to_string(),
732 };
733 assert!(e.is_recoverable());
734 assert!(!e.is_fatal());
735 }
736
737 #[test]
738 fn connection_failed_should_be_fatal_and_not_recoverable() {
739 let e = DecodeError::ConnectionFailed {
740 code: -111,
741 endpoint: "rtmp://example.com/live".to_string(),
742 message: "connection refused".to_string(),
743 };
744 assert!(e.is_fatal());
745 assert!(!e.is_recoverable());
746 }
747
748 #[test]
749 fn stream_interrupted_should_be_recoverable_and_not_fatal() {
750 let e = DecodeError::StreamInterrupted {
751 code: -5,
752 endpoint: "rtmp://example.com/live".to_string(),
753 message: "I/O error".to_string(),
754 };
755 assert!(e.is_recoverable());
756 assert!(!e.is_fatal());
757 }
758
759 #[test]
760 fn seek_not_supported_should_be_neither_fatal_nor_recoverable() {
761 let e = DecodeError::SeekNotSupported;
762 assert!(!e.is_fatal());
763 assert!(!e.is_recoverable());
764 }
765
766 #[test]
767 fn unsupported_resolution_display_should_contain_width_x_height() {
768 let e = DecodeError::UnsupportedResolution {
769 width: 40000,
770 height: 480,
771 };
772 let msg = e.to_string();
773 assert!(msg.contains("40000x480"), "expected '40000x480' in '{msg}'");
774 }
775
776 #[test]
777 fn unsupported_resolution_display_should_contain_axes_hint() {
778 let e = DecodeError::UnsupportedResolution {
779 width: 640,
780 height: 40000,
781 };
782 let msg = e.to_string();
783 assert!(msg.contains("32768"), "expected '32768' limit in '{msg}'");
784 }
785
786 #[test]
787 fn unsupported_resolution_should_be_neither_fatal_nor_recoverable() {
788 let e = DecodeError::UnsupportedResolution {
789 width: 40000,
790 height: 40000,
791 };
792 assert!(!e.is_fatal());
793 assert!(!e.is_recoverable());
794 }
795
796 #[test]
797 fn stream_corrupted_display_should_contain_packet_count() {
798 let e = DecodeError::StreamCorrupted {
799 consecutive_invalid_packets: 32,
800 };
801 let msg = e.to_string();
802 assert!(msg.contains("32"), "expected '32' in '{msg}'");
803 }
804
805 #[test]
806 fn stream_corrupted_display_should_mention_consecutive() {
807 let e = DecodeError::StreamCorrupted {
808 consecutive_invalid_packets: 32,
809 };
810 let msg = e.to_string();
811 assert!(
812 msg.contains("consecutive"),
813 "expected 'consecutive' in '{msg}'"
814 );
815 }
816
817 #[test]
818 fn stream_corrupted_should_be_fatal_and_not_recoverable() {
819 let e = DecodeError::StreamCorrupted {
820 consecutive_invalid_packets: 32,
821 };
822 assert!(e.is_fatal());
823 assert!(!e.is_recoverable());
824 }
825
826 #[test]
827 fn decode_error_no_frame_at_timestamp_should_display_correctly() {
828 let e = DecodeError::NoFrameAtTimestamp {
829 timestamp: Duration::from_secs(5),
830 };
831 let msg = e.to_string();
832 assert!(
833 msg.contains("no frame found at timestamp"),
834 "unexpected message: {msg}"
835 );
836 assert!(msg.contains("5s"), "expected timestamp in message: {msg}");
837 }
838
839 #[test]
840 fn decode_error_analysis_failed_should_display_correctly() {
841 let e = DecodeError::AnalysisFailed {
842 reason: "interval must be non-zero".to_string(),
843 };
844 let msg = e.to_string();
845 assert!(msg.contains("analysis failed"), "unexpected message: {msg}");
846 assert!(
847 msg.contains("interval must be non-zero"),
848 "expected reason in message: {msg}"
849 );
850 }
851
852 #[test]
853 fn no_frame_at_timestamp_should_be_neither_fatal_nor_recoverable() {
854 let e = DecodeError::NoFrameAtTimestamp {
855 timestamp: Duration::from_secs(10),
856 };
857 assert!(!e.is_fatal());
858 assert!(!e.is_recoverable());
859 }
860
861 #[test]
862 fn analysis_failed_should_be_fatal_and_not_recoverable() {
863 let e = DecodeError::AnalysisFailed {
864 reason: "zero interval".to_string(),
865 };
866 assert!(e.is_fatal());
867 assert!(!e.is_recoverable());
868 }
869}