Skip to main content

haki_dl/
observability.rs

1//! Logging, metadata, progress formatting, and event collection helpers.
2
3use std::future::Future;
4use std::path::{Path, PathBuf};
5use std::pin::Pin;
6use std::time::Duration;
7
8use crate::config::LogLevel;
9use crate::decrypt::redact_secrets;
10use crate::error::{Error, Result};
11use crate::event::ProgressEvent;
12use crate::manifest::{
13    Choice, EncryptionInfo, EncryptionMethod, MediaPart, MediaSegment, MediaType, MssData,
14    Playlist, RoleType, Stream,
15};
16
17/// Planned log file behavior.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct LogFilePlan {
20    /// Whether file logging is enabled.
21    pub enabled: bool,
22    /// Resolved file path when enabled.
23    pub path: Option<PathBuf>,
24}
25
26/// Logging configuration used by CLI and API adapters.
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct LogPlanConfig {
29    /// Active log level.
30    pub level: LogLevel,
31    /// Disable file logging.
32    pub no_log: bool,
33    /// User-requested log file path.
34    pub log_file_path: Option<PathBuf>,
35    /// Directory used when no file path is supplied.
36    pub default_log_dir: PathBuf,
37    /// Deterministic suffix for tests or caller-provided timestamps.
38    pub suffix: String,
39}
40
41/// Result of a non-blocking release update check.
42#[derive(Clone, Debug, Default, Eq, PartialEq)]
43pub struct UpdateCheckResult {
44    /// Latest version tag when a release redirect exposed one.
45    pub latest_version: Option<String>,
46    /// Whether `latest_version` differs from the current package version.
47    pub update_available: bool,
48}
49
50/// Injectable update-check transport used by CLI adapters and tests.
51pub trait UpdateCheckClient {
52    /// Returns the redirect location for a release endpoint, if one exists.
53    fn redirect_location<'a>(
54        &'a self,
55        url: &'a str,
56    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>>;
57}
58
59/// HTTP update-check transport used by the CLI compatibility path.
60#[derive(Clone, Debug)]
61pub struct UpdateCheckHttpClient {
62    timeout: Duration,
63}
64
65/// Default endpoint used by the compatibility update check.
66pub const DEFAULT_UPDATE_CHECK_URL: &str = "https://github.com/bakgio/haki-dl/releases/latest";
67
68impl Default for UpdateCheckHttpClient {
69    fn default() -> Self {
70        Self {
71            timeout: Duration::from_secs(5),
72        }
73    }
74}
75
76impl UpdateCheckHttpClient {
77    /// Creates an update-check client.
78    pub fn new() -> Self {
79        Self::default()
80    }
81
82    /// Overrides the request timeout.
83    pub fn with_timeout(mut self, timeout: Duration) -> Self {
84        self.timeout = timeout;
85        self
86    }
87}
88
89impl UpdateCheckClient for UpdateCheckHttpClient {
90    fn redirect_location<'a>(
91        &'a self,
92        url: &'a str,
93    ) -> Pin<Box<dyn Future<Output = Result<Option<String>>> + Send + 'a>> {
94        Box::pin(async move {
95            let client = reqwest::Client::builder()
96                .timeout(self.timeout)
97                .redirect(reqwest::redirect::Policy::none())
98                .build()
99                .map_err(|error| Error::http(error.to_string()))?;
100            let response = client
101                .get(url)
102                .send()
103                .await
104                .map_err(|error| Error::http(error.to_string()))?;
105            if matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) {
106                return Ok(response
107                    .headers()
108                    .get(reqwest::header::LOCATION)
109                    .and_then(|value| value.to_str().ok())
110                    .map(str::to_string));
111            }
112            Ok(None)
113        })
114    }
115}
116
117/// Terminal-independent progress summary.
118#[derive(Clone, Debug, Default, Eq, PartialEq)]
119pub struct ProgressSummary {
120    pub downloaded_bytes: u64,
121    pub total_bytes: Option<u64>,
122    pub bytes_per_second: u64,
123    pub completed_segments: u64,
124    pub total_segments: Option<u64>,
125    pub refreshed_duration: Option<Duration>,
126    pub recorded_duration: Option<Duration>,
127    pub warnings: Vec<String>,
128    pub outputs: Vec<PathBuf>,
129    pub finished: Option<bool>,
130}
131
132/// In-memory event sink for API users and tests.
133#[derive(Clone, Debug, Default, Eq, PartialEq)]
134pub struct ProgressEventCollector {
135    events: Vec<ProgressEvent>,
136}
137
138impl ProgressEventCollector {
139    /// Creates an empty collector.
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// Emits an event after redacting secrets in diagnostic text.
145    pub fn emit(&mut self, event: ProgressEvent) {
146        self.events.push(redact_progress_event(event));
147    }
148
149    /// Returns collected events.
150    pub fn events(&self) -> &[ProgressEvent] {
151        &self.events
152    }
153
154    /// Builds a terminal-independent summary from collected events.
155    pub fn summary(&self) -> ProgressSummary {
156        summarize_events(&self.events)
157    }
158}
159
160/// Returns whether a message at `message_level` should be emitted.
161pub fn should_log(active: LogLevel, message_level: LogLevel) -> bool {
162    if active == LogLevel::Off {
163        return false;
164    }
165    rank(message_level) >= rank(active)
166}
167
168/// Resolves the log file path, including collision suffixing.
169pub async fn plan_log_file(config: &LogPlanConfig) -> Result<LogFilePlan> {
170    if config.no_log {
171        return Ok(LogFilePlan {
172            enabled: false,
173            path: None,
174        });
175    }
176    let mut path = match &config.log_file_path {
177        Some(path) => path.clone(),
178        None => config
179            .default_log_dir
180            .join(format!("{}.log", config.suffix)),
181    };
182    if let Some(parent) = path.parent() {
183        tokio::fs::create_dir_all(parent).await?;
184    }
185    if tokio::fs::try_exists(&path).await? {
186        let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
187        let stem = path
188            .file_stem()
189            .and_then(|value| value.to_str())
190            .unwrap_or("log")
191            .to_string();
192        let extension = path
193            .extension()
194            .and_then(|value| value.to_str())
195            .unwrap_or("log")
196            .to_string();
197        let mut index = 1_u32;
198        loop {
199            let candidate = parent.join(format!("{stem}-{index}.{extension}"));
200            if !tokio::fs::try_exists(&candidate).await? {
201                path = candidate;
202                break;
203            }
204            index += 1;
205        }
206    }
207    Ok(LogFilePlan {
208        enabled: true,
209        path: Some(path),
210    })
211}
212
213/// Creates the session log file and writes its startup header.
214pub async fn initialize_log_file(
215    config: &LogPlanConfig,
216    started_at: &str,
217    command_line: Option<&str>,
218) -> Result<LogFilePlan> {
219    let plan = plan_log_file(config).await?;
220    let Some(path) = &plan.path else {
221        return Ok(plan);
222    };
223    let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
224    let mut text = String::new();
225    text.push_str("LOG ");
226    text.push_str(started_at.split_whitespace().next().unwrap_or(started_at));
227    text.push('\n');
228    text.push_str("Save Path: ");
229    text.push_str(&parent.display().to_string());
230    text.push('\n');
231    text.push_str("Task Start: ");
232    text.push_str(started_at);
233    text.push('\n');
234    if let Some(command_line) = command_line {
235        text.push_str("Task CommandLine: ");
236        text.push_str(command_line);
237        text.push('\n');
238    }
239    text.push('\n');
240    tokio::fs::write(path, text).await?;
241    Ok(plan)
242}
243
244/// Appends one plain diagnostic line to a planned log file.
245pub async fn append_log_file(plan: &LogFilePlan, line: &str) -> Result<()> {
246    let Some(path) = &plan.path else {
247        return Ok(());
248    };
249    use tokio::io::AsyncWriteExt;
250
251    let mut file = tokio::fs::OpenOptions::new()
252        .append(true)
253        .open(path)
254        .await?;
255    file.write_all(line.as_bytes()).await?;
256    file.write_all(b"\n").await?;
257    Ok(())
258}
259
260/// Checks a release endpoint with an injected client.
261pub async fn check_update_with_client(
262    client: &impl UpdateCheckClient,
263    endpoint: &str,
264    current_version: &str,
265) -> Result<UpdateCheckResult> {
266    let Some(location) = client.redirect_location(endpoint).await? else {
267        return Ok(UpdateCheckResult::default());
268    };
269    let latest_version = latest_version_from_release_redirect(&location);
270    let update_available = latest_version
271        .as_deref()
272        .is_some_and(|latest| normalize_version(latest) != normalize_version(current_version));
273    Ok(UpdateCheckResult {
274        latest_version,
275        update_available,
276    })
277}
278
279/// Extracts a release tag from a redirect URL.
280pub fn latest_version_from_release_redirect(location: &str) -> Option<String> {
281    let marker = "/tag/";
282    let index = location.find(marker)?;
283    let tag = location.get(index + marker.len()..)?.trim();
284    if tag.is_empty() || tag.starts_with("http") {
285        return None;
286    }
287    Some(tag.to_string())
288}
289
290/// Starts the compatibility update check in the background.
291pub fn spawn_update_check_if_enabled(disabled: bool) -> bool {
292    if disabled {
293        return false;
294    }
295    tokio::spawn(async {
296        let client = UpdateCheckHttpClient::new();
297        let _ =
298            check_update_with_client(&client, DEFAULT_UPDATE_CHECK_URL, env!("CARGO_PKG_VERSION"))
299                .await;
300    });
301    true
302}
303
304/// Formats a byte count for progress displays.
305pub fn format_file_size(bytes: u64) -> String {
306    let units = ["B", "KiB", "MiB", "GiB", "TiB"];
307    let mut value = bytes as f64;
308    let mut unit = units[0];
309    for candidate in units.iter().skip(1) {
310        if value < 1024.0 {
311            break;
312        }
313        value /= 1024.0;
314        unit = candidate;
315    }
316    if unit == "B" {
317        format!("{bytes}B")
318    } else {
319        format!("{value:.2}{unit}")
320    }
321}
322
323fn normalize_version(version: &str) -> &str {
324    version.trim().trim_start_matches('v')
325}
326
327/// Formats a duration as `HH:MM:SS`.
328pub fn format_duration(duration: Duration) -> String {
329    let seconds = duration.as_secs();
330    let hours = seconds / 3600;
331    let minutes = (seconds / 60) % 60;
332    let seconds = seconds % 60;
333    format!("{hours:02}:{minutes:02}:{seconds:02}")
334}
335
336/// Redacts secrets from warning and progress diagnostic text.
337pub fn redact_progress_event(event: ProgressEvent) -> ProgressEvent {
338    match event {
339        ProgressEvent::Log { level, message } => ProgressEvent::Log {
340            level,
341            message: redact_secrets(&message),
342        },
343        ProgressEvent::ExtraLog { message } => ProgressEvent::ExtraLog {
344            message: redact_secrets(&message),
345        },
346        ProgressEvent::MediaInfo { stream_id, lines } => ProgressEvent::MediaInfo {
347            stream_id,
348            lines: lines
349                .into_iter()
350                .map(|line| redact_secrets(&line))
351                .collect(),
352        },
353        ProgressEvent::Warning { message } => ProgressEvent::Warning {
354            message: redact_secrets(&message),
355        },
356        ProgressEvent::DecryptProgress { stream_id, message } => ProgressEvent::DecryptProgress {
357            stream_id,
358            message: redact_secrets(&message),
359        },
360        ProgressEvent::MergeProgress { stream_id, message } => ProgressEvent::MergeProgress {
361            stream_id,
362            message: redact_secrets(&message),
363        },
364        ProgressEvent::SubtitleProgress { stream_id, message } => ProgressEvent::SubtitleProgress {
365            stream_id,
366            message: redact_secrets(&message),
367        },
368        ProgressEvent::MuxProgress { message } => ProgressEvent::MuxProgress {
369            message: redact_secrets(&message),
370        },
371        ProgressEvent::ExternalToolOutput { message } => ProgressEvent::ExternalToolOutput {
372            message: redact_secrets(&message),
373        },
374        ProgressEvent::ConsoleLine { message } => ProgressEvent::ConsoleLine {
375            message: redact_secrets(&message),
376        },
377        other => other,
378    }
379}
380
381/// Summarizes events for callers that do not render terminal progress columns.
382pub fn summarize_events(events: &[ProgressEvent]) -> ProgressSummary {
383    let mut summary = ProgressSummary::default();
384    for event in events {
385        match event {
386            ProgressEvent::AggregateProgress(progress) => {
387                summary.downloaded_bytes = progress.downloaded_bytes;
388                summary.total_bytes = progress.total_bytes;
389                summary.bytes_per_second = progress.bytes_per_second;
390            }
391            ProgressEvent::StreamProgress(progress) => {
392                summary.downloaded_bytes = summary.downloaded_bytes.max(progress.downloaded_bytes);
393                summary.completed_segments =
394                    summary.completed_segments.max(progress.completed_segments);
395                summary.total_segments = progress.total_segments;
396                summary.bytes_per_second = summary.bytes_per_second.max(progress.bytes_per_second);
397            }
398            ProgressEvent::LiveRefresh {
399                refreshed_duration,
400                recorded_duration,
401                ..
402            } => {
403                summary.refreshed_duration = Some(*refreshed_duration);
404                summary.recorded_duration = Some(*recorded_duration);
405            }
406            ProgressEvent::Warning { message } => summary.warnings.push(message.clone()),
407            ProgressEvent::ExternalToolOutput { message } => summary.warnings.push(message.clone()),
408            ProgressEvent::ConsoleLine { message } => summary.warnings.push(message.clone()),
409            ProgressEvent::OutputArtifact(artifact) => summary.outputs.push(artifact.path.clone()),
410            ProgressEvent::Finished { success } => summary.finished = Some(*success),
411            _ => {}
412        }
413    }
414    summary
415}
416
417/// Serializes stream metadata into a stable JSON document.
418pub fn streams_metadata_json(streams: &[Stream]) -> String {
419    if streams.is_empty() {
420        return "[]".to_string();
421    }
422    let mut output = String::from("[\n");
423    for (index, stream) in streams.iter().enumerate() {
424        if index > 0 {
425            output.push_str(",\n");
426        }
427        output.push_str("  ");
428        output.push_str(&stream_metadata_json(stream, 2));
429    }
430    output.push_str("\n]");
431    n_json_line_endings(output)
432}
433
434/// Writes raw and selected stream metadata JSON files.
435pub async fn write_metadata_jsons(
436    directory: &Path,
437    raw_streams: &[Stream],
438    selected_streams: &[Stream],
439) -> Result<Vec<PathBuf>> {
440    tokio::fs::create_dir_all(directory).await?;
441    let raw = directory.join("meta.json");
442    let selected = directory.join("meta_selected.json");
443    write_n_utf8_text_file(&raw, &streams_metadata_json(raw_streams)).await?;
444    write_n_utf8_text_file(&selected, &streams_metadata_json(selected_streams)).await?;
445    Ok(vec![raw, selected])
446}
447
448fn stream_metadata_json(stream: &Stream, indent: usize) -> String {
449    let mut fields = Vec::new();
450    push_option_enum(
451        &mut fields,
452        "MediaType",
453        stream.media_type.map(media_type_text),
454    );
455    push_option_string(&mut fields, "GroupId", stream.group_id.as_deref());
456    push_option_string(&mut fields, "Language", stream.language.as_deref());
457    push_option_string(&mut fields, "Name", stream.name.as_deref());
458    push_option_enum(&mut fields, "Default", stream.default.map(choice_text));
459    push_option_number(&mut fields, "SkippedDuration", stream.skipped_duration);
460    if let Some(data) = &stream.mss_data {
461        fields.push(("MSSData".to_string(), mss_data_json(data, indent + 2)));
462    }
463    push_option_number(&mut fields, "Bandwidth", stream.bandwidth);
464    push_option_string(&mut fields, "Codecs", stream.codecs.as_deref());
465    push_option_string(&mut fields, "Resolution", stream.resolution.as_deref());
466    push_option_number(&mut fields, "FrameRate", stream.frame_rate);
467    push_option_string(&mut fields, "Channels", stream.channels.as_deref());
468    push_option_string(&mut fields, "Extension", stream.extension.as_deref());
469    if let Some(role) = stream.role {
470        fields.push(("Role".to_string(), json_string(&role_text(role))));
471    }
472    push_option_string(&mut fields, "VideoRange", stream.video_range.as_deref());
473    push_option_string(
474        &mut fields,
475        "Characteristics",
476        stream.characteristics.as_deref(),
477    );
478    push_option_string(&mut fields, "PublishTime", stream.publish_time.as_deref());
479    push_option_string(&mut fields, "AudioId", stream.audio_id.as_deref());
480    push_option_string(&mut fields, "VideoId", stream.video_id.as_deref());
481    push_option_string(&mut fields, "SubtitleId", stream.subtitle_id.as_deref());
482    push_option_string(&mut fields, "PeriodId", stream.period_id.as_deref());
483    fields.push((
484        "Url".to_string(),
485        json_string(&metadata_stream_url(&stream.url)),
486    ));
487    fields.push((
488        "OriginalUrl".to_string(),
489        json_string(&metadata_stream_url(&stream.original_url)),
490    ));
491    if let Some(playlist) = &stream.playlist {
492        fields.push(("Playlist".to_string(), playlist_json(playlist, indent + 2)));
493    }
494    fields.push((
495        "SegmentsCount".to_string(),
496        stream.segments_count().to_string(),
497    ));
498    object_json(&fields, indent)
499}
500
501fn playlist_json(playlist: &Playlist, indent: usize) -> String {
502    let mut fields = vec![
503        ("Url".to_string(), json_string("")),
504        ("IsLive".to_string(), playlist.is_live.to_string()),
505        (
506            "RefreshIntervalMs".to_string(),
507            number_json(playlist.refresh_interval_ms),
508        ),
509        (
510            "TotalDuration".to_string(),
511            number_json(playlist.total_duration()),
512        ),
513    ];
514    push_option_number(&mut fields, "TargetDuration", playlist.target_duration);
515    if let Some(init) = &playlist.media_init {
516        fields.push(("MediaInit".to_string(), segment_json(init, indent + 2)));
517    }
518    fields.push((
519        "MediaParts".to_string(),
520        array_json(
521            &playlist
522                .media_parts
523                .iter()
524                .map(|part| media_part_json(part, indent + 4))
525                .collect::<Vec<_>>(),
526            indent + 2,
527        ),
528    ));
529    object_json(&fields, indent)
530}
531
532fn media_part_json(part: &MediaPart, indent: usize) -> String {
533    object_json(
534        &[(
535            "MediaSegments".to_string(),
536            array_json(
537                &part
538                    .media_segments
539                    .iter()
540                    .map(|segment| segment_json(segment, indent + 4))
541                    .collect::<Vec<_>>(),
542                indent + 2,
543            ),
544        )],
545        indent,
546    )
547}
548
549fn segment_json(segment: &MediaSegment, indent: usize) -> String {
550    let mut fields = vec![
551        ("Index".to_string(), segment.index.to_string()),
552        ("Duration".to_string(), number_json(segment.duration)),
553    ];
554    push_option_string(&mut fields, "Title", segment.title.as_deref());
555    push_option_string(
556        &mut fields,
557        "DateTime",
558        segment.program_date_time.as_deref(),
559    );
560    push_option_number(&mut fields, "StartRange", segment.start_range);
561    push_option_number(&mut fields, "StopRange", segment.stop_range());
562    push_option_number(&mut fields, "ExpectLength", segment.expected_length);
563    fields.push((
564        "EncryptInfo".to_string(),
565        encryption_json(&segment.encryption, indent + 2),
566    ));
567    fields.push((
568        "IsEncrypted".to_string(),
569        segment.is_encrypted().to_string(),
570    ));
571    fields.push((
572        "Url".to_string(),
573        json_string(&metadata_segment_url(&segment.url)),
574    ));
575    push_option_string(&mut fields, "NameFromVar", segment.name_from_var.as_deref());
576    object_json(&fields, indent)
577}
578
579fn encryption_json(encryption: &EncryptionInfo, indent: usize) -> String {
580    let mut fields = vec![(
581        "Method".to_string(),
582        json_string(encryption_method_text(encryption.method)),
583    )];
584    push_option_bytes(&mut fields, "Key", encryption.key.as_deref());
585    push_option_bytes(&mut fields, "IV", encryption.iv.as_deref());
586    object_json(&fields, indent)
587}
588
589fn mss_data_json(data: &MssData, indent: usize) -> String {
590    object_json(
591        &[
592            ("FourCC".to_string(), json_string(&data.four_cc)),
593            (
594                "CodecPrivateData".to_string(),
595                json_string(&data.codec_private_data),
596            ),
597            ("Type".to_string(), json_string(&data.stream_type)),
598            ("Timesacle".to_string(), data.timescale.to_string()),
599            ("SamplingRate".to_string(), data.sampling_rate.to_string()),
600            ("Channels".to_string(), data.channels.to_string()),
601            (
602                "BitsPerSample".to_string(),
603                data.bits_per_sample.to_string(),
604            ),
605            (
606                "NalUnitLengthField".to_string(),
607                data.nal_unit_length_field.to_string(),
608            ),
609            ("Duration".to_string(), data.duration.to_string()),
610            ("IsProtection".to_string(), data.is_protection.to_string()),
611            (
612                "ProtectionSystemID".to_string(),
613                json_string(&data.protection_system_id),
614            ),
615            (
616                "ProtectionData".to_string(),
617                json_string(&data.protection_data),
618            ),
619        ],
620        indent,
621    )
622}
623
624fn metadata_stream_url(value: &str) -> String {
625    value.replace(' ', "%20")
626}
627
628fn metadata_segment_url(value: &str) -> String {
629    value.replace("%20", " ")
630}
631
632fn n_json_line_endings(value: String) -> String {
633    if cfg!(windows) {
634        value.replace('\n', "\r\n")
635    } else {
636        value
637    }
638}
639
640async fn write_n_utf8_text_file(path: &Path, text: &str) -> std::io::Result<()> {
641    let mut bytes = Vec::with_capacity(3 + text.len());
642    bytes.extend_from_slice(&[0xef, 0xbb, 0xbf]);
643    bytes.extend_from_slice(text.as_bytes());
644    tokio::fs::write(path, bytes).await
645}
646
647fn object_json(fields: &[(String, String)], indent: usize) -> String {
648    if fields.is_empty() {
649        return "{}".to_string();
650    }
651    let current = " ".repeat(indent);
652    let child = " ".repeat(indent + 2);
653    let mut output = String::from("{\n");
654    for (index, (name, value)) in fields.iter().enumerate() {
655        if index > 0 {
656            output.push_str(",\n");
657        }
658        output.push_str(&child);
659        output.push_str(&json_string(name));
660        output.push_str(": ");
661        output.push_str(value);
662    }
663    output.push('\n');
664    output.push_str(&current);
665    output.push('}');
666    output
667}
668
669fn array_json(values: &[String], indent: usize) -> String {
670    if values.is_empty() {
671        return "[]".to_string();
672    }
673    let current = " ".repeat(indent);
674    let child = " ".repeat(indent + 2);
675    let mut output = String::from("[\n");
676    for (index, value) in values.iter().enumerate() {
677        if index > 0 {
678            output.push_str(",\n");
679        }
680        output.push_str(&child);
681        output.push_str(value);
682    }
683    output.push('\n');
684    output.push_str(&current);
685    output.push(']');
686    output
687}
688
689fn push_option_string(fields: &mut Vec<(String, String)>, name: &str, value: Option<&str>) {
690    if let Some(value) = value {
691        fields.push((name.to_string(), json_string(value)));
692    }
693}
694
695fn push_option_enum(fields: &mut Vec<(String, String)>, name: &str, value: Option<&str>) {
696    if let Some(value) = value {
697        fields.push((name.to_string(), json_string(value)));
698    }
699}
700
701fn push_option_number<T: ToString>(
702    fields: &mut Vec<(String, String)>,
703    name: &str,
704    value: Option<T>,
705) {
706    if let Some(value) = value {
707        fields.push((name.to_string(), value.to_string()));
708    }
709}
710
711fn push_option_bytes(fields: &mut Vec<(String, String)>, name: &str, value: Option<&[u8]>) {
712    if let Some(value) = value {
713        fields.push((name.to_string(), json_string(&base64_encode(value))));
714    }
715}
716
717fn media_type_text(value: MediaType) -> &'static str {
718    match value {
719        MediaType::Audio => "AUDIO",
720        MediaType::Video => "VIDEO",
721        MediaType::Subtitles => "SUBTITLES",
722        MediaType::ClosedCaptions => "CLOSED_CAPTIONS",
723    }
724}
725
726fn role_text(value: RoleType) -> String {
727    match value {
728        RoleType::Subtitle => "Subtitle".to_string(),
729        RoleType::Main => "Main".to_string(),
730        RoleType::Alternate => "Alternate".to_string(),
731        RoleType::Supplementary => "Supplementary".to_string(),
732        RoleType::Commentary => "Commentary".to_string(),
733        RoleType::Dub => "Dub".to_string(),
734        RoleType::Description => "Description".to_string(),
735        RoleType::Sign => "Sign".to_string(),
736        RoleType::Metadata => "Metadata".to_string(),
737        RoleType::ForcedSubtitle => "ForcedSubtitle".to_string(),
738        RoleType::Numeric(value) => value.to_string(),
739    }
740}
741
742fn choice_text(value: Choice) -> &'static str {
743    match value {
744        Choice::No => "NO",
745        Choice::Yes => "YES",
746    }
747}
748
749fn encryption_method_text(value: EncryptionMethod) -> &'static str {
750    match value {
751        EncryptionMethod::None => "NONE",
752        EncryptionMethod::Aes128 => "AES_128",
753        EncryptionMethod::Aes128Ecb => "AES_128_ECB",
754        EncryptionMethod::SampleAes => "SAMPLE_AES",
755        EncryptionMethod::SampleAesCtr => "SAMPLE_AES_CTR",
756        EncryptionMethod::Cenc => "CENC",
757        EncryptionMethod::Chacha20 => "CHACHA20",
758        EncryptionMethod::Unknown => "UNKNOWN",
759    }
760}
761
762fn number_json(value: f64) -> String {
763    value.to_string()
764}
765
766fn json_string(value: &str) -> String {
767    format!("\"{}\"", escape_json(value))
768}
769
770fn escape_json(value: &str) -> String {
771    let mut output = String::new();
772    for ch in value.chars() {
773        match ch {
774            '"' => output.push_str("\\\""),
775            '\\' => output.push_str("\\\\"),
776            '\n' => output.push_str("\\n"),
777            '\r' => output.push_str("\\r"),
778            '\t' => output.push_str("\\t"),
779            _ => output.push(ch),
780        }
781    }
782    output
783}
784
785fn base64_encode(bytes: &[u8]) -> String {
786    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
787    let mut output = String::new();
788    for chunk in bytes.chunks(3) {
789        let b0 = chunk[0];
790        let b1 = *chunk.get(1).unwrap_or(&0);
791        let b2 = *chunk.get(2).unwrap_or(&0);
792        let triple = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
793        output.push(char::from(TABLE[((triple >> 18) & 0x3f) as usize]));
794        output.push(char::from(TABLE[((triple >> 12) & 0x3f) as usize]));
795        if chunk.len() > 1 {
796            output.push(char::from(TABLE[((triple >> 6) & 0x3f) as usize]));
797        } else {
798            output.push('=');
799        }
800        if chunk.len() > 2 {
801            output.push(char::from(TABLE[(triple & 0x3f) as usize]));
802        } else {
803            output.push('=');
804        }
805    }
806    output
807}
808
809fn rank(level: LogLevel) -> u8 {
810    match level {
811        LogLevel::Debug => 0,
812        LogLevel::Info => 1,
813        LogLevel::Warn => 2,
814        LogLevel::Error => 3,
815        LogLevel::Off => 4,
816    }
817}