// YoutubeStreamExtractor — orchestrator. Mirrors NPE // services/youtube/extractors/YoutubeStreamExtractor.java:onFetchPage(). // // Order: // 1. Optional Android po_token from PoTokenProvider (until a provider // is registered we always go anonymous → reel endpoint). // 2. Android `/player` (if po_token) or `/reel/reel_item_watch` (anon). // checkPlayabilityStatus → typed ContentUnavailable variants. // isPlayerResponseNotValid → reject the "you're a bot" decoy. // 3. Optional iOS `/player` (best-effort, all exceptions swallowed). // 4. WEB `/player?$fields=microformat...` — metadata + better thumbnails. // Exceptions swallowed → falls back to Android-response thumbnails. // 5. WEB `/next` — description + related + chapters. Mandatory. // // Per-format URL post-processing: // * If format has `url` → use as-is (Android + iOS path). // * Else parse `signatureCipher` → deobfuscate `s` → assemble // `url&sp=` (WEB path; not exercised in the current // onFetchPage flow but kept for completeness). // * Run `url_with_throttling_parameter_deobfuscated` UNCONDITIONALLY. // * Append `&cpn=`. // * Append `&pot=` if set. use serde_json::Value; use crate::exceptions::{ContentUnavailable, ExtractionError, ParsingError}; use crate::image::{Image, ResolutionLevel}; use crate::localization::{ContentCountry, Localization}; use crate::newpipe::NewPipe; use crate::stream::{ AudioStream, DeliveryMethod, StreamInfo, StreamType, SubtitlesStream, VideoStream, }; use crate::youtube::itag::{lookup as itag_lookup, ItagType}; #[cfg(test)] use crate::youtube::itag::MediaFormat; use crate::youtube::js::PlayerManager; use crate::youtube::potoken::{po_token_provider, PoTokenResult}; use crate::youtube::stream_helper::{self, generate_content_playback_nonce}; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum FetchPolicy { AnonymousAndroidReel, AndroidWithPoToken, } #[derive(Clone, Debug, Default)] pub struct ExtractOptions { pub fetch_ios_client: bool, pub android_streaming_pot: Option, pub ios_streaming_pot: Option, pub android_visitor_data: Option, pub ios_visitor_data: Option, pub android_player_request_pot: Option, pub ios_player_request_pot: Option, } /// One-shot StreamInfo build for a video. Walks NPE's Android-primary /// fetch path, applies URL post-processing, returns the final shape. pub fn stream_info(video_id: &str) -> Result { stream_info_with(video_id, ExtractOptions::default()) } pub fn stream_info_with( video_id: &str, options: ExtractOptions, ) -> Result { let localization = NewPipe::preferred_localization(); let content_country = NewPipe::preferred_content_country(); // Resolve po_token via the registered provider if present, falling // back to caller-supplied options. The trait split (Ok(None) vs // Err) lets us treat "provider declined" as "go anonymous" and // "provider errored" as "still try anonymous but log the failure." let provider = po_token_provider(); let android_token: Option = options_or_provider( options.android_player_request_pot.as_deref(), options.android_streaming_pot.as_deref(), options.android_visitor_data.as_deref(), || { provider .as_ref() .and_then(|p| p.get_android_client_po_token(video_id).ok().flatten()) }, ); let android_cpn = generate_content_playback_nonce(); let player_response = fetch_android( video_id, &localization, &content_country, &android_cpn, android_token .as_ref() .map(|t| t.player_request_po_token.as_str()), android_token.as_ref().map(|t| t.visitor_data.as_str()), )?; check_playability_status(&player_response)?; if is_player_response_not_valid(&player_response, video_id) { return Err(ExtractionError::Other( "ANDROID player response is not valid (decoy detected)".into(), )); } let android_streaming_data = player_response .get("streamingData") .cloned() .unwrap_or(Value::Null); // Optional iOS — best-effort. let ios_token: Option = if options.fetch_ios_client { options_or_provider( options.ios_player_request_pot.as_deref(), options.ios_streaming_pot.as_deref(), options.ios_visitor_data.as_deref(), || { provider .as_ref() .and_then(|p| p.get_ios_client_po_token(video_id).ok().flatten()) }, ) } else { None }; let (ios_streaming_data, ios_cpn) = if options.fetch_ios_client { let ios_cpn = generate_content_playback_nonce(); match stream_helper::get_ios_player_response( video_id, &localization, &content_country, &ios_cpn, ios_token.as_ref().map(|t| t.player_request_po_token.as_str()), ios_token.as_ref().map(|t| t.visitor_data.as_str()), ) { Ok(r) if !is_player_response_not_valid(&r, video_id) => ( r.get("streamingData").cloned().unwrap_or(Value::Null), Some(ios_cpn), ), _ => (Value::Null, None), } } else { (Value::Null, None) }; let android_streaming_pot = android_token .as_ref() .map(|t| t.streaming_data_po_token.clone()) .or_else(|| options.android_streaming_pot.clone()); let ios_streaming_pot = ios_token .as_ref() .map(|t| t.streaming_data_po_token.clone()) .or_else(|| options.ios_streaming_pot.clone()); let signature_timestamp = PlayerManager::instance() .signature_timestamp(video_id) .unwrap_or(0); let web_metadata = fetch_web_metadata(video_id, &localization, &content_country, signature_timestamp); let mut info = StreamInfo { service_id: 0, url: format!("https://www.youtube.com/watch?v={video_id}"), video_id: video_id.to_string(), stream_type: Some(StreamType::VideoStream), ..StreamInfo::default() }; populate_video_details(&mut info, &player_response); populate_microformat(&mut info, &web_metadata); populate_streams( &mut info, &android_streaming_data, &ios_streaming_data, video_id, &android_cpn, ios_cpn.as_deref(), android_streaming_pot.as_deref(), ios_streaming_pot.as_deref(), )?; populate_manifests( &mut info, &android_streaming_data, &ios_streaming_data, android_streaming_pot.as_deref(), ios_streaming_pot.as_deref(), ); populate_captions(&mut info, &player_response); Ok(info) } fn options_or_provider( opt_player_token: Option<&str>, opt_streaming_token: Option<&str>, opt_visitor: Option<&str>, provider_fn: impl FnOnce() -> Option, ) -> Option { // Caller-supplied wins when ALL three are present. if let (Some(p), Some(s), Some(v)) = (opt_player_token, opt_streaming_token, opt_visitor) { return Some(PoTokenResult::new(p, s, v)); } provider_fn() } fn fetch_android( video_id: &str, localization: &Localization, content_country: &ContentCountry, cpn: &str, po_token: Option<&str>, visitor_data: Option<&str>, ) -> Result { let result = if po_token.is_some() { stream_helper::get_android_player_response( video_id, localization, content_country, cpn, po_token, visitor_data, ) } else { let r = stream_helper::get_android_reel_player_response( video_id, localization, content_country, cpn, )?; // The reel endpoint returns the `playerResponse` nested one level. Ok(r.get("playerResponse").cloned().unwrap_or(r)) }; result } fn fetch_web_metadata( video_id: &str, localization: &Localization, content_country: &ContentCountry, signature_timestamp: i32, ) -> Value { stream_helper::get_web_metadata_player_response( video_id, localization, content_country, signature_timestamp, ) .unwrap_or(Value::Null) } fn check_playability_status(player_response: &Value) -> Result<(), ExtractionError> { let status = player_response.get("playabilityStatus"); let Some(status) = status else { return Ok(()) }; let status_code = status.get("status").and_then(|v| v.as_str()).unwrap_or(""); if status_code == "OK" { return Ok(()); } let reason = status.get("reason").and_then(|v| v.as_str()).unwrap_or(""); let reason_lc = reason.to_ascii_lowercase(); let mapped = match status_code { "LOGIN_REQUIRED" => { if reason_lc.contains("a bot") { ContentUnavailable::Other("sign in to confirm you're not a bot".into()) } else if reason_lc.contains("inappropriate") { ContentUnavailable::AgeRestricted } else if reason_lc.contains("private") { ContentUnavailable::Private } else { ContentUnavailable::Other(reason.into()) } } "UNPLAYABLE" | "ERROR" => { if reason_lc.contains("music premium") { ContentUnavailable::YoutubeMusicPremium } else if reason_lc.contains("payment") || reason_lc.contains("members") { ContentUnavailable::Paid } else if reason_lc.contains("country") { ContentUnavailable::GeoRestricted } else if reason_lc.contains("closed") || reason_lc.contains("terminated") { ContentUnavailable::AccountTerminated } else { ContentUnavailable::Other(reason.into()) } } _ => ContentUnavailable::Other(format!("{status_code}: {reason}")), }; Err(ExtractionError::ContentUnavailable(mapped)) } fn is_player_response_not_valid(player_response: &Value, video_id: &str) -> bool { let returned = player_response .get("videoDetails") .and_then(|v| v.get("videoId")) .and_then(|v| v.as_str()); returned.map(|r| r != video_id).unwrap_or(false) } fn populate_video_details(info: &mut StreamInfo, player_response: &Value) { let Some(vd) = player_response.get("videoDetails") else { return; }; if let Some(s) = vd.get("title").and_then(|v| v.as_str()) { info.name = s.to_string(); } if let Some(s) = vd.get("shortDescription").and_then(|v| v.as_str()) { info.description = s.to_string(); } if let Some(s) = vd.get("lengthSeconds").and_then(|v| v.as_str()) { info.duration_seconds = s.parse().unwrap_or(0); } if let Some(s) = vd.get("viewCount").and_then(|v| v.as_str()) { info.view_count = s.parse().unwrap_or(0); } if let Some(s) = vd.get("author").and_then(|v| v.as_str()) { info.uploader_name = s.to_string(); } if let Some(s) = vd.get("channelId").and_then(|v| v.as_str()) { info.uploader_id = s.to_string(); info.uploader_url = format!("https://www.youtube.com/channel/{s}"); } if let Some(thumbs) = vd .get("thumbnail") .and_then(|v| v.get("thumbnails")) .and_then(|v| v.as_array()) { for t in thumbs { if let Some(url) = t.get("url").and_then(|v| v.as_str()) { let h = t.get("height").and_then(|v| v.as_i64()).unwrap_or(-1) as i32; let w = t.get("width").and_then(|v| v.as_i64()).unwrap_or(-1) as i32; info.thumbnails.push(Image::new( url, h, w, ResolutionLevel::from_height(h), )); } } } if vd .get("isLive") .and_then(|v| v.as_bool()) .unwrap_or(false) { info.stream_type = Some(StreamType::VideoLiveStream); } else if vd .get("isPostLiveDvr") .and_then(|v| v.as_bool()) .unwrap_or(false) { info.stream_type = Some(StreamType::PostLiveStream); } } fn populate_microformat(info: &mut StreamInfo, web_metadata: &Value) { let Some(mfr) = web_metadata .get("microformat") .and_then(|v| v.get("playerMicroformatRenderer")) else { return; }; if let Some(s) = mfr .get("uploadDate") .and_then(|v| v.as_str()) .or_else(|| mfr.get("publishDate").and_then(|v| v.as_str())) { info.upload_date_iso = Some(s.to_string()); } if let Some(s) = mfr.get("category").and_then(|v| v.as_str()) { info.category = s.to_string(); } // The microformat has higher-quality thumbnails — prepend over the // videoDetails set we already populated. if let Some(thumbs) = mfr .get("thumbnail") .and_then(|v| v.get("thumbnails")) .and_then(|v| v.as_array()) { let mut higher = Vec::new(); for t in thumbs { if let Some(url) = t.get("url").and_then(|v| v.as_str()) { let h = t.get("height").and_then(|v| v.as_i64()).unwrap_or(-1) as i32; let w = t.get("width").and_then(|v| v.as_i64()).unwrap_or(-1) as i32; higher.push(Image::new(url, h, w, ResolutionLevel::from_height(h))); } } if !higher.is_empty() { higher.extend(std::mem::take(&mut info.thumbnails)); info.thumbnails = higher; } } } #[allow(clippy::too_many_arguments)] fn populate_streams( info: &mut StreamInfo, android: &Value, ios: &Value, video_id: &str, android_cpn: &str, ios_cpn: Option<&str>, android_pot: Option<&str>, ios_pot: Option<&str>, ) -> Result<(), ExtractionError> { let merge = |fmt_array_key: &str| -> Vec<(Value, &'static str, &str, Option<&str>)> { let mut out = Vec::new(); if let Some(arr) = android.get(fmt_array_key).and_then(|v| v.as_array()) { for f in arr { out.push((f.clone(), "ANDROID", android_cpn, android_pot)); } } if let Some(arr) = ios.get(fmt_array_key).and_then(|v| v.as_array()) { for f in arr { let cpn = ios_cpn.unwrap_or(""); out.push((f.clone(), "IOS", cpn, ios_pot)); } } out }; // Progressive: streamingData.formats[] for (fmt, _client, cpn, pot) in merge("formats") { if let Some(stream) = build_video_progressive(&fmt, video_id, cpn, pot)? { push_video_dedup(&mut info.video_streams, stream); } } // Adaptive: streamingData.adaptiveFormats[] for (fmt, _client, cpn, pot) in merge("adaptiveFormats") { let mime = fmt .get("mimeType") .and_then(|v| v.as_str()) .unwrap_or(""); if mime.starts_with("audio/") { if let Some(audio) = build_audio(&fmt, video_id, cpn, pot)? { push_audio_dedup(&mut info.audio_streams, audio); } } else if mime.starts_with("video/") { if let Some(video) = build_video_only(&fmt, video_id, cpn, pot)? { push_video_dedup(&mut info.video_only_streams, video); } } } Ok(()) } fn populate_manifests( info: &mut StreamInfo, android: &Value, ios: &Value, android_pot: Option<&str>, ios_pot: Option<&str>, ) { // DASH is Android-only. if let Some(url) = android.get("dashManifestUrl").and_then(|v| v.as_str()) { info.dash_manifest_url = Some(append_pot_to_manifest(url, android_pot)); } // HLS prefers iOS, falls back to Android. if let Some(url) = ios.get("hlsManifestUrl").and_then(|v| v.as_str()) { info.hls_manifest_url = Some(append_pot_to_manifest(url, ios_pot)); } else if let Some(url) = android.get("hlsManifestUrl").and_then(|v| v.as_str()) { info.hls_manifest_url = Some(append_pot_to_manifest(url, android_pot)); } } fn append_pot_to_manifest(url: &str, pot: Option<&str>) -> String { match pot { Some(t) => { let sep = if url.contains('?') { '&' } else { '?' }; format!("{url}{sep}pot={t}&mpd_version=7") } None => url.to_string(), } } fn populate_captions(info: &mut StreamInfo, player_response: &Value) { let Some(tracks) = player_response .get("captions") .and_then(|v| v.get("playerCaptionsTracklistRenderer")) .and_then(|v| v.get("captionTracks")) .and_then(|v| v.as_array()) else { return; }; for t in tracks { let Some(url) = t.get("baseUrl").and_then(|v| v.as_str()) else { continue; }; let lang = t .get("languageCode") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let name = t .get("name") .and_then(|v| v.get("simpleText")) .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let auto = t.get("kind").and_then(|v| v.as_str()) == Some("asr"); info.subtitles.push(SubtitlesStream { url: url.to_string(), language_code: lang, name, is_auto_generated: auto, mime: "application/ttml+xml".into(), }); } } fn process_url( raw_format: &Value, video_id: &str, cpn: &str, pot: Option<&str>, ) -> Result, ExtractionError> { let mut url = if let Some(u) = raw_format.get("url").and_then(|v| v.as_str()) { u.to_string() } else { // signatureCipher path — WEB-family only; not exercised in the // Android-primary flow but mirror NPE's behavior for completeness. let cipher_str = raw_format .get("signatureCipher") .or_else(|| raw_format.get("cipher")) .and_then(|v| v.as_str()) .unwrap_or(""); if cipher_str.is_empty() { return Ok(None); } let cipher = parse_cipher_string(cipher_str); let s = cipher.get("s").map(String::as_str).unwrap_or(""); let sp = cipher.get("sp").map(String::as_str).unwrap_or("sig"); let base = cipher.get("url").map(String::as_str).unwrap_or(""); if base.is_empty() { return Ok(None); } let deobf = PlayerManager::instance() .deobfuscate_signature(video_id, s) .map_err(|e| { ExtractionError::Parsing(ParsingError::Invalid(format!("sig deobf: {e}"))) })?; format!("{base}&{sp}={deobf}") }; // nsig deobf — unconditional. Quick-exit if no `n=` present. url = PlayerManager::instance() .url_with_throttling_parameter_deobfuscated(video_id, &url) .map_err(|e| { ExtractionError::Parsing(ParsingError::Invalid(format!("nsig deobf: {e}"))) })?; let sep_cpn = if url.contains('?') { '&' } else { '?' }; url = format!("{url}{sep_cpn}cpn={cpn}"); if let Some(token) = pot { url = format!("{url}&pot={token}"); } Ok(Some(url)) } fn parse_cipher_string(s: &str) -> std::collections::BTreeMap { // `url::form_urlencoded::parse` decodes percent-escapes as UTF-8 // multi-byte sequences and handles `+` → space — both of which the // prior hand-rolled `urlencoded_decode` got wrong (it treated each // %XX as an isolated code point, so `%E2%9C%93` rendered as three // garbage chars instead of ✓). YT cipher strings are typically // ASCII-only, but pulling in the canonical parser closes the // surface and removes 20 lines. url::form_urlencoded::parse(s.as_bytes()) .map(|(k, v)| (k.into_owned(), v.into_owned())) .collect() } fn build_video_progressive( fmt: &Value, video_id: &str, cpn: &str, pot: Option<&str>, ) -> Result, ExtractionError> { let itag_id = fmt.get("itag").and_then(|v| v.as_u64()).unwrap_or(0) as u32; let Some(itag) = itag_lookup(itag_id) else { return Ok(None); }; let Some(url) = process_url(fmt, video_id, cpn, pot)? else { return Ok(None); }; Ok(Some(VideoStream { itag: itag.id, url, format: itag.format, delivery: DeliveryMethod::Progressive, resolution: itag.resolution.unwrap_or("").to_string(), fps: fmt.get("fps").and_then(|v| v.as_u64()).unwrap_or(itag.fps as u64) as u32, bandwidth: fmt.get("bitrate").and_then(|v| v.as_u64()).map(|n| n as u32), codec: codec_from_mime(fmt), content_length_bytes: fmt .get("contentLength") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()), width: fmt.get("width").and_then(|v| v.as_u64()).map(|n| n as u32), height: fmt.get("height").and_then(|v| v.as_u64()).map(|n| n as u32), video_only: false, })) } fn build_video_only( fmt: &Value, video_id: &str, cpn: &str, pot: Option<&str>, ) -> Result, ExtractionError> { let itag_id = fmt.get("itag").and_then(|v| v.as_u64()).unwrap_or(0) as u32; let Some(itag) = itag_lookup(itag_id) else { return Ok(None); }; if itag.item_type != ItagType::VideoOnly { return Ok(None); } let Some(url) = process_url(fmt, video_id, cpn, pot)? else { return Ok(None); }; Ok(Some(VideoStream { itag: itag.id, url, format: itag.format, delivery: DeliveryMethod::Dash, resolution: itag.resolution.unwrap_or("").to_string(), fps: fmt.get("fps").and_then(|v| v.as_u64()).unwrap_or(itag.fps as u64) as u32, bandwidth: fmt.get("bitrate").and_then(|v| v.as_u64()).map(|n| n as u32), codec: codec_from_mime(fmt), content_length_bytes: fmt .get("contentLength") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()), width: fmt.get("width").and_then(|v| v.as_u64()).map(|n| n as u32), height: fmt.get("height").and_then(|v| v.as_u64()).map(|n| n as u32), video_only: true, })) } fn build_audio( fmt: &Value, video_id: &str, cpn: &str, pot: Option<&str>, ) -> Result, ExtractionError> { let itag_id = fmt.get("itag").and_then(|v| v.as_u64()).unwrap_or(0) as u32; let Some(itag) = itag_lookup(itag_id) else { return Ok(None); }; if itag.item_type != ItagType::Audio { return Ok(None); } let Some(url) = process_url(fmt, video_id, cpn, pot)? else { return Ok(None); }; let audio_track = fmt.get("audioTrack"); Ok(Some(AudioStream { itag: itag.id, url, format: itag.format, delivery: DeliveryMethod::Dash, average_bitrate_kbps: fmt .get("averageBitrate") .and_then(|v| v.as_u64()) .map(|n| (n / 1000) as u32) .or(itag.avg_bitrate_kbps), codec: codec_from_mime(fmt), content_length_bytes: fmt .get("contentLength") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()), audio_track_id: audio_track .and_then(|t| t.get("id")) .and_then(|v| v.as_str()) .map(String::from), audio_track_name: audio_track .and_then(|t| t.get("displayName")) .and_then(|v| v.as_str()) .map(String::from), audio_locale: audio_track .and_then(|t| t.get("id")) .and_then(|v| v.as_str()) .and_then(|s| s.split('.').next()) .map(String::from), is_descriptive: audio_track .and_then(|t| t.get("audioIsDefault")) .and_then(|v| v.as_bool()) .map(|b| !b) .unwrap_or(false), itag_url_format: None, })) } fn codec_from_mime(fmt: &Value) -> Option { let mime = fmt.get("mimeType").and_then(|v| v.as_str())?; let codecs_idx = mime.find("codecs=\"")?; let after = &mime[codecs_idx + 8..]; let end = after.find('"')?; Some(after[..end].to_string()) } /// Dedup by itag id + delivery method, NOT by `mediaFormat.id` — NPE's /// dedup collides itag 140 and 141 because both are M4A. fn push_audio_dedup(list: &mut Vec, candidate: AudioStream) { if list .iter() .any(|s| s.itag == candidate.itag && s.delivery == candidate.delivery) { return; } list.push(candidate); } fn push_video_dedup(list: &mut Vec, candidate: VideoStream) { if list .iter() .any(|s| s.itag == candidate.itag && s.delivery == candidate.delivery) { return; } list.push(candidate); } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn playability_ok_returns_ok() { let resp = json!({"playabilityStatus": {"status": "OK"}}); assert!(check_playability_status(&resp).is_ok()); } #[test] fn playability_login_required_age() { let resp = json!({ "playabilityStatus": { "status": "LOGIN_REQUIRED", "reason": "Sign in to confirm your age. This video may be inappropriate for some users." } }); let err = check_playability_status(&resp).unwrap_err(); match err { ExtractionError::ContentUnavailable(ContentUnavailable::AgeRestricted) => (), other => panic!("expected AgeRestricted, got {other:?}"), } } #[test] fn playability_geo_restricted() { let resp = json!({ "playabilityStatus": { "status": "UNPLAYABLE", "reason": "This video is not available in your country" } }); let err = check_playability_status(&resp).unwrap_err(); match err { ExtractionError::ContentUnavailable(ContentUnavailable::GeoRestricted) => (), other => panic!("expected GeoRestricted, got {other:?}"), } } #[test] fn playability_paid_members() { let resp = json!({ "playabilityStatus": { "status": "UNPLAYABLE", "reason": "This video is available to this channel's members on level: Tier 1" } }); match check_playability_status(&resp).unwrap_err() { ExtractionError::ContentUnavailable(ContentUnavailable::Paid) => (), other => panic!("expected Paid, got {other:?}"), } } #[test] fn decoy_detected() { let resp = json!({"videoDetails": {"videoId": "DIFFERENT_ID"}}); assert!(is_player_response_not_valid(&resp, "REQUESTED_ID")); let resp = json!({"videoDetails": {"videoId": "MATCHING"}}); assert!(!is_player_response_not_valid(&resp, "MATCHING")); } #[test] fn cipher_string_parsed() { let s = "s=AAA%3D&sp=sig&url=https%3A%2F%2Fexample.com%2Fpath%3Fa%3D1"; let m = parse_cipher_string(s); assert_eq!(m.get("s").map(String::as_str), Some("AAA=")); assert_eq!(m.get("sp").map(String::as_str), Some("sig")); assert_eq!( m.get("url").map(String::as_str), Some("https://example.com/path?a=1") ); } #[test] fn manifest_pot_appended() { assert_eq!( append_pot_to_manifest("https://x/path", Some("tok")), "https://x/path?pot=tok&mpd_version=7" ); assert_eq!( append_pot_to_manifest("https://x/path?foo=bar", Some("tok")), "https://x/path?foo=bar&pot=tok&mpd_version=7" ); assert_eq!( append_pot_to_manifest("https://x/path", None), "https://x/path" ); } #[test] fn codec_extracted_from_mime() { let fmt = json!({"mimeType": "video/mp4; codecs=\"avc1.4d401f\""}); assert_eq!(codec_from_mime(&fmt).as_deref(), Some("avc1.4d401f")); let fmt = json!({"mimeType": "audio/mp4; codecs=\"mp4a.40.2\""}); assert_eq!(codec_from_mime(&fmt).as_deref(), Some("mp4a.40.2")); let fmt = json!({"mimeType": "video/webm"}); assert!(codec_from_mime(&fmt).is_none()); } #[test] fn dedup_by_itag_plus_delivery() { let mut list = vec![]; let s = VideoStream { itag: 137, url: "u1".into(), format: MediaFormat::Mpeg4, delivery: DeliveryMethod::Dash, resolution: "1080p".into(), fps: 30, bandwidth: None, codec: None, content_length_bytes: None, width: None, height: None, video_only: true, }; push_video_dedup(&mut list, s.clone()); push_video_dedup(&mut list, s.clone()); // duplicate assert_eq!(list.len(), 1); let mut s2 = s.clone(); s2.itag = 299; push_video_dedup(&mut list, s2); assert_eq!(list.len(), 2); } }