Skip to main content

haki_dl/
processor.rs

1//! Parser configuration and processor hooks.
2
3use std::collections::BTreeMap;
4use std::future::Future;
5use std::path::{Path, PathBuf};
6use std::pin::Pin;
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use md5::{Digest, Md5};
11use regex::Regex;
12use time::OffsetDateTime;
13
14use crate::attribute::hls_attribute;
15use crate::config::{DownloadOptions, LogLevel};
16use crate::error::{Error, Result};
17use crate::http::{DefaultHttpClient, HttpRequest};
18use crate::manifest::{EncryptionInfo, EncryptionMethod, ExtractorType, KeySource};
19
20/// Compatibility default user-agent applied before user headers override it.
21pub const DEFAULT_USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.108 Safari/537.36";
22
23/// Parser diagnostic produced while applying processor hooks.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ParserDiagnostic {
26    /// Diagnostic log level.
27    pub level: LogLevel,
28    /// Diagnostic text.
29    pub message: String,
30}
31
32#[doc(hidden)]
33#[derive(Clone, Debug, Default)]
34pub struct ParserDiagnostics {
35    inner: Arc<Mutex<Vec<ParserDiagnostic>>>,
36}
37
38impl ParserDiagnostics {
39    fn push(&self, level: LogLevel, message: impl Into<String>) {
40        if let Ok(mut diagnostics) = self.inner.lock() {
41            diagnostics.push(ParserDiagnostic {
42                level,
43                message: message.into(),
44            });
45        }
46    }
47
48    fn drain(&self) -> Vec<ParserDiagnostic> {
49        match self.inner.lock() {
50            Ok(mut diagnostics) => diagnostics.drain(..).collect(),
51            Err(_) => Vec::new(),
52        }
53    }
54}
55
56/// Parser and loader configuration shared by manifest processors.
57#[derive(Clone, Debug)]
58pub struct ParserConfig {
59    /// Current URL after redirects.
60    pub url: String,
61    /// Original user input URL.
62    pub original_url: String,
63    /// Base URL override.
64    pub base_url: String,
65    /// Custom parser arguments.
66    pub custom_parser_args: BTreeMap<String, String>,
67    /// Request headers.
68    pub headers: BTreeMap<String, String>,
69    /// Append the manifest query string to processed segment URLs.
70    pub append_url_params: bool,
71    /// Arguments for custom URL processors.
72    pub urlprocessor_args: Option<String>,
73    /// Key retry count.
74    pub key_retry_count: u32,
75    /// Segment URL keywords retained for higher-level cleanup after selection.
76    pub ad_keywords: Vec<String>,
77    /// Custom encryption method override.
78    pub custom_method: Option<EncryptionMethod>,
79    /// Custom key override.
80    pub custom_key: Option<Vec<u8>>,
81    /// Custom IV override.
82    pub custom_iv: Option<Vec<u8>>,
83    #[doc(hidden)]
84    pub diagnostics: ParserDiagnostics,
85}
86
87impl Default for ParserConfig {
88    fn default() -> Self {
89        Self {
90            url: String::new(),
91            original_url: String::new(),
92            base_url: String::new(),
93            custom_parser_args: BTreeMap::new(),
94            headers: compatibility_headers(&BTreeMap::new()),
95            append_url_params: false,
96            urlprocessor_args: None,
97            key_retry_count: 3,
98            ad_keywords: Vec::new(),
99            custom_method: None,
100            custom_key: None,
101            custom_iv: None,
102            diagnostics: ParserDiagnostics::default(),
103        }
104    }
105}
106
107impl ParserConfig {
108    /// Builds parser configuration from public download options.
109    pub fn from_options(options: &DownloadOptions) -> Self {
110        let mut custom_parser_args = BTreeMap::new();
111        if options.allow_hls_multi_ext_map {
112            custom_parser_args.insert("AllowHlsMultiExtMap".to_string(), "true".to_string());
113        }
114        Self {
115            base_url: options.base_url.clone().unwrap_or_default(),
116            custom_parser_args,
117            headers: compatibility_headers(&options.headers),
118            append_url_params: options.append_url_params,
119            urlprocessor_args: options.urlprocessor_args.clone(),
120            ad_keywords: Vec::new(),
121            custom_method: options
122                .custom_hls_method
123                .as_ref()
124                .map(|method| match method {
125                    crate::config::HlsMethod::None => EncryptionMethod::None,
126                    crate::config::HlsMethod::Aes128 => EncryptionMethod::Aes128,
127                    crate::config::HlsMethod::Aes128Ecb => EncryptionMethod::Aes128Ecb,
128                    crate::config::HlsMethod::Cenc => EncryptionMethod::Cenc,
129                    crate::config::HlsMethod::SampleAes => EncryptionMethod::SampleAes,
130                    crate::config::HlsMethod::SampleAesCtr => EncryptionMethod::SampleAesCtr,
131                    crate::config::HlsMethod::Chacha20 => EncryptionMethod::Chacha20,
132                    crate::config::HlsMethod::Unknown => EncryptionMethod::Unknown,
133                }),
134            custom_key: options
135                .custom_hls_key
136                .as_ref()
137                .filter(|key| !key.is_empty())
138                .cloned(),
139            custom_iv: options
140                .custom_hls_iv
141                .as_ref()
142                .filter(|iv| !iv.is_empty())
143                .cloned(),
144            ..Self::default()
145        }
146    }
147
148    /// Appends a parser diagnostic for the session to consume.
149    pub fn push_diagnostic(&self, level: LogLevel, message: impl Into<String>) {
150        self.diagnostics.push(level, message);
151    }
152
153    /// Drains pending parser diagnostics.
154    pub fn drain_diagnostics(&self) -> Vec<ParserDiagnostic> {
155        self.diagnostics.drain()
156    }
157}
158
159/// Builds request headers with compatibility defaults and case-insensitive user overrides.
160pub fn compatibility_headers(user_headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
161    let mut headers = BTreeMap::new();
162    insert_header_override(&mut headers, "user-agent", DEFAULT_USER_AGENT);
163    for (key, value) in user_headers {
164        insert_header_override(&mut headers, key, value);
165    }
166    headers
167}
168
169fn insert_header_override(headers: &mut BTreeMap<String, String>, key: &str, value: &str) {
170    let existing_key = headers
171        .keys()
172        .find(|existing| existing.eq_ignore_ascii_case(key))
173        .cloned();
174    if let Some(existing_key) = existing_key {
175        headers.remove(&existing_key);
176    }
177    headers.insert(key.to_string(), value.to_string());
178}
179
180/// Content preprocessor hook.
181pub trait ContentProcessor: Send + Sync {
182    /// Returns whether this processor should run.
183    fn can_process(
184        &self,
185        extractor_type: ExtractorType,
186        raw_text: &str,
187        config: &ParserConfig,
188    ) -> bool;
189
190    /// Processes manifest text.
191    fn process(&self, raw_text: &str, config: &ParserConfig) -> Result<String>;
192}
193
194/// URL preprocessor hook.
195pub trait UrlProcessor: Send + Sync {
196    /// Returns whether this processor should run.
197    fn can_process(&self, extractor_type: ExtractorType, url: &str, config: &ParserConfig) -> bool;
198
199    /// Processes one URL.
200    fn process(&self, url: &str, config: &ParserConfig) -> Result<String>;
201}
202
203/// Key parser hook.
204pub trait KeyProcessor: Send + Sync {
205    /// Returns whether this processor should run.
206    fn can_process(
207        &self,
208        extractor_type: ExtractorType,
209        key_line: &str,
210        manifest_url: &str,
211        manifest_content: &str,
212        config: &ParserConfig,
213    ) -> bool;
214
215    /// Processes one key line.
216    fn process<'a>(
217        &'a self,
218        key_line: &'a str,
219        manifest_url: &'a str,
220        manifest_content: &'a str,
221        config: &'a ParserConfig,
222    ) -> Pin<Box<dyn Future<Output = Result<EncryptionInfo>> + Send + 'a>>;
223}
224
225/// Default URL processor for append-url-params behavior.
226#[derive(Clone, Copy, Debug, Default)]
227pub struct DefaultUrlProcessor;
228
229impl UrlProcessor for DefaultUrlProcessor {
230    fn can_process(
231        &self,
232        _extractor_type: ExtractorType,
233        url: &str,
234        config: &ParserConfig,
235    ) -> bool {
236        config.append_url_params && url.starts_with("http")
237    }
238
239    fn process(&self, url: &str, config: &ParserConfig) -> Result<String> {
240        let processed = append_query_params(url, &config.url);
241        if !query_part(&processed).is_empty() {
242            config.push_diagnostic(LogLevel::Debug, format!("Before: {url}"));
243            config.push_diagnostic(LogLevel::Debug, format!("After: {processed}"));
244        }
245        Ok(processed)
246    }
247}
248
249/// DASH URL processor for signed media URLs using `--urlprocessor-args`.
250#[derive(Clone, Copy, Debug, Default)]
251pub struct SignedDashUrlProcessor;
252
253impl UrlProcessor for SignedDashUrlProcessor {
254    fn can_process(
255        &self,
256        extractor_type: ExtractorType,
257        _url: &str,
258        config: &ParserConfig,
259    ) -> bool {
260        extractor_type == ExtractorType::MpegDash
261            && config
262                .urlprocessor_args
263                .as_deref()
264                .is_some_and(|args| args.starts_with("nowehoryzonty:"))
265    }
266
267    fn process(&self, url: &str, config: &ParserConfig) -> Result<String> {
268        const SIGNED_DASH_PROCESSOR_TAG: &str = "nowehoryzonty:";
269        let args = config
270            .urlprocessor_args
271            .as_deref()
272            .and_then(|value| value.strip_prefix(SIGNED_DASH_PROCESSOR_TAG))
273            .ok_or_else(|| Error::config("url processor arguments are missing"))?;
274        let token = hls_attribute(args, "filminfo.secureToken")?.unwrap_or_default();
275        let time_difference = match hls_attribute(args, "timeDifference")? {
276            Some(value) => i64::from(parse_i32(value.trim(), "url processor timeDifference")?),
277            None => 0,
278        };
279        let now_ms = i64::try_from(OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000)
280            .map_err(|_| Error::config("current timestamp is out of range"))?;
281        let path = absolute_url_path(url)?;
282        Ok(format!(
283            "{url}?secure={}",
284            signed_dash_secure_value(&path, &token, time_difference, now_ms)
285        ))
286    }
287}
288
289/// Default DASH content processor for missing namespace declarations.
290#[derive(Clone, Copy, Debug, Default)]
291pub struct DefaultDashContentProcessor;
292
293impl ContentProcessor for DefaultDashContentProcessor {
294    fn can_process(
295        &self,
296        extractor_type: ExtractorType,
297        raw_text: &str,
298        _config: &ParserConfig,
299    ) -> bool {
300        extractor_type == ExtractorType::MpegDash
301            && ["cenc", "mspr", "mas"]
302                .iter()
303                .any(|prefix| namespace_missing(raw_text, prefix))
304    }
305
306    fn process(&self, raw_text: &str, config: &ParserConfig) -> Result<String> {
307        config.push_diagnostic(LogLevel::Info, "Namespace missing, try fix...");
308        let mut declarations = Vec::new();
309        for (prefix, uri) in [
310            ("cenc", "urn:mpeg:cenc:2013"),
311            ("mspr", "urn:microsoft:playready"),
312            ("mas", "urn:marlin:mas:1-0:services:schemas:mpd"),
313        ] {
314            if namespace_missing(raw_text, prefix) {
315                declarations.push(format!("xmlns:{prefix}=\"{uri}\""));
316            }
317        }
318        if declarations.is_empty() {
319            return Ok(raw_text.to_string());
320        }
321        Ok(replace_first(
322            raw_text,
323            "<MPD ",
324            &format!("<MPD {} ", declarations.join(" ")),
325        ))
326    }
327}
328
329/// Default HLS content processor for low-risk text normalizations.
330#[derive(Clone, Copy, Debug, Default)]
331pub struct DefaultHlsContentProcessor;
332
333impl ContentProcessor for DefaultHlsContentProcessor {
334    fn can_process(
335        &self,
336        extractor_type: ExtractorType,
337        _raw_text: &str,
338        _config: &ParserConfig,
339    ) -> bool {
340        extractor_type == ExtractorType::Hls
341    }
342
343    fn process(&self, raw_text: &str, config: &ParserConfig) -> Result<String> {
344        let mut normalized = if raw_text.contains('\r') && !raw_text.contains('\n') {
345            raw_text.replace('\r', "\n")
346        } else {
347            raw_text.to_string()
348        };
349        if config.url.contains("tlivecloud-playback-cdn.ysp.cctv.cn")
350            && config.url.contains("endtime=")
351            && !normalized.contains("#EXT-X-ENDLIST")
352        {
353            normalized.push('\n');
354            normalized.push_str("#EXT-X-ENDLIST");
355        }
356        if normalized.contains("#EXT-X-DISCONTINUITY")
357            && normalized.contains("#EXT-X-MAP")
358            && normalized.contains("ott.cibntv.net")
359            && normalized.contains("ccode=")
360        {
361            normalized = rewrite_youku_dolby_maps(&normalized)?;
362        }
363        if normalized.contains("#EXT-X-DISCONTINUITY")
364            && normalized.contains("#EXT-X-MAP")
365            && config.url.contains("media.dssott.com/")
366        {
367            normalized = replace_first_regex(
368                &normalized,
369                r#"(?s)#EXT-X-MAP:URI=".*?BUMPER/.*?#EXT-X-DISCONTINUITY"#,
370                "#XXX",
371            )?;
372        }
373        if normalized.contains("#EXT-X-DISCONTINUITY")
374            && normalized.contains("seg_00000.vtt")
375            && config.url.contains("media.dssott.com/")
376        {
377            normalized = replace_first_regex(
378                &normalized,
379                r#"(?s)#EXTINF:.*?,\s+.*BUMPER.*\s+?#EXT-X-DISCONTINUITY"#,
380                "#XXX",
381            )?;
382        }
383        if normalized.contains("#EXT-X-DISCONTINUITY")
384            && normalized.contains("#EXT-X-MAP")
385            && (config.url.contains(".apple.com/")
386                || Regex::new(r"#EXT-X-MAP.*\.apple\.com/")
387                    .map_err(|error| Error::protocol(error.to_string()))?
388                    .is_match(&normalized))
389        {
390            normalized = keep_apple_encrypted_hls_range(&normalized)?;
391        }
392        Ok(fix_hls_key_order(&normalized))
393    }
394}
395
396/// Default HLS key processor.
397#[derive(Clone, Debug)]
398pub struct DefaultHlsKeyProcessor {
399    http: DefaultHttpClient,
400}
401
402impl Default for DefaultHlsKeyProcessor {
403    fn default() -> Self {
404        Self {
405            http: DefaultHttpClient::new(),
406        }
407    }
408}
409
410impl DefaultHlsKeyProcessor {
411    /// Creates a key processor with a custom HTTP client.
412    pub fn new(http: DefaultHttpClient) -> Self {
413        Self { http }
414    }
415}
416
417impl KeyProcessor for DefaultHlsKeyProcessor {
418    fn can_process(
419        &self,
420        extractor_type: ExtractorType,
421        _key_line: &str,
422        _manifest_url: &str,
423        _manifest_content: &str,
424        _config: &ParserConfig,
425    ) -> bool {
426        extractor_type == ExtractorType::Hls
427    }
428
429    fn process<'a>(
430        &'a self,
431        key_line: &'a str,
432        manifest_url: &'a str,
433        _manifest_content: &'a str,
434        config: &'a ParserConfig,
435    ) -> Pin<Box<dyn Future<Output = Result<EncryptionInfo>> + Send + 'a>> {
436        Box::pin(async move {
437            let method = hls_attribute(key_line, "METHOD")?;
438            let uri = hls_attribute(key_line, "URI")?;
439            let iv = hls_attribute(key_line, "IV")?;
440            let original_method = method.as_deref().unwrap_or_default().to_string();
441            config.push_diagnostic(
442                LogLevel::Debug,
443                format!(
444                    "METHOD:{},URI:{},IV:{}",
445                    method.as_deref().unwrap_or_default(),
446                    uri.as_deref().unwrap_or_default(),
447                    iv.as_deref().unwrap_or_default()
448                ),
449            );
450            let mut info = EncryptionInfo {
451                method: EncryptionMethod::parse(method.as_deref()),
452                iv: match iv {
453                    Some(value) => Some(hex_to_bytes(strip_hex_prefix(&value))?),
454                    None => None,
455                },
456                ..EncryptionInfo::default()
457            };
458            if let Some(custom_iv) = &config.custom_iv
459                && !custom_iv.is_empty()
460            {
461                info.iv = Some(custom_iv.clone());
462            }
463            if let Some(custom_key) = &config.custom_key
464                && !custom_key.is_empty()
465            {
466                info.key = Some(custom_key.clone());
467                info.source = KeySource::Custom;
468            } else if let Some(uri) = uri {
469                match self.load_key(&uri, manifest_url, config).await {
470                    Ok(key) => {
471                        info.key = Some(key.0);
472                        info.source = key.1;
473                    }
474                    Err(error) => {
475                        config.push_diagnostic(
476                            LogLevel::Error,
477                            format!(
478                                "Failed to get KEY, ignore.: {}",
479                                error.compatibility_message()
480                            ),
481                        );
482                        info.method = EncryptionMethod::Unknown;
483                    }
484                }
485            } else if info.method != EncryptionMethod::None {
486                info.method = EncryptionMethod::Unknown;
487            }
488            if let Some(custom_method) = config.custom_method {
489                config.push_diagnostic(
490                    LogLevel::Warn,
491                    format!(
492                        "METHOD changed from {} to {}",
493                        original_method,
494                        encryption_method_compat_name(custom_method)
495                    ),
496                );
497                info.method = custom_method;
498            }
499            Ok(info)
500        })
501    }
502}
503
504/// Resolves a media URL with base-url override and URL processors.
505pub fn resolve_media_url(
506    extractor_type: ExtractorType,
507    url: &str,
508    manifest_url: &str,
509    config: &ParserConfig,
510    processors: &[&dyn UrlProcessor],
511) -> Result<String> {
512    let base = if config.base_url.is_empty() {
513        manifest_url
514    } else {
515        &config.base_url
516    };
517    let mut resolved = combine_url(base, url);
518    for processor in processors {
519        if processor.can_process(extractor_type, &resolved, config) {
520            resolved = processor.process(&resolved, config)?;
521        }
522    }
523    Ok(resolved)
524}
525
526impl DefaultHlsKeyProcessor {
527    async fn load_key(
528        &self,
529        uri: &str,
530        manifest_url: &str,
531        config: &ParserConfig,
532    ) -> Result<(Vec<u8>, KeySource)> {
533        let lower = uri.to_ascii_lowercase();
534        if let Some(value) = lower
535            .strip_prefix("base64:")
536            .and_then(|_| uri.get("base64:".len()..))
537        {
538            return Ok((base64_decode(value)?, KeySource::Inline));
539        }
540        if let Some(value) = lower
541            .strip_prefix("data:;base64,")
542            .and_then(|_| uri.get("data:;base64,".len()..))
543        {
544            return Ok((base64_decode(value)?, KeySource::Inline));
545        }
546        if let Some(value) = lower
547            .strip_prefix("data:text/plain;base64,")
548            .and_then(|_| uri.get("data:text/plain;base64,".len()..))
549        {
550            return Ok((base64_decode(value)?, KeySource::Inline));
551        }
552        let path = Path::new(uri);
553        if tokio::fs::metadata(path).await.is_ok() {
554            return Ok((tokio::fs::read(path).await?, KeySource::File));
555        }
556        let key_url = resolve_media_url(
557            ExtractorType::Hls,
558            uri,
559            manifest_url,
560            config,
561            &[&DefaultUrlProcessor],
562        )?;
563        if key_url.starts_with("file:") {
564            return Ok((
565                tokio::fs::read(file_uri_to_path(&key_url)).await?,
566                KeySource::File,
567            ));
568        }
569        let path = Path::new(&key_url);
570        if tokio::fs::metadata(path).await.is_ok() {
571            return Ok((tokio::fs::read(path).await?, KeySource::File));
572        }
573        let attempts = config.key_retry_count.saturating_add(1);
574        let mut last_error = None;
575        for attempt in 0..attempts {
576            match self.load_http_key_once(&key_url, config).await {
577                Ok(bytes) => return Ok((bytes, KeySource::Uri)),
578                Err(error) => {
579                    let remaining_retries = attempts.saturating_sub(attempt + 1);
580                    if !error
581                        .compatibility_message()
582                        .contains("scheme is not supported.")
583                    {
584                        config.push_diagnostic(
585                            LogLevel::Warn,
586                            format!(
587                                "{} retryCount: {remaining_retries}",
588                                error.compatibility_message()
589                            ),
590                        );
591                    }
592                    last_error = Some(error);
593                    if attempt + 1 < attempts {
594                        tokio::time::sleep(Duration::from_secs(1)).await;
595                    }
596                }
597            }
598        }
599        Err(last_error.unwrap_or_else(|| Error::http("key request failed")))
600    }
601
602    async fn load_http_key_once(&self, key_url: &str, config: &ParserConfig) -> Result<Vec<u8>> {
603        config.push_diagnostic(LogLevel::Debug, format!("Fetch: {key_url}"));
604        config.push_diagnostic(
605            LogLevel::Debug,
606            format_source_http_request_headers(&config.headers),
607        );
608        let mut request = HttpRequest::new(key_url);
609        request.headers = config.headers.clone();
610        let response = self.http.send(request).await?;
611        for debug_log in &response.debug_logs {
612            config.push_diagnostic(LogLevel::Debug, debug_log.clone());
613        }
614        config.push_diagnostic(LogLevel::Debug, bytes_to_spaced_hex(&response.body));
615        Ok(response.body)
616    }
617}
618
619fn format_source_http_request_headers(headers: &BTreeMap<String, String>) -> String {
620    let mut lines = vec![
621        "Accept-Encoding: gzip, deflate".to_string(),
622        "Cache-Control: no-cache".to_string(),
623    ];
624    lines.extend(headers.iter().map(|(key, value)| format!("{key}: {value}")));
625    lines.join("\n")
626}
627
628fn bytes_to_spaced_hex(bytes: &[u8]) -> String {
629    bytes
630        .iter()
631        .map(|byte| format!("{byte:02x}"))
632        .collect::<Vec<_>>()
633        .join(" ")
634}
635
636fn encryption_method_compat_name(method: EncryptionMethod) -> &'static str {
637    match method {
638        EncryptionMethod::None => "NONE",
639        EncryptionMethod::Aes128 => "AES_128",
640        EncryptionMethod::Aes128Ecb => "AES_128_ECB",
641        EncryptionMethod::SampleAes => "SAMPLE_AES",
642        EncryptionMethod::SampleAesCtr => "SAMPLE_AES_CTR",
643        EncryptionMethod::Cenc => "CENC",
644        EncryptionMethod::Chacha20 => "CHACHA20",
645        EncryptionMethod::Unknown => "UNKNOWN",
646    }
647}
648
649fn file_uri_to_path(value: &str) -> PathBuf {
650    if let Ok(url) = reqwest::Url::parse(value)
651        && url.scheme() == "file"
652        && let Ok(path) = url.to_file_path()
653    {
654        return path;
655    }
656    let stripped = value
657        .trim_start_matches("file://")
658        .trim_start_matches("file:");
659    #[cfg(windows)]
660    {
661        let mut normalized = stripped.trim_start_matches('/');
662        if let Some(rest) = normalized.strip_prefix("?/") {
663            normalized = rest;
664        }
665        PathBuf::from(normalized)
666    }
667    #[cfg(not(windows))]
668    {
669        PathBuf::from(stripped)
670    }
671}
672
673fn namespace_missing(raw_text: &str, prefix: &str) -> bool {
674    !raw_text.contains(&format!("xmlns:{prefix}")) && raw_text.contains(&format!("<{prefix}:"))
675}
676
677fn replace_first(source: &str, old: &str, new: &str) -> String {
678    match source.find(old) {
679        Some(index) => {
680            let before = source.get(..index).unwrap_or_default();
681            let after = source.get(index + old.len()..).unwrap_or_default();
682            format!("{before}{new}{after}")
683        }
684        None => source.to_string(),
685    }
686}
687
688/// Calculates the signed query value for the DASH URL processor.
689pub fn signed_dash_secure_value(
690    path: &str,
691    secure_token: &str,
692    time_difference_ms: i64,
693    now_ms: i64,
694) -> String {
695    let ms_time = now_ms
696        .saturating_add(60_000)
697        .saturating_add(time_difference_ms);
698    let payload = format!("{ms_time}{path}{secure_token}");
699    let mut hasher = Md5::new();
700    hasher.update(payload.as_bytes());
701    let hash = hasher.finalize();
702    let hash_text = base64_encode(&hash).replace('+', "-").replace('/', "_");
703    format!("{hash_text},{ms_time}")
704}
705
706fn absolute_url_path(url: &str) -> Result<String> {
707    let parsed = reqwest::Url::parse(url)
708        .map_err(|_| Error::config("url processor input must be absolute"))?;
709    Ok(parsed.path().to_string())
710}
711
712fn parse_i32(value: &str, field: &str) -> Result<i32> {
713    value
714        .parse::<i32>()
715        .map_err(|_| Error::config(format!("{field} is invalid")))
716}
717
718fn replace_first_regex(source: &str, pattern: &str, replacement: &str) -> Result<String> {
719    let regex = Regex::new(pattern).map_err(|error| Error::protocol(error.to_string()))?;
720    if let Some(matched) = regex.find(source) {
721        let before = source.get(..matched.start()).unwrap_or_default();
722        let after = source.get(matched.end()..).unwrap_or_default();
723        return Ok(format!("{before}{replacement}{after}"));
724    }
725    Ok(source.to_string())
726}
727
728fn rewrite_youku_dolby_maps(raw_text: &str) -> Result<String> {
729    let regex = Regex::new(r#"#EXT-X-DISCONTINUITY\s+#EXT-X-MAP:URI="(.*?)",BYTERANGE="(.*?)""#)
730        .map_err(|error| Error::protocol(error.to_string()))?;
731    Ok(regex
732        .replace_all(raw_text, |captures: &regex::Captures<'_>| {
733            let uri = captures
734                .get(1)
735                .map(|value| value.as_str())
736                .unwrap_or_default();
737            let byterange = captures
738                .get(2)
739                .map(|value| value.as_str())
740                .unwrap_or_default();
741            format!("#EXTINF:0.000000,\n#EXT-X-BYTERANGE:{byterange}\n{uri}")
742        })
743        .into_owned())
744}
745
746fn keep_apple_encrypted_hls_range(raw_text: &str) -> Result<String> {
747    let regex = Regex::new(r#"(?s)(#EXT-X-KEY:.*?)(#EXT-X-DISCONTINUITY|#EXT-X-ENDLIST)"#)
748        .map_err(|error| Error::protocol(error.to_string()))?;
749    let Some(captures) = regex.captures(raw_text) else {
750        return Ok(raw_text.to_string());
751    };
752    let key_range = captures
753        .get(1)
754        .map(|value| value.as_str())
755        .unwrap_or_default();
756    Ok(format!("#EXTM3U\r\n{key_range}\r\n#EXT-X-ENDLIST"))
757}
758
759fn fix_hls_key_order(raw_text: &str) -> String {
760    let separator = if raw_text.contains("\r\n") {
761        "\r\n"
762    } else {
763        "\n"
764    };
765    let lines = raw_text.lines().map(str::to_string).collect::<Vec<_>>();
766    let mut fixed = Vec::with_capacity(lines.len());
767    let mut index = 0;
768    while index < lines.len() {
769        let current = lines.get(index).map(String::as_str).unwrap_or_default();
770        if current.starts_with("#EXTINF") {
771            let mut key_index = index + 1;
772            while lines
773                .get(key_index)
774                .is_some_and(|line| line.trim().is_empty())
775            {
776                key_index += 1;
777            }
778            if lines
779                .get(key_index)
780                .is_some_and(|line| line.starts_with("#EXT-X-KEY"))
781            {
782                fixed.push(lines[key_index].clone());
783                fixed.extend(lines[index + 1..key_index].iter().cloned());
784                fixed.push(current.to_string());
785                index = key_index + 1;
786                continue;
787            }
788        }
789        fixed.push(current.to_string());
790        index += 1;
791    }
792    fixed.join(separator)
793}
794
795fn append_query_params(url: &str, source_url: &str) -> String {
796    let source_query = query_part(source_url);
797    if source_query.is_empty() {
798        return url.to_string();
799    }
800    let mut target_pairs = parse_query(query_part(url), DuplicateMode::Keep);
801    for pair in parse_query(source_query, DuplicateMode::Join) {
802        set_or_add_query_pair(&mut target_pairs, pair);
803    }
804    let path = url
805        .split_once('?')
806        .map_or(url, |(path, _)| path)
807        .split_once('#')
808        .map_or_else(
809            || url.split_once('?').map_or(url, |(path, _)| path),
810            |(path, _)| path,
811        );
812    if target_pairs.is_empty() {
813        path.to_string()
814    } else {
815        format!("{path}?{}", format_query(&target_pairs))
816    }
817}
818
819fn query_part(url: &str) -> &str {
820    url.split_once('?').map_or("", |(_, query)| {
821        query.split_once('#').map_or(query, |(query, _)| query)
822    })
823}
824
825#[derive(Clone, Copy)]
826enum DuplicateMode {
827    Keep,
828    Join,
829}
830
831#[derive(Clone, Debug)]
832struct QueryPair {
833    key: String,
834    value: String,
835    has_equals: bool,
836}
837
838fn parse_query(query: &str, duplicate_mode: DuplicateMode) -> Vec<QueryPair> {
839    let pairs = query
840        .trim_end_matches('?')
841        .split('&')
842        .filter(|part| !part.is_empty())
843        .map(|part| match part.split_once('=') {
844            Some((key, value)) => QueryPair {
845                key: form_decode(key),
846                value: form_decode(value),
847                has_equals: true,
848            },
849            None => QueryPair {
850                key: form_decode(part),
851                value: String::new(),
852                has_equals: false,
853            },
854        })
855        .collect::<Vec<_>>();
856    match duplicate_mode {
857        DuplicateMode::Keep => pairs,
858        DuplicateMode::Join => join_duplicate_query_pairs(pairs),
859    }
860}
861
862fn join_duplicate_query_pairs(pairs: Vec<QueryPair>) -> Vec<QueryPair> {
863    let mut output: Vec<QueryPair> = Vec::new();
864    for pair in pairs {
865        if let Some(existing) = output.iter_mut().find(|existing| existing.key == pair.key) {
866            existing.has_equals |= pair.has_equals;
867            if existing.value.is_empty() {
868                existing.value = pair.value;
869            } else if !pair.value.is_empty() {
870                existing.value.push(',');
871                existing.value.push_str(&pair.value);
872            }
873        } else {
874            output.push(pair);
875        }
876    }
877    output
878}
879
880fn set_or_add_query_pair(pairs: &mut Vec<QueryPair>, pair: QueryPair) {
881    if !pair.has_equals {
882        pairs.push(pair);
883        return;
884    }
885    let Some(first_index) = pairs.iter().position(|existing| existing.key == pair.key) else {
886        pairs.push(pair);
887        return;
888    };
889    pairs[first_index].value = pair.value;
890    pairs[first_index].has_equals = pair.has_equals;
891    let mut index = 0;
892    let key = pairs[first_index].key.clone();
893    pairs.retain(|existing| {
894        let keep = existing.key != key || index == first_index;
895        index += 1;
896        keep
897    });
898}
899
900fn format_query(pairs: &[QueryPair]) -> String {
901    pairs
902        .iter()
903        .map(|pair| {
904            let key = form_encode(&pair.key);
905            if pair.has_equals {
906                format!("{key}={}", form_encode(&pair.value))
907            } else {
908                key
909            }
910        })
911        .collect::<Vec<_>>()
912        .join("&")
913}
914
915fn form_decode(value: &str) -> String {
916    let bytes = value.as_bytes();
917    let mut output = Vec::with_capacity(bytes.len());
918    let mut index = 0;
919    while index < bytes.len() {
920        match bytes[index] {
921            b'+' => {
922                output.push(b' ');
923                index += 1;
924            }
925            b'%' if index + 2 < bytes.len() => {
926                let high = hex_value_char(bytes[index + 1]);
927                let low = hex_value_char(bytes[index + 2]);
928                if let (Some(high), Some(low)) = (high, low) {
929                    output.push((high << 4) | low);
930                    index += 3;
931                } else {
932                    output.push(bytes[index]);
933                    index += 1;
934                }
935            }
936            byte => {
937                output.push(byte);
938                index += 1;
939            }
940        }
941    }
942    String::from_utf8_lossy(&output).to_string()
943}
944
945fn form_encode(value: &str) -> String {
946    let mut output = String::new();
947    for byte in value.as_bytes() {
948        match *byte {
949            b'A'..=b'Z'
950            | b'a'..=b'z'
951            | b'0'..=b'9'
952            | b'-'
953            | b'_'
954            | b'.'
955            | b'!'
956            | b'*'
957            | b'('
958            | b')' => output.push(char::from(*byte)),
959            b' ' => output.push('+'),
960            byte => {
961                output.push('%');
962                output.push(hex_digit(byte >> 4));
963                output.push(hex_digit(byte & 0x0f));
964            }
965        }
966    }
967    output
968}
969
970fn hex_value_char(byte: u8) -> Option<u8> {
971    match byte {
972        b'0'..=b'9' => Some(byte - b'0'),
973        b'a'..=b'f' => Some(byte - b'a' + 10),
974        b'A'..=b'F' => Some(byte - b'A' + 10),
975        _ => None,
976    }
977}
978
979fn hex_digit(value: u8) -> char {
980    char::from(b"0123456789abcdef"[usize::from(value)])
981}
982
983pub(crate) fn combine_url(base: &str, value: &str) -> String {
984    if base.is_empty() {
985        return value.to_string();
986    }
987    if let Ok(base_url) = reqwest::Url::parse(base)
988        && let Ok(joined) = base_url.join(value)
989    {
990        return joined.to_string().replace("%7B", "{").replace("%7D", "}");
991    }
992    if reqwest::Url::parse(value).is_ok() {
993        return value.to_string();
994    }
995    match base.rfind(['/', '\\']) {
996        Some(index) => {
997            let prefix = base.get(..=index).unwrap_or(base);
998            format!("{prefix}{value}")
999        }
1000        None => value.to_string(),
1001    }
1002}
1003
1004fn hex_to_bytes(value: &str) -> Result<Vec<u8>> {
1005    if value.len() & 1 != 0 {
1006        return Err(Error::config("hex length must be even"));
1007    }
1008    let mut bytes = Vec::with_capacity(value.len() / 2);
1009    let mut chars = value.chars();
1010    while let Some(high) = chars.next() {
1011        let low = chars
1012            .next()
1013            .ok_or_else(|| Error::config("hex length must be even"))?;
1014        let high = hex_value(high).ok_or_else(|| Error::config("hex is invalid"))?;
1015        let low = hex_value(low).ok_or_else(|| Error::config("hex is invalid"))?;
1016        bytes.push((high << 4) | low);
1017    }
1018    Ok(bytes)
1019}
1020
1021fn strip_hex_prefix(value: &str) -> &str {
1022    let trimmed = value.trim();
1023    trimmed
1024        .strip_prefix("0x")
1025        .or_else(|| trimmed.strip_prefix("0X"))
1026        .unwrap_or(trimmed)
1027}
1028
1029fn hex_value(ch: char) -> Option<u8> {
1030    match ch {
1031        '0'..='9' => Some(ch as u8 - b'0'),
1032        'a'..='f' => Some(ch as u8 - b'a' + 10),
1033        'A'..='F' => Some(ch as u8 - b'A' + 10),
1034        _ => None,
1035    }
1036}
1037
1038fn base64_decode(value: &str) -> Result<Vec<u8>> {
1039    crate::base64::decode_base64(value).map_err(Error::config)
1040}
1041
1042fn base64_encode(bytes: &[u8]) -> String {
1043    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1044    let mut output = String::with_capacity(bytes.len().div_ceil(3) * 4);
1045    for chunk in bytes.chunks(3) {
1046        let first = u32::from(chunk[0]);
1047        let second = chunk.get(1).copied().map(u32::from).unwrap_or(0);
1048        let third = chunk.get(2).copied().map(u32::from).unwrap_or(0);
1049        let packed = (first << 16) | (second << 8) | third;
1050        let indexes = [
1051            ((packed >> 18) & 0x3f) as usize,
1052            ((packed >> 12) & 0x3f) as usize,
1053            ((packed >> 6) & 0x3f) as usize,
1054            (packed & 0x3f) as usize,
1055        ];
1056        output.push(char::from(TABLE[indexes[0]]));
1057        output.push(char::from(TABLE[indexes[1]]));
1058        if chunk.len() > 1 {
1059            output.push(char::from(TABLE[indexes[2]]));
1060        } else {
1061            output.push('=');
1062        }
1063        if chunk.len() > 2 {
1064            output.push(char::from(TABLE[indexes[3]]));
1065        } else {
1066            output.push('=');
1067        }
1068    }
1069    output
1070}