Skip to main content

ff_decode/audio/
decoder_inner.rs

1//! Internal audio decoder implementation using FFmpeg.
2//!
3//! This module contains the low-level decoder logic that directly interacts
4//! with FFmpeg's C API through the ff-sys crate. It is not exposed publicly.
5
6// Allow unsafe code in this module as it's necessary for FFmpeg FFI
7#![allow(unsafe_code)]
8// Allow specific clippy lints for FFmpeg FFI code
9#![allow(clippy::similar_names)]
10#![allow(clippy::too_many_lines)]
11#![allow(clippy::cast_sign_loss)]
12#![allow(clippy::cast_possible_truncation)]
13#![allow(clippy::cast_possible_wrap)]
14#![allow(clippy::module_name_repetitions)]
15#![allow(clippy::match_same_arms)]
16#![allow(clippy::ptr_as_ptr)]
17#![allow(clippy::doc_markdown)]
18#![allow(clippy::unnecessary_cast)]
19#![allow(clippy::if_not_else)]
20#![allow(clippy::unnecessary_wraps)]
21#![allow(clippy::cast_precision_loss)]
22#![allow(clippy::if_same_then_else)]
23#![allow(clippy::cast_lossless)]
24
25use std::ffi::CStr;
26use std::path::Path;
27use std::time::Duration;
28
29use ff_format::channel::ChannelLayout;
30use ff_format::codec::AudioCodec;
31use ff_format::container::ContainerInfo;
32use ff_format::{AudioFrame, AudioStreamInfo, NetworkOptions, SampleFormat};
33use ff_sys::{AVCodecID, AVMediaType_AVMEDIA_TYPE_AUDIO, Frame, InputFormatContext, Packet};
34
35use super::resample_inner;
36
37use crate::error::DecodeError;
38use crate::shared::guards_inner::{open_input_ctx, open_url_ctx};
39
40/// Internal decoder state holding FFmpeg contexts.
41///
42/// This structure manages the lifecycle of FFmpeg objects and is responsible
43/// for proper cleanup when dropped.
44pub(crate) struct AudioDecoderInner {
45    /// Format context for reading the media file
46    format_ctx: InputFormatContext,
47    /// Codec context for decoding audio frames
48    codec_ctx: ff_sys::CodecContext,
49    /// Audio stream index in the format context
50    stream_index: i32,
51    /// Target output sample format (if conversion is needed)
52    output_format: Option<SampleFormat>,
53    /// Target output sample rate (if resampling is needed)
54    output_sample_rate: Option<u32>,
55    /// Target output channel count (if remixing is needed)
56    output_channels: Option<u32>,
57    /// Cached `SwrContext` — reused across frames to preserve FIR filter state.
58    swr_ctx: Option<ff_sys::ResampleContext>,
59    /// Key for the cached context; rebuilt when source or target parameters change.
60    swr_key: Option<resample_inner::SwrKey>,
61    /// Whether the source is a live/streaming input (seeking is not supported)
62    is_live: bool,
63    /// Whether end of file has been reached
64    eof: bool,
65    /// Current playback position
66    position: Duration,
67    /// Reusable packet for reading from file
68    packet: Packet,
69    /// Reusable frame for decoding
70    frame: Frame,
71    /// URL used to open this source — `None` for file-path sources.
72    url: Option<String>,
73    /// Network options used for the initial open (timeouts, reconnect config).
74    network_opts: NetworkOptions,
75    /// Number of successful reconnects so far (for logging).
76    reconnect_count: u32,
77}
78
79impl AudioDecoderInner {
80    /// Opens a media file and initializes the audio decoder.
81    ///
82    /// # Arguments
83    ///
84    /// * `path` - Path to the media file
85    /// * `output_format` - Optional target sample format for conversion
86    /// * `output_sample_rate` - Optional target sample rate for resampling
87    /// * `output_channels` - Optional target channel count for remixing
88    ///
89    /// # Errors
90    ///
91    /// Returns an error if:
92    /// - The file cannot be opened
93    /// - No audio stream is found
94    /// - The codec is not supported
95    /// - Decoder initialization fails
96    #[allow(clippy::too_many_arguments)]
97    pub(crate) fn new(
98        path: &Path,
99        output_format: Option<SampleFormat>,
100        output_sample_rate: Option<u32>,
101        output_channels: Option<u32>,
102        network_opts: Option<NetworkOptions>,
103    ) -> Result<(Self, AudioStreamInfo, ContainerInfo), DecodeError> {
104        // Ensure FFmpeg is initialized (thread-safe and idempotent)
105        ff_sys::ensure_initialized();
106
107        let path_str = path.to_str().unwrap_or("");
108        let is_network_url = crate::network::is_url(path_str);
109
110        let url = if is_network_url {
111            Some(path_str.to_owned())
112        } else {
113            None
114        };
115        let stored_network_opts = network_opts.clone().unwrap_or_default();
116
117        // Verify SRT availability before attempting to open (feature + runtime check).
118        if is_network_url {
119            crate::network::check_srt_url(path_str)?;
120        }
121
122        // Open the input source (owned demux context).
123        let mut ctx = if is_network_url {
124            let network = network_opts.unwrap_or_default();
125            log::info!(
126                "opening network audio source url={} connect_timeout_ms={} read_timeout_ms={}",
127                crate::network::sanitize_url(path_str),
128                network.connect_timeout.as_millis(),
129                network.read_timeout.as_millis()
130            );
131            open_url_ctx(path_str, &network)?
132        } else {
133            open_input_ctx(path)?
134        };
135
136        // Read stream information
137        ctx.find_stream_info().map_err(|e| DecodeError::Ffmpeg {
138            code: e.code(),
139            message: format!(
140                "Failed to find stream info: {}",
141                ff_sys::av_error_string(e.code())
142            ),
143        })?;
144
145        // Detect live/streaming source via the AVFMT_TS_DISCONT flag on AVInputFormat.
146        let is_live = (ctx.iformat_flags() & ff_sys::AVFMT_TS_DISCONT) != 0;
147
148        // Find the audio stream
149        let (stream_index, codec_id) =
150            Self::find_audio_stream(&ctx).ok_or_else(|| DecodeError::NoAudioStream {
151                path: path.to_path_buf(),
152            })?;
153
154        // Find the decoder for this codec
155        // SAFETY: codec_id is valid from FFmpeg
156        let codec_name = unsafe { Self::extract_codec_name(codec_id) };
157        let codec =
158            ff_sys::Codec::find_decoder(codec_id).ok_or_else(|| DecodeError::UnsupportedCodec {
159                codec: format!("{codec_name} (codec_id={codec_id:?})"),
160            })?;
161
162        // Allocate codec context (freed on drop by CodecContext).
163        let mut codec_ctx =
164            ff_sys::CodecContext::new(Some(codec)).map_err(|e| DecodeError::Ffmpeg {
165                code: e.code(),
166                message: format!(
167                    "Failed to allocate codec context: {}",
168                    ff_sys::av_error_string(e.code())
169                ),
170            })?;
171
172        // Copy codec parameters from stream to context
173        let codecpar = ctx
174            .stream(stream_index)
175            .ok_or_else(|| DecodeError::NoAudioStream {
176                path: path.to_path_buf(),
177            })?
178            .codecpar();
179        codec_ctx
180            .apply_parameters(&codecpar)
181            .map_err(|e| DecodeError::Ffmpeg {
182                code: e.code(),
183                message: format!(
184                    "Failed to copy codec parameters: {}",
185                    ff_sys::av_error_string(e.code())
186                ),
187            })?;
188
189        // Open the codec
190        codec_ctx
191            .open_codec(codec)
192            .map_err(|e| DecodeError::Ffmpeg {
193                code: e.code(),
194                message: format!(
195                    "Failed to open codec: {}",
196                    ff_sys::av_error_string(e.code())
197                ),
198            })?;
199
200        // Extract stream and container information through the borrowed
201        // stream / codec-context accessors.
202        let duration_val = ctx.duration();
203        let stream = ctx
204            .stream(stream_index)
205            .ok_or_else(|| DecodeError::NoAudioStream {
206                path: path.to_path_buf(),
207            })?;
208        let stream_info = Self::extract_stream_info(stream, &codec_ctx, duration_val)?;
209
210        // Extract container information
211        let container_info = Self::extract_container_info(&ctx);
212
213        // Allocate packet and frame (owned; free on drop, including on an early
214        // return from a later `?` in this constructor).
215        let packet = Packet::new().map_err(|e| DecodeError::Ffmpeg {
216            code: e.code(),
217            message: format!(
218                "Failed to allocate packet: {}",
219                ff_sys::av_error_string(e.code())
220            ),
221        })?;
222        let frame = Frame::new().map_err(|e| DecodeError::Ffmpeg {
223            code: e.code(),
224            message: format!(
225                "Failed to allocate frame: {}",
226                ff_sys::av_error_string(e.code())
227            ),
228        })?;
229
230        // All initialization successful - transfer ownership to AudioDecoderInner
231        Ok((
232            Self {
233                format_ctx: ctx,
234                codec_ctx,
235                stream_index: stream_index as i32,
236                output_format,
237                output_sample_rate,
238                output_channels,
239                swr_ctx: None,
240                swr_key: None,
241                is_live,
242                eof: false,
243                position: Duration::ZERO,
244                packet,
245                frame,
246                url,
247                network_opts: stored_network_opts,
248                reconnect_count: 0,
249            },
250            stream_info,
251            container_info,
252        ))
253    }
254
255    /// Finds the first audio stream in the format context.
256    ///
257    /// Returns `Some((index, codec_id))` if an audio stream is found, `None` otherwise.
258    fn find_audio_stream(format_ctx: &InputFormatContext) -> Option<(usize, AVCodecID)> {
259        for stream in format_ctx.streams() {
260            let codecpar = stream.codecpar();
261            if codecpar.codec_type() == AVMediaType_AVMEDIA_TYPE_AUDIO {
262                return Some((stream.index() as usize, codecpar.codec_id()));
263            }
264        }
265        None
266    }
267
268    /// Returns the human-readable codec name for a given `AVCodecID`.
269    unsafe fn extract_codec_name(codec_id: ff_sys::AVCodecID) -> String {
270        // SAFETY: avcodec_get_name is safe for any codec ID value
271        let name_ptr = unsafe { ff_sys::avcodec_get_name(codec_id) };
272        if name_ptr.is_null() {
273            return String::from("unknown");
274        }
275        // SAFETY: avcodec_get_name returns a valid C string with static lifetime
276        unsafe { CStr::from_ptr(name_ptr).to_string_lossy().into_owned() }
277    }
278
279    /// Extracts audio stream information from the borrowed stream and codec
280    /// context.
281    fn extract_stream_info(
282        stream: ff_sys::StreamRef<'_>,
283        codec_ctx: &ff_sys::CodecContext,
284        duration_val: i64,
285    ) -> Result<AudioStreamInfo, DecodeError> {
286        let codecpar = stream.codecpar();
287        let stream_index = stream.index();
288        let channel_layout = codecpar.ch_layout();
289        let sample_rate = codecpar.sample_rate() as u32;
290        let channels = channel_layout.nb_channels as u32;
291        let sample_fmt = codec_ctx.sample_fmt();
292        let codec_id = codecpar.codec_id();
293
294        // Extract duration
295        let duration = if duration_val > 0 {
296            let duration_secs = duration_val as f64 / 1_000_000.0;
297            Some(Duration::from_secs_f64(duration_secs))
298        } else {
299            None
300        };
301
302        // Extract sample format
303        let sample_format = resample_inner::convert_sample_format(sample_fmt);
304
305        // Extract channel layout
306        let channel_layout_enum = Self::convert_channel_layout(&channel_layout, channels);
307
308        // Extract codec
309        let codec = Self::convert_codec(codec_id);
310        let codec_name = unsafe { Self::extract_codec_name(codec_id) };
311
312        // Build stream info
313        let mut builder = AudioStreamInfo::builder()
314            .index(stream_index as u32)
315            .codec(codec)
316            .codec_name(codec_name)
317            .sample_rate(sample_rate)
318            .channels(channels)
319            .sample_format(sample_format)
320            .channel_layout(channel_layout_enum);
321
322        if let Some(d) = duration {
323            builder = builder.duration(d);
324        }
325
326        Ok(builder.build())
327    }
328
329    /// Extracts container-level information from the format context.
330    fn extract_container_info(format_ctx: &InputFormatContext) -> ContainerInfo {
331        let format_name = format_ctx.iformat_name().unwrap_or_default();
332
333        let bit_rate = {
334            let br = format_ctx.bit_rate();
335            if br > 0 { Some(br as u64) } else { None }
336        };
337
338        let nb_streams = format_ctx.nb_streams();
339
340        let mut builder = ContainerInfo::builder()
341            .format_name(format_name)
342            .nb_streams(nb_streams);
343        if let Some(br) = bit_rate {
344            builder = builder.bit_rate(br);
345        }
346        builder.build()
347    }
348
349    /// Converts FFmpeg channel layout to our `ChannelLayout` enum.
350    fn convert_channel_layout(layout: &ff_sys::AVChannelLayout, channels: u32) -> ChannelLayout {
351        if layout.order == ff_sys::AVChannelOrder_AV_CHANNEL_ORDER_NATIVE {
352            // SAFETY: When order is AV_CHANNEL_ORDER_NATIVE, the mask field is valid
353            let mask = unsafe { layout.u.mask };
354            match mask {
355                0x4 => ChannelLayout::Mono,
356                0x3 => ChannelLayout::Stereo,
357                0x103 => ChannelLayout::Stereo2_1,
358                0x7 => ChannelLayout::Surround3_0,
359                0x33 => ChannelLayout::Quad,
360                0x37 => ChannelLayout::Surround5_0,
361                0x3F => ChannelLayout::Surround5_1,
362                0x13F => ChannelLayout::Surround6_1,
363                0x63F => ChannelLayout::Surround7_1,
364                _ => {
365                    log::warn!(
366                        "channel_layout mask has no mapping, deriving from channel count \
367                         mask={mask} channels={channels}"
368                    );
369                    ChannelLayout::from_channels(channels)
370                }
371            }
372        } else {
373            log::warn!(
374                "channel_layout order is not NATIVE, deriving from channel count \
375                 order={order} channels={channels}",
376                order = layout.order
377            );
378            ChannelLayout::from_channels(channels)
379        }
380    }
381
382    /// Converts FFmpeg codec ID to our `AudioCodec` enum.
383    fn convert_codec(codec_id: AVCodecID) -> AudioCodec {
384        if codec_id == ff_sys::AVCodecID_AV_CODEC_ID_AAC {
385            AudioCodec::Aac
386        } else if codec_id == ff_sys::AVCodecID_AV_CODEC_ID_MP3 {
387            AudioCodec::Mp3
388        } else if codec_id == ff_sys::AVCodecID_AV_CODEC_ID_OPUS {
389            AudioCodec::Opus
390        } else if codec_id == ff_sys::AVCodecID_AV_CODEC_ID_VORBIS {
391            AudioCodec::Vorbis
392        } else if codec_id == ff_sys::AVCodecID_AV_CODEC_ID_FLAC {
393            AudioCodec::Flac
394        } else if codec_id == ff_sys::AVCodecID_AV_CODEC_ID_PCM_S16LE {
395            AudioCodec::Pcm
396        } else {
397            log::warn!(
398                "audio codec unsupported, falling back to Aac codec_id={codec_id} fallback=Aac"
399            );
400            AudioCodec::Aac
401        }
402    }
403
404    /// Decodes the next audio frame.
405    ///
406    /// Transparently reconnects on `StreamInterrupted` when
407    /// `NetworkOptions::reconnect_on_error` is enabled.
408    ///
409    /// # Returns
410    ///
411    /// - `Ok(Some(frame))` - Successfully decoded a frame
412    /// - `Ok(None)` - End of stream reached
413    /// - `Err(_)` - Decoding error occurred
414    pub(crate) fn decode_one(&mut self) -> Result<Option<AudioFrame>, DecodeError> {
415        loop {
416            match self.decode_one_inner() {
417                Ok(frame) => return Ok(frame),
418                Err(DecodeError::StreamInterrupted { .. })
419                    if self.url.is_some() && self.network_opts.reconnect_on_error =>
420                {
421                    self.attempt_reconnect()?;
422                }
423                Err(e) => return Err(e),
424            }
425        }
426    }
427
428    fn decode_one_inner(&mut self) -> Result<Option<AudioFrame>, DecodeError> {
429        if self.eof {
430            return Ok(None);
431        }
432
433        unsafe {
434            loop {
435                // Try to receive a frame from the decoder
436                match self.codec_ctx.receive_frame(&mut self.frame).map_err(|e| {
437                    DecodeError::DecodingFailed {
438                        timestamp: Some(self.position),
439                        reason: ff_sys::av_error_string(e.code()),
440                    }
441                })? {
442                    ff_sys::ReceiveOutcome::Frame => {
443                        // Successfully received a frame.
444                        // SAFETY (within the enclosing unsafe block): `stream_index`
445                        // is valid for this decoder's context.
446                        let audio_frame = resample_inner::convert_frame_to_audio_frame(
447                            &self.frame,
448                            &self.format_ctx,
449                            self.stream_index,
450                            self.output_format,
451                            self.output_sample_rate,
452                            self.output_channels,
453                            &mut self.swr_ctx,
454                            &mut self.swr_key,
455                        )?;
456
457                        // Update position based on frame timestamp
458                        let pts = self.frame.pts();
459                        if pts != ff_sys::AV_NOPTS_VALUE
460                            && let Some(stream) = self.format_ctx.stream(self.stream_index as usize)
461                        {
462                            let time_base = stream.time_base();
463                            let timestamp_secs =
464                                pts as f64 * time_base.num as f64 / time_base.den as f64;
465                            self.position = Duration::from_secs_f64(timestamp_secs);
466                        }
467
468                        return Ok(Some(audio_frame));
469                    }
470                    ff_sys::ReceiveOutcome::NeedInput => {
471                        // Need to send more packets to the decoder
472                        // Read a packet from the file
473                        match self.format_ctx.read_frame(&mut self.packet) {
474                            Ok(()) => {}
475                            Err(e) if e.is_eof() => {
476                                // End of file - flush the decoder
477                                let _ = self.codec_ctx.send_eof();
478                                self.eof = true;
479                                continue;
480                            }
481                            Err(e) => {
482                                let read_ret = e.code();
483                                return Err(if let Some(url) = &self.url {
484                                    // Network source: map to typed variant so reconnect can detect it.
485                                    crate::network::map_network_error(
486                                        read_ret,
487                                        crate::network::sanitize_url(url),
488                                    )
489                                } else {
490                                    DecodeError::Ffmpeg {
491                                        code: read_ret,
492                                        message: format!(
493                                            "Failed to read frame: {}",
494                                            ff_sys::av_error_string(read_ret)
495                                        ),
496                                    }
497                                });
498                            }
499                        }
500
501                        // Check if this packet belongs to the audio stream
502                        if self.packet.stream_index() == self.stream_index {
503                            // Send the packet to the decoder
504                            let send_result = self.codec_ctx.send_packet(&self.packet);
505                            self.packet.unref();
506
507                            if let Err(se) = send_result
508                                && !se.is_eagain()
509                            {
510                                return Err(DecodeError::Ffmpeg {
511                                    code: se.code(),
512                                    message: format!(
513                                        "Failed to send packet: {}",
514                                        ff_sys::av_error_string(se.code())
515                                    ),
516                                });
517                            }
518                        } else {
519                            // Not our stream, unref and continue
520                            self.packet.unref();
521                        }
522                    }
523                    ff_sys::ReceiveOutcome::Drained => {
524                        // Decoder has been fully flushed
525                        self.eof = true;
526                        return Ok(None);
527                    }
528                }
529            }
530        }
531    }
532
533    /// Returns the current playback position.
534    pub(crate) fn position(&self) -> Duration {
535        self.position
536    }
537
538    /// Returns whether end of file has been reached.
539    pub(crate) fn is_eof(&self) -> bool {
540        self.eof
541    }
542
543    /// Returns whether the source is a live or streaming input.
544    ///
545    /// Live sources have the `AVFMT_TS_DISCONT` flag set on their `AVInputFormat`.
546    /// Seeking is not meaningful on live sources.
547    pub(crate) fn is_live(&self) -> bool {
548        self.is_live
549    }
550
551    /// Converts a `Duration` to a presentation timestamp (PTS) in stream time_base units.
552    fn duration_to_pts(&self, duration: Duration) -> i64 {
553        // The stream index is valid for this decoder's context; fall back to a
554        // 1/1 time base if it is somehow absent (unreachable in practice).
555        let time_base = self
556            .format_ctx
557            .stream(self.stream_index as usize)
558            .map_or(ff_sys::AVRational { num: 1, den: 1 }, |s| s.time_base());
559
560        // Convert: duration (seconds) * (time_base.den / time_base.num) = PTS
561        let time_base_f64 = time_base.den as f64 / time_base.num as f64;
562        (duration.as_secs_f64() * time_base_f64) as i64
563    }
564
565    /// Seeks to a specified position in the audio stream.
566    ///
567    /// # Arguments
568    ///
569    /// * `position` - Target position to seek to.
570    /// * `mode` - Seek mode (Keyframe, Exact, or Backward).
571    ///
572    /// # Errors
573    ///
574    /// Returns [`DecodeError::SeekFailed`] if the seek operation fails.
575    pub(crate) fn seek(
576        &mut self,
577        position: Duration,
578        mode: crate::SeekMode,
579    ) -> Result<(), DecodeError> {
580        use crate::SeekMode;
581
582        let timestamp = self.duration_to_pts(position);
583        let flags = ff_sys::avformat::seek_flags::BACKWARD;
584
585        // 1. Clear any pending packet and frame
586        self.packet.unref();
587        self.frame.unref();
588
589        // 2. Seek in the format context
590        self.format_ctx
591            .seek_frame(self.stream_index, timestamp, flags)
592            .map_err(|e| DecodeError::SeekFailed {
593                target: position,
594                reason: ff_sys::av_error_string(e.code()),
595            })?;
596
597        // 3. Flush decoder buffers and reset the cached SwrContext so the
598        //    resampler does not carry stale delay samples across the seek point.
599        // SAFETY: the codec context was opened during construction.
600        unsafe { self.codec_ctx.flush_buffers() };
601        self.swr_ctx = None;
602        self.swr_key = None;
603
604        // 4. Drain any remaining frames from the decoder after flush
605        // Drain while frames are produced. NeedInput / Drained / real errors all
606        // end draining (preserves the pre-migration `Err(_) => break` behaviour that
607        // swallowed errors here).
608        while let Ok(ff_sys::ReceiveOutcome::Frame) = self.codec_ctx.receive_frame(&mut self.frame)
609        {
610            self.frame.unref();
611        }
612
613        // 5. Reset internal state
614        self.eof = false;
615
616        // 6. For exact mode, skip frames to reach exact position
617        if mode == SeekMode::Exact {
618            self.skip_to_exact(position)?;
619        }
620        // For Keyframe/Backward modes, we're already at the keyframe after av_seek_frame
621
622        Ok(())
623    }
624
625    /// Skips frames until reaching the exact target position.
626    ///
627    /// This is used by [`Self::seek`] when `SeekMode::Exact` is specified.
628    ///
629    /// # Arguments
630    ///
631    /// * `target` - The exact target position.
632    fn skip_to_exact(&mut self, target: Duration) -> Result<(), DecodeError> {
633        // Decode frames until we reach or pass the target
634        while let Some(frame) = self.decode_one()? {
635            let frame_time = frame.timestamp().as_duration();
636            if frame_time >= target {
637                // We've reached the target position
638                break;
639            }
640            // Continue decoding to get closer (frames are automatically dropped)
641        }
642        Ok(())
643    }
644
645    /// Flushes the decoder's internal buffers.
646    pub(crate) fn flush(&mut self) {
647        // SAFETY: the codec context was opened during construction.
648        unsafe { self.codec_ctx.flush_buffers() };
649        self.eof = false;
650    }
651
652    // Reconnect helpers
653
654    /// Attempts to reconnect to the stream URL using exponential backoff.
655    ///
656    /// Called from `decode_one()` when `StreamInterrupted` is received and
657    /// `NetworkOptions::reconnect_on_error` is `true`. After all attempts fail,
658    /// returns a `StreamInterrupted` error.
659    fn attempt_reconnect(&mut self) -> Result<(), DecodeError> {
660        let url = match self.url.as_deref() {
661            Some(u) => u.to_owned(),
662            None => return Ok(()), // file-path source: no reconnect
663        };
664        let max = self.network_opts.max_reconnect_attempts;
665
666        for attempt in 1..=max {
667            let backoff_ms = 100u64 * (1u64 << (attempt - 1).min(10));
668            log::warn!(
669                "reconnecting attempt={attempt} url={} backoff_ms={backoff_ms}",
670                crate::network::sanitize_url(&url)
671            );
672            std::thread::sleep(Duration::from_millis(backoff_ms));
673            match self.reopen(&url) {
674                Ok(()) => {
675                    self.reconnect_count += 1;
676                    log::info!(
677                        "reconnected attempt={attempt} url={} total_reconnects={}",
678                        crate::network::sanitize_url(&url),
679                        self.reconnect_count
680                    );
681                    return Ok(());
682                }
683                Err(e) => log::warn!("reconnect attempt={attempt} failed err={e}"),
684            }
685        }
686
687        Err(DecodeError::StreamInterrupted {
688            code: 0,
689            endpoint: crate::network::sanitize_url(&url),
690            message: format!("stream did not recover after {max} attempts"),
691        })
692    }
693
694    /// Closes the current `AVFormatContext`, re-opens the URL, re-reads stream info,
695    /// re-finds the audio stream, and flushes the codec.
696    fn reopen(&mut self, url: &str) -> Result<(), DecodeError> {
697        // Re-open the URL with the stored network timeouts. Assigning the fresh
698        // context drops the previous one, which closes and frees it.
699        self.format_ctx = open_url_ctx(url, &self.network_opts)?;
700
701        // Re-read stream information.
702        self.format_ctx
703            .find_stream_info()
704            .map_err(|e| DecodeError::Ffmpeg {
705                code: e.code(),
706                message: format!(
707                    "reconnect find_stream_info failed: {}",
708                    ff_sys::av_error_string(e.code())
709                ),
710            })?;
711
712        // Re-find the audio stream (index may differ in theory after reconnect).
713        let (stream_index, _) = Self::find_audio_stream(&self.format_ctx)
714            .ok_or_else(|| DecodeError::NoAudioStream { path: url.into() })?;
715        self.stream_index = stream_index as i32;
716
717        // Flush codec buffers to discard stale decoded state from before the drop.
718        // SAFETY: the codec context was opened during construction.
719        unsafe { self.codec_ctx.flush_buffers() };
720
721        self.eof = false;
722        Ok(())
723    }
724}
725
726// All fields own their FFmpeg resources (`Frame`, `Packet`, `CodecContext`,
727// `InputFormatContext`, `ResampleContext`) and free themselves on drop, so no
728// manual `Drop` impl is required.
729
730// SAFETY: AudioDecoderInner manages FFmpeg contexts which are thread-safe when not shared.
731// We don't expose mutable access across threads, so Send is safe.
732unsafe impl Send for AudioDecoderInner {}
733
734#[cfg(test)]
735#[allow(unsafe_code)]
736mod tests {
737    use ff_format::channel::ChannelLayout;
738
739    use super::AudioDecoderInner;
740
741    /// Constructs an `AVChannelLayout` with `AV_CHANNEL_ORDER_NATIVE` and the given mask.
742    fn native_layout(mask: u64, nb_channels: i32) -> ff_sys::AVChannelLayout {
743        ff_sys::AVChannelLayout {
744            order: ff_sys::AVChannelOrder_AV_CHANNEL_ORDER_NATIVE,
745            nb_channels,
746            u: ff_sys::AVChannelLayout__bindgen_ty_1 { mask },
747            opaque: std::ptr::null_mut(),
748        }
749    }
750
751    /// Constructs an `AVChannelLayout` with `AV_CHANNEL_ORDER_UNSPEC`.
752    fn unspec_layout(nb_channels: i32) -> ff_sys::AVChannelLayout {
753        ff_sys::AVChannelLayout {
754            order: ff_sys::AVChannelOrder_AV_CHANNEL_ORDER_UNSPEC,
755            nb_channels,
756            u: ff_sys::AVChannelLayout__bindgen_ty_1 { mask: 0 },
757            opaque: std::ptr::null_mut(),
758        }
759    }
760
761    #[test]
762    fn native_mask_mono() {
763        let layout = native_layout(0x4, 1);
764        assert_eq!(
765            AudioDecoderInner::convert_channel_layout(&layout, 1),
766            ChannelLayout::Mono
767        );
768    }
769
770    #[test]
771    fn native_mask_stereo() {
772        let layout = native_layout(0x3, 2);
773        assert_eq!(
774            AudioDecoderInner::convert_channel_layout(&layout, 2),
775            ChannelLayout::Stereo
776        );
777    }
778
779    #[test]
780    fn native_mask_stereo2_1() {
781        let layout = native_layout(0x103, 3);
782        assert_eq!(
783            AudioDecoderInner::convert_channel_layout(&layout, 3),
784            ChannelLayout::Stereo2_1
785        );
786    }
787
788    #[test]
789    fn native_mask_surround3_0() {
790        let layout = native_layout(0x7, 3);
791        assert_eq!(
792            AudioDecoderInner::convert_channel_layout(&layout, 3),
793            ChannelLayout::Surround3_0
794        );
795    }
796
797    #[test]
798    fn native_mask_quad() {
799        let layout = native_layout(0x33, 4);
800        assert_eq!(
801            AudioDecoderInner::convert_channel_layout(&layout, 4),
802            ChannelLayout::Quad
803        );
804    }
805
806    #[test]
807    fn native_mask_surround5_0() {
808        let layout = native_layout(0x37, 5);
809        assert_eq!(
810            AudioDecoderInner::convert_channel_layout(&layout, 5),
811            ChannelLayout::Surround5_0
812        );
813    }
814
815    #[test]
816    fn native_mask_surround5_1() {
817        let layout = native_layout(0x3F, 6);
818        assert_eq!(
819            AudioDecoderInner::convert_channel_layout(&layout, 6),
820            ChannelLayout::Surround5_1
821        );
822    }
823
824    #[test]
825    fn native_mask_surround6_1() {
826        let layout = native_layout(0x13F, 7);
827        assert_eq!(
828            AudioDecoderInner::convert_channel_layout(&layout, 7),
829            ChannelLayout::Surround6_1
830        );
831    }
832
833    #[test]
834    fn native_mask_surround7_1() {
835        let layout = native_layout(0x63F, 8);
836        assert_eq!(
837            AudioDecoderInner::convert_channel_layout(&layout, 8),
838            ChannelLayout::Surround7_1
839        );
840    }
841
842    #[test]
843    fn native_mask_unknown_falls_back_to_from_channels() {
844        // mask=0x1 is not a standard layout; should fall back to from_channels(2)
845        let layout = native_layout(0x1, 2);
846        assert_eq!(
847            AudioDecoderInner::convert_channel_layout(&layout, 2),
848            ChannelLayout::from_channels(2)
849        );
850    }
851
852    #[test]
853    fn non_native_order_falls_back_to_from_channels() {
854        let layout = unspec_layout(6);
855        assert_eq!(
856            AudioDecoderInner::convert_channel_layout(&layout, 6),
857            ChannelLayout::from_channels(6)
858        );
859    }
860
861    // -------------------------------------------------------------------------
862    // extract_codec_name
863    // -------------------------------------------------------------------------
864
865    #[test]
866    fn codec_name_should_return_h264_for_h264_codec_id() {
867        let name =
868            unsafe { AudioDecoderInner::extract_codec_name(ff_sys::AVCodecID_AV_CODEC_ID_H264) };
869        assert_eq!(name, "h264");
870    }
871
872    #[test]
873    fn codec_name_should_return_none_for_none_codec_id() {
874        let name =
875            unsafe { AudioDecoderInner::extract_codec_name(ff_sys::AVCodecID_AV_CODEC_ID_NONE) };
876        assert_eq!(name, "none");
877    }
878
879    #[test]
880    fn unsupported_codec_error_should_include_codec_name() {
881        let codec_id = ff_sys::AVCodecID_AV_CODEC_ID_MP3;
882        let codec_name = unsafe { AudioDecoderInner::extract_codec_name(codec_id) };
883        let error = crate::error::DecodeError::UnsupportedCodec {
884            codec: format!("{codec_name} (codec_id={codec_id:?})"),
885        };
886        let msg = error.to_string();
887        assert!(msg.contains("mp3"), "expected codec name in error: {msg}");
888        assert!(
889            msg.contains("codec_id="),
890            "expected codec_id in error: {msg}"
891        );
892    }
893}