Skip to main content

haki_dl/
mux.rs

1//! Container, merge, mux, and output artifact models.
2
3use std::ffi::OsString;
4use std::path::{Path, PathBuf};
5
6use crate::config::MuxerKind;
7use crate::datetime::current_local_iso_timestamp;
8use crate::error::{Error, Result};
9use crate::manifest::{EncryptionMethod, MediaType, Stream};
10
11/// Final mux output format.
12#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
13#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
14pub enum MuxFormat {
15    /// MP4 output.
16    #[default]
17    Mp4,
18    /// Matroska output.
19    Mkv,
20    /// MPEG-TS output.
21    Ts,
22}
23
24/// Per-stream ffmpeg merge output format.
25#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
26pub enum MergeOutputFormat {
27    /// MP4 output.
28    #[default]
29    Mp4,
30    /// Matroska output.
31    Mkv,
32    /// Flash Video output.
33    Flv,
34    /// MPEG-4 audio output.
35    M4a,
36    /// MPEG-TS output.
37    Ts,
38    /// E-AC-3 elementary output.
39    Eac3,
40    /// AAC audio output.
41    Aac,
42    /// AC-3 elementary output.
43    Ac3,
44}
45
46/// Planned external command without executing a process.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct MuxCommandPlan {
49    /// Program path.
50    pub program: PathBuf,
51    /// Command arguments.
52    pub arguments: String,
53    /// Working directory.
54    pub working_directory: PathBuf,
55    /// Expected output path.
56    pub output_path: PathBuf,
57}
58
59/// Metadata used when planning a per-stream ffmpeg merge.
60#[derive(Clone, Debug, Default, Eq, PartialEq)]
61pub struct FfmpegMergeMetadata {
62    pub poster: Option<PathBuf>,
63    /// Optional extra DD+ audio input prepended before the original audio track.
64    pub ddp_audio: Option<PathBuf>,
65    pub audio_name: String,
66    pub title: String,
67    pub copyright: String,
68    pub comment: String,
69    pub encoding_tool: String,
70    pub recording_time: Option<String>,
71    pub date_string: Option<String>,
72}
73
74/// Input for planning a per-stream ffmpeg merge command.
75#[derive(Clone, Copy, Debug)]
76pub struct FfmpegMergeRequest<'a> {
77    /// Program path.
78    pub binary: &'a Path,
79    /// Ordered input segment files.
80    pub files: &'a [PathBuf],
81    /// Output path without an extension.
82    pub output_base_path: &'a Path,
83    /// Requested output format.
84    pub format: MergeOutputFormat,
85    /// Apply AAC ADTS-to-ASC bitstream filter.
86    pub use_aac_filter: bool,
87    /// Apply MP4 faststart flag.
88    pub fast_start: bool,
89    /// Write date metadata.
90    pub write_date: bool,
91    /// Use ffmpeg concat demuxer instead of concat protocol.
92    pub use_concat_demuxer: bool,
93    /// Concat demuxer list file path when concat demuxer mode is enabled.
94    pub concat_list_path: Option<&'a Path>,
95    /// Metadata fields.
96    pub metadata: &'a FfmpegMergeMetadata,
97}
98
99/// mp4forge validation result for a single stream.
100#[derive(Clone, Debug, Eq, PartialEq)]
101pub enum Mp4forgeSupport {
102    /// Metadata is supported.
103    Supported,
104    /// Metadata is insufficient and a bounded probe is required before download.
105    RequiresProbe { reason: String },
106    /// Metadata identifies an unsupported case.
107    Unsupported { reason: String },
108}
109
110/// Versioned support matrix for explicit mp4forge backend requests.
111#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct Mp4forgeSupportMatrix {
113    pub version: String,
114    pub video_codecs: Vec<String>,
115    pub audio_codecs: Vec<String>,
116}
117
118impl Default for Mp4forgeSupportMatrix {
119    fn default() -> Self {
120        Self {
121            version: "haki-dl-mp4forge-matrix-v1".to_string(),
122            video_codecs: vec![
123                "avc1".to_string(),
124                "h264".to_string(),
125                "hvc1".to_string(),
126                "hev1".to_string(),
127                "hevc".to_string(),
128                "dvhe".to_string(),
129                "dvh1".to_string(),
130            ],
131            audio_codecs: vec![
132                "mp4a".to_string(),
133                "aac".to_string(),
134                "ec-3".to_string(),
135                "ec3".to_string(),
136                "ac-3".to_string(),
137                "ac3".to_string(),
138            ],
139        }
140    }
141}
142
143/// File created by a download, merge, decrypt, subtitle, or mux operation.
144#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct OutputArtifact {
147    /// Final artifact path.
148    pub path: PathBuf,
149    /// Media type associated with the artifact when known.
150    pub media_type: Option<crate::manifest::MediaType>,
151    /// Language associated with the artifact when known.
152    pub language: Option<String>,
153    /// Human-readable stream description when known.
154    pub description: Option<String>,
155}
156
157impl OutputArtifact {
158    /// Creates a new output artifact model.
159    pub fn new(path: impl Into<PathBuf>) -> Self {
160        Self {
161            path: path.into(),
162            media_type: None,
163            language: None,
164            description: None,
165        }
166    }
167}
168
169/// External file imported into a final mux operation.
170#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
171#[derive(Clone, Debug, Eq, PartialEq)]
172pub struct MuxImport {
173    /// Input file path.
174    pub path: PathBuf,
175    /// Optional language code.
176    pub language: Option<String>,
177    /// Optional track name.
178    pub name: Option<String>,
179    /// Track ordering index used by the compatibility layer.
180    pub index: i32,
181}
182
183impl MuxImport {
184    /// Creates an import with compatibility ordering.
185    pub fn new(path: impl Into<PathBuf>) -> Self {
186        Self {
187            path: path.into(),
188            language: None,
189            name: None,
190            index: 999,
191        }
192    }
193}
194
195/// Final mux options matching the compatibility workflow model.
196#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
197#[derive(Clone, Debug, Eq, PartialEq)]
198pub struct MuxOptions {
199    /// Use mkvmerge instead of the default ffmpeg path.
200    pub use_mkvmerge: bool,
201    /// Requested mux format.
202    pub format: MuxFormat,
203    /// Keep intermediate files after mux succeeds.
204    pub keep_files: bool,
205    /// Exclude subtitle artifacts from mux input.
206    pub skip_subtitle: bool,
207    /// Optional process binary path.
208    pub bin_path: Option<PathBuf>,
209}
210
211impl Default for MuxOptions {
212    fn default() -> Self {
213        Self {
214            use_mkvmerge: false,
215            format: MuxFormat::Mp4,
216            keep_files: false,
217            skip_subtitle: false,
218            bin_path: None,
219        }
220    }
221}
222
223/// Media information captured for an output file.
224#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
225#[derive(Clone, Debug, Default, Eq, PartialEq)]
226pub struct MediaInfo {
227    pub id: Option<String>,
228    pub text: Option<String>,
229    pub base_info: Option<String>,
230    pub bitrate: Option<String>,
231    pub resolution: Option<String>,
232    pub fps: Option<String>,
233    pub media_type: Option<String>,
234    pub start_time_millis: Option<i64>,
235    pub dolby_vision: bool,
236    pub hdr: bool,
237}
238
239/// Output file model used by merge, decrypt, subtitle, and mux work.
240#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
241#[derive(Clone, Debug, Eq, PartialEq)]
242pub struct OutputFile {
243    /// Media type associated with the file when known.
244    pub media_type: Option<crate::manifest::MediaType>,
245    /// Compatibility ordering index.
246    pub index: i32,
247    /// File path.
248    pub file_path: PathBuf,
249    /// Language code.
250    pub lang_code: Option<String>,
251    /// Track description.
252    pub description: Option<String>,
253    /// Optional media-info records.
254    pub media_infos: Vec<MediaInfo>,
255}
256
257impl OutputFile {
258    /// Creates a new output file with the required index and path.
259    pub fn new(index: i32, file_path: impl Into<PathBuf>) -> Self {
260        Self {
261            media_type: None,
262            index,
263            file_path: file_path.into(),
264            lang_code: None,
265            description: None,
266            media_infos: Vec::new(),
267        }
268    }
269}
270
271/// Returns the final mux extension for a mux-after-done format.
272pub fn mux_extension(format: MuxFormat) -> &'static str {
273    match format {
274        MuxFormat::Mp4 => ".mp4",
275        MuxFormat::Mkv => ".mkv",
276        MuxFormat::Ts => ".ts",
277    }
278}
279
280/// Returns the per-stream merge extension for an ffmpeg merge format.
281pub fn merge_extension(format: MergeOutputFormat) -> &'static str {
282    match format {
283        MergeOutputFormat::Mp4 => ".mp4",
284        MergeOutputFormat::Mkv => ".mkv",
285        MergeOutputFormat::Flv => ".flv",
286        MergeOutputFormat::M4a | MergeOutputFormat::Aac => ".m4a",
287        MergeOutputFormat::Ts => ".ts",
288        MergeOutputFormat::Eac3 => ".eac3",
289        MergeOutputFormat::Ac3 => ".ac3",
290    }
291}
292
293/// Combines files by byte concatenation, copying a single input directly.
294pub async fn combine_files(files: &[PathBuf], output_path: &Path) -> Result<()> {
295    if files.is_empty() {
296        return Ok(());
297    }
298    if let Some(parent) = output_path.parent() {
299        tokio::fs::create_dir_all(parent).await?;
300    }
301    if files.len() == 1 {
302        tokio::fs::copy(&files[0], output_path).await?;
303        return Ok(());
304    }
305    let mut output = tokio::fs::File::create(output_path).await?;
306    for path in files {
307        if path.as_os_str().is_empty() {
308            continue;
309        }
310        let mut input = tokio::fs::File::open(path).await?;
311        tokio::io::copy(&mut input, &mut output).await?;
312    }
313    Ok(())
314}
315
316/// Partially combines large file lists into grouped temporary TS files and deletes grouped inputs.
317pub async fn partial_combine_files(files: &[PathBuf]) -> Result<Vec<PathBuf>> {
318    if files.is_empty() {
319        return Ok(Vec::new());
320    }
321    let div = if files.len() <= 90_000 { 100 } else { 200 };
322    let parent = files[0]
323        .parent()
324        .ok_or_else(|| Error::mux("input file has no parent directory"))?;
325    let mut new_files = Vec::new();
326    for (index, chunk) in files.chunks(div).enumerate() {
327        if chunk.is_empty() {
328            continue;
329        }
330        let output = parent.join(format!("T{index:04}.ts"));
331        combine_files(chunk, &output).await?;
332        for path in chunk {
333            if tokio::fs::try_exists(path).await? {
334                tokio::fs::remove_file(path).await?;
335            }
336        }
337        new_files.push(output);
338    }
339    Ok(new_files)
340}
341
342/// Builds the ffmpeg per-stream merge command line.
343pub fn plan_ffmpeg_merge(request: FfmpegMergeRequest<'_>) -> Result<MuxCommandPlan> {
344    let FfmpegMergeRequest {
345        binary,
346        files,
347        output_base_path,
348        format,
349        use_aac_filter,
350        fast_start,
351        write_date,
352        use_concat_demuxer,
353        concat_list_path,
354        metadata,
355    } = request;
356    let first = files
357        .first()
358        .ok_or_else(|| Error::mux("ffmpeg merge requires at least one input"))?;
359    let working_directory = first
360        .parent()
361        .map(Path::to_path_buf)
362        .ok_or_else(|| Error::mux("input file has no parent directory"))?;
363    let mut command = String::from("-loglevel warning -nostdin ");
364    if use_concat_demuxer {
365        let concat_list_path = concat_list_path
366            .ok_or_else(|| Error::mux("ffmpeg concat demuxer requires a concat list path"))?;
367        let concat_list_path = absolute_output_base_path(concat_list_path)?;
368        command.push_str(&format!(
369            " -f concat -safe 0 -i \"{}\"",
370            concat_list_path.display()
371        ));
372    } else {
373        command.push_str(" -i concat:\"");
374        for path in files {
375            if let Some(name) = path.file_name().and_then(|value| value.to_str()) {
376                command.push_str(name);
377                command.push('|');
378            }
379        }
380        command.push('"');
381    }
382
383    let output_base_path = absolute_output_base_path(output_base_path)?;
384    let output_path = path_with_appended_extension(&output_base_path, merge_extension(format));
385    let generated_date;
386    let date_string = match metadata
387        .recording_time
388        .as_deref()
389        .or(metadata.date_string.as_deref())
390    {
391        Some(value) => value,
392        None => {
393            generated_date = current_local_iso_timestamp();
394            generated_date.as_str()
395        }
396    };
397    let ddp_audio = metadata.ddp_audio.as_ref();
398    let mut use_aac_filter = use_aac_filter;
399    if ddp_audio.is_some() {
400        use_aac_filter = false;
401    }
402    match format {
403        MergeOutputFormat::Mp4 => {
404            if let Some(poster) = &metadata.poster {
405                command.push_str(&format!(" -i \"{}\"", poster.display()));
406            }
407            if let Some(ddp_audio) = ddp_audio {
408                command.push_str(&format!(" -i \"{}\"", ddp_audio.display()));
409            }
410            command.push_str(" -map 0:v?");
411            if ddp_audio.is_some() {
412                let ddp_input_index = if metadata.poster.is_some() { 2 } else { 1 };
413                command.push_str(&format!(" -map {ddp_input_index}:a -map 0:a?"));
414            } else {
415                command.push_str(" -map 0:a?");
416            }
417            command.push_str(" -map 0:s?");
418            if metadata.poster.is_some() {
419                command.push_str(" -map 1 -c:v:1 copy -disposition:v:1 attached_pic");
420            }
421            if write_date {
422                command.push_str(&format!(" -metadata date=\"{date_string}\""));
423            }
424            let audio_metadata_index = if ddp_audio.is_some() { 1 } else { 0 };
425            command.push_str(&format!(
426                " -metadata encoding_tool=\"{}\" -metadata title=\"{}\" -metadata copyright=\"{}\" -metadata comment=\"{}\" -metadata:s:a:{audio_metadata_index} title=\"{}\" -metadata:s:a:{audio_metadata_index} handler=\"{}\"",
427                metadata.encoding_tool,
428                metadata.title,
429                metadata.copyright,
430                metadata.comment,
431                metadata.audio_name,
432                metadata.audio_name
433            ));
434            if ddp_audio.is_some() {
435                command.push_str(" -metadata:s:a:0 title=\"DD+\" -metadata:s:a:0 handler=\"DD+\"");
436            }
437            if fast_start {
438                command.push_str(" -movflags +faststart");
439            }
440            command.push_str(" -c copy -y");
441            if use_aac_filter {
442                command.push_str(" -bsf:a aac_adtstoasc");
443            }
444            command.push_str(&format!(" \"{}\"", output_path.display()));
445        }
446        MergeOutputFormat::Mkv => append_simple_ffmpeg_output(
447            &mut command,
448            " -map 0 -c copy -y",
449            use_aac_filter,
450            &output_path,
451        ),
452        MergeOutputFormat::Flv => append_simple_ffmpeg_output(
453            &mut command,
454            " -map 0 -c copy -y",
455            use_aac_filter,
456            &output_path,
457        ),
458        MergeOutputFormat::M4a => append_simple_ffmpeg_output(
459            &mut command,
460            " -map 0 -c copy -f mp4 -y",
461            use_aac_filter,
462            &output_path,
463        ),
464        MergeOutputFormat::Ts => {
465            command.push_str(&format!(
466                " -map 0 -c copy -y -f mpegts -bsf:v h264_mp4toannexb \"{}\"",
467                output_path.display()
468            ));
469        }
470        MergeOutputFormat::Eac3 => {
471            command.push_str(&format!(
472                " -map 0:a -c copy -y \"{}\"",
473                output_path.display()
474            ));
475        }
476        MergeOutputFormat::Aac => {
477            command.push_str(&format!(
478                " -map 0:a -c copy -y \"{}\"",
479                output_path.display()
480            ));
481        }
482        MergeOutputFormat::Ac3 => {
483            command.push_str(&format!(
484                " -map 0:a -c copy -y \"{}\"",
485                output_path.display()
486            ));
487        }
488    }
489
490    Ok(MuxCommandPlan {
491        program: binary.to_path_buf(),
492        arguments: command,
493        working_directory,
494        output_path,
495    })
496}
497
498/// Builds the ffmpeg mux-after-done command line.
499pub fn plan_ffmpeg_mux(
500    binary: impl Into<PathBuf>,
501    files: &[OutputFile],
502    output_base_path: &Path,
503    format: MuxFormat,
504    date_info: bool,
505    date_string: Option<&str>,
506) -> Result<MuxCommandPlan> {
507    let mut command = String::from("-loglevel warning -nostdin -y -dn ");
508    for file in files {
509        command.push_str(&format!(" -i \"{}\" ", file.file_path.display()));
510    }
511    for index in 0..files.len() {
512        command.push_str(&format!(" -map {index} "));
513    }
514    let has_srt = files
515        .iter()
516        .any(|file| has_extension(&file.file_path, "srt"));
517    match format {
518        MuxFormat::Mp4 => {
519            command.push_str(" -strict unofficial -c:a copy -c:v copy -c:s mov_text ")
520        }
521        MuxFormat::Ts => command.push_str(" -strict unofficial -c:a copy -c:v copy "),
522        MuxFormat::Mkv => {
523            command.push_str(" -strict unofficial -c:a copy -c:v copy -c:s ");
524            command.push_str(if has_srt { "srt " } else { "webvtt " });
525        }
526    }
527    command.push_str(" -map_metadata -1 ");
528    let mut stream_index = 0_usize;
529    for file in files {
530        let (language, description) = mux_language_and_description(file);
531        command.push_str(&format!(
532            " -metadata:s:{stream_index} language=\"{}\" ",
533            language
534        ));
535        if let Some(description) = &description
536            && !description.is_empty()
537        {
538            command.push_str(&format!(
539                " -metadata:s:{stream_index} title=\"{description}\" "
540            ));
541        }
542        stream_index += file.media_infos.len().max(1);
543    }
544    if files.iter().any(|file| {
545        file.media_type != Some(MediaType::Audio) && file.media_type != Some(MediaType::Subtitles)
546    }) {
547        command.push_str(" -disposition:v:0 default ");
548    }
549    let audio_count = files
550        .iter()
551        .filter(|file| file.media_type == Some(MediaType::Audio))
552        .count();
553    if audio_count > 0 {
554        command.push_str(" -disposition:a:0 default ");
555        for index in 1..audio_count {
556            command.push_str(&format!(" -disposition:a:{index} 0 "));
557        }
558        command.push_str(" -disposition:s 0 ");
559    }
560    if date_info {
561        let generated_date;
562        let date_string = match date_string {
563            Some(value) => value,
564            None => {
565                generated_date = current_local_iso_timestamp();
566                generated_date.as_str()
567            }
568        };
569        command.push_str(&format!(" -metadata date=\"{}\" ", date_string));
570    }
571    command.push_str(" -ignore_unknown -copy_unknown ");
572    let output_base_path = absolute_output_base_path(output_base_path)?;
573    let output_path = path_with_appended_extension(&output_base_path, mux_extension(format));
574    command.push_str(&format!(" \"{}\"", output_path.display()));
575    Ok(MuxCommandPlan {
576        program: binary.into(),
577        arguments: command,
578        working_directory: std::env::current_dir()?,
579        output_path,
580    })
581}
582
583/// Builds the mkvmerge mux-after-done command line.
584pub fn plan_mkvmerge_mux(
585    binary: impl Into<PathBuf>,
586    files: &[OutputFile],
587    output_base_path: &Path,
588) -> Result<MuxCommandPlan> {
589    let output_base_path = absolute_output_base_path(output_base_path)?;
590    let output_path = path_with_appended_extension(&output_base_path, ".mkv");
591    let mut command = format!("-q --output \"{}\"  --no-chapters ", output_path.display());
592    let mut audio_default_seen = false;
593    for file in files {
594        let (language, description) = mux_language_and_description(file);
595        command.push_str(&format!(" --language 0:\"{}\" ", language));
596        if file.media_type == Some(MediaType::Subtitles) {
597            command.push_str(" --default-track 0:no ");
598        }
599        if file.media_type == Some(MediaType::Audio) {
600            if audio_default_seen {
601                command.push_str(" --default-track 0:no ");
602            }
603            audio_default_seen = true;
604        }
605        if let Some(description) = &description
606            && !description.is_empty()
607        {
608            command.push_str(&format!(" --track-name 0:\"{description}\" "));
609        }
610        command.push_str(&format!(" \"{}\" ", file.file_path.display()));
611    }
612    Ok(MuxCommandPlan {
613        program: binary.into(),
614        arguments: command,
615        working_directory: std::env::current_dir()?,
616        output_path,
617    })
618}
619
620fn mux_language_and_description(file: &OutputFile) -> (String, Option<String>) {
621    let Some(original) = file.lang_code.as_deref().filter(|value| !value.is_empty()) else {
622        return ("und".to_string(), file.description.clone());
623    };
624    let language = convert_language_code(original);
625    let description = file
626        .description
627        .as_ref()
628        .filter(|value| !value.is_empty())
629        .cloned()
630        .or_else(|| language_display_name(original));
631    (language, description)
632}
633
634fn convert_language_code(value: &str) -> String {
635    let normalized = value.trim();
636    let lower = normalized.to_ascii_lowercase();
637    if let Some(language) = language_code_map(lower.as_str()) {
638        return language.to_string();
639    }
640    if let Some((prefix, _)) = lower.split_once('-')
641        && let Some(language) = language_code_map(prefix)
642    {
643        return language.to_string();
644    }
645    if normalized.len() == 3 && normalized.chars().all(|ch| ch.is_ascii_alphabetic()) {
646        return lower;
647    }
648    "und".to_string()
649}
650
651fn language_display_name(value: &str) -> Option<String> {
652    let lower = value.trim().to_ascii_lowercase();
653    language_name_map(lower.as_str())
654        .or_else(|| {
655            lower
656                .split_once('-')
657                .and_then(|(prefix, _)| language_name_map(prefix))
658        })
659        .map(str::to_string)
660        .or_else(|| Some(value.to_string()))
661}
662
663fn language_code_map(value: &str) -> Option<&'static str> {
664    match value {
665        "default" => Some("und"),
666        "ar" => Some("ara"),
667        "bg" => Some("bul"),
668        "ca" => Some("cat"),
669        "zh" | "zho" | "chi" | "chs" | "zh-cn" | "zh-sg" | "zh-mo" | "zh-hans" | "zh-hant"
670        | "zh-tw" | "zh-hant-tw" | "zh-hk" | "zh-hant-hk" | "yue" | "cmn" | "cmn-hans"
671        | "cmn-hant" | "cantonese" | "mandarin" | "cn" | "cc" | "cz" => Some("chi"),
672        "cs" => Some("ces"),
673        "da" => Some("dan"),
674        "de" => Some("deu"),
675        "el" => Some("ell"),
676        "en" | "english" => Some("eng"),
677        "es" => Some("spa"),
678        "fi" => Some("fin"),
679        "fr" => Some("fra"),
680        "he" => Some("heb"),
681        "hu" => Some("hun"),
682        "is" => Some("isl"),
683        "it" => Some("ita"),
684        "ja" | "japanese" => Some("jpn"),
685        "ko" | "korean" => Some("kor"),
686        "nl" => Some("nld"),
687        "nb" => Some("nob"),
688        "pl" => Some("pol"),
689        "pt" => Some("por"),
690        "ro" => Some("ron"),
691        "ru" => Some("rus"),
692        "hr" => Some("hrv"),
693        "sk" => Some("slk"),
694        "sq" => Some("sqi"),
695        "sv" => Some("swe"),
696        "th" | "thai" => Some("tha"),
697        "tr" => Some("tur"),
698        "ur" => Some("urd"),
699        "id" => Some("ind"),
700        "uk" => Some("ukr"),
701        "vi" | "vietnamese" => Some("vie"),
702        "ms" | "ma" => Some("msa"),
703        _ => None,
704    }
705}
706
707fn language_name_map(value: &str) -> Option<&'static str> {
708    match value {
709        "default" => Some("default"),
710        "en" | "eng" | "english" => Some("English"),
711        "ja" | "jpn" | "japanese" => Some("\u{65e5}\u{672c}\u{8a9e}"),
712        "ko" | "kor" | "korean" => Some("\u{d55c}\u{ad6d}\u{c5b4}"),
713        "vi" | "vie" | "vietnamese" => Some("Vietnamese"),
714        "th" | "tha" | "thai" => Some("Thai"),
715        "fr" | "fra" => Some("French"),
716        "es" | "spa" => Some("Spanish"),
717        "de" | "deu" => Some("German"),
718        "it" | "ita" => Some("Italian"),
719        "pt" | "por" => Some("Portuguese"),
720        "ru" | "rus" => Some("Russian"),
721        "zh" | "zho" | "chi" | "chs" | "zh-cn" | "zh-sg" | "zh-hans" | "cmn" | "cmn-hans"
722        | "mandarin" | "cn" | "cz" => Some("\u{4e2d}\u{6587}"),
723        "zh-mo" | "zh-hant" | "zh-tw" | "zh-hant-tw" | "zh-hk" | "zh-hant-hk" | "yue"
724        | "cmn-hant" | "cantonese" | "cc" => Some("\u{4e2d}\u{6587}"),
725        _ => None,
726    }
727}
728
729fn path_with_appended_extension(base: &Path, extension: &str) -> PathBuf {
730    let mut value = OsString::from(base.as_os_str());
731    value.push(extension);
732    PathBuf::from(value)
733}
734
735fn absolute_output_base_path(path: &Path) -> Result<PathBuf> {
736    if path.is_absolute() {
737        return Ok(path.to_path_buf());
738    }
739    Ok(std::env::current_dir()?.join(path))
740}
741
742/// Creates output-file entries for mux imports with compatibility ordering.
743pub fn output_files_with_imports(
744    mut outputs: Vec<OutputFile>,
745    imports: &[MuxImport],
746    skip_subtitle: bool,
747) -> Vec<OutputFile> {
748    outputs.extend(imports.iter().map(|import| OutputFile {
749        media_type: None,
750        index: import.index,
751        file_path: import.path.clone(),
752        lang_code: import.language.clone(),
753        description: import.name.clone(),
754        media_infos: Vec::new(),
755    }));
756    if skip_subtitle {
757        outputs.retain(|file| file.media_type != Some(MediaType::Subtitles));
758    }
759    outputs.sort_by_key(|file| file.index);
760    outputs
761}
762
763/// Validates explicit mp4forge mux-after-done requests before side effects.
764pub fn validate_mp4forge_mux_after_done(
765    format: MuxFormat,
766    muxer: MuxerKind,
767    files: &[OutputFile],
768    matrix: &Mp4forgeSupportMatrix,
769) -> Result<()> {
770    if muxer != MuxerKind::Mp4forge {
771        return Ok(());
772    }
773    if format != MuxFormat::Mp4 {
774        return Err(Error::mux(
775            "mp4forge mux-after-done supports only mp4 output",
776        ));
777    }
778    for file in files {
779        if file.media_type == Some(MediaType::Subtitles) {
780            return Err(Error::mux(
781                "mp4forge mux-after-done does not support subtitle inputs",
782            ));
783        }
784        if let Some(info) = file.media_infos.first()
785            && let Some(codec) = info.base_info.as_deref().or(info.text.as_deref())
786        {
787            validate_codec_text(codec, file.media_type, matrix)?;
788        }
789    }
790    Ok(())
791}
792
793/// Validates explicit mp4forge per-stream merge requests before side effects.
794pub fn validate_mp4forge_merge_request(
795    output_path: &Path,
796    stream: &Stream,
797    binary_concat_only: bool,
798    live_pipe_mux: bool,
799    matrix: &Mp4forgeSupportMatrix,
800) -> Result<Mp4forgeSupport> {
801    if binary_concat_only {
802        return Err(Error::mux(
803            "mp4forge merge cannot be used for binary-concat-only paths",
804        ));
805    }
806    if live_pipe_mux {
807        return Err(Error::mux(
808            "mp4forge merge cannot be used for live pipe mux paths",
809        ));
810    }
811    if stream.media_type == Some(MediaType::Subtitles) {
812        return Err(Error::mux(
813            "mp4forge merge does not support subtitle outputs",
814        ));
815    }
816    let extension = output_path
817        .extension()
818        .and_then(|value| value.to_str())
819        .unwrap_or_default()
820        .to_ascii_lowercase();
821    if !matches!(extension.as_str(), "mp4" | "m4a") {
822        return Err(Error::mux(
823            "mp4forge merge supports only mp4 and m4a outputs",
824        ));
825    }
826    mp4forge_support_for_stream(stream, matrix)
827}
828
829/// Checks stream metadata against the explicit mp4forge support matrix.
830pub fn mp4forge_support_for_stream(
831    stream: &Stream,
832    matrix: &Mp4forgeSupportMatrix,
833) -> Result<Mp4forgeSupport> {
834    if stream.playlist.as_ref().is_some_and(|playlist| {
835        playlist.media_parts.iter().any(|part| {
836            part.media_segments.iter().any(|segment| {
837                !matches!(
838                    segment.encryption.method,
839                    EncryptionMethod::None | EncryptionMethod::Aes128 | EncryptionMethod::Aes128Ecb
840                )
841            })
842        })
843    }) {
844        return Ok(Mp4forgeSupport::Unsupported {
845            reason: "unsupported encrypted sample family".to_string(),
846        });
847    }
848    let Some(codecs) = stream.codecs.as_deref().filter(|value| !value.is_empty()) else {
849        return Ok(Mp4forgeSupport::RequiresProbe {
850            reason: "codec metadata is not available before media probing".to_string(),
851        });
852    };
853    validate_codec_text(codecs, stream.media_type, matrix)?;
854    Ok(Mp4forgeSupport::Supported)
855}
856
857fn validate_codec_text(
858    codec_text: &str,
859    media_type: Option<MediaType>,
860    matrix: &Mp4forgeSupportMatrix,
861) -> Result<()> {
862    let codecs = codec_text
863        .split(',')
864        .map(|codec| codec.trim().to_ascii_lowercase())
865        .filter(|codec| !codec.is_empty())
866        .collect::<Vec<_>>();
867    if codecs.is_empty() {
868        return Ok(());
869    }
870    let supported = match media_type {
871        Some(MediaType::Audio) => codecs
872            .iter()
873            .all(|codec| has_any_prefix(codec, &matrix.audio_codecs)),
874        Some(MediaType::Video) | None => codecs.iter().all(|codec| {
875            has_any_prefix(codec, &matrix.video_codecs)
876                || has_any_prefix(codec, &matrix.audio_codecs)
877        }),
878        Some(MediaType::Subtitles | MediaType::ClosedCaptions) => false,
879    };
880    if supported {
881        Ok(())
882    } else {
883        Err(Error::mux(format!(
884            "mp4forge does not support codec family: {codec_text}"
885        )))
886    }
887}
888
889fn has_any_prefix(value: &str, prefixes: &[String]) -> bool {
890    prefixes.iter().any(|prefix| value.starts_with(prefix))
891}
892
893fn append_simple_ffmpeg_output(
894    command: &mut String,
895    base: &str,
896    use_aac_filter: bool,
897    output_path: &Path,
898) {
899    command.push_str(base);
900    if use_aac_filter {
901        command.push_str(" -bsf:a aac_adtstoasc");
902    }
903    command.push_str(&format!(" \"{}\"", output_path.display()));
904}
905
906fn has_extension(path: &Path, extension: &str) -> bool {
907    path.extension()
908        .and_then(|value| value.to_str())
909        .is_some_and(|value| value.eq_ignore_ascii_case(extension))
910}