1use 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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
86pub enum SessionState {
87 #[default]
89 Planned,
90 Running,
92 Finished,
94 Failed,
96 Cancelled,
98}
99
100#[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 pub fn new(request: DownloadRequest) -> Self {
153 Self {
154 request,
155 state: SessionState::Planned,
156 }
157 }
158
159 pub fn request(&self) -> &DownloadRequest {
161 &self.request
162 }
163
164 pub fn state(&self) -> SessionState {
166 self.state
167 }
168
169 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 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(¤t_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(¤t_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, ¤t_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, ©_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}