1use 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
20pub 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#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct ParserDiagnostic {
26 pub level: LogLevel,
28 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#[derive(Clone, Debug)]
58pub struct ParserConfig {
59 pub url: String,
61 pub original_url: String,
63 pub base_url: String,
65 pub custom_parser_args: BTreeMap<String, String>,
67 pub headers: BTreeMap<String, String>,
69 pub append_url_params: bool,
71 pub urlprocessor_args: Option<String>,
73 pub key_retry_count: u32,
75 pub ad_keywords: Vec<String>,
77 pub custom_method: Option<EncryptionMethod>,
79 pub custom_key: Option<Vec<u8>>,
81 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 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 pub fn push_diagnostic(&self, level: LogLevel, message: impl Into<String>) {
150 self.diagnostics.push(level, message);
151 }
152
153 pub fn drain_diagnostics(&self) -> Vec<ParserDiagnostic> {
155 self.diagnostics.drain()
156 }
157}
158
159pub 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
180pub trait ContentProcessor: Send + Sync {
182 fn can_process(
184 &self,
185 extractor_type: ExtractorType,
186 raw_text: &str,
187 config: &ParserConfig,
188 ) -> bool;
189
190 fn process(&self, raw_text: &str, config: &ParserConfig) -> Result<String>;
192}
193
194pub trait UrlProcessor: Send + Sync {
196 fn can_process(&self, extractor_type: ExtractorType, url: &str, config: &ParserConfig) -> bool;
198
199 fn process(&self, url: &str, config: &ParserConfig) -> Result<String>;
201}
202
203pub trait KeyProcessor: Send + Sync {
205 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 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#[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#[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#[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#[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#[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 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
504pub 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
688pub 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: ®ex::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}