Skip to main content

haki_dl/
session.rs

1//! Session state models.
2
3use std::collections::{BTreeMap, HashSet};
4use std::env;
5use std::ffi::OsString;
6use std::fs::File;
7#[cfg(not(windows))]
8use std::fs::OpenOptions;
9use std::io::IsTerminal;
10#[cfg(not(windows))]
11use std::io::Write;
12use std::path::{Path, PathBuf};
13use std::process::{Child, Command as StdCommand, Stdio};
14use std::sync::{Arc, Mutex, mpsc};
15use std::thread;
16use std::time::{Duration, Instant};
17
18use time::OffsetDateTime;
19use tokio::io::AsyncReadExt;
20use tokio::process::{Child as TokioChild, Command};
21
22use crate::api::{DownloadRequest, ProgressCallback};
23use crate::cancellation::CancellationToken;
24use crate::config::{
25    CustomRange, DecryptionEngine, DownloadOptions, LogLevel, MuxAfterDoneOptions, MuxerKind,
26    SubtitleFormat,
27};
28use crate::dash::DashParser;
29use crate::datetime::{current_local_iso_timestamp, parse_manifest_timestamp_millis};
30use crate::decrypt::{
31    SelectedKey, custom_keys_to_pairs, decrypt_hls_segment_file, read_mp4_protection_info,
32    redact_secrets, search_key_text_file, select_key_pair,
33};
34use crate::download::{
35    DownloadEventEmitter, DownloadHooks, DownloadScheduler, SegmentCompletedHook,
36    StreamCompletedHook, StreamDownloadResult, http_client, planned_output_path,
37};
38use crate::error::{Error, Result};
39use crate::event::ProgressEvent;
40use crate::hls::HlsParser;
41use crate::http::{
42    DefaultHttpClient, LIVE_REFRESH_RETRY_ATTEMPTS, LIVE_REFRESH_RETRY_DELAY,
43    SOURCE_RETRY_ATTEMPTS, SOURCE_RETRY_DELAY, SOURCE_RETRY_DELAY_INCREMENT, apply_request_headers,
44    sleep_for_retry,
45};
46use crate::live::{
47    HttpLiveTsState, LiveStreamState, add_recorded_duration, compute_live_wait_seconds,
48    filter_new_live_segments, live_option_effects, plan_live_pipe_mux, sync_live_startup_windows,
49    update_http_live_ts_state,
50};
51use crate::manifest::{
52    EncryptionMethod, ExtractorType, Manifest, MediaSegment, MediaType, Stream, StreamSelector,
53};
54use crate::media_info::{media_info_console_label, probe_ffmpeg_media_infos};
55use crate::mss::MssParser;
56use crate::mux::{
57    FfmpegMergeMetadata, FfmpegMergeRequest, MediaInfo, MergeOutputFormat, Mp4forgeSupportMatrix,
58    MuxCommandPlan, MuxFormat, OutputArtifact, OutputFile, combine_files,
59    mp4forge_support_for_stream, mux_extension, output_files_with_imports, partial_combine_files,
60    plan_ffmpeg_merge, plan_ffmpeg_mux, plan_mkvmerge_mux, validate_mp4forge_mux_after_done,
61};
62use crate::observability::{
63    DEFAULT_UPDATE_CHECK_URL, LogFilePlan, LogPlanConfig, UpdateCheckHttpClient, append_log_file,
64    check_update_with_client, initialize_log_file, should_log, streams_metadata_json,
65};
66use crate::processor::ParserConfig;
67use crate::progress::AggregateProgress;
68use crate::selection::{
69    apply_custom_range, auto_select_streams, clean_ad_segments, filter_drop, filter_keep,
70    format_save_pattern, handle_file_collision, handle_file_collision_with_reserved,
71    interactive_default_streams, order_streams, save_name_from_input, subtitle_only_streams,
72    valid_file_name,
73};
74use crate::source::{
75    HTTP_LIVE_TS_MARKER, LoadedSource, LoadedSourceKind, SourceLoader, write_raw_files,
76};
77use crate::stream_label::{stream_full_label, stream_short_label};
78use crate::subtitle::{
79    WebVttSubtitle, check_stpp_init, check_wvtt_init, extract_stpp_from_files,
80    extract_ttml_from_files, extract_wvtt_from_files_with_console_lines, format_subtitle,
81    parse_webvtt_bytes, write_image_pngs,
82};
83
84/// High-level session state.
85#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
86pub enum SessionState {
87    /// Session was created but has not started.
88    #[default]
89    Planned,
90    /// Session is running.
91    Running,
92    /// Session completed successfully.
93    Finished,
94    /// Session failed.
95    Failed,
96    /// Session was cancelled.
97    Cancelled,
98}
99
100/// A planned download session.
101#[derive(Clone, Debug)]
102pub struct DownloadSession {
103    request: DownloadRequest,
104    state: SessionState,
105}
106
107#[derive(Clone)]
108struct WorkDirectories {
109    temp_root: PathBuf,
110    save_dir: PathBuf,
111}
112
113struct ExternalDecryptCommand {
114    arguments: Vec<String>,
115    working_directory: Option<PathBuf>,
116}
117
118const KEEP_IMAGE_SEGMENTS_ENV: &str = "HAKI_DL_KEEP_IMAGE_SEGMENTS";
119const LIVE_PIPE_OPTIONS_ENV: &str = "HAKI_DL_LIVE_PIPE_OPTIONS";
120const LIVE_PIPE_TMP_DIR_ENV: &str = "HAKI_DL_LIVE_PIPE_TMP_DIR";
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123struct LivePipeEnvironment {
124    custom_destination: Option<String>,
125    pipe_dir: PathBuf,
126}
127
128fn live_pipe_environment() -> LivePipeEnvironment {
129    live_pipe_environment_from(|name| env::var_os(name), env::temp_dir())
130}
131
132fn live_pipe_environment_from(
133    mut lookup: impl FnMut(&str) -> Option<OsString>,
134    default_pipe_dir: PathBuf,
135) -> LivePipeEnvironment {
136    let custom_destination = lookup(LIVE_PIPE_OPTIONS_ENV)
137        .and_then(|value| value.into_string().ok())
138        .filter(|value| !value.is_empty());
139    let pipe_dir = lookup(LIVE_PIPE_TMP_DIR_ENV)
140        .map(PathBuf::from)
141        .filter(|path| !path.as_os_str().is_empty())
142        .unwrap_or(default_pipe_dir);
143
144    LivePipeEnvironment {
145        custom_destination,
146        pipe_dir,
147    }
148}
149
150impl DownloadSession {
151    /// Creates a new planned session.
152    pub fn new(request: DownloadRequest) -> Self {
153        Self {
154            request,
155            state: SessionState::Planned,
156        }
157    }
158
159    /// Returns the original request.
160    pub fn request(&self) -> &DownloadRequest {
161        &self.request
162    }
163
164    /// Returns current session state.
165    pub fn state(&self) -> SessionState {
166        self.state
167    }
168
169    /// Starts the session.
170    pub async fn start(self) -> Result<Vec<ProgressEvent>> {
171        Box::pin(execute_request(self.request)).await
172    }
173}
174
175async fn execute_request(mut request: DownloadRequest) -> Result<Vec<ProgressEvent>> {
176    request.cancellation_token.check()?;
177    let progress_callback = request.progress_callback.clone();
178    let mut emitted_event_count = 0_usize;
179    let mut events = vec![ProgressEvent::PlanningStarted];
180    emit_new_progress_events(
181        &events,
182        progress_callback.as_ref(),
183        &mut emitted_event_count,
184    )?;
185    let log_plan = initialize_session_log(&request.options, &mut events).await;
186    spawn_update_check_logger(
187        request.options.disable_update_check,
188        progress_callback.clone(),
189        log_plan.clone(),
190    );
191    let live_pipe_mux_requested = request.options.live_pipe_mux;
192    let mux_after_done_forced_binary =
193        request.options.mux_after_done.is_some() && !request.options.binary_merge;
194    normalize_session_options(&mut request.options);
195    if live_pipe_mux_requested {
196        push_session_log(
197            &mut events,
198            &log_plan,
199            request.options.log_level,
200            LogLevel::Warn,
201            "LivePipeMux detected, forced enable LiveRealTimeMerge",
202        )
203        .await;
204    }
205    if mux_after_done_forced_binary {
206        push_session_log(
207            &mut events,
208            &log_plan,
209            request.options.log_level,
210            LogLevel::Warn,
211            "MuxAfterDone is detected, binary merging is automatically enabled",
212        )
213        .await;
214    }
215    validate_source_option_shape(&request.options).await?;
216    validate_runtime_tools(&mut request.options).await?;
217    push_startup_extra_logs(&mut events, &log_plan, &request.options).await;
218    emit_new_progress_events(
219        &events,
220        progress_callback.as_ref(),
221        &mut emitted_event_count,
222    )?;
223    wait_for_task_start(
224        &request.options,
225        &request.cancellation_token,
226        &mut events,
227        &log_plan,
228    )
229    .await?;
230    emit_new_progress_events(
231        &events,
232        progress_callback.as_ref(),
233        &mut emitted_event_count,
234    )?;
235    ensure_session_save_name(&mut request.options, &request.input);
236    push_session_log(
237        &mut events,
238        &log_plan,
239        request.options.log_level,
240        LogLevel::Info,
241        format!("Loading URL: {}", request.input),
242    )
243    .await;
244    events.push(ProgressEvent::ManifestLoading);
245    emit_new_progress_events(
246        &events,
247        progress_callback.as_ref(),
248        &mut emitted_event_count,
249    )?;
250    let mut config = ParserConfig::from_options(&request.options);
251    if is_http_url(&request.input) {
252        push_session_log(
253            &mut events,
254            &log_plan,
255            request.options.log_level,
256            LogLevel::Debug,
257            format_fetch_debug_message(&request.input, &config.headers),
258        )
259        .await;
260    }
261    let transport = DefaultHttpClient::from_options(&request.options);
262    let loader = source_loader_for_transport(&transport);
263    let session_http_client = http_client(&request.options)?;
264    let loaded =
265        load_source_with_retry(&loader, &request, &mut config, &mut events, &log_plan).await?;
266    request.cancellation_token.check()?;
267    for debug_log in &loaded.debug_logs {
268        push_session_log(
269            &mut events,
270            &log_plan,
271            request.options.log_level,
272            LogLevel::Debug,
273            debug_log,
274        )
275        .await;
276    }
277    push_session_log(
278        &mut events,
279        &log_plan,
280        request.options.log_level,
281        LogLevel::Info,
282        format!("Content Matched: {}", source_content_label(loaded.kind)),
283    )
284    .await;
285    push_session_log(
286        &mut events,
287        &log_plan,
288        request.options.log_level,
289        LogLevel::Info,
290        "Parsing streams...",
291    )
292    .await;
293    push_parser_diagnostics(&mut events, &log_plan, request.options.log_level, &config).await;
294    emit_new_progress_events(
295        &events,
296        progress_callback.as_ref(),
297        &mut emitted_event_count,
298    )?;
299    let dirs = prepare_work_directories(&request.options)?;
300    if loaded.kind == LoadedSourceKind::HttpLiveTs {
301        if request.options.write_meta_json {
302            let stream = direct_http_live_stream(&loaded.final_url, &loaded.original_url);
303            push_session_log(
304                &mut events,
305                &log_plan,
306                request.options.log_level,
307                LogLevel::Warn,
308                "Writing meta json",
309            )
310            .await;
311            let _ = write_source_sidecars(&dirs.temp_root, &loaded.raw_files, &[stream]).await?;
312        }
313        let events = execute_http_live_ts(
314            &request,
315            &loaded.final_url,
316            &config.headers,
317            &dirs,
318            events,
319            &log_plan,
320        )
321        .await?;
322        emit_new_progress_events(
323            &events,
324            progress_callback.as_ref(),
325            &mut emitted_event_count,
326        )?;
327        return Ok(events);
328    }
329
330    let mut manifest =
331        parse_loaded_manifest(&loaded.text, loaded.kind, &loaded.final_url, &config).await?;
332    push_parser_diagnostics(&mut events, &log_plan, request.options.log_level, &config).await;
333    for warning in &manifest.warnings {
334        events.push(ProgressEvent::Warning {
335            message: warning.clone(),
336        });
337    }
338    events.push(ProgressEvent::ManifestParsed {
339        stream_count: manifest.streams.len(),
340    });
341    let raw_streams = manifest.streams.clone();
342    if request.options.write_meta_json {
343        push_session_log(
344            &mut events,
345            &log_plan,
346            request.options.log_level,
347            LogLevel::Warn,
348            "Writing meta json",
349        )
350        .await;
351        let _ = write_source_sidecars(&dirs.temp_root, &loaded.raw_files, &raw_streams).await?;
352    }
353    push_extracted_stream_logs(
354        &mut events,
355        &log_plan,
356        request.options.log_level,
357        &manifest.streams,
358    )
359    .await;
360    emit_new_progress_events(
361        &events,
362        progress_callback.as_ref(),
363        &mut emitted_event_count,
364    )?;
365    let mut selected = select_streams(&manifest.streams, &request)?;
366    if selected_requires_playlist_fetch(loaded.kind, &selected) {
367        push_session_log(
368            &mut events,
369            &log_plan,
370            request.options.log_level,
371            LogLevel::Info,
372            "Parsing streams...",
373        )
374        .await;
375        match loaded.kind {
376            LoadedSourceKind::Hls => {
377                hls_parser_for_transport(&transport)
378                    .fetch_playlists(&mut selected, &mut config)
379                    .await?;
380            }
381            LoadedSourceKind::Dash => {
382                DashParser::new().process_stream_urls(&mut selected, &config)?;
383            }
384            LoadedSourceKind::Mss => {
385                MssParser::new().process_stream_urls(&mut selected, &config)?;
386            }
387            LoadedSourceKind::BinaryData => {}
388            _ => {}
389        }
390        push_parser_diagnostics(&mut events, &log_plan, request.options.log_level, &config).await;
391    }
392    let selected_is_live = selected.iter().any(stream_is_live);
393    let mut live_states = Vec::new();
394    let mut live_record_limit_reached_logged = false;
395    if selected_is_live && !request.options.live_perform_as_vod {
396        apply_live_runtime_option_effects(&mut request.options, &selected);
397        push_session_log(
398            &mut events,
399            &log_plan,
400            request.options.log_level,
401            LogLevel::Warn,
402            "Live stream found",
403        )
404        .await;
405        emit_new_progress_events(
406            &events,
407            progress_callback.as_ref(),
408            &mut emitted_event_count,
409        )?;
410    }
411    if !selected_is_live || request.options.live_perform_as_vod {
412        if let Some(custom_range) = &request.options.custom_range {
413            push_session_log(
414                &mut events,
415                &log_plan,
416                request.options.log_level,
417                LogLevel::Info,
418                format!("User customed range: {}", custom_range_input(custom_range)),
419            )
420            .await;
421            push_session_log(
422                &mut events,
423                &log_plan,
424                request.options.log_level,
425                LogLevel::Warn,
426                "Please note that custom range may sometimes result in audio and video being out of sync",
427            )
428            .await;
429        }
430        apply_custom_range(&mut selected, request.options.custom_range.as_ref());
431    }
432    clean_ad_segments_with_logs(
433        &mut selected,
434        &request.options.ad_keywords,
435        &mut events,
436        &log_plan,
437        request.options.log_level,
438    )
439    .await?;
440    if selected.is_empty() {
441        return Err(Error::compatibility("no streams were selected"));
442    }
443    validate_mp4forge_decrypt_before_download(&selected, &request.options)?;
444    validate_explicit_mp4forge_before_download(&selected, &request.options)?;
445    for stream in &selected {
446        events.push(ProgressEvent::StreamSelected {
447            stream_id: stream_identity(stream),
448        });
449    }
450    push_selected_stream_logs(&mut events, &log_plan, request.options.log_level, &selected).await;
451    if request.options.write_meta_json {
452        push_session_log(
453            &mut events,
454            &log_plan,
455            request.options.log_level,
456            LogLevel::Warn,
457            "Writing meta json",
458        )
459        .await;
460        let mut selected_file = BTreeMap::new();
461        selected_file.insert(
462            "meta_selected.json".to_string(),
463            streams_metadata_json(&selected),
464        );
465        for path in write_raw_files(&selected_file, &dirs.temp_root).await? {
466            events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(path)));
467        }
468    }
469    emit_new_progress_events(
470        &events,
471        progress_callback.as_ref(),
472        &mut emitted_event_count,
473    )?;
474    request.cancellation_token.check()?;
475    manifest.streams = selected.clone();
476    manifest.is_live = selected.iter().any(|stream| {
477        stream
478            .playlist
479            .as_ref()
480            .is_some_and(|playlist| playlist.is_live)
481    });
482    if request.options.skip_download {
483        events.push(ProgressEvent::Finished { success: true });
484        emit_new_progress_events(
485            &events,
486            progress_callback.as_ref(),
487            &mut emitted_event_count,
488        )?;
489        return Ok(events);
490    }
491
492    push_save_name_log(
493        &mut events,
494        &log_plan,
495        request.options.log_level,
496        request.options.save_name.as_deref(),
497    )
498    .await;
499    tokio::fs::create_dir_all(&dirs.temp_root).await?;
500    tokio::fs::create_dir_all(&dirs.save_dir).await?;
501    if selected_is_live && !request.options.live_perform_as_vod {
502        sync_live_startup_windows(&mut selected, request.options.live_take_count);
503        let live_wait_seconds =
504            compute_live_wait_seconds(&selected, request.options.live_wait_time);
505        push_session_log(
506            &mut events,
507            &log_plan,
508            request.options.log_level,
509            LogLevel::Warn,
510            format!("set refresh interval to {live_wait_seconds} seconds"),
511        )
512        .await;
513        if let Some(limit) = request.options.live_record_limit {
514            push_session_log(
515                &mut events,
516                &log_plan,
517                request.options.log_level,
518                LogLevel::Warn,
519                format!(
520                    "Live recording duration limit: {}",
521                    format_duration_short_for_log(limit)
522                ),
523            )
524            .await;
525        }
526        if matches!(
527            loaded.kind,
528            LoadedSourceKind::Hls | LoadedSourceKind::Dash | LoadedSourceKind::Mss
529        ) {
530            validate_live_refresh_request(&selected, &request.options)?;
531            live_states = seed_live_states(
532                &mut selected,
533                loaded.kind == LoadedSourceKind::Hls,
534                request.options.live_record_limit,
535                &mut events,
536            )?;
537            log_live_record_limit_reached_if_needed(
538                &live_states,
539                &request.options,
540                &mut live_record_limit_reached_logged,
541                &mut events,
542                &log_plan,
543            )
544            .await;
545        }
546        if !request.options.binary_merge && live_streams_need_fmp4_binary_merge(&selected) {
547            request.options.binary_merge = true;
548            push_session_log(
549                &mut events,
550                &log_plan,
551                request.options.log_level,
552                LogLevel::Warn,
553                "fMP4 is detected, binary merging is automatically enabled",
554            )
555            .await;
556        }
557    }
558    emit_new_progress_events(
559        &events,
560        progress_callback.as_ref(),
561        &mut emitted_event_count,
562    )?;
563    let realtime_hooks = realtime_decrypt_hooks(&request.options, &request.cancellation_token);
564    let (download_result, download_events) = DownloadScheduler::new()
565        .download_streams_with_first_media_probe_and_http_client_capture_with_hooks(
566            &selected,
567            &dirs.temp_root,
568            &dirs.save_dir,
569            &request.options,
570            &config.headers,
571            loaded.kind == LoadedSourceKind::Mss,
572            &session_http_client,
573            progress_callback.as_ref(),
574            &realtime_hooks,
575        )
576        .await;
577    append_extra_log_events(&log_plan, &download_events).await;
578    events.extend(download_events);
579    emitted_event_count = events.len();
580    let mut download_results = download_result?;
581    apply_media_info_decisions(
582        &mut selected,
583        &mut download_results,
584        &mut request.options,
585        loaded.kind,
586        &dirs.save_dir,
587        &mut events,
588        &log_plan,
589    )
590    .await?;
591    emit_new_progress_events(
592        &events,
593        progress_callback.as_ref(),
594        &mut emitted_event_count,
595    )?;
596    request.cancellation_token.check()?;
597    let is_live_session = selected_is_live && !request.options.live_perform_as_vod;
598    let live_audio_start_ms = if is_live_session {
599        probe_live_audio_start_ms(&selected, &download_results, &request.options)?
600    } else {
601        None
602    };
603    if is_live_session && request.options.live_pipe_mux {
604        let mut live_pipe_session = LivePipeMuxSession::start(
605            &selected,
606            &download_results,
607            &request.options,
608            &dirs,
609            &mut events,
610        )
611        .await?;
612        emit_new_progress_events(
613            &events,
614            progress_callback.as_ref(),
615            &mut emitted_event_count,
616        )?;
617        stream_downloads_to_live_pipe(
618            &selected,
619            &download_results,
620            &request.options,
621            true,
622            live_audio_start_ms,
623            &mut events,
624            &mut live_pipe_session,
625            &request.cancellation_token,
626        )
627        .await?;
628        emit_new_progress_events(
629            &events,
630            progress_callback.as_ref(),
631            &mut emitted_event_count,
632        )?;
633        if selected_is_live
634            && !request.options.live_perform_as_vod
635            && matches!(
636                loaded.kind,
637                LoadedSourceKind::Hls | LoadedSourceKind::Dash | LoadedSourceKind::Mss
638            )
639        {
640            record_live_downloaded_durations(&selected, &mut live_states, &mut events);
641            download_live_refreshes(
642                &selected,
643                &mut live_states,
644                &download_results,
645                &request,
646                loaded.kind,
647                &mut config,
648                &dirs,
649                &transport,
650                Some(&mut live_pipe_session),
651                live_audio_start_ms,
652                &mut events,
653            )
654            .await?;
655            log_live_record_limit_reached_if_needed(
656                &live_states,
657                &request.options,
658                &mut live_record_limit_reached_logged,
659                &mut events,
660                &log_plan,
661            )
662            .await;
663            emit_new_progress_events(
664                &events,
665                progress_callback.as_ref(),
666                &mut emitted_event_count,
667            )?;
668        }
669        live_pipe_session
670            .finish(&mut events, &request.cancellation_token)
671            .await?;
672        emit_new_progress_events(
673            &events,
674            progress_callback.as_ref(),
675            &mut emitted_event_count,
676        )?;
677    } else {
678        let write_live_outputs = !is_live_session || request.options.live_real_time_merge;
679        let merged_outputs = if write_live_outputs {
680            let outputs = finalize_downloads(
681                &selected,
682                &download_results,
683                &request.options,
684                is_live_session,
685                live_audio_start_ms,
686                &mut events,
687                &request.cancellation_token,
688            )
689            .await?;
690            emit_new_progress_events(
691                &events,
692                progress_callback.as_ref(),
693                &mut emitted_event_count,
694            )?;
695            outputs
696        } else {
697            Vec::new()
698        };
699        if selected_is_live
700            && !request.options.live_perform_as_vod
701            && matches!(
702                loaded.kind,
703                LoadedSourceKind::Hls | LoadedSourceKind::Dash | LoadedSourceKind::Mss
704            )
705        {
706            record_live_downloaded_durations(&selected, &mut live_states, &mut events);
707            download_live_refreshes(
708                &selected,
709                &mut live_states,
710                &download_results,
711                &request,
712                loaded.kind,
713                &mut config,
714                &dirs,
715                &transport,
716                None,
717                live_audio_start_ms,
718                &mut events,
719            )
720            .await?;
721            log_live_record_limit_reached_if_needed(
722                &live_states,
723                &request.options,
724                &mut live_record_limit_reached_logged,
725                &mut events,
726                &log_plan,
727            )
728            .await;
729            emit_new_progress_events(
730                &events,
731                progress_callback.as_ref(),
732                &mut emitted_event_count,
733            )?;
734        }
735        if !merged_outputs.is_empty() {
736            run_mux_after_done(
737                &merged_outputs,
738                &request.options,
739                &mut events,
740                &request.cancellation_token,
741            )
742            .await?;
743        }
744        emit_new_progress_events(
745            &events,
746            progress_callback.as_ref(),
747            &mut emitted_event_count,
748        )?;
749    }
750    if should_cleanup_task_temp_root(&request.options, is_live_session) {
751        cleanup_task_temp_root(&dirs.temp_root, &mut events).await?;
752        emit_new_progress_events(
753            &events,
754            progress_callback.as_ref(),
755            &mut emitted_event_count,
756        )?;
757    }
758    events.push(ProgressEvent::Finished { success: true });
759    emit_new_progress_events(
760        &events,
761        progress_callback.as_ref(),
762        &mut emitted_event_count,
763    )?;
764    if should_log(request.options.log_level, LogLevel::Info) {
765        append_session_log_file(&log_plan, LogLevel::Info, "session finished").await;
766    }
767    Ok(events)
768}
769
770fn spawn_update_check_logger(
771    disabled: bool,
772    progress_callback: Option<ProgressCallback>,
773    log_plan: LogFilePlan,
774) {
775    if disabled {
776        return;
777    }
778    tokio::spawn(async move {
779        let client = UpdateCheckHttpClient::new();
780        let Ok(result) =
781            check_update_with_client(&client, DEFAULT_UPDATE_CHECK_URL, env!("CARGO_PKG_VERSION"))
782                .await
783        else {
784            return;
785        };
786        let Some(latest_version) = result.latest_version.filter(|_| result.update_available) else {
787            return;
788        };
789        let message = format!("New version detected! {latest_version}");
790        append_session_log_file(&log_plan, LogLevel::Info, &message).await;
791        let Some(progress_callback) = progress_callback else {
792            return;
793        };
794        let _ = progress_callback.emit(&ProgressEvent::Log {
795            level: LogLevel::Info,
796            message,
797        });
798    });
799}
800
801async fn validate_source_option_shape(options: &DownloadOptions) -> Result<()> {
802    if !options.mux_imports.is_empty() && options.mux_after_done.is_none() {
803        return Err(Error::config("--mux-import requires --mux-after-done"));
804    }
805    validate_mux_after_done_fallback_shape(options)?;
806    for import in &options.mux_imports {
807        if !tokio::fs::metadata(&import.path)
808            .await
809            .is_ok_and(|metadata| metadata.is_file())
810        {
811            return Err(Error::config("--mux-import path must be an existing file"));
812        }
813    }
814    for (name, filters) in [
815        ("--select-video", options.select_video.as_slice()),
816        ("--select-audio", options.select_audio.as_slice()),
817        ("--select-subtitle", options.select_subtitle.as_slice()),
818        ("--drop-video", options.drop_video.as_slice()),
819        ("--drop-audio", options.drop_audio.as_slice()),
820        ("--drop-subtitle", options.drop_subtitle.as_slice()),
821    ] {
822        if filters.len() > 1 {
823            return Err(Error::config(format!("{name} expects a single value")));
824        }
825    }
826    Ok(())
827}
828
829fn validate_mux_after_done_fallback_shape(options: &DownloadOptions) -> Result<()> {
830    let Some(mux_options) = &options.mux_after_done else {
831        return Ok(());
832    };
833    let Some(fallback_muxer) = mux_options.fallback_muxer else {
834        return Ok(());
835    };
836    if mux_options.muxer != MuxerKind::Mp4forge {
837        return Err(Error::config(
838            "fallback_muxer is only valid with muxer=mp4forge",
839        ));
840    }
841    match fallback_muxer {
842        MuxerKind::Ffmpeg => Ok(()),
843        MuxerKind::Mkvmerge => Err(Error::config(
844            "mkvmerge cannot be used as an mp4forge fallback for mp4 mux-after-done",
845        )),
846        MuxerKind::Mp4forge => Err(Error::config("fallback_muxer must be ffmpeg")),
847    }
848}
849
850fn emit_new_progress_events(
851    events: &[ProgressEvent],
852    progress_callback: Option<&ProgressCallback>,
853    emitted_event_count: &mut usize,
854) -> Result<()> {
855    let Some(progress_callback) = progress_callback else {
856        *emitted_event_count = events.len();
857        return Ok(());
858    };
859    while *emitted_event_count < events.len() {
860        progress_callback.emit(&events[*emitted_event_count])?;
861        *emitted_event_count += 1;
862    }
863    Ok(())
864}
865
866fn normalize_session_options(options: &mut DownloadOptions) {
867    if options
868        .custom_proxy
869        .as_deref()
870        .is_some_and(|value| value.trim().is_empty())
871    {
872        options.custom_proxy = None;
873    }
874    if options
875        .custom_hls_key
876        .as_ref()
877        .is_some_and(|value| value.is_empty())
878    {
879        options.custom_hls_key = None;
880    }
881    if options
882        .custom_hls_iv
883        .as_ref()
884        .is_some_and(|value| value.is_empty())
885    {
886        options.custom_hls_iv = None;
887    }
888    if options.use_shaka_packager {
889        options.decryption_engine = DecryptionEngine::ShakaPackager;
890    }
891    if options.live_pipe_mux {
892        options.live_real_time_merge = true;
893    }
894    if let Some(mux_options) = &options.mux_after_done {
895        options.binary_merge = true;
896        if mux_options.muxer == MuxerKind::Ffmpeg && options.ffmpeg_binary_path.is_none() {
897            options.ffmpeg_binary_path = mux_options.bin_path.clone();
898        }
899    }
900}
901
902fn ensure_session_save_name(options: &mut DownloadOptions, input: &str) {
903    if options
904        .save_name
905        .as_deref()
906        .is_none_or(|value| value.is_empty())
907    {
908        options.save_name = Some(save_name_from_input(input, true));
909    }
910}
911
912fn source_loader_for_transport(transport: &DefaultHttpClient) -> SourceLoader {
913    SourceLoader::new().with_http(transport.clone())
914}
915
916fn hls_parser_for_transport(transport: &DefaultHttpClient) -> HlsParser {
917    HlsParser::new().with_http(transport.clone())
918}
919
920async fn load_source_with_retry(
921    loader: &SourceLoader,
922    request: &DownloadRequest,
923    config: &mut ParserConfig,
924    events: &mut Vec<ProgressEvent>,
925    log_plan: &LogFilePlan,
926) -> Result<LoadedSource> {
927    if SOURCE_RETRY_ATTEMPTS == 0 {
928        return Err(Error::http("Failed to execute action after 0 retries."));
929    }
930    let mut retry_count = 0_usize;
931    let mut next_delay = SOURCE_RETRY_DELAY;
932    loop {
933        match loader.load_source(&request.input, config).await {
934            Ok(loaded) => return Ok(loaded),
935            Err(error) if source_retryable_error(&error) => {
936                retry_count += 1;
937                let message = format!(
938                    "{} ({retry_count}/{SOURCE_RETRY_ATTEMPTS})",
939                    error.compatibility_message()
940                );
941                if should_log(request.options.log_level, LogLevel::Warn) {
942                    append_session_log_file(log_plan, LogLevel::Warn, &message).await;
943                }
944                events.push(ProgressEvent::Warning { message });
945                sleep_for_retry(next_delay, Some(&request.cancellation_token)).await?;
946                if retry_count >= SOURCE_RETRY_ATTEMPTS {
947                    return Err(Error::http(format!(
948                        "Failed to execute action after {SOURCE_RETRY_ATTEMPTS} retries."
949                    )));
950                }
951                next_delay = next_delay.saturating_add(SOURCE_RETRY_DELAY_INCREMENT);
952            }
953            Err(error) => return Err(error),
954        }
955    }
956}
957
958fn source_retryable_error(error: &Error) -> bool {
959    matches!(error, Error::Http { .. } | Error::Io(_))
960}
961
962async fn validate_runtime_tools(options: &mut DownloadOptions) -> Result<()> {
963    validate_ffmpeg_runtime_tool(options).await?;
964    validate_mkvmerge_runtime_tool(options).await?;
965    validate_decrypt_runtime_tool(options).await
966}
967
968async fn validate_ffmpeg_runtime_tool(options: &mut DownloadOptions) -> Result<()> {
969    if let Some(path) = &options.ffmpeg_binary_path {
970        validate_runtime_tool_path(path, "ffmpeg").await?;
971    } else {
972        let path = find_runtime_tool(&["ffmpeg"])
973            .await
974            .ok_or_else(|| Error::config("ffmpeg runtime tool was not found"))?;
975        options.ffmpeg_binary_path = Some(path);
976    }
977
978    Ok(())
979}
980
981async fn validate_mkvmerge_runtime_tool(options: &mut DownloadOptions) -> Result<()> {
982    let Some(mux_options) = &options.mux_after_done else {
983        return Ok(());
984    };
985    let needs_mkvmerge = mux_options.muxer == MuxerKind::Mkvmerge
986        || mux_options.fallback_muxer == Some(MuxerKind::Mkvmerge);
987    if !needs_mkvmerge {
988        return Ok(());
989    }
990    if mux_options.muxer == MuxerKind::Mkvmerge
991        && let Some(path) = &mux_options.bin_path
992    {
993        validate_runtime_tool_path(path, "mkvmerge").await?;
994        return Ok(());
995    }
996    if let Some(path) = &options.mkvmerge_binary_path {
997        validate_runtime_tool_path(path, "mkvmerge").await?;
998        return Ok(());
999    }
1000    let path = find_runtime_tool(&["mkvmerge"])
1001        .await
1002        .ok_or_else(|| Error::config("mkvmerge runtime tool was not found"))?;
1003    options.mkvmerge_binary_path = Some(path);
1004    Ok(())
1005}
1006
1007async fn validate_decrypt_runtime_tool(options: &mut DownloadOptions) -> Result<()> {
1008    validate_decrypt_runtime_tool_with_dirs(options, &runtime_tool_search_dirs()).await
1009}
1010
1011async fn validate_decrypt_runtime_tool_with_dirs(
1012    options: &mut DownloadOptions,
1013    search_dirs: &[PathBuf],
1014) -> Result<()> {
1015    if !decrypt_runtime_tool_required(options) {
1016        return Ok(());
1017    }
1018    if options.decryption_engine != DecryptionEngine::Mp4forge
1019        && let Some(path) = &options.decryption_binary_path
1020    {
1021        validate_runtime_tool_path(path, "decryption").await?;
1022        return Ok(());
1023    }
1024    let path = match options.decryption_engine {
1025        DecryptionEngine::Mp4forge => return validate_mp4forge_decrypt_feature(),
1026        DecryptionEngine::Ffmpeg => {
1027            // Any explicit decrypt path was validated above.
1028            options
1029                .ffmpeg_binary_path
1030                .clone()
1031                .ok_or_else(|| Error::config("ffmpeg runtime tool was not found"))?
1032        }
1033        DecryptionEngine::Mp4decrypt => find_runtime_tool_in_dirs(&["mp4decrypt"], search_dirs)
1034            .await
1035            .ok_or_else(|| Error::config("mp4decrypt runtime tool was not found"))?,
1036        DecryptionEngine::ShakaPackager => find_runtime_tool_in_dirs(
1037            &[
1038                "shaka-packager",
1039                "shaka_packager",
1040                "packager-linux-x64",
1041                "packager-osx-x64",
1042                "packager-win-x64",
1043            ],
1044            search_dirs,
1045        )
1046        .await
1047        .ok_or_else(|| Error::config("shaka-packager runtime tool was not found"))?,
1048    };
1049    options.decryption_binary_path = Some(path);
1050    Ok(())
1051}
1052
1053async fn initialize_session_log(
1054    options: &DownloadOptions,
1055    events: &mut Vec<ProgressEvent>,
1056) -> LogFilePlan {
1057    let started_at = log_timestamp();
1058    let config = LogPlanConfig {
1059        level: options.log_level,
1060        no_log: options.no_log,
1061        log_file_path: options.log_file_path.clone(),
1062        default_log_dir: default_log_dir(),
1063        suffix: log_file_suffix(&started_at),
1064    };
1065    match initialize_log_file(&config, &started_at, Some(&session_command_line())).await {
1066        Ok(plan) => {
1067            if let Some(path) = &plan.path {
1068                events.push(ProgressEvent::LogFileCreated { path: path.clone() });
1069            }
1070            push_session_log(
1071                events,
1072                &plan,
1073                options.log_level,
1074                LogLevel::Info,
1075                format!("haki-dl {}", env!("CARGO_PKG_VERSION")),
1076            )
1077            .await;
1078            plan
1079        }
1080        Err(error) => {
1081            events.push(ProgressEvent::Warning {
1082                message: format!("log init failed: {error}"),
1083            });
1084            LogFilePlan {
1085                enabled: false,
1086                path: None,
1087            }
1088        }
1089    }
1090}
1091
1092async fn push_session_log(
1093    events: &mut Vec<ProgressEvent>,
1094    log_plan: &LogFilePlan,
1095    active_level: LogLevel,
1096    level: LogLevel,
1097    message: impl Into<String>,
1098) {
1099    if !should_log(active_level, level) {
1100        return;
1101    }
1102    let message = message.into();
1103    append_session_log_file(log_plan, level, &message).await;
1104    events.push(ProgressEvent::Log { level, message });
1105}
1106
1107async fn push_media_info_log(
1108    events: &mut Vec<ProgressEvent>,
1109    log_plan: &LogFilePlan,
1110    active_level: LogLevel,
1111    stream_id: Option<String>,
1112    media_infos: &[MediaInfo],
1113) {
1114    let show_warn = should_log(active_level, LogLevel::Warn);
1115    let show_info = should_log(active_level, LogLevel::Info);
1116    if !show_warn && !show_info {
1117        return;
1118    }
1119    if show_warn {
1120        append_session_log_file(log_plan, LogLevel::Warn, "Reading media info...").await;
1121    }
1122    let lines = media_infos
1123        .iter()
1124        .map(media_info_console_label)
1125        .collect::<Vec<_>>();
1126    if show_info {
1127        for line in &lines {
1128            append_session_log_file(log_plan, LogLevel::Info, line).await;
1129        }
1130    }
1131    events.push(ProgressEvent::MediaInfo { stream_id, lines });
1132}
1133
1134fn push_raw_console_lines<I>(events: &mut Vec<ProgressEvent>, lines: I)
1135where
1136    I: IntoIterator<Item = String>,
1137{
1138    for message in lines {
1139        events.push(ProgressEvent::ConsoleLine { message });
1140    }
1141}
1142
1143async fn log_live_record_limit_reached_if_needed(
1144    live_states: &[LiveStreamState],
1145    options: &DownloadOptions,
1146    already_logged: &mut bool,
1147    events: &mut Vec<ProgressEvent>,
1148    log_plan: &LogFilePlan,
1149) {
1150    if *already_logged
1151        || options.live_record_limit.is_none()
1152        || live_states.is_empty()
1153        || live_states.iter().any(|state| !state.record_limit_reached)
1154    {
1155        return;
1156    }
1157    push_session_log(
1158        events,
1159        log_plan,
1160        options.log_level,
1161        LogLevel::Warn,
1162        "Live recording limit reached, will stop recording soon",
1163    )
1164    .await;
1165    *already_logged = true;
1166}
1167
1168async fn push_parser_diagnostics(
1169    events: &mut Vec<ProgressEvent>,
1170    log_plan: &LogFilePlan,
1171    active_level: LogLevel,
1172    config: &ParserConfig,
1173) {
1174    for diagnostic in config.drain_diagnostics() {
1175        push_session_log(
1176            events,
1177            log_plan,
1178            active_level,
1179            diagnostic.level,
1180            diagnostic.message,
1181        )
1182        .await;
1183    }
1184}
1185
1186fn parser_diagnostic_events(config: &ParserConfig) -> Vec<ProgressEvent> {
1187    config
1188        .drain_diagnostics()
1189        .into_iter()
1190        .map(|diagnostic| ProgressEvent::Log {
1191            level: diagnostic.level,
1192            message: diagnostic.message,
1193        })
1194        .collect()
1195}
1196
1197fn log_file_level_prefix(level: LogLevel) -> &'static str {
1198    match level {
1199        LogLevel::Debug => "DEBUG:",
1200        LogLevel::Info => "INFO :",
1201        LogLevel::Warn => "WARN :",
1202        LogLevel::Error => "ERROR:",
1203        LogLevel::Off => "OFF:",
1204    }
1205}
1206
1207async fn append_session_log_file(log_plan: &LogFilePlan, level: LogLevel, message: &str) {
1208    let prefix = log_file_level_prefix(level);
1209    if message.is_empty() {
1210        let _ = append_log_file(log_plan, &format!("{} {prefix}", log_file_time())).await;
1211        return;
1212    }
1213    for line in message.lines() {
1214        let _ = append_log_file(log_plan, &format!("{} {prefix} {line}", log_file_time())).await;
1215    }
1216}
1217
1218async fn append_extra_log_file(log_plan: &LogFilePlan, message: impl AsRef<str>) {
1219    let message = message.as_ref();
1220    if message.is_empty() {
1221        let _ = append_log_file(log_plan, &format!("{} EXTRA:", log_file_time())).await;
1222        return;
1223    }
1224    for line in message.lines() {
1225        let _ = append_log_file(log_plan, &format!("{} EXTRA: {line}", log_file_time())).await;
1226    }
1227}
1228
1229async fn append_extra_log_events(log_plan: &LogFilePlan, events: &[ProgressEvent]) {
1230    for event in events {
1231        if let ProgressEvent::ExtraLog { message } = event {
1232            append_extra_log_file(log_plan, message).await;
1233        }
1234    }
1235}
1236
1237fn log_file_time() -> String {
1238    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
1239    format!(
1240        "{:02}:{:02}:{:02}.{:03}",
1241        now.hour(),
1242        now.minute(),
1243        now.second(),
1244        now.nanosecond() / 1_000_000
1245    )
1246}
1247
1248fn session_command_line() -> String {
1249    env::args().collect::<Vec<_>>().join(" ")
1250}
1251
1252fn format_fetch_debug_message(url: &str, headers: &BTreeMap<String, String>) -> String {
1253    if !is_http_url(url) {
1254        return format!("Fetch: {}", redact_secrets(url));
1255    }
1256    let mut message = format!("Fetch: {}", redact_secrets(url));
1257    message.push_str("\nAccept-Encoding: gzip, deflate");
1258    message.push_str("\nCache-Control: no-cache");
1259    for (key, value) in headers {
1260        message.push('\n');
1261        message.push_str(key);
1262        message.push_str(": ");
1263        message.push_str(&redact_secrets(value));
1264    }
1265    message
1266}
1267
1268fn format_request_headers_debug_message(headers: &BTreeMap<String, String>) -> String {
1269    headers
1270        .iter()
1271        .map(|(key, value)| format!("{key}: {}", redact_secrets(value)))
1272        .collect::<Vec<_>>()
1273        .join("\n")
1274}
1275
1276async fn push_startup_extra_logs(
1277    events: &mut Vec<ProgressEvent>,
1278    log_plan: &LogFilePlan,
1279    options: &DownloadOptions,
1280) {
1281    if let Some(path) = &options.ffmpeg_binary_path {
1282        append_extra_log_file(log_plan, format!("ffmpeg => {}", path.display())).await;
1283    }
1284    if let Some(path) = selected_mkvmerge_path(options) {
1285        append_extra_log_file(log_plan, format!("mkvmerge => {}", path.display())).await;
1286    }
1287    if decrypt_runtime_tool_required(options) {
1288        match options.decryption_engine {
1289            DecryptionEngine::Mp4forge => {
1290                append_extra_log_file(log_plan, "mp4forge => in-process").await;
1291            }
1292            DecryptionEngine::Mp4decrypt => {
1293                if let Some(path) = &options.decryption_binary_path {
1294                    append_extra_log_file(log_plan, format!("mp4decrypt => {}", path.display()))
1295                        .await;
1296                }
1297            }
1298            DecryptionEngine::ShakaPackager => {
1299                if let Some(path) = &options.decryption_binary_path {
1300                    append_extra_log_file(
1301                        log_plan,
1302                        format!("shaka-packager => {}", path.display()),
1303                    )
1304                    .await;
1305                }
1306            }
1307            DecryptionEngine::Ffmpeg => {}
1308        }
1309    }
1310    for (key, value) in &options.headers {
1311        push_startup_extra_log(
1312            events,
1313            log_plan,
1314            format!("User-Defined Header => {key}: {}", redact_secrets(value)),
1315        )
1316        .await;
1317    }
1318    push_filter_extra_logs(events, log_plan, options).await;
1319}
1320
1321async fn push_startup_extra_log(
1322    events: &mut Vec<ProgressEvent>,
1323    log_plan: &LogFilePlan,
1324    message: impl AsRef<str>,
1325) {
1326    let message = message.as_ref().to_string();
1327    append_extra_log_file(log_plan, &message).await;
1328    events.push(ProgressEvent::ExtraLog { message });
1329}
1330
1331fn selected_mkvmerge_path(options: &DownloadOptions) -> Option<&Path> {
1332    let mux_options = options.mux_after_done.as_ref()?;
1333    if mux_options.muxer != MuxerKind::Mkvmerge
1334        && mux_options.fallback_muxer != Some(MuxerKind::Mkvmerge)
1335    {
1336        return None;
1337    }
1338    mux_options
1339        .bin_path
1340        .as_deref()
1341        .or(options.mkvmerge_binary_path.as_deref())
1342}
1343
1344async fn push_filter_extra_logs(
1345    events: &mut Vec<ProgressEvent>,
1346    log_plan: &LogFilePlan,
1347    options: &DownloadOptions,
1348) {
1349    push_filter_extra_log(
1350        events,
1351        log_plan,
1352        options,
1353        "DropVideoFilter",
1354        &options.drop_video,
1355    )
1356    .await;
1357    push_filter_extra_log(
1358        events,
1359        log_plan,
1360        options,
1361        "DropAudioFilter",
1362        &options.drop_audio,
1363    )
1364    .await;
1365    push_filter_extra_log(
1366        events,
1367        log_plan,
1368        options,
1369        "DropSubtitleFilter",
1370        &options.drop_subtitle,
1371    )
1372    .await;
1373    push_filter_extra_log(
1374        events,
1375        log_plan,
1376        options,
1377        "VideoFilter",
1378        &options.select_video,
1379    )
1380    .await;
1381    push_filter_extra_log(
1382        events,
1383        log_plan,
1384        options,
1385        "AudioFilter",
1386        &options.select_audio,
1387    )
1388    .await;
1389    push_filter_extra_log(
1390        events,
1391        log_plan,
1392        options,
1393        "SubtitleFilter",
1394        &options.select_subtitle,
1395    )
1396    .await;
1397}
1398
1399async fn push_filter_extra_log(
1400    events: &mut Vec<ProgressEvent>,
1401    log_plan: &LogFilePlan,
1402    _options: &DownloadOptions,
1403    name: &str,
1404    filters: &[crate::config::StreamFilter],
1405) {
1406    if filters.is_empty() {
1407        return;
1408    }
1409    let value = filters
1410        .iter()
1411        .map(format_stream_filter_for_log)
1412        .collect::<Vec<_>>()
1413        .join(";");
1414    let message = format!("{name} => {value}");
1415    append_extra_log_file(log_plan, &message).await;
1416    events.push(ProgressEvent::ExtraLog { message });
1417}
1418
1419fn format_stream_filter_for_log(filter: &crate::config::StreamFilter) -> String {
1420    let mut parts = Vec::new();
1421    if !filter.for_choice.is_empty() {
1422        parts.push(format!("for={}", filter.for_choice));
1423    }
1424    push_filter_part(&mut parts, "id", filter.id.as_deref());
1425    push_filter_part(&mut parts, "lang", filter.language.as_deref());
1426    push_filter_part(&mut parts, "name", filter.name.as_deref());
1427    push_filter_part(&mut parts, "codecs", filter.codecs.as_deref());
1428    push_filter_part(&mut parts, "res", filter.resolution.as_deref());
1429    push_filter_part(&mut parts, "frame", filter.frame_rate.as_deref());
1430    push_filter_part(&mut parts, "channel", filter.channels.as_deref());
1431    push_filter_part(&mut parts, "range", filter.range.as_deref());
1432    push_filter_part(&mut parts, "url", filter.url.as_deref());
1433    push_filter_part_i64(&mut parts, "segsMin", filter.segment_count_min);
1434    push_filter_part_i64(&mut parts, "segsMax", filter.segment_count_max);
1435    push_filter_part_f64(&mut parts, "plistDurMin", filter.playlist_duration_min);
1436    push_filter_part_f64(&mut parts, "plistDurMax", filter.playlist_duration_max);
1437    push_filter_part_i64(&mut parts, "bwMin", filter.bandwidth_min);
1438    push_filter_part_i64(&mut parts, "bwMax", filter.bandwidth_max);
1439    if let Some(role) = filter.role {
1440        parts.push(format!("role={role:?}"));
1441    }
1442    if parts.is_empty() {
1443        "all".to_string()
1444    } else {
1445        parts.join(":")
1446    }
1447}
1448
1449fn push_filter_part(parts: &mut Vec<String>, name: &str, value: Option<&str>) {
1450    if let Some(value) = value.filter(|value| !value.is_empty()) {
1451        parts.push(format!("{name}={value}"));
1452    }
1453}
1454
1455fn push_filter_part_i64(parts: &mut Vec<String>, name: &str, value: Option<i64>) {
1456    if let Some(value) = value {
1457        parts.push(format!("{name}={value}"));
1458    }
1459}
1460
1461fn push_filter_part_f64(parts: &mut Vec<String>, name: &str, value: Option<f64>) {
1462    if let Some(value) = value {
1463        parts.push(format!("{name}={value}"));
1464    }
1465}
1466
1467fn is_http_url(url: &str) -> bool {
1468    let lower = url
1469        .get(..url.len().min(8))
1470        .unwrap_or(url)
1471        .to_ascii_lowercase();
1472    lower.starts_with("http://") || lower.starts_with("https://")
1473}
1474
1475fn push_debug_event(events: &mut Vec<ProgressEvent>, message: impl Into<String>) {
1476    events.push(ProgressEvent::Log {
1477        level: LogLevel::Debug,
1478        message: message.into(),
1479    });
1480}
1481
1482async fn push_save_name_log(
1483    events: &mut Vec<ProgressEvent>,
1484    log_plan: &LogFilePlan,
1485    active_level: LogLevel,
1486    save_name: Option<&str>,
1487) {
1488    if let Some(save_name) = save_name.filter(|value| !value.is_empty()) {
1489        push_session_log(
1490            events,
1491            log_plan,
1492            active_level,
1493            LogLevel::Info,
1494            format!("Save Name: {save_name}"),
1495        )
1496        .await;
1497    }
1498}
1499
1500fn push_decrypt_protection_logs(
1501    events: &mut Vec<ProgressEvent>,
1502    stream_id: Option<String>,
1503    init_info: &crate::decrypt::Mp4ProtectionInfo,
1504    file_info: &crate::decrypt::Mp4ProtectionInfo,
1505    kid: Option<&str>,
1506) {
1507    if let Some(scheme) = file_info.scheme.as_ref().or(init_info.scheme.as_ref()) {
1508        events.push(ProgressEvent::DecryptProgress {
1509            stream_id: stream_id.clone(),
1510            message: format!("Type: {scheme}"),
1511        });
1512    }
1513    for (label, pssh) in pssh_console_fields(file_info, init_info) {
1514        events.push(ProgressEvent::DecryptProgress {
1515            stream_id: stream_id.clone(),
1516            message: format!("PSSH({label}): {pssh}"),
1517        });
1518    }
1519    if let Some(kid) = kid.filter(|value| !value.is_empty()) {
1520        events.push(ProgressEvent::DecryptProgress {
1521            stream_id,
1522            message: format!("KID: {kid}"),
1523        });
1524    }
1525}
1526
1527fn pssh_console_fields<'a>(
1528    file_info: &'a crate::decrypt::Mp4ProtectionInfo,
1529    init_info: &'a crate::decrypt::Mp4ProtectionInfo,
1530) -> Vec<(&'static str, &'a str)> {
1531    file_info
1532        .psshs
1533        .iter()
1534        .chain(init_info.psshs.iter())
1535        .map(|info| (pssh_system_label(info.system), info.data.as_str()))
1536        .collect()
1537}
1538
1539fn pssh_system_label(system: crate::decrypt::PsshSystem) -> &'static str {
1540    match system {
1541        crate::decrypt::PsshSystem::Widevine => "WV",
1542        crate::decrypt::PsshSystem::PlayReady => "PR",
1543        crate::decrypt::PsshSystem::FairPlay => "FP",
1544    }
1545}
1546
1547async fn push_extracted_stream_logs(
1548    events: &mut Vec<ProgressEvent>,
1549    log_plan: &LogFilePlan,
1550    active_level: LogLevel,
1551    streams: &[Stream],
1552) {
1553    let (basic_streams, audio_streams, subtitle_streams) = split_stream_groups(streams);
1554    push_session_log(
1555        events,
1556        log_plan,
1557        active_level,
1558        LogLevel::Info,
1559        format!(
1560            "Extracted, there are {} streams, with {} basic streams, {} audio streams, {} subtitle streams",
1561            streams.len(),
1562            basic_streams.len(),
1563            audio_streams.len(),
1564            subtitle_streams.len()
1565        ),
1566    )
1567    .await;
1568    for stream in display_stream_order(streams) {
1569        push_session_log(
1570            events,
1571            log_plan,
1572            active_level,
1573            LogLevel::Info,
1574            stream_full_label(&stream),
1575        )
1576        .await;
1577    }
1578}
1579
1580async fn push_selected_stream_logs(
1581    events: &mut Vec<ProgressEvent>,
1582    log_plan: &LogFilePlan,
1583    active_level: LogLevel,
1584    streams: &[Stream],
1585) {
1586    push_session_log(
1587        events,
1588        log_plan,
1589        active_level,
1590        LogLevel::Info,
1591        "Selected streams:",
1592    )
1593    .await;
1594    for stream in streams {
1595        push_session_log(
1596            events,
1597            log_plan,
1598            active_level,
1599            LogLevel::Info,
1600            stream_full_label(stream),
1601        )
1602        .await;
1603    }
1604}
1605
1606fn display_stream_order(streams: &[Stream]) -> Vec<Stream> {
1607    let mut streams = streams.to_vec();
1608    order_streams(&mut streams);
1609    let (basic_streams, audios, subtitles) = split_stream_groups(&streams);
1610    basic_streams
1611        .into_iter()
1612        .chain(audios)
1613        .chain(subtitles)
1614        .collect()
1615}
1616
1617fn source_content_label(kind: LoadedSourceKind) -> &'static str {
1618    match kind {
1619        LoadedSourceKind::Hls => "HTTP Live Streaming",
1620        LoadedSourceKind::Dash => "Dynamic Adaptive Streaming over HTTP",
1621        LoadedSourceKind::Mss => "Microsoft Smooth Streaming",
1622        LoadedSourceKind::HttpLiveTs => "HTTP Live MPEG2-TS",
1623        LoadedSourceKind::BinaryData => "Binary Data",
1624    }
1625}
1626
1627async fn clean_ad_segments_with_logs(
1628    streams: &mut [Stream],
1629    keywords: &[String],
1630    events: &mut Vec<ProgressEvent>,
1631    log_plan: &LogFilePlan,
1632    active_level: LogLevel,
1633) -> Result<()> {
1634    if keywords.is_empty() {
1635        return Ok(());
1636    }
1637    for keyword in keywords {
1638        push_session_log(
1639            events,
1640            log_plan,
1641            active_level,
1642            LogLevel::Info,
1643            format!("User customed Ad keyword: {keyword}"),
1644        )
1645        .await;
1646    }
1647    let before = streams
1648        .iter()
1649        .map(Stream::segments_count)
1650        .collect::<Vec<_>>();
1651    clean_ad_segments(streams, keywords)?;
1652    for (stream, count_before) in streams.iter().zip(before) {
1653        let count_after = stream.segments_count();
1654        if count_before != count_after {
1655            push_session_log(
1656                events,
1657                log_plan,
1658                active_level,
1659                LogLevel::Warn,
1660                format!("{count_before} segments => {count_after} segments"),
1661            )
1662            .await;
1663        }
1664    }
1665    Ok(())
1666}
1667
1668fn custom_range_input(range: &CustomRange) -> &str {
1669    match range {
1670        CustomRange::Segment { input, .. } | CustomRange::Time { input, .. } => input,
1671    }
1672}
1673
1674async fn wait_for_task_start(
1675    options: &DownloadOptions,
1676    cancellation_token: &crate::cancellation::CancellationToken,
1677    events: &mut Vec<ProgressEvent>,
1678    log_plan: &LogFilePlan,
1679) -> Result<()> {
1680    let Some(task_start_at) = &options.task_start_at else {
1681        return Ok(());
1682    };
1683    let Some(remaining) = task_start_at.duration_until_now()? else {
1684        return Ok(());
1685    };
1686    events.push(ProgressEvent::TaskStartDelay {
1687        until: task_start_at.as_str().to_string(),
1688        remaining,
1689    });
1690    if should_log(options.log_level, LogLevel::Info) {
1691        append_session_log_file(
1692            log_plan,
1693            LogLevel::Info,
1694            &format!("The program will wait until: {}", task_start_at.as_str()),
1695        )
1696        .await;
1697    }
1698    let deadline = Instant::now()
1699        .checked_add(remaining)
1700        .ok_or_else(|| Error::config("task start delay is too large"))?;
1701    loop {
1702        cancellation_token.check()?;
1703        let now = Instant::now();
1704        if now >= deadline {
1705            return Ok(());
1706        }
1707        let sleep_for = (deadline - now).min(Duration::from_secs(1));
1708        tokio::time::sleep(sleep_for).await;
1709    }
1710}
1711
1712fn default_log_dir() -> PathBuf {
1713    if let Ok(current_exe) = env::current_exe()
1714        && let Some(parent) = current_exe.parent()
1715        && !parent.as_os_str().is_empty()
1716    {
1717        return parent.join("Logs");
1718    }
1719    env::current_dir()
1720        .map(|path| path.join("Logs"))
1721        .unwrap_or_else(|_| PathBuf::from("Logs"))
1722}
1723
1724fn log_timestamp() -> String {
1725    let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
1726    format!(
1727        "{:04}/{:02}/{:02} {:02}:{:02}:{:02}",
1728        now.year(),
1729        u8::from(now.month()),
1730        now.day(),
1731        now.hour(),
1732        now.minute(),
1733        now.second()
1734    )
1735}
1736
1737fn log_file_suffix(started_at: &str) -> String {
1738    started_at
1739        .chars()
1740        .map(|ch| if ch.is_ascii_digit() { ch } else { '-' })
1741        .collect::<String>()
1742        .trim_matches('-')
1743        .to_string()
1744}
1745
1746fn decrypt_runtime_tool_required(options: &DownloadOptions) -> bool {
1747    !options.keys.is_empty() || options.key_text_file.is_some()
1748}
1749
1750#[cfg(feature = "mp4forge")]
1751fn validate_mp4forge_decrypt_feature() -> Result<()> {
1752    Ok(())
1753}
1754
1755#[cfg(not(feature = "mp4forge"))]
1756fn validate_mp4forge_decrypt_feature() -> Result<()> {
1757    Err(Error::config(
1758        "mp4forge decryption requires the mp4forge feature",
1759    ))
1760}
1761
1762async fn validate_runtime_tool_path(path: &Path, name: &str) -> Result<()> {
1763    if tokio::fs::metadata(path)
1764        .await
1765        .is_ok_and(|metadata| metadata.is_file())
1766    {
1767        return Ok(());
1768    }
1769    Err(Error::config(format!(
1770        "{name} runtime tool path does not exist: {}",
1771        path.display()
1772    )))
1773}
1774
1775async fn find_runtime_tool(names: &[&str]) -> Option<PathBuf> {
1776    find_runtime_tool_in_dirs(names, &runtime_tool_search_dirs()).await
1777}
1778
1779async fn find_runtime_tool_in_dirs(names: &[&str], directories: &[PathBuf]) -> Option<PathBuf> {
1780    for directory in directories {
1781        for name in names {
1782            for file_name in runtime_tool_file_names(name) {
1783                let path = directory.join(file_name);
1784                if tokio::fs::metadata(&path)
1785                    .await
1786                    .is_ok_and(|metadata| metadata.is_file())
1787                {
1788                    return Some(path);
1789                }
1790            }
1791        }
1792    }
1793    None
1794}
1795
1796fn runtime_tool_search_dirs() -> Vec<PathBuf> {
1797    let mut dirs = Vec::new();
1798    if let Ok(current) = env::current_dir() {
1799        dirs.push(current);
1800    }
1801    if let Ok(current_exe) = env::current_exe()
1802        && let Some(parent) = current_exe.parent()
1803        && !parent.as_os_str().is_empty()
1804    {
1805        dirs.push(parent.to_path_buf());
1806    }
1807    if let Some(path) = env::var_os("PATH") {
1808        dirs.extend(env::split_paths(&path));
1809    }
1810    dirs
1811}
1812
1813fn runtime_tool_file_names(name: &str) -> Vec<String> {
1814    if cfg!(windows) && !name.to_ascii_lowercase().ends_with(".exe") {
1815        vec![format!("{name}.exe")]
1816    } else {
1817        vec![name.to_string()]
1818    }
1819}
1820
1821fn validate_live_refresh_request(_streams: &[Stream], options: &DownloadOptions) -> Result<()> {
1822    if options.live_record_limit.is_none() {
1823        return Ok(());
1824    }
1825    Ok(())
1826}
1827
1828fn apply_live_runtime_option_effects(options: &mut DownloadOptions, streams: &[Stream]) {
1829    let effects = live_option_effects(options, streams);
1830    if effects.concurrent_download {
1831        options.concurrent_download = true;
1832    }
1833    if effects.mp4_real_time_decryption {
1834        options.mp4_real_time_decryption = true;
1835    }
1836    options.live_fix_vtt_by_audio = effects.live_fix_vtt_by_audio;
1837}
1838
1839fn live_streams_need_fmp4_binary_merge(streams: &[Stream]) -> bool {
1840    streams.iter().any(|stream| {
1841        stream.media_type != Some(MediaType::Subtitles)
1842            && stream
1843                .playlist
1844                .as_ref()
1845                .is_some_and(|playlist| playlist.media_init.is_some())
1846    })
1847}
1848
1849fn seed_live_states(
1850    streams: &mut [Stream],
1851    extractor_is_hls: bool,
1852    record_limit: Option<Duration>,
1853    events: &mut Vec<ProgressEvent>,
1854) -> Result<Vec<LiveStreamState>> {
1855    let mut states = Vec::with_capacity(streams.len());
1856    for (stream_index, stream) in streams.iter_mut().enumerate() {
1857        let mut state = LiveStreamState::default();
1858        let mut refresh =
1859            filter_new_live_segments(stream, &mut state, extractor_is_hls, record_limit)?;
1860        annotate_live_refresh_events(&mut refresh.events, stream, stream_index, false, None);
1861        events.extend(refresh.events);
1862        states.push(state);
1863    }
1864    Ok(states)
1865}
1866
1867fn record_live_downloaded_durations(
1868    streams: &[Stream],
1869    states: &mut [LiveStreamState],
1870    events: &mut Vec<ProgressEvent>,
1871) {
1872    for (stream_index, (stream, state)) in streams.iter().zip(states.iter_mut()).enumerate() {
1873        let segments = stream_media_segments(stream);
1874        add_recorded_duration(state, &segments);
1875        let (recorded_segments, total_segments) = live_display_segment_counts(stream, state);
1876        events.push(ProgressEvent::LiveRefresh {
1877            stream_id: Some(live_stream_task_id(stream, stream_index)),
1878            label: Some(stream_short_label(stream)),
1879            refreshed_duration: Duration::from_secs(state.refreshed_duration_secs),
1880            recorded_duration: Duration::from_secs(state.recorded_duration_secs),
1881            recorded_segments,
1882            total_segments,
1883            is_waiting: recorded_segments >= total_segments,
1884            recorded_size: None,
1885        });
1886    }
1887}
1888
1889fn live_display_segment_counts(stream: &Stream, state: &LiveStreamState) -> (u64, u64) {
1890    let init_count = u64::from(
1891        stream
1892            .playlist
1893            .as_ref()
1894            .is_some_and(|playlist| playlist.media_init.is_some()),
1895    );
1896    (
1897        state.recorded_segments.saturating_add(init_count),
1898        state.refreshed_segments.saturating_add(init_count),
1899    )
1900}
1901
1902fn probe_live_audio_start_ms(
1903    streams: &[Stream],
1904    results: &[StreamDownloadResult],
1905    options: &DownloadOptions,
1906) -> Result<Option<i64>> {
1907    if !options.live_fix_vtt_by_audio {
1908        return Ok(None);
1909    }
1910    let Some((stream, result)) = streams
1911        .iter()
1912        .zip(results.iter())
1913        .find(|(stream, _)| stream.media_type == Some(MediaType::Audio))
1914    else {
1915        return Ok(None);
1916    };
1917    let Some(path) = first_media_file_for_probe(stream, result) else {
1918        return Ok(None);
1919    };
1920    let binary = options
1921        .ffmpeg_binary_path
1922        .clone()
1923        .unwrap_or_else(|| PathBuf::from("ffmpeg"));
1924    probe_ffmpeg_start_ms(&binary, path).map(Some)
1925}
1926
1927fn first_media_file_for_probe<'a>(
1928    stream: &Stream,
1929    result: &'a StreamDownloadResult,
1930) -> Option<&'a Path> {
1931    let offset = usize::from(
1932        stream
1933            .playlist
1934            .as_ref()
1935            .is_some_and(|playlist| playlist.media_init.is_some()),
1936    );
1937    result
1938        .files
1939        .get(offset)
1940        .or_else(|| result.files.first())
1941        .map(PathBuf::as_path)
1942}
1943
1944fn probe_ffmpeg_start_ms(binary: &Path, file: &Path) -> Result<i64> {
1945    let output = StdCommand::new(binary)
1946        .arg("-hide_banner")
1947        .arg("-i")
1948        .arg(file)
1949        .stdout(Stdio::null())
1950        .stderr(Stdio::piped())
1951        .output()
1952        .map_err(|error| Error::live(error.to_string()))?;
1953    Ok(parse_ffmpeg_start_ms(&String::from_utf8_lossy(
1954        &output.stderr,
1955    )))
1956}
1957
1958fn parse_ffmpeg_start_ms(output: &str) -> i64 {
1959    output
1960        .lines()
1961        .find_map(|line| {
1962            let start = line.find("start: ")? + "start: ".len();
1963            let value = line.get(start..)?.split(',').next()?.trim();
1964            parse_seconds_to_ms(value)
1965        })
1966        .unwrap_or_default()
1967}
1968
1969fn parse_seconds_to_ms(value: &str) -> Option<i64> {
1970    let (whole, fraction) = value.split_once('.').unwrap_or((value, ""));
1971    let whole_ms = whole.trim().parse::<i64>().ok()?.checked_mul(1000)?;
1972    let mut frac = 0_i64;
1973    let mut scale = 100_i64;
1974    for ch in fraction.chars().take(3) {
1975        if !ch.is_ascii_digit() {
1976            break;
1977        }
1978        frac += i64::from(ch as u8 - b'0') * scale;
1979        scale /= 10;
1980    }
1981    whole_ms.checked_add(frac)
1982}
1983
1984async fn apply_media_info_decisions(
1985    streams: &mut [Stream],
1986    results: &mut [StreamDownloadResult],
1987    options: &mut DownloadOptions,
1988    source_kind: LoadedSourceKind,
1989    save_dir: &Path,
1990    events: &mut Vec<ProgressEvent>,
1991    log_plan: &LogFilePlan,
1992) -> Result<()> {
1993    let ffmpeg = options
1994        .ffmpeg_binary_path
1995        .clone()
1996        .ok_or_else(|| Error::config("ffmpeg runtime tool was not found"))?;
1997    for (index, (stream, result)) in streams.iter_mut().zip(results.iter_mut()).enumerate() {
1998        let media_infos = if result.media_infos.is_empty() {
1999            probe_download_media_info(stream, result, source_kind, &ffmpeg).await?
2000        } else {
2001            result.media_infos.clone()
2002        };
2003        if !media_infos.is_empty() && result.media_infos.is_empty() {
2004            push_media_info_log(
2005                events,
2006                log_plan,
2007                options.log_level,
2008                Some(result.stream_id.clone()),
2009                &media_infos,
2010            )
2011            .await;
2012        }
2013        apply_change_spec_info(stream, result, options, &media_infos, events);
2014        result.media_infos = media_infos;
2015        result.output_path = planned_output_path(save_dir, stream, index, options);
2016    }
2017    reserve_unique_output_paths(streams, results);
2018    Ok(())
2019}
2020
2021fn reserve_unique_output_paths(streams: &[Stream], results: &mut [StreamDownloadResult]) {
2022    let mut reserved = HashSet::new();
2023    for (stream, result) in streams.iter().zip(results.iter_mut()) {
2024        result.output_path =
2025            handle_file_collision_with_reserved(&result.output_path, stream, &reserved);
2026        reserved.insert(result.output_path.clone());
2027    }
2028}
2029
2030async fn probe_download_media_info(
2031    stream: &Stream,
2032    result: &StreamDownloadResult,
2033    source_kind: LoadedSourceKind,
2034    ffmpeg: &Path,
2035) -> Result<Vec<MediaInfo>> {
2036    let Some(path) = media_info_probe_file(stream, result, source_kind) else {
2037        return Ok(Vec::new());
2038    };
2039    probe_ffmpeg_media_infos(ffmpeg, path).await
2040}
2041
2042fn media_info_probe_file<'a>(
2043    stream: &Stream,
2044    result: &'a StreamDownloadResult,
2045    source_kind: LoadedSourceKind,
2046) -> Option<&'a Path> {
2047    if source_kind != LoadedSourceKind::Mss
2048        && stream
2049            .playlist
2050            .as_ref()
2051            .is_some_and(|playlist| playlist.media_init.is_some())
2052    {
2053        return result.files.first().map(PathBuf::as_path);
2054    }
2055    first_media_file_for_probe(stream, result)
2056}
2057
2058fn apply_change_spec_info(
2059    stream: &mut Stream,
2060    result: &mut StreamDownloadResult,
2061    options: &mut DownloadOptions,
2062    media_infos: &[MediaInfo],
2063    events: &mut Vec<ProgressEvent>,
2064) {
2065    if media_infos.is_empty() {
2066        return;
2067    }
2068    if !options.binary_merge && media_infos.iter().any(|info| info.dolby_vision) {
2069        options.binary_merge = true;
2070        result.binary_merge_required = true;
2071        events.push(ProgressEvent::Warning {
2072            message: "Dolby Vision content is detected, binary merging is automatically enabled"
2073                .to_string(),
2074        });
2075    }
2076    if options.mux_after_done.is_some() && media_infos.iter().any(|info| info.dolby_vision) {
2077        options.mux_after_done = None;
2078        events.push(ProgressEvent::Warning {
2079            message: "Dolby Vision content is detected, mux after done is automatically disabled"
2080                .to_string(),
2081        });
2082    }
2083    if media_infos
2084        .iter()
2085        .filter(|info| info.media_type.as_deref() == Some("Audio"))
2086        .all(|info| {
2087            info.base_info
2088                .as_deref()
2089                .unwrap_or_default()
2090                .to_ascii_lowercase()
2091                .contains("aac")
2092        })
2093    {
2094        result.use_aac_filter = true;
2095    }
2096    if media_infos
2097        .iter()
2098        .all(|info| info.media_type.as_deref() == Some("Audio"))
2099    {
2100        stream.media_type = Some(MediaType::Audio);
2101    } else if media_infos
2102        .iter()
2103        .all(|info| info.media_type.as_deref() == Some("Subtitle"))
2104    {
2105        stream.media_type = Some(MediaType::Subtitles);
2106        if stream
2107            .extension
2108            .as_deref()
2109            .is_none_or(|extension| extension.eq_ignore_ascii_case("ts"))
2110        {
2111            stream.extension = Some("vtt".to_string());
2112        }
2113    }
2114}
2115
2116#[allow(clippy::too_many_arguments)]
2117async fn download_live_refreshes(
2118    base_streams: &[Stream],
2119    states: &mut [LiveStreamState],
2120    initial_results: &[StreamDownloadResult],
2121    request: &DownloadRequest,
2122    source_kind: LoadedSourceKind,
2123    config: &mut ParserConfig,
2124    dirs: &WorkDirectories,
2125    transport: &DefaultHttpClient,
2126    live_pipe_session: Option<&mut LivePipeMuxSession>,
2127    live_audio_start_ms: Option<i64>,
2128    events: &mut Vec<ProgressEvent>,
2129) -> Result<()> {
2130    if states.is_empty() || states.iter().all(|state| state.record_limit_reached) {
2131        return Ok(());
2132    }
2133    let output_paths = initial_results
2134        .iter()
2135        .map(|result| result.output_path.clone())
2136        .collect::<Vec<_>>();
2137    let pipe_indexes = live_pipe_indexes(base_streams);
2138    let http_client = http_client(&request.options)?;
2139    let extractor_is_hls = source_kind == LoadedSourceKind::Hls;
2140    let mut live_pipe_session = live_pipe_session;
2141    wait_for_live_refresh(
2142        compute_live_wait_seconds(base_streams, request.options.live_wait_time),
2143        &request.cancellation_token,
2144    )
2145    .await?;
2146    while states.iter().any(|state| !state.record_limit_reached) {
2147        request.cancellation_token.check()?;
2148        let mut refreshed_streams = base_streams.to_vec();
2149        let mut retry_events = Vec::new();
2150        let mut attempt = 0_usize;
2151        loop {
2152            attempt = attempt.saturating_add(1);
2153            match refresh_live_streams(
2154                &mut refreshed_streams,
2155                source_kind,
2156                request,
2157                config,
2158                transport,
2159            )
2160            .await
2161            {
2162                Ok(()) => break,
2163                Err(error) if attempt < LIVE_REFRESH_RETRY_ATTEMPTS => {
2164                    retry_events.push(ProgressEvent::Warning {
2165                        message: format!(
2166                            "{} ({attempt}/{LIVE_REFRESH_RETRY_ATTEMPTS})",
2167                            error.compatibility_message()
2168                        ),
2169                    });
2170                    sleep_for_retry(LIVE_REFRESH_RETRY_DELAY, Some(&request.cancellation_token))
2171                        .await?;
2172                }
2173                Err(error) => return Err(error),
2174            }
2175        }
2176        retry_events.extend(parser_diagnostic_events(config));
2177        let refresh_wait_seconds =
2178            compute_live_wait_seconds(&refreshed_streams, request.options.live_wait_time);
2179        let mut batches = Vec::new();
2180        for (stream_index, (stream, state)) in refreshed_streams
2181            .iter_mut()
2182            .zip(states.iter_mut())
2183            .enumerate()
2184        {
2185            if state.record_limit_reached {
2186                clear_stream_media_segments(stream);
2187                continue;
2188            }
2189            let refresh = filter_new_live_segments(
2190                stream,
2191                state,
2192                extractor_is_hls,
2193                request.options.live_record_limit,
2194            )?;
2195            if refresh.new_segments.is_empty() {
2196                continue;
2197            }
2198            let mut queued_events = retry_events.clone();
2199            let mut refresh_events = refresh.events;
2200            annotate_live_refresh_events(&mut refresh_events, stream, stream_index, false, None);
2201            queued_events.extend(refresh_events);
2202            batches.push(LiveQueuedSegments {
2203                stream_index,
2204                stream: stream.clone(),
2205                state: state.clone(),
2206                events: queued_events,
2207            });
2208        }
2209        if batches.is_empty() {
2210            if states.iter().all(|state| state.record_limit_reached) {
2211                break;
2212            }
2213            wait_for_live_refresh(refresh_wait_seconds, &request.cancellation_token).await?;
2214            continue;
2215        }
2216        for batch in &batches {
2217            events.extend(batch.events.clone());
2218        }
2219        let downloaded = download_live_batches(
2220            &mut batches,
2221            request,
2222            source_kind,
2223            dirs,
2224            &config.headers,
2225            &http_client,
2226            &output_paths,
2227            events,
2228        )
2229        .await?;
2230        if let Some(session) = live_pipe_session.as_deref_mut() {
2231            stream_indexed_downloads_to_live_pipe(
2232                &downloaded,
2233                &request.options,
2234                true,
2235                live_audio_start_ms,
2236                events,
2237                session,
2238                &pipe_indexes,
2239                &request.cancellation_token,
2240            )
2241            .await?;
2242        } else {
2243            let streams = downloaded
2244                .iter()
2245                .map(|item| item.stream.clone())
2246                .collect::<Vec<_>>();
2247            let results = downloaded
2248                .iter()
2249                .map(|item| item.result.clone())
2250                .collect::<Vec<_>>();
2251            append_live_refresh_downloads(
2252                &streams,
2253                &results,
2254                &request.options,
2255                true,
2256                live_audio_start_ms,
2257                events,
2258                &request.cancellation_token,
2259            )
2260            .await?;
2261        }
2262        record_live_downloaded_indexed(&downloaded, states, events);
2263        if states.iter().all(|state| state.record_limit_reached) {
2264            break;
2265        }
2266        wait_for_live_refresh(refresh_wait_seconds, &request.cancellation_token).await?;
2267    }
2268    Ok(())
2269}
2270
2271struct LiveQueuedSegments {
2272    stream_index: usize,
2273    stream: Stream,
2274    state: LiveStreamState,
2275    events: Vec<ProgressEvent>,
2276}
2277
2278struct LiveDownloadedBatch {
2279    stream_index: usize,
2280    stream: Stream,
2281    state: LiveStreamState,
2282    result: StreamDownloadResult,
2283}
2284
2285#[allow(clippy::too_many_arguments)]
2286async fn download_live_batches(
2287    batches: &mut [LiveQueuedSegments],
2288    request: &DownloadRequest,
2289    source_kind: LoadedSourceKind,
2290    dirs: &WorkDirectories,
2291    headers: &BTreeMap<String, String>,
2292    http_client: &reqwest::Client,
2293    output_paths: &[PathBuf],
2294    events: &mut Vec<ProgressEvent>,
2295) -> Result<Vec<LiveDownloadedBatch>> {
2296    clean_ad_segments_in_batches(batches, &request.options.ad_keywords)?;
2297    let shared_events = Arc::new(std::sync::Mutex::new(Vec::new()));
2298    let emitter = DownloadEventEmitter::default();
2299    let realtime_hooks = realtime_decrypt_hooks(&request.options, &request.cancellation_token);
2300    let mut handles = tokio::task::JoinSet::new();
2301    for batch in batches.iter() {
2302        let scheduler = DownloadScheduler::new();
2303        let stream = batch.stream.clone();
2304        let state = batch.state.clone();
2305        let stream_index = batch.stream_index;
2306        let dirs = dirs.clone();
2307        let options = request.options.clone();
2308        let headers = headers.clone();
2309        let http_client = http_client.clone();
2310        let events = Arc::clone(&shared_events);
2311        let emitter = emitter.clone();
2312        let hooks = realtime_hooks.clone();
2313        handles.spawn(async move {
2314            let mut local_events = Vec::new();
2315            let result = scheduler
2316                .download_stream_with_http_client_and_hooks(
2317                    &stream,
2318                    stream_index,
2319                    &dirs.temp_root,
2320                    &dirs.save_dir,
2321                    &options,
2322                    &headers,
2323                    source_kind == LoadedSourceKind::Mss,
2324                    &mut local_events,
2325                    &http_client,
2326                    &emitter,
2327                    &hooks,
2328                )
2329                .await;
2330            if let Ok(mut guard) = events.lock() {
2331                guard.extend(local_events);
2332            }
2333            result.map(|result| LiveDownloadedBatch {
2334                stream_index,
2335                stream,
2336                state,
2337                result,
2338            })
2339        });
2340    }
2341    let mut downloaded = Vec::new();
2342    while let Some(joined) = handles.join_next().await {
2343        match joined {
2344            Ok(Ok(mut batch)) => {
2345                if let Some(output_path) = output_paths.get(batch.stream_index) {
2346                    batch.result.output_path = output_path.clone();
2347                }
2348                downloaded.push(batch);
2349            }
2350            Ok(Err(error)) => return Err(error),
2351            Err(_) => return Err(Error::http("live download worker failed")),
2352        }
2353    }
2354    if let Ok(mut guard) = shared_events.lock() {
2355        events.append(&mut guard);
2356    }
2357    downloaded.sort_by_key(|batch| batch.stream_index);
2358    Ok(downloaded)
2359}
2360
2361fn clean_ad_segments_in_batches(
2362    batches: &mut [LiveQueuedSegments],
2363    ad_keywords: &[String],
2364) -> Result<()> {
2365    if ad_keywords.is_empty() {
2366        return Ok(());
2367    }
2368    let mut streams = batches
2369        .iter()
2370        .map(|batch| batch.stream.clone())
2371        .collect::<Vec<_>>();
2372    clean_ad_segments(&mut streams, ad_keywords)?;
2373    for (batch, stream) in batches.iter_mut().zip(streams) {
2374        batch.stream = stream;
2375    }
2376    Ok(())
2377}
2378
2379fn record_live_downloaded_indexed(
2380    downloaded: &[LiveDownloadedBatch],
2381    states: &mut [LiveStreamState],
2382    events: &mut Vec<ProgressEvent>,
2383) {
2384    for item in downloaded {
2385        let Some(state) = states.get_mut(item.stream_index) else {
2386            continue;
2387        };
2388        let recorded_duration_secs = state.recorded_duration_secs;
2389        let recorded_segments = state.recorded_segments;
2390        *state = item.state.clone();
2391        state.recorded_duration_secs = recorded_duration_secs;
2392        state.recorded_segments = recorded_segments;
2393        let segments = stream_media_segments(&item.stream);
2394        add_recorded_duration(state, &segments);
2395        let (recorded_segments, total_segments) = live_display_segment_counts(&item.stream, state);
2396        events.push(ProgressEvent::LiveRefresh {
2397            stream_id: Some(item.result.stream_id.clone()),
2398            label: Some(stream_short_label(&item.stream)),
2399            refreshed_duration: Duration::from_secs(state.refreshed_duration_secs),
2400            recorded_duration: Duration::from_secs(state.recorded_duration_secs),
2401            recorded_segments,
2402            total_segments,
2403            is_waiting: recorded_segments >= total_segments,
2404            recorded_size: None,
2405        });
2406    }
2407}
2408
2409async fn refresh_live_streams(
2410    streams: &mut [Stream],
2411    source_kind: LoadedSourceKind,
2412    request: &DownloadRequest,
2413    config: &mut ParserConfig,
2414    transport: &DefaultHttpClient,
2415) -> Result<()> {
2416    match source_kind {
2417        LoadedSourceKind::Hls => hls_parser_for_transport(transport)
2418            .refresh_playlists(streams, config)
2419            .await
2420            .map(|_| ()),
2421        LoadedSourceKind::Dash => {
2422            let loaded = reload_live_manifest(source_kind, request, config, transport).await?;
2423            DashParser::new().refresh_streams(streams, &loaded.text, &loaded.final_url, config)
2424        }
2425        LoadedSourceKind::Mss => {
2426            let loaded = reload_live_manifest(source_kind, request, config, transport).await?;
2427            MssParser::new().refresh_streams(streams, &loaded.text, &loaded.final_url, config)
2428        }
2429        LoadedSourceKind::HttpLiveTs => Ok(()),
2430        LoadedSourceKind::BinaryData => Ok(()),
2431    }
2432}
2433
2434async fn reload_live_manifest(
2435    expected_kind: LoadedSourceKind,
2436    request: &DownloadRequest,
2437    config: &mut ParserConfig,
2438    transport: &DefaultHttpClient,
2439) -> Result<crate::source::LoadedSource> {
2440    let original_url = config.original_url.clone();
2441    let current_url = if config.url.is_empty() {
2442        request.input.clone()
2443    } else {
2444        config.url.clone()
2445    };
2446    let loader = source_loader_for_transport(transport);
2447    match loader.load_source(&current_url, config).await {
2448        Ok(loaded) => {
2449            if !original_url.is_empty() {
2450                config.original_url = original_url;
2451            }
2452            ensure_loaded_kind(expected_kind, loaded)
2453        }
2454        Err(first_error) if !original_url.is_empty() && original_url != current_url => {
2455            let loaded = loader
2456                .load_source(&original_url, config)
2457                .await
2458                .map_err(|_| first_error)?;
2459            config.original_url = original_url;
2460            ensure_loaded_kind(expected_kind, loaded)
2461        }
2462        Err(error) => Err(error),
2463    }
2464}
2465
2466fn ensure_loaded_kind(
2467    expected_kind: LoadedSourceKind,
2468    loaded: crate::source::LoadedSource,
2469) -> Result<crate::source::LoadedSource> {
2470    if loaded.kind == expected_kind {
2471        Ok(loaded)
2472    } else {
2473        Err(Error::live("live refresh source kind changed"))
2474    }
2475}
2476
2477fn clear_stream_media_segments(stream: &mut Stream) {
2478    if let Some(playlist) = stream.playlist.as_mut() {
2479        let Some(part) = playlist.media_parts.first_mut() else {
2480            return;
2481        };
2482        part.media_segments.clear();
2483        playlist.media_parts.truncate(1);
2484    }
2485}
2486
2487async fn wait_for_live_refresh(
2488    wait_seconds: u32,
2489    cancellation_token: &crate::cancellation::CancellationToken,
2490) -> Result<()> {
2491    let wait = Duration::from_secs(u64::from(wait_seconds.max(1)));
2492    let started = Instant::now();
2493    loop {
2494        cancellation_token.check()?;
2495        let elapsed = started.elapsed();
2496        if elapsed >= wait {
2497            return Ok(());
2498        }
2499        tokio::time::sleep(wait.saturating_sub(elapsed).min(Duration::from_millis(50))).await;
2500    }
2501}
2502
2503async fn append_live_refresh_downloads(
2504    streams: &[Stream],
2505    results: &[StreamDownloadResult],
2506    options: &DownloadOptions,
2507    is_live_session: bool,
2508    live_audio_start_ms: Option<i64>,
2509    events: &mut Vec<ProgressEvent>,
2510    cancellation_token: &CancellationToken,
2511) -> Result<()> {
2512    if is_live_session && !options.live_real_time_merge {
2513        for result in results {
2514            if should_cleanup_stream_temp_dir(options, is_live_session) {
2515                cleanup_stream_temp_dir(&result.temp_dir, events).await?;
2516            }
2517        }
2518        return Ok(());
2519    }
2520    for (index, result) in results.iter().enumerate() {
2521        if let Some(stream) = streams.get(index) {
2522            decrypt_stream_files(stream, result, options, events, cancellation_token).await?;
2523        }
2524        if result.files.is_empty() {
2525            if should_cleanup_stream_temp_dir(options, is_live_session) {
2526                cleanup_stream_temp_dir(&result.temp_dir, events).await?;
2527            }
2528            continue;
2529        }
2530        if options.skip_merge {
2531            for path in &result.files {
2532                events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
2533                    path.clone(),
2534                )));
2535            }
2536            continue;
2537        }
2538        if let Some(stream) = streams.get(index)
2539            && should_format_subtitle(stream, options)
2540        {
2541            events.push(ProgressEvent::MergeProgress {
2542                stream_id: Some(result.stream_id.clone()),
2543                message: "appending refreshed live segments".to_string(),
2544            });
2545            append_live_subtitle_refresh(
2546                stream,
2547                result,
2548                options,
2549                SubtitleTimingContext {
2550                    is_live_session,
2551                    live_audio_start_ms,
2552                },
2553                events,
2554            )
2555            .await?;
2556            events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
2557                result.output_path.clone(),
2558            )));
2559            if should_cleanup_stream_temp_dir(options, is_live_session) {
2560                cleanup_stream_temp_dir(&result.temp_dir, events).await?;
2561            }
2562            continue;
2563        }
2564        events.push(ProgressEvent::MergeProgress {
2565            stream_id: Some(result.stream_id.clone()),
2566            message: "appending refreshed live segments".to_string(),
2567        });
2568        append_files(&result.files, &result.output_path).await?;
2569        events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
2570            result.output_path.clone(),
2571        )));
2572        if should_cleanup_stream_temp_dir(options, is_live_session) {
2573            cleanup_stream_temp_dir(&result.temp_dir, events).await?;
2574        }
2575    }
2576    Ok(())
2577}
2578
2579fn should_cleanup_stream_temp_dir(options: &DownloadOptions, is_live_session: bool) -> bool {
2580    options.del_after_done && !(is_live_session && options.live_keep_segments)
2581}
2582
2583fn should_cleanup_task_temp_root(options: &DownloadOptions, is_live_session: bool) -> bool {
2584    if !options.del_after_done {
2585        return false;
2586    }
2587    if options.skip_merge || options.skip_download {
2588        return false;
2589    }
2590    if is_live_session && options.live_keep_segments {
2591        return false;
2592    }
2593    true
2594}
2595
2596async fn append_files(files: &[PathBuf], output_path: &Path) -> Result<()> {
2597    if let Some(parent) = output_path.parent() {
2598        tokio::fs::create_dir_all(parent).await?;
2599    }
2600    let mut output = tokio::fs::OpenOptions::new()
2601        .create(true)
2602        .append(true)
2603        .open(output_path)
2604        .await?;
2605    for file in files {
2606        let mut input = tokio::fs::File::open(file).await?;
2607        tokio::io::copy(&mut input, &mut output).await?;
2608    }
2609    Ok(())
2610}
2611
2612async fn execute_http_live_ts(
2613    request: &DownloadRequest,
2614    final_url: &str,
2615    headers: &BTreeMap<String, String>,
2616    dirs: &WorkDirectories,
2617    mut events: Vec<ProgressEvent>,
2618    log_plan: &LogFilePlan,
2619) -> Result<Vec<ProgressEvent>> {
2620    let stream = direct_http_live_stream(final_url, final_url);
2621    events.push(ProgressEvent::ManifestParsed { stream_count: 1 });
2622    push_extracted_stream_logs(
2623        &mut events,
2624        log_plan,
2625        request.options.log_level,
2626        std::slice::from_ref(&stream),
2627    )
2628    .await;
2629    events.push(ProgressEvent::StreamSelected {
2630        stream_id: stream_identity(&stream),
2631    });
2632    push_selected_stream_logs(
2633        &mut events,
2634        log_plan,
2635        request.options.log_level,
2636        std::slice::from_ref(&stream),
2637    )
2638    .await;
2639    if request.options.write_meta_json {
2640        push_session_log(
2641            &mut events,
2642            log_plan,
2643            request.options.log_level,
2644            LogLevel::Warn,
2645            "Writing meta json",
2646        )
2647        .await;
2648        let mut selected_file = BTreeMap::new();
2649        selected_file.insert(
2650            "meta_selected.json".to_string(),
2651            streams_metadata_json(std::slice::from_ref(&stream)),
2652        );
2653        let _ = write_raw_files(&selected_file, &dirs.temp_root).await?;
2654    }
2655    if request.options.skip_download {
2656        events.push(ProgressEvent::Finished { success: true });
2657        return Ok(events);
2658    }
2659    push_save_name_log(
2660        &mut events,
2661        log_plan,
2662        request.options.log_level,
2663        request.options.save_name.as_deref(),
2664    )
2665    .await;
2666    if let Some(limit) = request.options.live_record_limit {
2667        push_session_log(
2668            &mut events,
2669            log_plan,
2670            request.options.log_level,
2671            LogLevel::Warn,
2672            format!(
2673                "Live recording duration limit: {}",
2674                format_duration_short_for_log(limit)
2675            ),
2676        )
2677        .await;
2678    }
2679    let output_path = direct_http_live_output_path(final_url, &dirs.save_dir, &request.options);
2680    let dir_name = direct_http_live_dir_name(&request.options, &stream, 0);
2681    let save_name = direct_http_live_save_name(&request.options, &stream, final_url, 0);
2682    push_session_log(
2683        &mut events,
2684        log_plan,
2685        request.options.log_level,
2686        LogLevel::Debug,
2687        format!(
2688            "dirName: {}; saveDir: {}; saveName: {}",
2689            dir_name,
2690            dirs.save_dir.display(),
2691            save_name
2692        ),
2693    )
2694    .await;
2695    push_session_log(
2696        &mut events,
2697        log_plan,
2698        request.options.log_level,
2699        LogLevel::Debug,
2700        format_request_headers_debug_message(headers),
2701    )
2702    .await;
2703    events.push(ProgressEvent::StreamTaskCreated {
2704        stream_id: stream_identity(&stream),
2705        label: stream_full_label(&stream),
2706    });
2707    events.push(ProgressEvent::SegmentQueued {
2708        stream_id: stream_identity(&stream),
2709        segment_index: 0,
2710    });
2711    events.push(ProgressEvent::SegmentStarted {
2712        stream_id: stream_identity(&stream),
2713        segment_index: 0,
2714    });
2715    record_http_live_ts_to_file(
2716        final_url,
2717        headers,
2718        &request.options,
2719        &request.cancellation_token,
2720        &output_path,
2721        &mut events,
2722    )
2723    .await?;
2724    if let Ok(metadata) = tokio::fs::metadata(&output_path).await {
2725        push_session_log(
2726            &mut events,
2727            log_plan,
2728            request.options.log_level,
2729            LogLevel::Info,
2730            format!("File Size: {}", format_console_file_size(metadata.len())),
2731        )
2732        .await;
2733    }
2734    events.push(ProgressEvent::SegmentFinished {
2735        stream_id: stream_identity(&stream),
2736        segment_index: 0,
2737    });
2738    events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
2739        output_path,
2740    )));
2741    events.push(ProgressEvent::Finished { success: true });
2742    Ok(events)
2743}
2744
2745#[allow(clippy::too_many_arguments)]
2746async fn record_http_live_ts_to_file(
2747    url: &str,
2748    headers: &BTreeMap<String, String>,
2749    options: &DownloadOptions,
2750    cancellation_token: &crate::cancellation::CancellationToken,
2751    output_path: &Path,
2752    events: &mut Vec<ProgressEvent>,
2753) -> Result<()> {
2754    if let Some(parent) = output_path.parent() {
2755        tokio::fs::create_dir_all(parent).await?;
2756    }
2757    let client = http_live_client(options)?;
2758    let mut current_url = url.to_string();
2759    cancellation_token.check()?;
2760    let mut request = client.get(&current_url);
2761    request = apply_request_headers(request, headers);
2762    let response = request
2763        .send()
2764        .await
2765        .map_err(|error| Error::http(error.to_string()))?;
2766    current_url = response.url().as_str().to_string();
2767    let status = response.status().as_u16();
2768    if !(200..=299).contains(&status) {
2769        let _ = response.bytes().await;
2770        return Err(Error::http(format!(
2771            "HTTP status {status} for {current_url}"
2772        )));
2773    }
2774    let mut reader = response;
2775    let mut output = tokio::fs::OpenOptions::new()
2776        .create(true)
2777        .write(true)
2778        .truncate(false)
2779        .open(output_path)
2780        .await?;
2781    let started = Instant::now();
2782    let mut last_progress = started;
2783    let mut last_progress_bytes = 0_u64;
2784    let mut state = HttpLiveTsState::default();
2785    let mut info_buffer = Vec::with_capacity(188 * 5000);
2786    let mut service_info_emitted = false;
2787    loop {
2788        cancellation_token.check()?;
2789        let Some(chunk) = reader
2790            .chunk()
2791            .await
2792            .map_err(|error| Error::http(error.to_string()))?
2793        else {
2794            break;
2795        };
2796        let size = chunk.len();
2797        if !service_info_emitted && info_buffer.len() < 188 * 5000 {
2798            let remaining = (188 * 5000_usize).saturating_sub(info_buffer.len());
2799            info_buffer.extend_from_slice(&chunk[..size.min(remaining)]);
2800            if let Some(info) = parse_http_live_ts_service_info(&info_buffer) {
2801                events.push(ProgressEvent::LiveServiceInfo {
2802                    program_id: info.program_id,
2803                    service_provider: non_empty_string(info.service_provider),
2804                    service_name: non_empty_string(info.service_name),
2805                });
2806                service_info_emitted = true;
2807            }
2808        }
2809        tokio::io::AsyncWriteExt::write_all(&mut output, &chunk).await?;
2810        let elapsed = started.elapsed();
2811        update_http_live_ts_state(&mut state, size, elapsed, options.live_record_limit);
2812        emit_http_live_progress(
2813            events,
2814            &state,
2815            &mut last_progress,
2816            &mut last_progress_bytes,
2817            false,
2818        );
2819        if state.stop_requested {
2820            break;
2821        }
2822    }
2823    emit_http_live_progress(
2824        events,
2825        &state,
2826        &mut last_progress,
2827        &mut last_progress_bytes,
2828        true,
2829    );
2830    tokio::io::AsyncWriteExt::flush(&mut output).await?;
2831    Ok(())
2832}
2833
2834#[derive(Clone, Debug, Default, Eq, PartialEq)]
2835struct HttpLiveTsServiceInfo {
2836    program_id: String,
2837    service_provider: String,
2838    service_name: String,
2839}
2840
2841fn parse_http_live_ts_service_info(data: &[u8]) -> Option<HttpLiveTsServiceInfo> {
2842    let mut info = HttpLiveTsServiceInfo::default();
2843    for offset in 0..data.len().saturating_sub(188) {
2844        if data.get(offset) != Some(&0x47) || data.get(offset + 188) != Some(&0x47) {
2845            continue;
2846        }
2847        let packet = data.get(offset..offset + 188)?;
2848        let header = u32::from_be_bytes([packet[0], packet[1], packet[2], packet[3]]);
2849        let pid = (header & 0x1fff00) >> 8;
2850        let payload = packet.get(4..)?;
2851        if pid == 0 {
2852            if let Some(program_id) = read_be_u16(payload.get(9..11)?) {
2853                info.program_id = program_id.to_string();
2854            }
2855        } else if pid == 0x0011
2856            && payload.get(1) == Some(&0x42)
2857            && let Some(section_length) =
2858                read_be_u16(payload.get(2..4)?).map(|value| value & 0x0fff)
2859        {
2860            let section_len = usize::from(section_length);
2861            let section = payload.get(4..4 + section_len)?;
2862            let descriptor_root = section.get(8..)?;
2863            let descriptor_loop_length =
2864                usize::from(read_be_u16(descriptor_root.get(3..5)?)? & 0x0fff);
2865            let descriptors = descriptor_root.get(5..5 + descriptor_loop_length)?;
2866            let provider_len = usize::from(*descriptors.get(3)?);
2867            let provider_start = 4;
2868            let provider_end = provider_start + provider_len;
2869            let name_len = usize::from(*descriptors.get(provider_end)?);
2870            let name_start = provider_end + 1;
2871            let name_end = name_start + name_len;
2872            info.service_provider =
2873                String::from_utf8_lossy(descriptors.get(provider_start..provider_end)?).to_string();
2874            info.service_name =
2875                String::from_utf8_lossy(descriptors.get(name_start..name_end)?).to_string();
2876        }
2877        if !info.program_id.is_empty()
2878            && (!info.service_name.is_empty() || !info.service_provider.is_empty())
2879        {
2880            return Some(info);
2881        }
2882    }
2883    None
2884}
2885
2886fn read_be_u16(bytes: &[u8]) -> Option<u16> {
2887    Some(u16::from_be_bytes([*bytes.first()?, *bytes.get(1)?]))
2888}
2889
2890fn non_empty_string(value: String) -> Option<String> {
2891    if value.is_empty() { None } else { Some(value) }
2892}
2893
2894fn emit_http_live_progress(
2895    events: &mut Vec<ProgressEvent>,
2896    state: &HttpLiveTsState,
2897    last_progress: &mut Instant,
2898    last_progress_bytes: &mut u64,
2899    force: bool,
2900) {
2901    let elapsed = last_progress.elapsed();
2902    if !force && elapsed < Duration::from_secs(1) {
2903        return;
2904    }
2905    let elapsed_secs = elapsed.as_secs().max(1);
2906    let bytes_delta = state
2907        .recording_size_bytes
2908        .saturating_sub(*last_progress_bytes);
2909    let first_report = *last_progress_bytes == 0 && state.recording_size_bytes > 0;
2910    let display_size = if force && first_report && state.recording_duration_secs == 0 {
2911        0
2912    } else {
2913        state.recording_size_bytes
2914    };
2915    let display_speed = if force && first_report && state.recording_duration_secs == 0 {
2916        0
2917    } else {
2918        bytes_delta / elapsed_secs
2919    };
2920    *last_progress = Instant::now();
2921    *last_progress_bytes = state.recording_size_bytes;
2922    events.push(ProgressEvent::AggregateProgress(AggregateProgress {
2923        downloaded_bytes: display_size,
2924        total_bytes: None,
2925        bytes_per_second: display_speed,
2926    }));
2927    let duration = Duration::from_secs(state.recording_duration_secs);
2928    events.push(ProgressEvent::LiveRefresh {
2929        stream_id: Some(HTTP_LIVE_TS_MARKER.to_string()),
2930        label: Some(format!("Vid Kbps | {HTTP_LIVE_TS_MARKER}")),
2931        refreshed_duration: duration,
2932        recorded_duration: duration,
2933        recorded_segments: 0,
2934        total_segments: 1,
2935        is_waiting: false,
2936        recorded_size: Some(display_size),
2937    });
2938}
2939
2940fn format_console_file_size(bytes: u64) -> String {
2941    const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
2942    let mut value = bytes as f64;
2943    let mut unit_index = 0_usize;
2944    while value >= 1024.0 && unit_index + 1 < UNITS.len() {
2945        value /= 1024.0;
2946        unit_index += 1;
2947    }
2948    format!("{value:.2}{}", UNITS[unit_index])
2949}
2950
2951fn http_live_client(_options: &DownloadOptions) -> Result<reqwest::Client> {
2952    reqwest::Client::builder()
2953        .timeout(Duration::from_secs(100))
2954        .connect_timeout(Duration::from_secs(100))
2955        .redirect(reqwest::redirect::Policy::limited(50))
2956        .build()
2957        .map_err(|error| Error::http(error.to_string()))
2958}
2959
2960fn direct_http_live_output_path(
2961    input: &str,
2962    save_dir: &Path,
2963    options: &DownloadOptions,
2964) -> PathBuf {
2965    let stream = direct_http_live_stream(input, input);
2966    let name = direct_http_live_save_name(options, &stream, input, 0);
2967    save_dir.join(format!("{name}.ts"))
2968}
2969
2970fn direct_http_live_save_name(
2971    options: &DownloadOptions,
2972    stream: &Stream,
2973    input: &str,
2974    task_id: usize,
2975) -> String {
2976    if let Some(pattern) = &options.save_pattern
2977        && !pattern.trim().is_empty()
2978    {
2979        return format_save_pattern(pattern, stream, options.save_name.as_deref(), task_id);
2980    }
2981    if let Some(value) = options.save_name.as_ref().filter(|value| !value.is_empty()) {
2982        return format!(
2983            "{}.{}",
2984            valid_file_name(value, "_", false),
2985            stream.language.as_deref().unwrap_or_default()
2986        )
2987        .trim_end_matches('.')
2988        .to_string();
2989    }
2990    save_name_from_input(input, false)
2991}
2992
2993fn direct_http_live_dir_name(options: &DownloadOptions, stream: &Stream, task_id: usize) -> String {
2994    let base_name = options
2995        .save_name
2996        .as_deref()
2997        .filter(|value| !value.is_empty())
2998        .map(|value| valid_file_name(value, "_", false))
2999        .unwrap_or_else(|| "haki-dl".to_string());
3000    format!(
3001        "{}_{}_{}_{}_{}_{}",
3002        base_name,
3003        task_id,
3004        valid_file_name(stream.group_id.as_deref().unwrap_or_default(), "-", false),
3005        stream.codecs.as_deref().unwrap_or_default(),
3006        stream
3007            .bandwidth
3008            .map(|value| value.to_string())
3009            .unwrap_or_default(),
3010        stream.language.as_deref().unwrap_or_default()
3011    )
3012}
3013
3014fn direct_http_live_stream(input: &str, original_url: &str) -> Stream {
3015    Stream {
3016        group_id: Some(HTTP_LIVE_TS_MARKER.to_string()),
3017        url: input.to_string(),
3018        original_url: original_url.to_string(),
3019        playlist: Some(crate::manifest::Playlist::default()),
3020        ..Stream::default()
3021    }
3022}
3023
3024async fn write_source_sidecars(
3025    directory: &Path,
3026    raw_files: &BTreeMap<String, String>,
3027    raw_streams: &[Stream],
3028) -> Result<Vec<PathBuf>> {
3029    let mut files = raw_files.clone();
3030    files.insert("meta.json".to_string(), streams_metadata_json(raw_streams));
3031    write_raw_files(&files, directory).await
3032}
3033
3034fn annotate_live_refresh_events(
3035    events: &mut [ProgressEvent],
3036    stream: &Stream,
3037    stream_index: usize,
3038    is_waiting: bool,
3039    recorded_size: Option<u64>,
3040) {
3041    let stream_id_value = live_stream_task_id(stream, stream_index);
3042    let label_value = stream_short_label(stream);
3043    for event in events {
3044        if let ProgressEvent::LiveRefresh {
3045            stream_id,
3046            label,
3047            is_waiting: event_is_waiting,
3048            recorded_size: event_recorded_size,
3049            ..
3050        } = event
3051        {
3052            if stream_id.is_none() {
3053                *stream_id = Some(stream_id_value.clone());
3054            }
3055            if label.is_none() {
3056                *label = Some(label_value.clone());
3057            }
3058            *event_is_waiting = is_waiting;
3059            *event_recorded_size = recorded_size;
3060        }
3061    }
3062}
3063
3064fn live_stream_task_id(stream: &Stream, task_id: usize) -> String {
3065    if !stream.id.is_empty() {
3066        return stream.id.clone();
3067    }
3068    stream
3069        .group_id
3070        .clone()
3071        .unwrap_or_else(|| format!("stream-{task_id}"))
3072}
3073
3074fn format_duration_short_for_log(duration: Duration) -> String {
3075    let seconds = duration.as_secs();
3076    let hours = seconds / 3600;
3077    let minutes = (seconds / 60) % 60;
3078    let seconds = seconds % 60;
3079    if hours > 0 {
3080        format!("{hours:02}h{minutes:02}m{seconds:02}s")
3081    } else {
3082        format!("{minutes:02}m{seconds:02}s")
3083    }
3084}
3085
3086fn prepare_work_directories(options: &DownloadOptions) -> Result<WorkDirectories> {
3087    let save_dir = match &options.save_dir {
3088        Some(path) => path.clone(),
3089        None => std::env::current_dir()?,
3090    };
3091    let temp_base = match &options.tmp_dir {
3092        Some(path) => path.clone(),
3093        None => std::env::current_dir()?,
3094    };
3095    let temp_name = options
3096        .save_name
3097        .as_deref()
3098        .filter(|value| !value.is_empty())
3099        .map(|value| valid_file_name(value, "_", false))
3100        .unwrap_or_else(|| "haki-dl".to_string());
3101    let temp_root = temp_base.join(temp_name);
3102    Ok(WorkDirectories {
3103        temp_root,
3104        save_dir,
3105    })
3106}
3107
3108async fn parse_loaded_manifest(
3109    text: &str,
3110    kind: LoadedSourceKind,
3111    final_url: &str,
3112    config: &ParserConfig,
3113) -> Result<Manifest> {
3114    match kind {
3115        LoadedSourceKind::Hls => {
3116            let parsed = HlsParser::new().parse(text, final_url, config).await?;
3117            let warnings = if parsed.is_master {
3118                vec!["Master List detected, try parse all streams".to_string()]
3119            } else {
3120                Vec::new()
3121            };
3122            Ok(Manifest {
3123                extractor_type: Some(ExtractorType::Hls),
3124                source: Some(final_url.to_string()),
3125                is_live: parsed.streams.iter().any(stream_is_live),
3126                streams: parsed.streams,
3127                warnings,
3128            })
3129        }
3130        LoadedSourceKind::Dash => {
3131            let parsed = DashParser::new().parse(text, final_url, config)?;
3132            Ok(Manifest {
3133                extractor_type: Some(ExtractorType::MpegDash),
3134                source: Some(final_url.to_string()),
3135                is_live: parsed.is_dynamic,
3136                streams: parsed.streams,
3137                warnings: Vec::new(),
3138            })
3139        }
3140        LoadedSourceKind::Mss => {
3141            let parsed = MssParser::new().parse(text, final_url, config)?;
3142            Ok(Manifest {
3143                extractor_type: Some(ExtractorType::Mss),
3144                source: Some(final_url.to_string()),
3145                is_live: parsed.is_live,
3146                streams: parsed.streams,
3147                warnings: parsed.warnings,
3148            })
3149        }
3150        LoadedSourceKind::HttpLiveTs => Err(Error::live(
3151            "direct HTTP live TS execution requires the live recorder pipeline",
3152        )),
3153        LoadedSourceKind::BinaryData => Err(Error::compatibility("Input not supported")),
3154    }
3155}
3156
3157fn selected_requires_playlist_fetch(kind: LoadedSourceKind, selected: &[Stream]) -> bool {
3158    selected.iter().any(|stream| stream.playlist.is_none())
3159        || matches!(kind, LoadedSourceKind::Dash | LoadedSourceKind::Mss)
3160}
3161
3162fn select_streams(streams: &[Stream], request: &DownloadRequest) -> Result<Vec<Stream>> {
3163    let mut streams = streams.to_vec();
3164    order_streams(&mut streams);
3165    let (mut basic_streams, mut audios, mut subtitles) = split_stream_groups(&streams);
3166    basic_streams = apply_drop_filter_list(basic_streams, &request.options.drop_video)?;
3167    audios = apply_drop_filter_list(audios, &request.options.drop_audio)?;
3168    subtitles = apply_drop_filter_list(subtitles, &request.options.drop_subtitle)?;
3169    let mut filtered = Vec::new();
3170    filtered.extend(basic_streams.clone());
3171    filtered.extend(audios.clone());
3172    filtered.extend(subtitles.clone());
3173    let selected = match &request.stream_selector {
3174        StreamSelector::Interactive if has_keep_filters(&request.options) => {
3175            let mut selected = Vec::new();
3176            selected.extend(apply_keep_filter_list(
3177                &basic_streams,
3178                &request.options.select_video,
3179            )?);
3180            selected.extend(apply_keep_filter_list(
3181                &audios,
3182                &request.options.select_audio,
3183            )?);
3184            selected.extend(apply_keep_filter_list(
3185                &subtitles,
3186                &request.options.select_subtitle,
3187            )?);
3188            selected
3189        }
3190        StreamSelector::Interactive => select_streams_interactively(&filtered, &request.options)?,
3191        StreamSelector::Auto => auto_select_streams(&filtered),
3192        StreamSelector::SubtitlesOnly => subtitle_only_streams(&filtered),
3193        StreamSelector::ExplicitIds(ids) => filtered
3194            .into_iter()
3195            .filter(|stream| ids.iter().any(|id| stream_matches_id(stream, id)))
3196            .collect(),
3197    };
3198    Ok(selected)
3199}
3200
3201fn split_stream_groups(streams: &[Stream]) -> (Vec<Stream>, Vec<Stream>, Vec<Stream>) {
3202    let basic_streams = streams
3203        .iter()
3204        .filter(|stream| stream.media_type.is_none() || stream.media_type == Some(MediaType::Video))
3205        .cloned()
3206        .collect();
3207    let audios = streams
3208        .iter()
3209        .filter(|stream| stream.media_type == Some(MediaType::Audio))
3210        .cloned()
3211        .collect();
3212    let subtitles = streams
3213        .iter()
3214        .filter(|stream| stream.media_type == Some(MediaType::Subtitles))
3215        .cloned()
3216        .collect();
3217    (basic_streams, audios, subtitles)
3218}
3219
3220fn has_keep_filters(options: &DownloadOptions) -> bool {
3221    !options.select_video.is_empty()
3222        || !options.select_audio.is_empty()
3223        || !options.select_subtitle.is_empty()
3224}
3225
3226fn apply_drop_filter_list(
3227    mut streams: Vec<Stream>,
3228    filters: &[crate::config::StreamFilter],
3229) -> Result<Vec<Stream>> {
3230    for filter in filters {
3231        streams = filter_drop(&streams, Some(filter))?;
3232    }
3233    Ok(streams)
3234}
3235
3236fn apply_keep_filter_list(
3237    streams: &[Stream],
3238    filters: &[crate::config::StreamFilter],
3239) -> Result<Vec<Stream>> {
3240    let mut selected = Vec::new();
3241    for filter in filters {
3242        selected.extend(filter_keep(streams, Some(filter))?);
3243    }
3244    Ok(selected)
3245}
3246
3247fn select_streams_interactively(
3248    streams: &[Stream],
3249    options: &DownloadOptions,
3250) -> Result<Vec<Stream>> {
3251    if streams.len() <= 1
3252        || ((!std::io::stdin().is_terminal() || !std::io::stdout().is_terminal())
3253            && !options.force_ansi_console)
3254    {
3255        return Ok(interactive_default_streams(streams));
3256    }
3257    let defaults = interactive_default_streams(streams);
3258    let default_indexes = streams
3259        .iter()
3260        .enumerate()
3261        .filter_map(|(index, stream)| {
3262            defaults
3263                .iter()
3264                .any(|default| stream_identity(default) == stream_identity(stream))
3265                .then_some(index)
3266        })
3267        .collect::<Vec<_>>();
3268
3269    #[cfg(feature = "cli")]
3270    let selected_indexes =
3271        crate::interactive_prompt::select_streams(streams, &default_indexes, options)?;
3272    #[cfg(not(feature = "cli"))]
3273    let selected_indexes = {
3274        let _ = options;
3275        default_indexes
3276    };
3277
3278    let selected = selected_indexes
3279        .into_iter()
3280        .filter_map(|index| streams.get(index).cloned())
3281        .collect::<Vec<_>>();
3282    if selected.is_empty() {
3283        return Err(Error::config("interactive selection cannot be empty"));
3284    }
3285    Ok(selected)
3286}
3287
3288fn validate_explicit_mp4forge_before_download(
3289    streams: &[Stream],
3290    options: &DownloadOptions,
3291) -> Result<()> {
3292    let Some(mux_options) = &options.mux_after_done else {
3293        return Ok(());
3294    };
3295    if mux_options.muxer != MuxerKind::Mp4forge {
3296        return Ok(());
3297    }
3298    if mux_options.format != MuxFormat::Mp4 {
3299        return Err(Error::mux(
3300            "mp4forge mux-after-done supports only mp4 output",
3301        ));
3302    }
3303    let has_fallback = mux_options.fallback_muxer.is_some();
3304    let matrix = Mp4forgeSupportMatrix::default();
3305    for stream in streams {
3306        if mux_options.skip_sub && stream.media_type == Some(MediaType::Subtitles) {
3307            continue;
3308        }
3309        if stream.media_type == Some(MediaType::Subtitles) {
3310            if has_fallback {
3311                continue;
3312            }
3313            return Err(Error::mux(
3314                "mp4forge mux-after-done does not support subtitle inputs; use fallback_muxer=ffmpeg or skip_sub=true",
3315            ));
3316        }
3317        let support = match mp4forge_support_for_stream(stream, &matrix) {
3318            Ok(support) => support,
3319            Err(error) if has_fallback => {
3320                let _ = error;
3321                continue;
3322            }
3323            Err(error) => {
3324                return Err(Error::mux(format!(
3325                    "{}; use fallback_muxer=ffmpeg for mp4forge mux-after-done",
3326                    error.compatibility_message()
3327                )));
3328            }
3329        };
3330        match support {
3331            crate::mux::Mp4forgeSupport::Supported => {}
3332            crate::mux::Mp4forgeSupport::Unsupported { reason } => {
3333                if !has_fallback {
3334                    return Err(Error::mux(format!(
3335                        "{reason}; use fallback_muxer=ffmpeg for mp4forge mux-after-done"
3336                    )));
3337                }
3338            }
3339            crate::mux::Mp4forgeSupport::RequiresProbe { reason } => {
3340                if !has_fallback {
3341                    return Err(Error::mux(format!(
3342                        "mp4forge requires media probing before download: {reason}; use fallback_muxer=ffmpeg for mp4forge mux-after-done"
3343                    )));
3344                }
3345            }
3346        }
3347    }
3348    Ok(())
3349}
3350
3351fn validate_mp4forge_decrypt_before_download(
3352    streams: &[Stream],
3353    options: &DownloadOptions,
3354) -> Result<()> {
3355    if options.decryption_engine != DecryptionEngine::Mp4forge
3356        || !decrypt_runtime_tool_required(options)
3357    {
3358        return Ok(());
3359    }
3360    for stream in streams {
3361        if !stream_requires_external_decrypt(stream) {
3362            continue;
3363        }
3364        validate_mp4forge_decrypt_stream(stream)?;
3365    }
3366    Ok(())
3367}
3368
3369fn validate_mp4forge_decrypt_stream(stream: &Stream) -> Result<()> {
3370    if matches!(
3371        stream.media_type,
3372        Some(MediaType::Subtitles | MediaType::ClosedCaptions)
3373    ) {
3374        return Err(Error::decrypt(
3375            "mp4forge decrypt does not support subtitle inputs",
3376        ));
3377    }
3378    let extension = stream_mp4_family_extension(stream).ok_or_else(|| {
3379        Error::decrypt("mp4forge decrypt requires MP4-family container metadata before download")
3380    })?;
3381    if !mp4forge_decrypt_extension_supported(&extension) {
3382        return Err(Error::decrypt(
3383            "mp4forge decrypt supports only MP4-family encrypted inputs",
3384        ));
3385    }
3386    validate_mp4forge_decrypt_schemes(stream)?;
3387    Ok(())
3388}
3389
3390fn validate_mp4forge_decrypt_schemes(stream: &Stream) -> Result<()> {
3391    for segment in stream_encryption_segments(stream) {
3392        if !matches!(
3393            segment.encryption.method,
3394            EncryptionMethod::Cenc | EncryptionMethod::SampleAes
3395        ) {
3396            continue;
3397        }
3398        if let Some(scheme) = segment.encryption.scheme.as_deref()
3399            && !scheme.is_empty()
3400            && !mp4forge_decrypt_scheme_supported(scheme)
3401        {
3402            return Err(Error::decrypt(format!(
3403                "mp4forge decrypt does not support encryption scheme: {scheme}"
3404            )));
3405        }
3406    }
3407    Ok(())
3408}
3409
3410fn stream_mp4_family_extension(stream: &Stream) -> Option<String> {
3411    stream
3412        .extension
3413        .as_deref()
3414        .and_then(normalized_extension)
3415        .or_else(|| {
3416            stream
3417                .playlist
3418                .as_ref()
3419                .and_then(|playlist| playlist.media_init.as_ref())
3420                .and_then(|segment| normalized_extension_from_url(&segment.url))
3421        })
3422        .or_else(|| {
3423            stream.playlist.as_ref().and_then(|playlist| {
3424                playlist
3425                    .media_parts
3426                    .iter()
3427                    .flat_map(|part| part.media_segments.iter())
3428                    .find_map(|segment| normalized_extension_from_url(&segment.url))
3429            })
3430        })
3431}
3432
3433fn normalized_extension(value: &str) -> Option<String> {
3434    value
3435        .trim()
3436        .trim_start_matches('.')
3437        .split(['?', '#'])
3438        .next()
3439        .filter(|value| !value.is_empty())
3440        .map(|value| value.to_ascii_lowercase())
3441}
3442
3443fn normalized_extension_from_url(value: &str) -> Option<String> {
3444    let path = value.split(['?', '#']).next().unwrap_or(value);
3445    let name = path
3446        .rsplit(['/', '\\'])
3447        .next()
3448        .filter(|value| !value.is_empty())?;
3449    name.rsplit_once('.')
3450        .map(|(_, extension)| extension)
3451        .and_then(normalized_extension)
3452}
3453
3454fn mp4forge_decrypt_extension_supported(extension: &str) -> bool {
3455    matches!(extension, "mp4" | "m4s" | "m4v" | "m4a" | "cmfv" | "cmfa")
3456}
3457
3458fn mp4forge_decrypt_scheme_supported(scheme: &str) -> bool {
3459    matches!(
3460        scheme.to_ascii_lowercase().as_str(),
3461        "cenc" | "cens" | "cbc1" | "cbcs" | "piff"
3462    )
3463}
3464
3465async fn finalize_downloads(
3466    streams: &[Stream],
3467    results: &[StreamDownloadResult],
3468    options: &DownloadOptions,
3469    is_live_session: bool,
3470    live_audio_start_ms: Option<i64>,
3471    events: &mut Vec<ProgressEvent>,
3472    cancellation_token: &CancellationToken,
3473) -> Result<Vec<OutputFile>> {
3474    let mut merged_outputs = Vec::new();
3475    for (index, result) in results.iter().enumerate() {
3476        let effective_options;
3477        let options = if result.disable_real_time_decryption && options.mp4_real_time_decryption {
3478            effective_options = DownloadOptions {
3479                mp4_real_time_decryption: false,
3480                ..options.clone()
3481            };
3482            &effective_options
3483        } else {
3484            options
3485        };
3486        if let Some(stream) = streams.get(index) {
3487            decrypt_stream_files(stream, result, options, events, cancellation_token).await?;
3488        }
3489        if options.skip_merge {
3490            for path in &result.files {
3491                events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
3492                    path.clone(),
3493                )));
3494            }
3495            continue;
3496        }
3497        let subtitle_write = if let Some(stream) = streams.get(index) {
3498            write_subtitle_output_if_needed(
3499                stream,
3500                &result.files,
3501                &result.output_path,
3502                &result.temp_dir,
3503                options,
3504                SubtitleTimingContext {
3505                    is_live_session,
3506                    live_audio_start_ms,
3507                },
3508                events,
3509            )
3510            .await?
3511        } else {
3512            SubtitleWriteOutcome::default()
3513        };
3514        let output_path = if subtitle_write.wrote {
3515            result.output_path.clone()
3516        } else {
3517            merge_stream_files(
3518                streams.get(index),
3519                result,
3520                options,
3521                is_live_session,
3522                events,
3523                cancellation_token,
3524            )
3525            .await?
3526        };
3527        events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
3528            output_path.clone(),
3529        )));
3530        if let Some(stream) = streams.get(index) {
3531            merged_outputs.push(output_file_from_stream(index, stream, result, &output_path));
3532        }
3533        if should_cleanup_stream_temp_dir(options, is_live_session)
3534            && !subtitle_write.retain_temp_dir
3535        {
3536            cleanup_stream_temp_dir(&result.temp_dir, events).await?;
3537        }
3538    }
3539    Ok(merged_outputs)
3540}
3541
3542async fn merge_stream_files(
3543    stream: Option<&Stream>,
3544    result: &StreamDownloadResult,
3545    options: &DownloadOptions,
3546    is_live_session: bool,
3547    events: &mut Vec<ProgressEvent>,
3548    cancellation_token: &CancellationToken,
3549) -> Result<PathBuf> {
3550    if should_binary_merge_stream(stream, result, options, is_live_session) {
3551        events.push(ProgressEvent::MergeProgress {
3552            stream_id: Some(result.stream_id.clone()),
3553            message: "Binary merging...".to_string(),
3554        });
3555        let files = merge_input_files(stream, result, options);
3556        combine_files(&files, &result.output_path).await?;
3557        return external_decrypt_if_needed(
3558            stream,
3559            &result.output_path,
3560            options,
3561            events,
3562            cancellation_token,
3563        )
3564        .await;
3565    }
3566
3567    let ffmpeg = options
3568        .ffmpeg_binary_path
3569        .as_deref()
3570        .ok_or_else(|| Error::config("ffmpeg runtime tool was not found"))?;
3571    let files = merge_input_files(stream, result, options);
3572    let merge_files = if files.len() >= 1800 {
3573        events.push(ProgressEvent::MergeProgress {
3574            stream_id: Some(result.stream_id.clone()),
3575            message: "Segments more than 1800, start partial merge...".to_string(),
3576        });
3577        partial_combine_files(&files).await?
3578    } else {
3579        files
3580    };
3581    let concat_list_path = if options.use_ffmpeg_concat_demuxer {
3582        let path = result.temp_dir.join("ffconcat.txt");
3583        write_ffmpeg_concat_list(&merge_files, &path).await?;
3584        Some(path)
3585    } else {
3586        None
3587    };
3588    let output_path = ffmpeg_merge_output_path(stream, &result.output_path);
3589    if let Some(parent) = output_path.parent() {
3590        tokio::fs::create_dir_all(parent).await?;
3591    }
3592    events.push(ProgressEvent::MergeProgress {
3593        stream_id: Some(result.stream_id.clone()),
3594        message: "ffmpeg merging...".to_string(),
3595    });
3596    let metadata = FfmpegMergeMetadata {
3597        ddp_audio: ffmpeg_merge_ddp_audio_sidecar(&output_path).await?,
3598        ..FfmpegMergeMetadata::default()
3599    };
3600    let plan = plan_ffmpeg_merge(FfmpegMergeRequest {
3601        binary: ffmpeg,
3602        files: &merge_files,
3603        output_base_path: &output_path,
3604        format: ffmpeg_merge_format(stream),
3605        use_aac_filter: result.use_aac_filter || stream_needs_aac_filter(stream),
3606        fast_start: false,
3607        write_date: !options.no_date_info,
3608        use_concat_demuxer: options.use_ffmpeg_concat_demuxer,
3609        concat_list_path: concat_list_path.as_deref(),
3610        metadata: &metadata,
3611    })?;
3612    run_mux_command_plan(&plan, cancellation_token, events).await?;
3613    external_decrypt_if_needed(
3614        stream,
3615        &plan.output_path,
3616        options,
3617        events,
3618        cancellation_token,
3619    )
3620    .await
3621}
3622
3623fn merge_input_files(
3624    stream: Option<&Stream>,
3625    result: &StreamDownloadResult,
3626    options: &DownloadOptions,
3627) -> Vec<PathBuf> {
3628    let mut files = result.files.clone();
3629    if options.mp4_real_time_decryption
3630        && !matches!(
3631            options.decryption_engine,
3632            DecryptionEngine::Mp4decrypt | DecryptionEngine::Mp4forge
3633        )
3634        && stream.is_some_and(stream_requires_external_decrypt)
3635        && stream
3636            .and_then(|stream| stream.playlist.as_ref())
3637            .and_then(|playlist| playlist.media_init.as_ref())
3638            .is_some()
3639        && !files.is_empty()
3640    {
3641        files.remove(0);
3642    }
3643    files
3644}
3645
3646async fn write_ffmpeg_concat_list(files: &[PathBuf], path: &Path) -> Result<()> {
3647    if let Some(parent) = path.parent() {
3648        tokio::fs::create_dir_all(parent).await?;
3649    }
3650    let mut text = String::new();
3651    for file in files {
3652        let absolute = absolute_path(file)?;
3653        let normalized = absolute.to_string_lossy().replace('\\', "/");
3654        let escaped = normalized.replace('\'', "'\\''");
3655        text.push_str("file '");
3656        text.push_str(&escaped);
3657        text.push_str("'\n");
3658    }
3659    tokio::fs::write(path, text).await?;
3660    Ok(())
3661}
3662
3663fn absolute_path(path: &Path) -> Result<PathBuf> {
3664    if path.is_absolute() {
3665        Ok(path.to_path_buf())
3666    } else {
3667        Ok(std::env::current_dir()?.join(path))
3668    }
3669}
3670
3671fn should_binary_merge_stream(
3672    stream: Option<&Stream>,
3673    result: &StreamDownloadResult,
3674    options: &DownloadOptions,
3675    is_live_session: bool,
3676) -> bool {
3677    is_live_session
3678        || options.binary_merge
3679        || result.binary_merge_required
3680        || stream.is_some_and(|stream| stream.media_type == Some(MediaType::Subtitles))
3681}
3682
3683fn ffmpeg_merge_format(stream: Option<&Stream>) -> MergeOutputFormat {
3684    if stream.is_some_and(|stream| stream.media_type == Some(MediaType::Audio)) {
3685        MergeOutputFormat::M4a
3686    } else {
3687        MergeOutputFormat::Mp4
3688    }
3689}
3690
3691fn ffmpeg_merge_output_path(stream: Option<&Stream>, binary_output_path: &Path) -> PathBuf {
3692    let extension = match ffmpeg_merge_format(stream) {
3693        MergeOutputFormat::M4a => "m4a",
3694        _ => "mp4",
3695    };
3696    let output = binary_output_path.with_extension(extension);
3697    let final_output = match stream {
3698        Some(stream) => handle_file_collision(&output, stream),
3699        None => output,
3700    };
3701    let mut output_base = final_output;
3702    output_base.set_extension("");
3703    output_base
3704}
3705
3706async fn ffmpeg_merge_ddp_audio_sidecar(output_base_path: &Path) -> Result<Option<PathBuf>> {
3707    let current_dir = std::env::current_dir()?;
3708    ffmpeg_merge_ddp_audio_sidecar_in(output_base_path, &current_dir).await
3709}
3710
3711async fn ffmpeg_merge_ddp_audio_sidecar_in(
3712    output_base_path: &Path,
3713    current_dir: &Path,
3714) -> Result<Option<PathBuf>> {
3715    let Some(stem) = output_base_path.file_name() else {
3716        return Ok(None);
3717    };
3718    let mut sidecar = current_dir.to_path_buf();
3719    sidecar.push(stem);
3720    sidecar.set_extension("txt");
3721    if !tokio::fs::try_exists(&sidecar).await? {
3722        return Ok(None);
3723    }
3724    let text = tokio::fs::read_to_string(&sidecar).await?;
3725    let path = PathBuf::from(text.trim());
3726    if path.as_os_str().is_empty() {
3727        Ok(None)
3728    } else {
3729        Ok(Some(path))
3730    }
3731}
3732
3733fn stream_needs_aac_filter(stream: Option<&Stream>) -> bool {
3734    stream
3735        .and_then(|stream| stream.codecs.as_deref())
3736        .is_some_and(|codecs| codecs.to_ascii_lowercase().contains("mp4a"))
3737}
3738
3739async fn append_live_subtitle_refresh(
3740    stream: &Stream,
3741    result: &StreamDownloadResult,
3742    options: &DownloadOptions,
3743    timing: SubtitleTimingContext,
3744    events: &mut Vec<ProgressEvent>,
3745) -> Result<()> {
3746    let base_timestamp_ms = subtitle_base_timestamp_ms(stream, options, timing);
3747    let stream_id = Some(stream_identity(stream));
3748    events.push(ProgressEvent::SubtitleProgress {
3749        stream_id: stream_id.clone(),
3750        message: subtitle_extraction_message(stream, &result.files).await?,
3751    });
3752    let mut extraction =
3753        extract_subtitle_output(stream, &result.files, base_timestamp_ms, timing).await?;
3754    push_raw_console_lines(events, extraction.console_lines.drain(..));
3755    let mut subtitle = extraction.subtitle;
3756    let images = write_image_pngs(&mut subtitle, &result.temp_dir).await?;
3757    if !images.is_empty() {
3758        events.push(ProgressEvent::SubtitleProgress {
3759            stream_id: stream_id.clone(),
3760            message: "Processing Image Sub".to_string(),
3761        });
3762    }
3763    if !images.is_empty() && env::var(KEEP_IMAGE_SEGMENTS_ENV).ok().as_deref() != Some("1") {
3764        for path in &result.files {
3765            if tokio::fs::try_exists(path).await? {
3766                tokio::fs::remove_file(path).await?;
3767            }
3768        }
3769    }
3770    if let Some(parent) = result.output_path.parent() {
3771        tokio::fs::create_dir_all(parent).await?;
3772    }
3773    let formatted = format_subtitle(&subtitle, options.sub_format);
3774    let existing = match tokio::fs::read_to_string(&result.output_path).await {
3775        Ok(text) => text,
3776        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
3777        Err(error) => return Err(Error::from(error)),
3778    };
3779    let merged = append_formatted_live_subtitle(&existing, &formatted, options.sub_format);
3780    tokio::fs::write(
3781        &result.output_path,
3782        subtitle_output_bytes(&merged, timing.is_live_session),
3783    )
3784    .await?;
3785    Ok(())
3786}
3787
3788fn append_formatted_live_subtitle(
3789    existing: &str,
3790    refreshed: &str,
3791    format: SubtitleFormat,
3792) -> String {
3793    if existing.trim().is_empty() {
3794        return refreshed.to_string();
3795    }
3796    match format {
3797        SubtitleFormat::Srt => append_srt_text(existing, refreshed),
3798        SubtitleFormat::Vtt => append_vtt_text(existing, refreshed),
3799    }
3800}
3801
3802fn append_srt_text(existing: &str, refreshed: &str) -> String {
3803    let mut output = trim_trailing_newlines(existing).to_string();
3804    let rewritten = reindex_srt_text(refreshed, count_srt_cues(existing));
3805    if !rewritten.trim().is_empty() {
3806        let newline = platform_newline();
3807        output.push_str(newline);
3808        output.push_str(newline);
3809        output.push_str(&rewritten);
3810        output.push_str(newline);
3811    }
3812    output
3813}
3814
3815fn append_vtt_text(existing: &str, refreshed: &str) -> String {
3816    let mut output = trim_trailing_newlines(existing).to_string();
3817    let body = refreshed
3818        .strip_prefix("WEBVTT")
3819        .unwrap_or(refreshed)
3820        .trim_start_matches(['\r', '\n']);
3821    if !body.trim().is_empty() {
3822        let newline = platform_newline();
3823        output.push_str(newline);
3824        output.push_str(newline);
3825        output.push_str(body);
3826    }
3827    output
3828}
3829
3830fn reindex_srt_text(text: &str, offset: usize) -> String {
3831    let lines = text.lines().collect::<Vec<_>>();
3832    let newline = platform_newline();
3833    let mut output = String::new();
3834    let mut index = 0_usize;
3835    let mut cue = 1_usize;
3836    while index < lines.len() {
3837        while index < lines.len() && lines[index].trim().is_empty() {
3838            index += 1;
3839        }
3840        if index >= lines.len() {
3841            break;
3842        }
3843        if index + 1 < lines.len()
3844            && lines[index].trim().parse::<usize>().is_ok()
3845            && lines[index + 1].contains(" --> ")
3846        {
3847            output.push_str(&(offset + cue).to_string());
3848            output.push_str(newline);
3849            output.push_str(lines[index + 1].trim_end());
3850            output.push_str(newline);
3851            index += 2;
3852            while index < lines.len() && !lines[index].trim().is_empty() {
3853                output.push_str(lines[index].trim_end());
3854                output.push_str(newline);
3855                index += 1;
3856            }
3857            output.push_str(newline);
3858            cue += 1;
3859            continue;
3860        }
3861        output.push_str(lines[index].trim_end());
3862        output.push_str(newline);
3863        index += 1;
3864    }
3865    if output.is_empty() {
3866        text.to_string()
3867    } else {
3868        output
3869    }
3870}
3871
3872fn count_srt_cues(text: &str) -> usize {
3873    let lines = text.lines().collect::<Vec<_>>();
3874    lines
3875        .windows(2)
3876        .filter(|window| window[0].trim().parse::<usize>().is_ok() && window[1].contains(" --> "))
3877        .count()
3878}
3879
3880fn trim_trailing_newlines(value: &str) -> &str {
3881    value.trim_end_matches(['\r', '\n'])
3882}
3883
3884fn platform_newline() -> &'static str {
3885    #[cfg(windows)]
3886    {
3887        "\r\n"
3888    }
3889    #[cfg(not(windows))]
3890    {
3891        "\n"
3892    }
3893}
3894
3895async fn write_subtitle_output_if_needed(
3896    stream: &Stream,
3897    files: &[PathBuf],
3898    output_path: &Path,
3899    temp_dir: &Path,
3900    options: &DownloadOptions,
3901    timing: SubtitleTimingContext,
3902    events: &mut Vec<ProgressEvent>,
3903) -> Result<SubtitleWriteOutcome> {
3904    if !should_format_subtitle(stream, options) {
3905        return Ok(SubtitleWriteOutcome::default());
3906    }
3907    let base_timestamp_ms = subtitle_base_timestamp_ms(stream, options, timing);
3908    let stream_id = Some(stream_identity(stream));
3909    events.push(ProgressEvent::SubtitleProgress {
3910        stream_id: stream_id.clone(),
3911        message: subtitle_extraction_message(stream, files).await?,
3912    });
3913    let mut extraction = extract_subtitle_output(stream, files, base_timestamp_ms, timing).await?;
3914    push_raw_console_lines(events, extraction.console_lines.drain(..));
3915    let mut subtitle = extraction.subtitle;
3916    let images = write_image_pngs(&mut subtitle, temp_dir).await?;
3917    if !images.is_empty() {
3918        events.push(ProgressEvent::SubtitleProgress {
3919            stream_id: stream_id.clone(),
3920            message: "Processing Image Sub".to_string(),
3921        });
3922    }
3923    if !images.is_empty() && env::var(KEEP_IMAGE_SEGMENTS_ENV).ok().as_deref() != Some("1") {
3924        for path in files {
3925            if tokio::fs::try_exists(path).await? {
3926                tokio::fs::remove_file(path).await?;
3927            }
3928        }
3929    }
3930    if let Some(parent) = output_path.parent() {
3931        tokio::fs::create_dir_all(parent).await?;
3932    }
3933    let formatted = format_subtitle(&subtitle, options.sub_format);
3934    tokio::fs::write(
3935        output_path,
3936        subtitle_output_bytes(&formatted, timing.is_live_session),
3937    )
3938    .await?;
3939    Ok(SubtitleWriteOutcome {
3940        wrote: true,
3941        retain_temp_dir: !images.is_empty(),
3942    })
3943}
3944
3945fn subtitle_output_bytes(text: &str, is_live_session: bool) -> Vec<u8> {
3946    if is_live_session {
3947        return text.as_bytes().to_vec();
3948    }
3949    let mut bytes = Vec::with_capacity(3 + text.len());
3950    bytes.extend_from_slice(&[0xef, 0xbb, 0xbf]);
3951    bytes.extend_from_slice(text.as_bytes());
3952    bytes
3953}
3954
3955#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3956struct SubtitleTimingContext {
3957    is_live_session: bool,
3958    live_audio_start_ms: Option<i64>,
3959}
3960
3961#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
3962struct SubtitleWriteOutcome {
3963    wrote: bool,
3964    retain_temp_dir: bool,
3965}
3966
3967#[derive(Debug, Default)]
3968struct SubtitleExtraction {
3969    subtitle: WebVttSubtitle,
3970    console_lines: Vec<String>,
3971}
3972
3973fn subtitle_base_timestamp_ms(
3974    stream: &Stream,
3975    options: &DownloadOptions,
3976    timing: SubtitleTimingContext,
3977) -> i64 {
3978    if options.live_fix_vtt_by_audio
3979        && stream.media_type == Some(MediaType::Subtitles)
3980        && should_format_raw_webvtt_subtitle(stream)
3981        && let Some(value) = timing.live_audio_start_ms
3982    {
3983        return value;
3984    }
3985    if timing.is_live_session
3986        && (should_format_ttml_subtitle(stream)
3987            || stream
3988                .codecs
3989                .as_deref()
3990                .is_some_and(|codecs| codecs.eq_ignore_ascii_case("stpp")))
3991        && let Some(value) = stream
3992            .publish_time
3993            .as_deref()
3994            .and_then(parse_manifest_timestamp_millis)
3995    {
3996        return value.saturating_sub(subtitle_batch_duration_ms(stream));
3997    }
3998    stream
3999        .skipped_duration
4000        .filter(|value| value.is_finite() && *value > 0.0)
4001        .map(|value| (value * 1000.0).min(i64::MAX as f64) as i64)
4002        .unwrap_or_default()
4003}
4004
4005fn subtitle_batch_duration_ms(stream: &Stream) -> i64 {
4006    stream
4007        .playlist
4008        .as_ref()
4009        .map(|playlist| {
4010            playlist
4011                .media_parts
4012                .iter()
4013                .flat_map(|part| part.media_segments.iter())
4014                .map(|segment| segment.duration.max(0.0))
4015                .sum::<f64>()
4016        })
4017        .filter(|value| value.is_finite() && *value > 0.0)
4018        .map(|value| (value * 1000.0).min(i64::MAX as f64) as i64)
4019        .unwrap_or_default()
4020}
4021
4022async fn extract_subtitle_output(
4023    stream: &Stream,
4024    files: &[PathBuf],
4025    base_timestamp_ms: i64,
4026    timing: SubtitleTimingContext,
4027) -> Result<SubtitleExtraction> {
4028    let init_path = stream
4029        .playlist
4030        .as_ref()
4031        .and_then(|playlist| playlist.media_init.as_ref())
4032        .and_then(|_| files.first());
4033    let media_files = if init_path.is_some() && files.len() > 1 {
4034        &files[1..]
4035    } else {
4036        files
4037    };
4038    if let Some(init_path) = init_path {
4039        let init = tokio::fs::read(init_path).await?;
4040        if let Some(timescale) = check_wvtt_init(&init)? {
4041            let extraction =
4042                extract_wvtt_from_files_with_console_lines(media_files, timescale).await?;
4043            let mut subtitle = extraction.subtitle;
4044            if !timing.is_live_session && base_timestamp_ms != 0 {
4045                subtitle.left_shift_ms(base_timestamp_ms);
4046            }
4047            return Ok(SubtitleExtraction {
4048                subtitle,
4049                console_lines: extraction.console_lines,
4050            });
4051        }
4052        if check_stpp_init(&init) {
4053            return extract_ttml_segmented_subtitle(
4054                stream,
4055                media_files,
4056                base_timestamp_ms,
4057                timing,
4058                TtmlExtractionKind::Mp4,
4059            )
4060            .await
4061            .map(subtitle_extraction_without_console_lines);
4062        }
4063    }
4064    if should_format_ttml_subtitle(stream) {
4065        return extract_ttml_segmented_subtitle(
4066            stream,
4067            media_files,
4068            base_timestamp_ms,
4069            timing,
4070            TtmlExtractionKind::Plain,
4071        )
4072        .await
4073        .map(subtitle_extraction_without_console_lines);
4074    }
4075    if should_format_raw_webvtt_subtitle(stream) {
4076        return extract_raw_webvtt_subtitle(stream, media_files, base_timestamp_ms, timing)
4077            .await
4078            .map(subtitle_extraction_without_console_lines);
4079    }
4080    Err(Error::subtitle(
4081        "unsupported subtitle format for automatic repair",
4082    ))
4083}
4084
4085fn subtitle_extraction_without_console_lines(subtitle: WebVttSubtitle) -> SubtitleExtraction {
4086    SubtitleExtraction {
4087        subtitle,
4088        console_lines: Vec::new(),
4089    }
4090}
4091
4092async fn subtitle_extraction_message(stream: &Stream, files: &[PathBuf]) -> Result<String> {
4093    let init_path = stream
4094        .playlist
4095        .as_ref()
4096        .and_then(|playlist| playlist.media_init.as_ref())
4097        .and_then(|_| files.first());
4098    if let Some(init_path) = init_path {
4099        let init = tokio::fs::read(init_path).await?;
4100        if check_wvtt_init(&init)?.is_some() {
4101            return Ok("Extracting VTT(mp4) subtitle...".to_string());
4102        }
4103        if check_stpp_init(&init) {
4104            return Ok("Extracting TTML(mp4) subtitle...".to_string());
4105        }
4106    }
4107    if should_format_ttml_subtitle(stream) {
4108        return Ok("Extracting TTML(raw) subtitle...".to_string());
4109    }
4110    Ok("Extracting VTT(raw) subtitle...".to_string())
4111}
4112
4113async fn extract_raw_webvtt_subtitle(
4114    stream: &Stream,
4115    files: &[PathBuf],
4116    base_timestamp_ms: i64,
4117    timing: SubtitleTimingContext,
4118) -> Result<WebVttSubtitle> {
4119    let mut subtitle = None;
4120    let segments = stream_media_segments(stream);
4121    for (index, path) in files.iter().enumerate() {
4122        let bytes = tokio::fs::read(path).await?;
4123        let parse_base_timestamp_ms = if timing.is_live_session {
4124            base_timestamp_ms
4125        } else {
4126            0
4127        };
4128        let mut parsed = parse_webvtt_bytes(&bytes, parse_base_timestamp_ms)?;
4129        if parsed.mpegts_timestamp == 0
4130            && let Some(timestamp) =
4131                manual_subtitle_mpegts_timestamp(stream, &segments, index, !timing.is_live_session)
4132        {
4133            parsed.mpegts_timestamp = timestamp;
4134        }
4135        merge_subtitle_segment(&mut subtitle, parsed);
4136    }
4137    let mut subtitle = subtitle.unwrap_or_default();
4138    if !timing.is_live_session && base_timestamp_ms != 0 {
4139        subtitle.left_shift_ms(base_timestamp_ms);
4140    }
4141    Ok(subtitle)
4142}
4143
4144#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4145enum TtmlExtractionKind {
4146    Plain,
4147    Mp4,
4148}
4149
4150async fn extract_ttml_segmented_subtitle(
4151    stream: &Stream,
4152    files: &[PathBuf],
4153    base_timestamp_ms: i64,
4154    timing: SubtitleTimingContext,
4155    kind: TtmlExtractionKind,
4156) -> Result<WebVttSubtitle> {
4157    let mut subtitle = None;
4158    let segments = stream_media_segments(stream);
4159    for (index, path) in files.iter().enumerate() {
4160        let mut parsed = match kind {
4161            TtmlExtractionKind::Plain => {
4162                extract_ttml_from_files(
4163                    std::slice::from_ref(path),
4164                    0,
4165                    base_timestamp_ms_for_parse(timing, base_timestamp_ms),
4166                )
4167                .await?
4168            }
4169            TtmlExtractionKind::Mp4 => {
4170                extract_stpp_from_files(
4171                    std::slice::from_ref(path),
4172                    0,
4173                    base_timestamp_ms_for_parse(timing, base_timestamp_ms),
4174                )
4175                .await?
4176            }
4177        };
4178        if parsed.mpegts_timestamp == 0
4179            && let Some(timestamp) =
4180                manual_subtitle_mpegts_timestamp(stream, &segments, index, !timing.is_live_session)
4181        {
4182            parsed.mpegts_timestamp = timestamp;
4183        }
4184        merge_subtitle_segment(&mut subtitle, parsed);
4185    }
4186    let mut subtitle = subtitle.unwrap_or_default();
4187    if !timing.is_live_session && base_timestamp_ms != 0 {
4188        subtitle.left_shift_ms(base_timestamp_ms);
4189    }
4190    Ok(subtitle)
4191}
4192
4193fn base_timestamp_ms_for_parse(timing: SubtitleTimingContext, base_timestamp_ms: i64) -> i64 {
4194    if timing.is_live_session {
4195        base_timestamp_ms
4196    } else {
4197        0
4198    }
4199}
4200
4201fn merge_subtitle_segment(subtitle: &mut Option<WebVttSubtitle>, parsed: WebVttSubtitle) {
4202    if let Some(existing) = subtitle {
4203        existing.add_cues_from_one(parsed);
4204    } else {
4205        *subtitle = Some(parsed);
4206    }
4207}
4208
4209fn should_format_subtitle(stream: &Stream, options: &DownloadOptions) -> bool {
4210    options.auto_subtitle_fix
4211        && stream.media_type == Some(MediaType::Subtitles)
4212        && (should_format_raw_webvtt_subtitle(stream)
4213            || should_format_ttml_subtitle(stream)
4214            || should_format_mp4_subtitle(stream))
4215}
4216
4217fn should_format_ttml_subtitle(stream: &Stream) -> bool {
4218    stream
4219        .codecs
4220        .as_deref()
4221        .is_some_and(|codecs| codecs.eq_ignore_ascii_case("stpp"))
4222        || stream.extension.as_deref().is_some_and(|extension| {
4223            extension.eq_ignore_ascii_case("ttml") || extension.eq_ignore_ascii_case("dfxp")
4224        })
4225}
4226
4227fn should_format_raw_webvtt_subtitle(stream: &Stream) -> bool {
4228    stream.extension.as_deref().is_some_and(|extension| {
4229        extension.eq_ignore_ascii_case("vtt") || extension.eq_ignore_ascii_case("webvtt")
4230    })
4231}
4232
4233fn should_format_mp4_subtitle(stream: &Stream) -> bool {
4234    stream.codecs.as_deref().is_some_and(|codecs| {
4235        codecs.eq_ignore_ascii_case("wvtt") || codecs.eq_ignore_ascii_case("stpp")
4236    }) || stream.extension.as_deref().is_some_and(|extension| {
4237        extension.eq_ignore_ascii_case("m4s") || extension.eq_ignore_ascii_case("mp4")
4238    })
4239}
4240
4241struct LivePipeMuxSession {
4242    senders: Vec<mpsc::Sender<LivePipeWriteJob>>,
4243    workers: Vec<thread::JoinHandle<Result<()>>>,
4244    child: Child,
4245    output_path: PathBuf,
4246    #[cfg(not(windows))]
4247    fifo_paths: Vec<PathBuf>,
4248}
4249
4250struct LivePipeWriteJob {
4251    files: Vec<PathBuf>,
4252    ack: mpsc::Sender<std::result::Result<(), String>>,
4253}
4254
4255impl LivePipeMuxSession {
4256    async fn start(
4257        streams: &[Stream],
4258        results: &[StreamDownloadResult],
4259        options: &DownloadOptions,
4260        _dirs: &WorkDirectories,
4261        events: &mut Vec<ProgressEvent>,
4262    ) -> Result<Self> {
4263        let files = live_pipe_output_files(streams, results);
4264        if files.is_empty() {
4265            return Err(Error::live(
4266                "live pipe mux requires at least one media stream",
4267            ));
4268        }
4269        let output_path = mux_output_base(&files, options)?.with_extension("ts");
4270        if let Some(parent) = output_path.parent() {
4271            tokio::fs::create_dir_all(parent).await?;
4272        }
4273        let LivePipeEnvironment {
4274            custom_destination,
4275            pipe_dir,
4276        } = live_pipe_environment();
4277        tokio::fs::create_dir_all(&pipe_dir).await?;
4278        let pipe_names = live_pipe_names(files.len());
4279        let endpoints = create_live_pipe_endpoints(&pipe_names, &pipe_dir)?;
4280        for pipe_name in &pipe_names {
4281            events.push(ProgressEvent::Log {
4282                level: LogLevel::Info,
4283                message: format!("Named pipe created: {pipe_name}"),
4284            });
4285        }
4286        let date_string = current_local_iso_timestamp();
4287        let plan = plan_live_pipe_mux(
4288            options
4289                .ffmpeg_binary_path
4290                .clone()
4291                .unwrap_or_else(|| PathBuf::from("ffmpeg")),
4292            &pipe_names,
4293            &output_path,
4294            &date_string,
4295            custom_destination.as_deref(),
4296            &pipe_dir,
4297            cfg!(windows),
4298        );
4299        events.push(ProgressEvent::MuxProgress {
4300            message: format!(
4301                "Mux with named pipe, to {}",
4302                output_path
4303                    .file_name()
4304                    .and_then(|value| value.to_str())
4305                    .unwrap_or("output.ts")
4306            ),
4307        });
4308        if custom_destination.is_some() {
4309            events.push(ProgressEvent::MuxProgress {
4310                message: plan.arguments.clone(),
4311            });
4312        }
4313        #[cfg(not(windows))]
4314        let fifo_paths = endpoints.clone();
4315        let workers = endpoints
4316            .into_iter()
4317            .map(spawn_live_pipe_writer)
4318            .collect::<Result<Vec<_>>>()?;
4319        let (senders, workers): (Vec<_>, Vec<_>) = workers.into_iter().unzip();
4320        tokio::time::sleep(Duration::from_millis(1000)).await;
4321        let child = spawn_live_pipe_mux_command_plan(&plan)?;
4322        Ok(Self {
4323            senders,
4324            workers,
4325            child,
4326            output_path,
4327            #[cfg(not(windows))]
4328            fifo_paths,
4329        })
4330    }
4331
4332    async fn write_stream_files_parallel(&self, jobs: Vec<(usize, Vec<PathBuf>)>) -> Result<()> {
4333        let senders = self.senders.clone();
4334        tokio::task::spawn_blocking(move || {
4335            let mut acks = Vec::with_capacity(jobs.len());
4336            for (stream_index, files) in jobs {
4337                let sender = senders
4338                    .get(stream_index)
4339                    .ok_or_else(|| Error::live("live pipe writer is missing"))?;
4340                let (ack, wait) = mpsc::channel();
4341                sender
4342                    .send(LivePipeWriteJob { files, ack })
4343                    .map_err(|_| Error::live("live pipe writer stopped"))?;
4344                acks.push(wait);
4345            }
4346            for ack in acks {
4347                match ack
4348                    .recv()
4349                    .map_err(|_| Error::live("live pipe writer stopped"))?
4350                {
4351                    Ok(()) => {}
4352                    Err(message) => return Err(Error::live(message)),
4353                }
4354            }
4355            Ok(())
4356        })
4357        .await
4358        .map_err(|_| Error::live("live pipe writer acknowledgement failed"))?
4359    }
4360
4361    async fn finish(
4362        self,
4363        events: &mut Vec<ProgressEvent>,
4364        cancellation_token: &CancellationToken,
4365    ) -> Result<()> {
4366        let cancellation_token = cancellation_token.clone();
4367        let output_path = tokio::task::spawn_blocking(move || {
4368            let mut session = self;
4369            drop(session.senders);
4370            for worker in session.workers {
4371                match worker.join() {
4372                    Ok(result) => result?,
4373                    Err(_) => return Err(Error::live("live pipe writer failed")),
4374                }
4375            }
4376            let status =
4377                wait_live_pipe_child_with_cancellation(&mut session.child, &cancellation_token)?;
4378            #[cfg(not(windows))]
4379            cleanup_live_fifos(&session.fifo_paths)?;
4380            if !status.success() {
4381                return Err(Error::mux("live pipe mux process failed"));
4382            }
4383            if !session.output_path.exists() {
4384                return Err(Error::mux("live pipe mux did not create the output file"));
4385            }
4386            Ok(session.output_path)
4387        })
4388        .await
4389        .map_err(|_| Error::live("live pipe finalizer failed"))??;
4390        events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
4391            output_path,
4392        )));
4393        Ok(())
4394    }
4395}
4396
4397#[allow(clippy::too_many_arguments)]
4398async fn stream_downloads_to_live_pipe(
4399    streams: &[Stream],
4400    results: &[StreamDownloadResult],
4401    options: &DownloadOptions,
4402    is_live_session: bool,
4403    live_audio_start_ms: Option<i64>,
4404    events: &mut Vec<ProgressEvent>,
4405    session: &mut LivePipeMuxSession,
4406    cancellation_token: &CancellationToken,
4407) -> Result<()> {
4408    let mut pipe_index = 0_usize;
4409    let mut pipe_jobs = Vec::new();
4410    let mut cleanup_dirs = Vec::new();
4411    for (index, result) in results.iter().enumerate() {
4412        let Some(stream) = streams.get(index) else {
4413            continue;
4414        };
4415        decrypt_stream_files(stream, result, options, events, cancellation_token).await?;
4416        if stream.media_type == Some(MediaType::Subtitles) {
4417            let subtitle_write = if !result.files.is_empty() && !options.skip_merge {
4418                write_subtitle_output_if_needed(
4419                    stream,
4420                    &result.files,
4421                    &result.output_path,
4422                    &result.temp_dir,
4423                    options,
4424                    SubtitleTimingContext {
4425                        is_live_session,
4426                        live_audio_start_ms,
4427                    },
4428                    events,
4429                )
4430                .await?
4431            } else {
4432                SubtitleWriteOutcome::default()
4433            };
4434            if subtitle_write.wrote {
4435                events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
4436                    result.output_path.clone(),
4437                )));
4438            }
4439            if should_cleanup_stream_temp_dir(options, is_live_session)
4440                && !subtitle_write.retain_temp_dir
4441            {
4442                cleanup_stream_temp_dir(&result.temp_dir, events).await?;
4443            }
4444            continue;
4445        }
4446        if result.files.is_empty() {
4447            if should_cleanup_stream_temp_dir(options, is_live_session) {
4448                cleanup_stream_temp_dir(&result.temp_dir, events).await?;
4449            }
4450            pipe_index = pipe_index.saturating_add(1);
4451            continue;
4452        }
4453        pipe_jobs.push((pipe_index, result.files.clone()));
4454        if should_cleanup_stream_temp_dir(options, is_live_session) {
4455            cleanup_dirs.push(result.temp_dir.clone());
4456        }
4457        pipe_index = pipe_index.saturating_add(1);
4458    }
4459    session.write_stream_files_parallel(pipe_jobs).await?;
4460    for temp_dir in cleanup_dirs {
4461        cleanup_stream_temp_dir(&temp_dir, events).await?;
4462    }
4463    Ok(())
4464}
4465
4466fn live_pipe_indexes(streams: &[Stream]) -> Vec<Option<usize>> {
4467    let mut pipe_index = 0_usize;
4468    streams
4469        .iter()
4470        .map(|stream| {
4471            if stream.media_type == Some(MediaType::Subtitles) {
4472                None
4473            } else {
4474                let current = pipe_index;
4475                pipe_index = pipe_index.saturating_add(1);
4476                Some(current)
4477            }
4478        })
4479        .collect()
4480}
4481
4482#[allow(clippy::too_many_arguments)]
4483async fn stream_indexed_downloads_to_live_pipe(
4484    downloaded: &[LiveDownloadedBatch],
4485    options: &DownloadOptions,
4486    is_live_session: bool,
4487    live_audio_start_ms: Option<i64>,
4488    events: &mut Vec<ProgressEvent>,
4489    session: &mut LivePipeMuxSession,
4490    pipe_indexes: &[Option<usize>],
4491    cancellation_token: &CancellationToken,
4492) -> Result<()> {
4493    let mut pipe_jobs = Vec::new();
4494    let mut cleanup_dirs = Vec::new();
4495    for item in downloaded {
4496        let stream = &item.stream;
4497        let result = &item.result;
4498        decrypt_stream_files(stream, result, options, events, cancellation_token).await?;
4499        if stream.media_type == Some(MediaType::Subtitles) {
4500            let subtitle_write = if !result.files.is_empty() && !options.skip_merge {
4501                write_subtitle_output_if_needed(
4502                    stream,
4503                    &result.files,
4504                    &result.output_path,
4505                    &result.temp_dir,
4506                    options,
4507                    SubtitleTimingContext {
4508                        is_live_session,
4509                        live_audio_start_ms,
4510                    },
4511                    events,
4512                )
4513                .await?
4514            } else {
4515                SubtitleWriteOutcome::default()
4516            };
4517            if subtitle_write.wrote {
4518                events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
4519                    result.output_path.clone(),
4520                )));
4521            }
4522            if should_cleanup_stream_temp_dir(options, is_live_session)
4523                && !subtitle_write.retain_temp_dir
4524            {
4525                cleanup_stream_temp_dir(&result.temp_dir, events).await?;
4526            }
4527            continue;
4528        }
4529        let pipe_index = pipe_indexes
4530            .get(item.stream_index)
4531            .and_then(|value| *value)
4532            .ok_or_else(|| Error::live("live pipe writer is missing"))?;
4533        if result.files.is_empty() {
4534            if should_cleanup_stream_temp_dir(options, is_live_session) {
4535                cleanup_stream_temp_dir(&result.temp_dir, events).await?;
4536            }
4537            continue;
4538        }
4539        pipe_jobs.push((pipe_index, result.files.clone()));
4540        if should_cleanup_stream_temp_dir(options, is_live_session) {
4541            cleanup_dirs.push(result.temp_dir.clone());
4542        }
4543    }
4544    session.write_stream_files_parallel(pipe_jobs).await?;
4545    for temp_dir in cleanup_dirs {
4546        cleanup_stream_temp_dir(&temp_dir, events).await?;
4547    }
4548    Ok(())
4549}
4550
4551fn live_pipe_output_files(streams: &[Stream], results: &[StreamDownloadResult]) -> Vec<OutputFile> {
4552    streams
4553        .iter()
4554        .zip(results.iter())
4555        .enumerate()
4556        .filter_map(|(index, (stream, result))| {
4557            if stream.media_type == Some(MediaType::Subtitles) {
4558                return None;
4559            }
4560            Some(output_file_from_stream(
4561                index,
4562                stream,
4563                result,
4564                &result.output_path,
4565            ))
4566        })
4567        .collect()
4568}
4569
4570fn live_pipe_names(count: usize) -> Vec<String> {
4571    let process = std::process::id();
4572    let stamp = std::time::SystemTime::now()
4573        .duration_since(std::time::UNIX_EPOCH)
4574        .map(|duration| duration.as_nanos())
4575        .unwrap_or_default();
4576    (0..count)
4577        .map(|index| format!("haki-dl-pipe-{process}-{stamp}-{index}"))
4578        .collect()
4579}
4580
4581#[cfg(windows)]
4582type LivePipeEndpoint = interprocess::os::windows::named_pipe::PipeListener<
4583    interprocess::os::windows::named_pipe::pipe_mode::Bytes,
4584    interprocess::os::windows::named_pipe::pipe_mode::Bytes,
4585>;
4586
4587#[cfg(not(windows))]
4588type LivePipeEndpoint = PathBuf;
4589
4590#[cfg(windows)]
4591fn create_live_pipe_endpoints(names: &[String], _pipe_dir: &Path) -> Result<Vec<LivePipeEndpoint>> {
4592    use interprocess::os::windows::named_pipe::PipeListenerOptions;
4593    use interprocess::os::windows::named_pipe::pipe_mode::Bytes;
4594
4595    names
4596        .iter()
4597        .map(|name| {
4598            let path = format!(r"\\.\pipe\{name}");
4599            PipeListenerOptions::new()
4600                .path(path)
4601                .create_duplex::<Bytes>()
4602                .map_err(|error| Error::live(error.to_string()))
4603        })
4604        .collect()
4605}
4606
4607#[cfg(not(windows))]
4608fn create_live_pipe_endpoints(names: &[String], pipe_dir: &Path) -> Result<Vec<LivePipeEndpoint>> {
4609    use nix::sys::stat::Mode;
4610    use nix::unistd::mkfifo;
4611
4612    let mut paths = Vec::with_capacity(names.len());
4613    for name in names {
4614        let path = pipe_dir.join(name);
4615        if path.exists() {
4616            std::fs::remove_file(&path)?;
4617        }
4618        mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR)
4619            .map_err(|error| Error::live(error.to_string()))?;
4620        paths.push(path);
4621    }
4622    Ok(paths)
4623}
4624
4625enum LivePipeWriter {
4626    #[cfg(windows)]
4627    Windows(
4628        interprocess::os::windows::named_pipe::PipeStream<
4629            interprocess::os::windows::named_pipe::pipe_mode::Bytes,
4630            interprocess::os::windows::named_pipe::pipe_mode::Bytes,
4631        >,
4632    ),
4633    #[cfg(not(windows))]
4634    Unix(File),
4635}
4636
4637#[cfg(windows)]
4638fn spawn_live_pipe_writer(
4639    endpoint: LivePipeEndpoint,
4640) -> Result<(
4641    mpsc::Sender<LivePipeWriteJob>,
4642    thread::JoinHandle<Result<()>>,
4643)> {
4644    let (sender, receiver) = mpsc::channel();
4645    let worker = thread::spawn(move || {
4646        let writer = endpoint
4647            .accept()
4648            .map(LivePipeWriter::Windows)
4649            .map_err(|error| Error::live(error.to_string()))?;
4650        run_live_pipe_writer(writer, receiver)
4651    });
4652    Ok((sender, worker))
4653}
4654
4655#[cfg(not(windows))]
4656fn spawn_live_pipe_writer(
4657    endpoint: LivePipeEndpoint,
4658) -> Result<(
4659    mpsc::Sender<LivePipeWriteJob>,
4660    thread::JoinHandle<Result<()>>,
4661)> {
4662    let (sender, receiver) = mpsc::channel();
4663    let worker = thread::spawn(move || {
4664        let writer = OpenOptions::new()
4665            .read(true)
4666            .write(true)
4667            .open(&endpoint)
4668            .map(LivePipeWriter::Unix)
4669            .map_err(Error::from)?;
4670        run_live_pipe_writer(writer, receiver)
4671    });
4672    Ok((sender, worker))
4673}
4674
4675fn run_live_pipe_writer(
4676    mut writer: LivePipeWriter,
4677    receiver: mpsc::Receiver<LivePipeWriteJob>,
4678) -> Result<()> {
4679    for job in receiver {
4680        let result = write_live_pipe_job(&mut writer, &job.files);
4681        let ack = result.as_ref().map(|_| ()).map_err(ToString::to_string);
4682        let _ = job.ack.send(ack);
4683        result?;
4684    }
4685    Ok(())
4686}
4687
4688fn write_live_pipe_job(writer: &mut LivePipeWriter, files: &[PathBuf]) -> Result<()> {
4689    for path in files {
4690        writer.write_file(path)?;
4691    }
4692    writer.flush_batch()
4693}
4694
4695impl LivePipeWriter {
4696    fn write_file(&mut self, path: &Path) -> Result<()> {
4697        let mut input = File::open(path)?;
4698        match self {
4699            #[cfg(windows)]
4700            Self::Windows(writer) => {
4701                std::io::copy(&mut input, writer)?;
4702            }
4703            #[cfg(not(windows))]
4704            Self::Unix(writer) => {
4705                std::io::copy(&mut input, writer)?;
4706            }
4707        }
4708        Ok(())
4709    }
4710
4711    fn flush_batch(&mut self) -> Result<()> {
4712        match self {
4713            #[cfg(windows)]
4714            Self::Windows(writer) => {
4715                writer.assume_flushed();
4716            }
4717            #[cfg(not(windows))]
4718            Self::Unix(writer) => {
4719                writer.flush()?;
4720            }
4721        }
4722        Ok(())
4723    }
4724}
4725
4726#[cfg(not(windows))]
4727fn cleanup_live_fifos(paths: &[PathBuf]) -> Result<()> {
4728    for path in paths {
4729        if path.exists() {
4730            std::fs::remove_file(path)?;
4731        }
4732    }
4733    Ok(())
4734}
4735
4736async fn run_mux_after_done(
4737    generated_outputs: &[OutputFile],
4738    options: &DownloadOptions,
4739    events: &mut Vec<ProgressEvent>,
4740    cancellation_token: &CancellationToken,
4741) -> Result<()> {
4742    let Some(mux_options) = &options.mux_after_done else {
4743        return Ok(());
4744    };
4745    let files = output_files_with_imports(
4746        generated_outputs.to_vec(),
4747        &options.mux_imports,
4748        mux_options.skip_sub,
4749    );
4750    if files.is_empty() {
4751        return Err(Error::mux(
4752            "mux-after-done requires at least one output file",
4753        ));
4754    }
4755    if !(mux_options.muxer == MuxerKind::Mp4forge && mux_options.fallback_muxer.is_some()) {
4756        validate_mp4forge_mux_after_done(
4757            mux_options.format,
4758            mux_options.muxer,
4759            &files,
4760            &Mp4forgeSupportMatrix::default(),
4761        )?;
4762    }
4763    let output_base = mux_output_base(generated_outputs, options)?;
4764    push_mux_after_done_start(
4765        events,
4766        &files,
4767        &output_base,
4768        mux_options.format,
4769        mux_options.muxer,
4770    );
4771    if mux_options.muxer == MuxerKind::Mp4forge {
4772        let output_path = output_base.with_extension("mp4");
4773        if let Err(mp4forge_error) = run_mp4forge_mux_after_done(&files, &output_path).await {
4774            let Some(fallback_muxer) = mux_options.fallback_muxer else {
4775                events.push(ProgressEvent::Log {
4776                    level: LogLevel::Error,
4777                    message: "Mux failed".to_string(),
4778                });
4779                return Err(mp4forge_error);
4780            };
4781            events.push(ProgressEvent::MuxProgress {
4782                message: format!(
4783                    "mp4forge mux-after-done failed; falling back to {}",
4784                    muxer_name(fallback_muxer)
4785                ),
4786            });
4787            let plan = mp4forge_fallback_mux_command_plan(
4788                mux_options,
4789                fallback_muxer,
4790                options,
4791                &files,
4792                &output_base,
4793            )?;
4794            if let Err(error) = run_mux_command_plan(&plan, cancellation_token, events).await {
4795                events.push(ProgressEvent::Log {
4796                    level: LogLevel::Error,
4797                    message: "Mux failed".to_string(),
4798                });
4799                return Err(Error::mux(format!(
4800                    "mp4forge mux-after-done failed; {} fallback failed: {error}",
4801                    muxer_name(fallback_muxer)
4802                )));
4803            }
4804            if !mux_options.keep {
4805                cleanup_mux_inputs(&files, &plan.output_path, events).await?;
4806            }
4807            let output_path = finalize_mux_output_path(plan.output_path.clone(), events).await?;
4808            events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
4809                output_path.clone(),
4810            )));
4811            return Ok(());
4812        }
4813        if !mux_options.keep {
4814            cleanup_mux_inputs(&files, &output_path, events).await?;
4815        }
4816        let output_path = finalize_mux_output_path(output_path, events).await?;
4817        events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
4818            output_path.clone(),
4819        )));
4820        return Ok(());
4821    }
4822    let plan = mux_command_plan(mux_options, options, &files, &output_base)?;
4823    if let Err(error) = run_mux_command_plan(&plan, cancellation_token, events).await {
4824        events.push(ProgressEvent::Log {
4825            level: LogLevel::Error,
4826            message: "Mux failed".to_string(),
4827        });
4828        return Err(error);
4829    }
4830    if !mux_options.keep {
4831        cleanup_mux_inputs(&files, &plan.output_path, events).await?;
4832    }
4833    let output_path = finalize_mux_output_path(plan.output_path.clone(), events).await?;
4834    events.push(ProgressEvent::OutputArtifact(OutputArtifact::new(
4835        output_path.clone(),
4836    )));
4837    Ok(())
4838}
4839
4840fn push_mux_after_done_start(
4841    events: &mut Vec<ProgressEvent>,
4842    files: &[OutputFile],
4843    output_base: &Path,
4844    format: MuxFormat,
4845    muxer: MuxerKind,
4846) {
4847    for file in files {
4848        if let Some(name) = file.file_path.file_name().and_then(|value| value.to_str()) {
4849            events.push(ProgressEvent::MuxProgress {
4850                message: name.to_string(),
4851            });
4852        }
4853    }
4854    let target = output_base
4855        .file_name()
4856        .and_then(|value| value.to_str())
4857        .map(|name| format!("{name}{}", mux_extension(format)))
4858        .unwrap_or_else(|| format!("output{}", mux_extension(format)));
4859    let message = if muxer == MuxerKind::Mp4forge {
4860        format!("Muxing to {target} using mp4forge")
4861    } else {
4862        format!("Muxing to {target}")
4863    };
4864    events.push(ProgressEvent::MuxProgress { message });
4865}
4866
4867fn mp4forge_fallback_mux_command_plan(
4868    mux_options: &MuxAfterDoneOptions,
4869    fallback_muxer: MuxerKind,
4870    options: &DownloadOptions,
4871    files: &[OutputFile],
4872    output_base: &Path,
4873) -> Result<MuxCommandPlan> {
4874    let fallback_options = MuxAfterDoneOptions {
4875        muxer: fallback_muxer,
4876        fallback_muxer: None,
4877        bin_path: None,
4878        ..mux_options.clone()
4879    };
4880    mux_command_plan(&fallback_options, options, files, output_base)
4881}
4882
4883fn muxer_name(muxer: MuxerKind) -> &'static str {
4884    match muxer {
4885        MuxerKind::Ffmpeg => "ffmpeg",
4886        MuxerKind::Mkvmerge => "mkvmerge",
4887        MuxerKind::Mp4forge => "mp4forge",
4888    }
4889}
4890
4891fn mux_command_plan(
4892    mux_options: &MuxAfterDoneOptions,
4893    options: &DownloadOptions,
4894    files: &[OutputFile],
4895    output_base: &Path,
4896) -> Result<MuxCommandPlan> {
4897    match mux_options.muxer {
4898        MuxerKind::Ffmpeg => plan_ffmpeg_mux(
4899            options
4900                .ffmpeg_binary_path
4901                .clone()
4902                .or_else(|| mux_options.bin_path.clone())
4903                .unwrap_or_else(|| PathBuf::from("ffmpeg")),
4904            files,
4905            output_base,
4906            mux_options.format,
4907            !options.no_date_info,
4908            None,
4909        ),
4910        MuxerKind::Mkvmerge => plan_mkvmerge_mux(
4911            mux_options
4912                .bin_path
4913                .clone()
4914                .or_else(|| options.mkvmerge_binary_path.clone())
4915                .unwrap_or_else(|| PathBuf::from("mkvmerge")),
4916            files,
4917            output_base,
4918        ),
4919        MuxerKind::Mp4forge => Err(Error::mux(
4920            "mp4forge mux-after-done uses a direct backend path",
4921        )),
4922    }
4923}
4924
4925fn manual_subtitle_mpegts_timestamp(
4926    stream: &Stream,
4927    segments: &[MediaSegment],
4928    index: usize,
4929    include_skipped_duration: bool,
4930) -> Option<i64> {
4931    let segment = segments.get(index)?;
4932    let skipped_duration = if include_skipped_duration {
4933        stream
4934            .skipped_duration
4935            .filter(|duration| duration.is_finite() && *duration > 0.0)
4936            .unwrap_or_default()
4937    } else {
4938        0.0
4939    };
4940    let seconds = segments
4941        .iter()
4942        .filter(|candidate| candidate.index < segment.index)
4943        .map(|candidate| candidate.duration.max(0.0))
4944        .sum::<f64>()
4945        + skipped_duration;
4946    if seconds <= 0.0 || !seconds.is_finite() {
4947        return None;
4948    }
4949    Some((seconds.trunc() * 90_000.0).min(i64::MAX as f64) as i64)
4950}
4951
4952#[cfg(feature = "mp4forge")]
4953async fn run_mp4forge_mux_after_done(files: &[OutputFile], output_path: &Path) -> Result<()> {
4954    let tracks = files
4955        .iter()
4956        .map(|file| mp4forge::mux::MuxTrackSpec::path(file.file_path.clone()))
4957        .collect::<Vec<_>>();
4958    let request = mp4forge::mux::MuxRequest::new(tracks);
4959    let output_path = output_path.to_path_buf();
4960    tokio::task::spawn_blocking(move || run_mp4forge_mux_on_worker_thread(request, output_path))
4961        .await
4962        .map_err(|_| Error::mux("mp4forge mux worker failed"))?
4963        .map_err(Error::mux)
4964}
4965
4966#[cfg(feature = "mp4forge")]
4967fn run_mp4forge_mux_on_worker_thread(
4968    request: mp4forge::mux::MuxRequest,
4969    output_path: PathBuf,
4970) -> std::result::Result<(), String> {
4971    const STACK_SIZE: usize = 16 * 1024 * 1024;
4972    thread::Builder::new()
4973        .name("haki-dl-mp4forge-mux".to_string())
4974        .stack_size(STACK_SIZE)
4975        .spawn(move || {
4976            let runtime = tokio::runtime::Builder::new_current_thread()
4977                .enable_all()
4978                .build()
4979                .map_err(|error| error.to_string())?;
4980            runtime.block_on(async {
4981                mp4forge::mux::mux_to_path_async(&request, &output_path)
4982                    .await
4983                    .map_err(|error| error.to_string())
4984            })
4985        })
4986        .map_err(|error| error.to_string())?
4987        .join()
4988        .map_err(|_| "mp4forge mux worker panicked".to_string())?
4989}
4990
4991#[cfg(not(feature = "mp4forge"))]
4992async fn run_mp4forge_mux_after_done(_files: &[OutputFile], _output_path: &Path) -> Result<()> {
4993    Err(Error::mux(
4994        "mp4forge mux-after-done requires the mp4forge feature",
4995    ))
4996}
4997
4998async fn run_mux_command_plan(
4999    plan: &MuxCommandPlan,
5000    cancellation_token: &CancellationToken,
5001    events: &mut Vec<ProgressEvent>,
5002) -> Result<()> {
5003    push_debug_event(
5004        events,
5005        format!("{}: {}", plan.program.display(), plan.arguments),
5006    );
5007    let args = split_command_arguments(&plan.arguments)?;
5008    let mut child = Command::new(&plan.program)
5009        .args(args)
5010        .current_dir(&plan.working_directory)
5011        .stdin(Stdio::null())
5012        .stdout(Stdio::null())
5013        .stderr(Stdio::piped())
5014        .spawn()
5015        .map_err(|error| Error::mux(error.to_string()))?;
5016    let stderr_handle = child.stderr.take().map(|mut stderr| {
5017        tokio::spawn(async move {
5018            let mut stderr_bytes = Vec::new();
5019            stderr
5020                .read_to_end(&mut stderr_bytes)
5021                .await
5022                .map(|_| stderr_bytes)
5023        })
5024    });
5025    let status_result = wait_child_with_cancellation(&mut child, cancellation_token).await;
5026    let stderr_bytes = collect_mux_child_stderr(stderr_handle).await?;
5027    let status = status_result?;
5028    push_mux_stderr_warnings(&stderr_bytes, events);
5029    if !status.success() {
5030        return Err(Error::mux("media process failed"));
5031    }
5032    if !tokio::fs::try_exists(&plan.output_path).await? {
5033        return Err(Error::mux("media process did not create the output file"));
5034    }
5035    Ok(())
5036}
5037
5038async fn wait_child_with_cancellation(
5039    child: &mut TokioChild,
5040    cancellation_token: &CancellationToken,
5041) -> Result<std::process::ExitStatus> {
5042    loop {
5043        if cancellation_token.is_cancelled() {
5044            let _ = child.kill().await;
5045            let _ = child.wait().await;
5046            return Err(Error::UserCancelled);
5047        }
5048        if let Some(status) = child.try_wait()? {
5049            return Ok(status);
5050        }
5051        tokio::time::sleep(Duration::from_millis(50)).await;
5052    }
5053}
5054
5055fn wait_live_pipe_child_with_cancellation(
5056    child: &mut Child,
5057    cancellation_token: &CancellationToken,
5058) -> Result<std::process::ExitStatus> {
5059    loop {
5060        if cancellation_token.is_cancelled() {
5061            let _ = child.kill();
5062            let _ = child.wait();
5063            return Err(Error::UserCancelled);
5064        }
5065        if let Some(status) = child.try_wait()? {
5066            return Ok(status);
5067        }
5068        thread::sleep(Duration::from_millis(50));
5069    }
5070}
5071
5072fn spawn_live_pipe_mux_command_plan(plan: &MuxCommandPlan) -> Result<Child> {
5073    let args = split_command_arguments(&plan.arguments)?;
5074    StdCommand::new(&plan.program)
5075        .args(args)
5076        .current_dir(&plan.working_directory)
5077        .stdin(Stdio::null())
5078        .stdout(Stdio::null())
5079        .stderr(Stdio::piped())
5080        .spawn()
5081        .map_err(|error| Error::mux(error.to_string()))
5082}
5083
5084async fn collect_mux_child_stderr(
5085    handle: Option<tokio::task::JoinHandle<std::io::Result<Vec<u8>>>>,
5086) -> Result<Vec<u8>> {
5087    match handle {
5088        Some(handle) => match handle.await {
5089            Ok(Ok(bytes)) => Ok(bytes),
5090            Ok(Err(error)) => Err(Error::mux(error.to_string())),
5091            Err(_) => Err(Error::mux("media process stderr reader failed")),
5092        },
5093        None => Ok(Vec::new()),
5094    }
5095}
5096
5097fn push_mux_stderr_warnings(stderr_bytes: &[u8], events: &mut Vec<ProgressEvent>) {
5098    let stderr = redact_secrets(&String::from_utf8_lossy(stderr_bytes));
5099    for line in stderr.lines().map(str::trim_end) {
5100        if line.is_empty() {
5101            continue;
5102        }
5103        events.push(ProgressEvent::ExternalToolOutput {
5104            message: line.to_string(),
5105        });
5106    }
5107}
5108
5109fn split_command_arguments(input: &str) -> Result<Vec<String>> {
5110    let mut args = Vec::new();
5111    let mut current = String::new();
5112    let mut in_quote = false;
5113    let mut chars = input.chars().peekable();
5114    while let Some(ch) = chars.next() {
5115        if ch == '\\' && in_quote {
5116            match chars.peek().copied() {
5117                Some('"') => {
5118                    if let Some(next) = chars.next() {
5119                        current.push(next);
5120                    }
5121                    continue;
5122                }
5123                _ => {
5124                    current.push(ch);
5125                    continue;
5126                }
5127            }
5128        }
5129        if ch == '"' {
5130            in_quote = !in_quote;
5131            continue;
5132        }
5133        if ch.is_whitespace() && !in_quote {
5134            if !current.is_empty() {
5135                args.push(std::mem::take(&mut current));
5136            }
5137            continue;
5138        }
5139        current.push(ch);
5140    }
5141    if in_quote {
5142        return Err(Error::mux(
5143            "mux command arguments contain an unterminated quote",
5144        ));
5145    }
5146    if !current.is_empty() {
5147        args.push(current);
5148    }
5149    Ok(args)
5150}
5151
5152fn mux_output_base(generated_outputs: &[OutputFile], options: &DownloadOptions) -> Result<PathBuf> {
5153    let first = generated_outputs
5154        .first()
5155        .ok_or_else(|| Error::mux("mux-after-done requires generated outputs"))?;
5156    let directory = first.file_path.parent().unwrap_or_else(|| Path::new(""));
5157    let name = match &options.save_name {
5158        Some(value) if !value.is_empty() => value.clone(),
5159        _ => first
5160            .file_path
5161            .file_stem()
5162            .and_then(|value| value.to_str())
5163            .map(str::to_string)
5164            .unwrap_or_else(|| "output".to_string()),
5165    };
5166    Ok(directory.join(format!("{name}.MUX")))
5167}
5168
5169async fn finalize_mux_output_path(
5170    output_path: PathBuf,
5171    events: &mut Vec<ProgressEvent>,
5172) -> Result<PathBuf> {
5173    let Some(stem) = output_path.file_stem().and_then(|value| value.to_str()) else {
5174        return Ok(output_path);
5175    };
5176    let Some(base_stem) = stem.strip_suffix(".MUX") else {
5177        return Ok(output_path);
5178    };
5179    let mut final_path = output_path.clone();
5180    final_path.set_file_name(base_stem);
5181    if let Some(extension) = output_path.extension() {
5182        final_path.set_extension(extension);
5183    }
5184    if tokio::fs::try_exists(&final_path).await? {
5185        return Ok(output_path);
5186    }
5187    tokio::fs::rename(&output_path, &final_path).await?;
5188    if let Some(name) = final_path.file_name().and_then(|value| value.to_str()) {
5189        events.push(ProgressEvent::MuxProgress {
5190            message: format!("Rename to {name}"),
5191        });
5192    }
5193    Ok(final_path)
5194}
5195
5196fn output_file_from_stream(
5197    index: usize,
5198    stream: &Stream,
5199    result: &StreamDownloadResult,
5200    path: &Path,
5201) -> OutputFile {
5202    let mut output = OutputFile::new(i32_from_usize(index), path.to_path_buf());
5203    output.media_type = stream.media_type;
5204    output.lang_code = stream.language.clone();
5205    output.description = stream.name.clone();
5206    output.media_infos = if result.media_infos.is_empty() {
5207        vec![MediaInfo {
5208            base_info: stream.codecs.clone(),
5209            bitrate: stream.bandwidth.map(|value| value.to_string()),
5210            resolution: stream.resolution.clone(),
5211            media_type: stream.media_type.map(media_type_name).map(str::to_string),
5212            ..MediaInfo::default()
5213        }]
5214    } else {
5215        result.media_infos.clone()
5216    };
5217    output
5218}
5219
5220async fn cleanup_mux_inputs(
5221    generated_outputs: &[OutputFile],
5222    mux_output: &Path,
5223    events: &mut Vec<ProgressEvent>,
5224) -> Result<()> {
5225    events.push(ProgressEvent::MuxProgress {
5226        message: "Cleaning files...".to_string(),
5227    });
5228    for output in generated_outputs {
5229        if output.file_path != mux_output && tokio::fs::try_exists(&output.file_path).await? {
5230            tokio::fs::remove_file(&output.file_path).await?;
5231            events.push(ProgressEvent::Cleanup {
5232                path: output.file_path.clone(),
5233            });
5234        }
5235    }
5236    Ok(())
5237}
5238
5239fn realtime_decrypt_hooks(
5240    options: &DownloadOptions,
5241    cancellation_token: &CancellationToken,
5242) -> DownloadHooks {
5243    if !options.mp4_real_time_decryption {
5244        return DownloadHooks::default();
5245    }
5246    let options = Arc::new(options.clone());
5247    let cancellation_token = cancellation_token.clone();
5248    let mp4forge_fragment_info_paths = Arc::new(Mutex::new(BTreeMap::<String, PathBuf>::new()));
5249
5250    let segment_options = Arc::clone(&options);
5251    let segment_cancellation = cancellation_token.clone();
5252    let segment_fragment_info_paths = Arc::clone(&mp4forge_fragment_info_paths);
5253    let segment_completed: SegmentCompletedHook = Arc::new(move |context| {
5254        let options = Arc::clone(&segment_options);
5255        let cancellation_token = segment_cancellation.clone();
5256        let fragment_info_paths = Arc::clone(&segment_fragment_info_paths);
5257        Box::pin(async move {
5258            let mut events = Vec::new();
5259            if context.is_init {
5260                if !matches!(
5261                    options.decryption_engine,
5262                    DecryptionEngine::Mp4decrypt | DecryptionEngine::Mp4forge
5263                ) || !matches!(
5264                    context.segment.encryption.method,
5265                    EncryptionMethod::Cenc | EncryptionMethod::SampleAes
5266                ) {
5267                    return Ok(events);
5268                }
5269                if options.decryption_engine == DecryptionEngine::Mp4forge {
5270                    let copy_path =
5271                        copy_mp4forge_fragment_info_init(&context.actual_file_path).await?;
5272                    {
5273                        let mut guard = fragment_info_paths.lock().map_err(|_| {
5274                            Error::config("realtime decrypt state lock was poisoned")
5275                        })?;
5276                        guard.insert(context.stream_id.clone(), copy_path);
5277                    }
5278                }
5279                let decrypt_result = run_external_decrypt_in_place(
5280                    &context.stream,
5281                    Some(&context.segment),
5282                    &context.actual_file_path,
5283                    None,
5284                    &options,
5285                    &mut events,
5286                    &cancellation_token,
5287                )
5288                .await;
5289                if decrypt_result.is_err()
5290                    && options.decryption_engine == DecryptionEngine::Mp4forge
5291                {
5292                    let path = {
5293                        let mut guard = fragment_info_paths.lock().map_err(|_| {
5294                            Error::config("realtime decrypt state lock was poisoned")
5295                        })?;
5296                        guard.remove(&context.stream_id)
5297                    };
5298                    if let Some(path) = path
5299                        && tokio::fs::try_exists(&path).await?
5300                    {
5301                        tokio::fs::remove_file(path).await?;
5302                    }
5303                }
5304                decrypt_result?;
5305                return Ok(events);
5306            }
5307
5308            if !matches!(
5309                context.segment.encryption.method,
5310                EncryptionMethod::Cenc | EncryptionMethod::SampleAes
5311            ) {
5312                return Ok(events);
5313            }
5314            let stored_fragment_info = if options.decryption_engine == DecryptionEngine::Mp4forge {
5315                let guard = fragment_info_paths
5316                    .lock()
5317                    .map_err(|_| Error::config("realtime decrypt state lock was poisoned"))?;
5318                guard.get(&context.stream_id).cloned()
5319            } else {
5320                None
5321            };
5322            let init_path = if options.decryption_engine == DecryptionEngine::Mp4forge {
5323                stored_fragment_info
5324                    .as_deref()
5325                    .or(context.init_file_path.as_deref())
5326            } else {
5327                context.init_file_path.as_deref()
5328            };
5329            run_external_decrypt_in_place(
5330                &context.stream,
5331                Some(&context.segment),
5332                &context.actual_file_path,
5333                init_path,
5334                &options,
5335                &mut events,
5336                &cancellation_token,
5337            )
5338            .await?;
5339            Ok(events)
5340        })
5341    });
5342
5343    let cleanup_fragment_info_paths = Arc::clone(&mp4forge_fragment_info_paths);
5344    let stream_completed: StreamCompletedHook = Arc::new(move |stream_id| {
5345        let fragment_info_paths = Arc::clone(&cleanup_fragment_info_paths);
5346        Box::pin(async move {
5347            let path = {
5348                let mut guard = fragment_info_paths
5349                    .lock()
5350                    .map_err(|_| Error::config("realtime decrypt state lock was poisoned"))?;
5351                guard.remove(&stream_id)
5352            };
5353            if let Some(path) = path
5354                && tokio::fs::try_exists(&path).await?
5355            {
5356                tokio::fs::remove_file(path).await?;
5357            }
5358            Ok(Vec::new())
5359        })
5360    });
5361
5362    DownloadHooks {
5363        segment_completed: Some(segment_completed),
5364        stream_completed: Some(stream_completed),
5365    }
5366}
5367
5368async fn decrypt_stream_files(
5369    stream: &Stream,
5370    result: &StreamDownloadResult,
5371    _options: &DownloadOptions,
5372    _events: &mut Vec<ProgressEvent>,
5373    _cancellation_token: &CancellationToken,
5374) -> Result<()> {
5375    let Some(playlist) = &stream.playlist else {
5376        return Ok(());
5377    };
5378    let media_segments = playlist
5379        .media_parts
5380        .iter()
5381        .flat_map(|part| part.media_segments.iter())
5382        .collect::<Vec<_>>();
5383    let offset = usize::from(playlist.media_init.is_some());
5384    for (segment, path) in media_segments.iter().zip(result.files.iter().skip(offset)) {
5385        if matches!(
5386            segment.encryption.method,
5387            EncryptionMethod::Cenc | EncryptionMethod::SampleAes | EncryptionMethod::Unknown
5388        ) {
5389            continue;
5390        }
5391        decrypt_segment_file(segment, path).await?;
5392    }
5393    Ok(())
5394}
5395
5396async fn copy_mp4forge_fragment_info_init(init_path: &Path) -> Result<PathBuf> {
5397    let parent = init_path.parent().unwrap_or_else(|| Path::new(""));
5398    let extension = init_path
5399        .extension()
5400        .and_then(|value| value.to_str())
5401        .map(|value| format!(".{value}"))
5402        .unwrap_or_default();
5403    let copy_path = parent.join(format!("{}_mp4forge_init{}", unique_temp_stem(), extension));
5404    tokio::fs::copy(init_path, &copy_path).await?;
5405    Ok(copy_path)
5406}
5407
5408async fn external_decrypt_if_needed(
5409    stream: Option<&Stream>,
5410    source_path: &Path,
5411    options: &DownloadOptions,
5412    events: &mut Vec<ProgressEvent>,
5413    cancellation_token: &CancellationToken,
5414) -> Result<PathBuf> {
5415    let Some(stream) = stream else {
5416        return Ok(source_path.to_path_buf());
5417    };
5418    if !stream_requires_external_decrypt(stream) {
5419        return Ok(source_path.to_path_buf());
5420    }
5421    if options.mp4_real_time_decryption {
5422        return Ok(source_path.to_path_buf());
5423    }
5424    run_external_decrypt_in_place(
5425        stream,
5426        None,
5427        source_path,
5428        None,
5429        options,
5430        events,
5431        cancellation_token,
5432    )
5433    .await?;
5434    Ok(source_path.to_path_buf())
5435}
5436
5437async fn run_external_decrypt_in_place(
5438    stream: &Stream,
5439    segment: Option<&MediaSegment>,
5440    source_path: &Path,
5441    init_path: Option<&Path>,
5442    options: &DownloadOptions,
5443    events: &mut Vec<ProgressEvent>,
5444    cancellation_token: &CancellationToken,
5445) -> Result<()> {
5446    let file_info = read_mp4_protection_info_from_path(source_path).await?;
5447    let init_info =
5448        init_path.map(|path| async move { read_mp4_protection_info_from_path(path).await });
5449    let init_info = match init_info {
5450        Some(future) => future.await?,
5451        None => crate::decrypt::Mp4ProtectionInfo::default(),
5452    };
5453    let mut kid = segment
5454        .and_then(|segment| segment.encryption.kid.as_ref().map(|kid| bytes_to_hex(kid)))
5455        .or_else(|| stream_kid_hex(stream))
5456        .or_else(|| init_info.kid.clone())
5457        .or_else(|| file_info.kid.clone());
5458    let program = options
5459        .decryption_binary_path
5460        .clone()
5461        .unwrap_or_else(|| default_decrypt_program(options.decryption_engine));
5462    if kid.as_deref().unwrap_or_default().is_empty()
5463        && options.decryption_engine == DecryptionEngine::ShakaPackager
5464    {
5465        kid = read_shaka_missing_key_id(&program, source_path).await?;
5466    }
5467    let mut keys = custom_keys_to_pairs(&options.keys);
5468    if should_log_key_text_search(options.key_text_file.as_deref(), kid.as_deref()).await {
5469        events.push(ProgressEvent::DecryptProgress {
5470            stream_id: Some(stream_identity(stream)),
5471            message: "Trying to search for KEY from text file...".to_string(),
5472        });
5473    }
5474    if let Some(found) =
5475        search_key_text_file(options.key_text_file.as_deref(), kid.as_deref()).await?
5476    {
5477        events.push(ProgressEvent::DecryptProgress {
5478            stream_id: Some(stream_identity(stream)),
5479            message: format!("OK {found}"),
5480        });
5481        keys.push(found);
5482    }
5483    let selected = select_key_pair(
5484        &keys,
5485        kid.as_deref(),
5486        init_info.is_multi_drm || file_info.is_multi_drm,
5487    );
5488    let Some(selected) = selected else {
5489        events.push(ProgressEvent::Warning {
5490            message: "external decrypt key is missing".to_string(),
5491        });
5492        return Ok(());
5493    };
5494    let stream_id = Some(stream_identity(stream));
5495    push_decrypt_protection_logs(
5496        events,
5497        stream_id.clone(),
5498        &init_info,
5499        &file_info,
5500        kid.as_deref(),
5501    );
5502    events.push(ProgressEvent::DecryptProgress {
5503        stream_id: stream_id.clone(),
5504        message: format!(
5505            "Decrypting using {}...",
5506            decryption_engine_name(options.decryption_engine)
5507        ),
5508    });
5509    let dest = decrypted_output_path(source_path);
5510    if options.decryption_engine == DecryptionEngine::Mp4forge {
5511        push_debug_event(events, "FileName: mp4forge (in-process)");
5512        push_debug_event(
5513            events,
5514            format!(
5515                "Arguments: {}",
5516                mp4forge_decrypt_debug_arguments(&keys, &selected, source_path, &dest, init_path)
5517            ),
5518        );
5519        run_mp4forge_decrypt_in_place(source_path, &dest, init_path, &keys, &selected).await?;
5520        tokio::fs::remove_file(source_path).await?;
5521        tokio::fs::rename(&dest, source_path).await?;
5522        return Ok(());
5523    }
5524    let prepared_source =
5525        prepare_external_decrypt_source(options.decryption_engine, source_path, init_path).await?;
5526    let mut command_source = prepared_source
5527        .as_deref()
5528        .unwrap_or(source_path)
5529        .to_path_buf();
5530    let mut command_dest = dest.clone();
5531    let mp4decrypt_temp = if options.decryption_engine == DecryptionEngine::Mp4decrypt {
5532        let temp = prepare_mp4decrypt_temp_paths(source_path);
5533        tokio::fs::rename(source_path, &temp.encrypted).await?;
5534        command_source = temp.encrypted.clone();
5535        command_dest = temp.decrypted.clone();
5536        Some(temp)
5537    } else {
5538        None
5539    };
5540    let command_init = if options.decryption_engine == DecryptionEngine::Mp4decrypt {
5541        init_path
5542    } else {
5543        None
5544    };
5545    let command = external_decrypt_command(
5546        options.decryption_engine,
5547        &keys,
5548        &selected,
5549        kid.as_deref(),
5550        &command_source,
5551        &command_dest,
5552        command_init,
5553    )?;
5554    let command_arguments = command.arguments.join(" ");
5555    push_debug_event(
5556        events,
5557        format!(
5558            "FileName: {}",
5559            redact_secrets(&program.display().to_string())
5560        ),
5561    );
5562    push_debug_event(events, format!("Arguments: {command_arguments}"));
5563    let mut process = Command::new(program);
5564    if let Some(directory) = &command.working_directory {
5565        process.current_dir(directory);
5566    }
5567    let mut child = match process
5568        .args(&command.arguments)
5569        .stdout(Stdio::null())
5570        .stderr(Stdio::piped())
5571        .spawn()
5572    {
5573        Ok(child) => child,
5574        Err(_error) => {
5575            restore_mp4decrypt_temp(source_path, mp4decrypt_temp.as_ref()).await?;
5576            cleanup_prepared_decrypt_source(prepared_source.as_deref()).await?;
5577            events.push(ProgressEvent::Log {
5578                level: LogLevel::Error,
5579                message: "Decryption failed".to_string(),
5580            });
5581            return Ok(());
5582        }
5583    };
5584    let stderr_handle = child.stderr.take().map(|mut stderr| {
5585        tokio::spawn(async move {
5586            let mut stderr_bytes = Vec::new();
5587            stderr
5588                .read_to_end(&mut stderr_bytes)
5589                .await
5590                .map(|_| stderr_bytes)
5591        })
5592    });
5593    let status = match wait_child_with_cancellation(&mut child, cancellation_token).await {
5594        Ok(status) => status,
5595        Err(error) => {
5596            let _ = collect_child_stderr(stderr_handle).await;
5597            restore_mp4decrypt_temp(source_path, mp4decrypt_temp.as_ref()).await?;
5598            cleanup_prepared_decrypt_source(prepared_source.as_deref()).await?;
5599            return Err(error);
5600        }
5601    };
5602    let _ = collect_child_stderr(stderr_handle).await?;
5603    if !status.success() {
5604        restore_mp4decrypt_temp(source_path, mp4decrypt_temp.as_ref()).await?;
5605        cleanup_prepared_decrypt_source(prepared_source.as_deref()).await?;
5606        events.push(ProgressEvent::Log {
5607            level: LogLevel::Error,
5608            message: "Decryption failed".to_string(),
5609        });
5610        return Ok(());
5611    }
5612    let produced_dest = mp4decrypt_temp
5613        .as_ref()
5614        .map(|temp| temp.decrypted.as_path())
5615        .unwrap_or(dest.as_path());
5616    if !tokio::fs::try_exists(produced_dest).await? {
5617        restore_mp4decrypt_temp(source_path, mp4decrypt_temp.as_ref()).await?;
5618        cleanup_prepared_decrypt_source(prepared_source.as_deref()).await?;
5619        events.push(ProgressEvent::Log {
5620            level: LogLevel::Error,
5621            message: "Decryption failed".to_string(),
5622        });
5623        return Ok(());
5624    }
5625    finalize_mp4decrypt_temp(source_path, &dest, mp4decrypt_temp.as_ref()).await?;
5626    cleanup_prepared_decrypt_source(prepared_source.as_deref()).await?;
5627    tokio::fs::remove_file(source_path).await?;
5628    tokio::fs::rename(&dest, source_path).await?;
5629    Ok(())
5630}
5631
5632async fn should_log_key_text_search(path: Option<&Path>, kid: Option<&str>) -> bool {
5633    let Some(path) = path else {
5634        return false;
5635    };
5636    if kid.is_none_or(|value| value.is_empty()) {
5637        return false;
5638    }
5639    tokio::fs::metadata(path)
5640        .await
5641        .is_ok_and(|metadata| metadata.is_file())
5642}
5643
5644async fn collect_child_stderr(
5645    handle: Option<tokio::task::JoinHandle<std::io::Result<Vec<u8>>>>,
5646) -> Result<Vec<u8>> {
5647    match handle {
5648        Some(handle) => match handle.await {
5649            Ok(Ok(bytes)) => Ok(bytes),
5650            Ok(Err(error)) => Err(Error::Io(error)),
5651            Err(_) => Err(Error::decrypt("external decrypt stderr reader failed")),
5652        },
5653        None => Ok(Vec::new()),
5654    }
5655}
5656
5657fn mp4forge_decrypt_debug_arguments(
5658    keys: &[String],
5659    selected: &SelectedKey,
5660    source_path: &Path,
5661    dest: &Path,
5662    init_path: Option<&Path>,
5663) -> String {
5664    let mut args = Vec::new();
5665    for key in normalized_mp4decrypt_keys(keys, selected) {
5666        args.push("--key".to_string());
5667        args.push(key);
5668    }
5669    if let Some(init_path) = init_path {
5670        args.push("--fragments-info".to_string());
5671        args.push(quoted_debug_path_arg(init_path));
5672    }
5673    args.push(quoted_debug_path_arg(source_path));
5674    args.push(quoted_debug_path_arg(dest));
5675    args.join(" ")
5676}
5677
5678fn quoted_debug_path_arg(path: &Path) -> String {
5679    let value = path.to_string_lossy().replace('"', "\\\"");
5680    format!("\"{value}\"")
5681}
5682
5683#[cfg(feature = "mp4forge")]
5684async fn run_mp4forge_decrypt_in_place(
5685    source_path: &Path,
5686    dest: &Path,
5687    init_path: Option<&Path>,
5688    keys: &[String],
5689    selected: &SelectedKey,
5690) -> Result<()> {
5691    let mut options = mp4forge::decrypt::DecryptOptions::new();
5692    for key in normalized_mp4decrypt_keys(keys, selected) {
5693        options
5694            .add_key_spec(&key)
5695            .map_err(|error| Error::decrypt(error.to_string()))?;
5696    }
5697    if let Some(init_path) = init_path {
5698        options.set_fragments_info_bytes(tokio::fs::read(init_path).await?);
5699    }
5700    mp4forge::decrypt::decrypt_file_async(source_path, dest, &options)
5701        .await
5702        .map_err(|error| Error::decrypt(error.to_string()))?;
5703    if !tokio::fs::try_exists(dest).await? {
5704        return Err(Error::decrypt(
5705            "mp4forge decrypt did not create the output file",
5706        ));
5707    }
5708    Ok(())
5709}
5710
5711#[cfg(not(feature = "mp4forge"))]
5712async fn run_mp4forge_decrypt_in_place(
5713    _source_path: &Path,
5714    _dest: &Path,
5715    _init_path: Option<&Path>,
5716    _keys: &[String],
5717    _selected: &SelectedKey,
5718) -> Result<()> {
5719    Err(Error::decrypt(
5720        "mp4forge decryption requires the mp4forge feature",
5721    ))
5722}
5723
5724struct Mp4decryptTempPaths {
5725    encrypted: PathBuf,
5726    decrypted: PathBuf,
5727}
5728
5729fn prepare_mp4decrypt_temp_paths(source_path: &Path) -> Mp4decryptTempPaths {
5730    let work_dir = source_path.parent().unwrap_or_else(|| Path::new(""));
5731    let extension = source_path
5732        .extension()
5733        .and_then(|value| value.to_str())
5734        .map(|value| format!(".{value}"))
5735        .unwrap_or_default();
5736    let stem = unique_temp_stem();
5737    Mp4decryptTempPaths {
5738        encrypted: work_dir.join(format!("{stem}{extension}")),
5739        decrypted: work_dir.join(format!("{stem}_dec{extension}")),
5740    }
5741}
5742
5743async fn restore_mp4decrypt_temp(
5744    source_path: &Path,
5745    temp: Option<&Mp4decryptTempPaths>,
5746) -> Result<()> {
5747    let Some(temp) = temp else {
5748        return Ok(());
5749    };
5750    if tokio::fs::try_exists(&temp.encrypted).await? && !tokio::fs::try_exists(source_path).await? {
5751        tokio::fs::rename(&temp.encrypted, source_path).await?;
5752    }
5753    if tokio::fs::try_exists(&temp.decrypted).await? {
5754        tokio::fs::remove_file(&temp.decrypted).await?;
5755    }
5756    Ok(())
5757}
5758
5759async fn finalize_mp4decrypt_temp(
5760    source_path: &Path,
5761    dest: &Path,
5762    temp: Option<&Mp4decryptTempPaths>,
5763) -> Result<()> {
5764    let Some(temp) = temp else {
5765        return Ok(());
5766    };
5767    if tokio::fs::try_exists(&temp.encrypted).await? && !tokio::fs::try_exists(source_path).await? {
5768        tokio::fs::rename(&temp.encrypted, source_path).await?;
5769    }
5770    if tokio::fs::try_exists(dest).await? {
5771        tokio::fs::remove_file(dest).await?;
5772    }
5773    tokio::fs::rename(&temp.decrypted, dest).await?;
5774    Ok(())
5775}
5776
5777fn unique_temp_stem() -> String {
5778    let process = std::process::id();
5779    let nanos = std::time::SystemTime::now()
5780        .duration_since(std::time::UNIX_EPOCH)
5781        .map(|duration| duration.as_nanos())
5782        .unwrap_or_default();
5783    format!("haki-dl-dec-{process}-{nanos}")
5784}
5785
5786fn stream_requires_external_decrypt(stream: &Stream) -> bool {
5787    stream_encryption_methods(stream)
5788        .into_iter()
5789        .any(|method| matches!(method, EncryptionMethod::Cenc | EncryptionMethod::SampleAes))
5790}
5791
5792async fn prepare_external_decrypt_source(
5793    engine: DecryptionEngine,
5794    source_path: &Path,
5795    init_path: Option<&Path>,
5796) -> Result<Option<PathBuf>> {
5797    if engine == DecryptionEngine::Mp4decrypt || init_path.is_none() {
5798        return Ok(None);
5799    }
5800    let prepared = source_path.with_extension("itmp");
5801    let init_path = init_path.ok_or_else(|| Error::decrypt("missing init path"))?;
5802    combine_files(
5803        &[init_path.to_path_buf(), source_path.to_path_buf()],
5804        &prepared,
5805    )
5806    .await?;
5807    Ok(Some(prepared))
5808}
5809
5810async fn cleanup_prepared_decrypt_source(path: Option<&Path>) -> Result<()> {
5811    if let Some(path) = path
5812        && tokio::fs::try_exists(path).await?
5813    {
5814        tokio::fs::remove_file(path).await?;
5815    }
5816    Ok(())
5817}
5818
5819async fn read_mp4_protection_info_from_path(
5820    path: &Path,
5821) -> Result<crate::decrypt::Mp4ProtectionInfo> {
5822    let mut file = tokio::fs::File::open(path).await?;
5823    let mut data = Vec::new();
5824    let mut limited = tokio::io::AsyncReadExt::take(&mut file, 1024 * 1024);
5825    limited.read_to_end(&mut data).await?;
5826    Ok(read_mp4_protection_info(&data))
5827}
5828
5829async fn read_shaka_missing_key_id(program: &Path, source_path: &Path) -> Result<Option<String>> {
5830    let tmp_output = source_path.with_extension("tmp.webm");
5831    let key_id = "00000000000000000000000000000000";
5832    let output = Command::new(program)
5833        .arg("--quiet")
5834        .arg("--enable_raw_key_decryption")
5835        .arg(format!(
5836            "input={},stream=0,output={}",
5837            source_path.display(),
5838            tmp_output.display()
5839        ))
5840        .arg("--keys")
5841        .arg(format!("key_id={key_id}:key={key_id}"))
5842        .stdout(Stdio::null())
5843        .stderr(Stdio::piped())
5844        .output()
5845        .await
5846        .map_err(|error| Error::decrypt(error.to_string()))?;
5847    if tokio::fs::try_exists(&tmp_output).await? {
5848        tokio::fs::remove_file(&tmp_output).await?;
5849    }
5850    let stderr = String::from_utf8_lossy(&output.stderr);
5851    let regex = regex::Regex::new(r"Key for key_id=([0-9a-f]+) was not found")
5852        .map_err(|error| Error::decrypt(error.to_string()))?;
5853    Ok(regex
5854        .captures(&stderr)
5855        .and_then(|captures| captures.get(1))
5856        .map(|capture| capture.as_str().to_string())
5857        .filter(|value| !value.is_empty()))
5858}
5859
5860fn stream_kid_hex(stream: &Stream) -> Option<String> {
5861    for segment in stream_encryption_segments(stream) {
5862        if let Some(kid) = &segment.encryption.kid {
5863            return Some(bytes_to_hex(kid));
5864        }
5865    }
5866    None
5867}
5868
5869fn stream_encryption_methods(stream: &Stream) -> Vec<EncryptionMethod> {
5870    stream_encryption_segments(stream)
5871        .into_iter()
5872        .map(|segment| segment.encryption.method)
5873        .collect()
5874}
5875
5876fn stream_media_segments(stream: &Stream) -> Vec<MediaSegment> {
5877    stream
5878        .playlist
5879        .as_ref()
5880        .map(|playlist| {
5881            playlist
5882                .media_parts
5883                .iter()
5884                .flat_map(|part| part.media_segments.iter().cloned())
5885                .collect()
5886        })
5887        .unwrap_or_default()
5888}
5889
5890fn stream_encryption_segments(stream: &Stream) -> Vec<&MediaSegment> {
5891    let mut segments = Vec::new();
5892    if let Some(playlist) = &stream.playlist {
5893        if let Some(init) = &playlist.media_init {
5894            segments.push(init);
5895        }
5896        segments.extend(
5897            playlist
5898                .media_parts
5899                .iter()
5900                .flat_map(|part| part.media_segments.iter()),
5901        );
5902    }
5903    segments
5904}
5905
5906fn external_decrypt_command(
5907    engine: DecryptionEngine,
5908    keys: &[String],
5909    selected: &SelectedKey,
5910    kid: Option<&str>,
5911    source: &Path,
5912    dest: &Path,
5913    init: Option<&Path>,
5914) -> Result<ExternalDecryptCommand> {
5915    let key_value = selected
5916        .key_pair
5917        .split_once(':')
5918        .map(|(_, key)| key)
5919        .unwrap_or(selected.key_pair.as_str());
5920    let arguments = match engine {
5921        DecryptionEngine::Mp4forge => {
5922            return Err(Error::decrypt("mp4forge decryption is handled in process"));
5923        }
5924        DecryptionEngine::Mp4decrypt => {
5925            let mut args = Vec::new();
5926            for key in normalized_mp4decrypt_keys(keys, selected) {
5927                args.push("--key".to_string());
5928                args.push(match selected.track_id.as_deref() {
5929                    Some(track_id) => {
5930                        let value = key.split_once(':').map(|(_, key)| key).unwrap_or(&key);
5931                        format!("{track_id}:{value}")
5932                    }
5933                    None => key,
5934                });
5935            }
5936            if let Some(init) = init {
5937                args.push("--fragments-info".to_string());
5938                args.push(mp4decrypt_fragment_info_arg(source, init));
5939            }
5940            args.push(mp4decrypt_path_arg(source));
5941            args.push(mp4decrypt_path_arg(dest));
5942            args
5943        }
5944        DecryptionEngine::ShakaPackager => {
5945            let key_id = selected
5946                .track_id
5947                .as_deref()
5948                .map(|_| "00000000000000000000000000000000")
5949                .or_else(|| {
5950                    selected
5951                        .key_pair
5952                        .split_once(':')
5953                        .map(|(key_id, _)| key_id)
5954                        .filter(|key_id| !key_id.is_empty())
5955                })
5956                .or(kid)
5957                .unwrap_or_default();
5958            let label = selected
5959                .track_id
5960                .as_deref()
5961                .map(|track_id| format!("label={track_id}:"))
5962                .unwrap_or_default();
5963            vec![
5964                "--quiet".to_string(),
5965                "--enable_raw_key_decryption".to_string(),
5966                format!(
5967                    "input={},stream=0,output={}",
5968                    source.display(),
5969                    dest.display()
5970                ),
5971                "--keys".to_string(),
5972                format!("{label}key_id={key_id}:key={key_value}"),
5973            ]
5974        }
5975        DecryptionEngine::Ffmpeg => vec![
5976            "-loglevel".to_string(),
5977            "error".to_string(),
5978            "-nostdin".to_string(),
5979            "-decryption_key".to_string(),
5980            key_value.to_string(),
5981            "-i".to_string(),
5982            source.to_string_lossy().to_string(),
5983            "-c".to_string(),
5984            "copy".to_string(),
5985            dest.to_string_lossy().to_string(),
5986        ],
5987    };
5988    Ok(ExternalDecryptCommand {
5989        arguments,
5990        working_directory: if engine == DecryptionEngine::Mp4decrypt {
5991            source
5992                .parent()
5993                .filter(|path| !path.as_os_str().is_empty())
5994                .map(Path::to_path_buf)
5995        } else {
5996            None
5997        },
5998    })
5999}
6000
6001fn normalized_mp4decrypt_keys(keys: &[String], selected: &SelectedKey) -> Vec<String> {
6002    if keys.len() == 1 && !keys[0].contains(':') && selected.key_pair.contains(':') {
6003        return vec![selected.key_pair.clone()];
6004    }
6005    keys.to_vec()
6006}
6007
6008fn mp4decrypt_path_arg(path: &Path) -> String {
6009    path.file_name()
6010        .and_then(|value| value.to_str())
6011        .map(str::to_string)
6012        .unwrap_or_else(|| path.to_string_lossy().to_string())
6013}
6014
6015fn mp4decrypt_fragment_info_arg(source: &Path, init: &Path) -> String {
6016    if source.parent() == init.parent() {
6017        return mp4decrypt_path_arg(init);
6018    }
6019    init.to_string_lossy().to_string()
6020}
6021
6022fn decryption_engine_name(engine: DecryptionEngine) -> &'static str {
6023    match engine {
6024        DecryptionEngine::Mp4forge => "mp4forge",
6025        DecryptionEngine::Mp4decrypt => "MP4DECRYPT",
6026        DecryptionEngine::ShakaPackager => "SHAKA_PACKAGER",
6027        DecryptionEngine::Ffmpeg => "FFMPEG",
6028    }
6029}
6030
6031fn default_decrypt_program(engine: DecryptionEngine) -> PathBuf {
6032    match engine {
6033        DecryptionEngine::Mp4forge => PathBuf::from("mp4forge"),
6034        DecryptionEngine::Mp4decrypt => PathBuf::from("mp4decrypt"),
6035        DecryptionEngine::ShakaPackager => PathBuf::from("shaka-packager"),
6036        DecryptionEngine::Ffmpeg => PathBuf::from("ffmpeg"),
6037    }
6038}
6039
6040fn decrypted_output_path(source_path: &Path) -> PathBuf {
6041    let parent = source_path.parent().unwrap_or_else(|| Path::new(""));
6042    let stem = source_path
6043        .file_stem()
6044        .and_then(|value| value.to_str())
6045        .unwrap_or("output");
6046    let extension = source_path
6047        .extension()
6048        .and_then(|value| value.to_str())
6049        .map(|value| format!(".{value}"))
6050        .unwrap_or_default();
6051    parent.join(format!("{stem}_dec{extension}"))
6052}
6053
6054async fn decrypt_segment_file(segment: &MediaSegment, path: &Path) -> Result<()> {
6055    match segment.encryption.method {
6056        EncryptionMethod::None => Ok(()),
6057        EncryptionMethod::Aes128
6058        | EncryptionMethod::Aes128Ecb
6059        | EncryptionMethod::Chacha20
6060        | EncryptionMethod::SampleAesCtr => {
6061            decrypt_hls_segment_file(
6062                path,
6063                segment.encryption.method,
6064                segment.encryption.key.as_deref(),
6065                segment.encryption.iv.as_deref(),
6066            )
6067            .await
6068        }
6069        EncryptionMethod::SampleAes | EncryptionMethod::Cenc => Err(Error::decrypt(
6070            "encrypted stream requires an external decrypt pipeline",
6071        )),
6072        EncryptionMethod::Unknown => Ok(()),
6073    }
6074}
6075
6076async fn cleanup_stream_temp_dir(path: &Path, events: &mut Vec<ProgressEvent>) -> Result<()> {
6077    if tokio::fs::try_exists(path).await? {
6078        tokio::fs::remove_dir_all(path).await?;
6079        events.push(ProgressEvent::Cleanup {
6080            path: path.to_path_buf(),
6081        });
6082    }
6083    Ok(())
6084}
6085
6086async fn cleanup_task_temp_root(path: &Path, events: &mut Vec<ProgressEvent>) -> Result<()> {
6087    if tokio::fs::try_exists(path).await? {
6088        tokio::fs::remove_dir_all(path).await?;
6089        events.push(ProgressEvent::Cleanup {
6090            path: path.to_path_buf(),
6091        });
6092    }
6093    Ok(())
6094}
6095
6096fn stream_is_live(stream: &Stream) -> bool {
6097    stream
6098        .playlist
6099        .as_ref()
6100        .is_some_and(|playlist| playlist.is_live)
6101}
6102
6103fn stream_matches_id(stream: &Stream, id: &str) -> bool {
6104    stream.id == id
6105        || stream.group_id.as_deref() == Some(id)
6106        || stream.url == id
6107        || stream.name.as_deref() == Some(id)
6108}
6109
6110fn stream_identity(stream: &Stream) -> String {
6111    if !stream.id.is_empty() {
6112        return stream.id.clone();
6113    }
6114    stream
6115        .group_id
6116        .clone()
6117        .unwrap_or_else(|| stream.url.clone())
6118}
6119
6120fn media_type_name(media_type: crate::manifest::MediaType) -> &'static str {
6121    match media_type {
6122        crate::manifest::MediaType::Audio => "audio",
6123        crate::manifest::MediaType::Video => "video",
6124        crate::manifest::MediaType::Subtitles => "subtitles",
6125        crate::manifest::MediaType::ClosedCaptions => "closed_captions",
6126    }
6127}
6128
6129fn i32_from_usize(value: usize) -> i32 {
6130    i32::try_from(value).unwrap_or(i32::MAX)
6131}
6132
6133fn bytes_to_hex(bytes: &[u8]) -> String {
6134    const HEX: &[u8; 16] = b"0123456789abcdef";
6135    let mut out = String::with_capacity(bytes.len() * 2);
6136    for byte in bytes {
6137        out.push(char::from(HEX[usize::from(byte >> 4)]));
6138        out.push(char::from(HEX[usize::from(byte & 0x0f)]));
6139    }
6140    out
6141}