use std::collections::HashSet;
use std::time::Duration;
use serde_json::{Value, json};
use crate::youtube::model::{Thumbs, Video};
#[cfg(feature = "youtube-tv")]
const BROWSE_URL: &str = "https://www.youtube.com/youtubei/v1/browse?prettyPrint=false";
const PLAYER_URL: &str = "https://www.youtube.com/youtubei/v1/player?prettyPrint=false";
#[cfg(feature = "youtube-tv")]
const TV_CLIENT_NAME: &str = "TVHTML5";
#[cfg(feature = "youtube-tv")]
const TV_CLIENT_VERSION: &str = "7.20250923.13.00";
#[cfg(feature = "youtube-tv")]
const TV_CLIENT_ID: &str = "7";
#[cfg(feature = "youtube-tv")]
const TV_USER_AGENT: &str = "Mozilla/5.0 (ChromiumStylePlatform) Cobalt/25.lts.30.1034943-gold \
(unlike Gecko) Unknown_TV_Unknown_0/Unknown (Unknown, Unknown)";
#[cfg(feature = "youtube-tv")]
const RECOMMENDATIONS_BROWSE_ID: &str = "FEwhat_to_watch";
const TESTSUITE_PARAMS: &str = "2AMB";
fn agent() -> ureq::Agent {
ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(20)))
.build()
.into()
}
fn post_json(url: &str, body: &Value, headers: &[(&str, &str)]) -> Result<(u16, String), String> {
let payload = serde_json::to_vec(body).map_err(|e| e.to_string())?;
let mut request = agent().post(url).header("Content-Type", "application/json");
for (name, value) in headers {
request = request.header(*name, *value);
}
let mut resp = request.send(&payload).map_err(|e| format!("innertube request: {e}"))?;
let status = resp.status().as_u16();
let text = resp.body_mut().read_to_string().map_err(|e| format!("innertube read: {e}"))?;
Ok((status, text))
}
#[cfg(feature = "youtube-tv")]
pub fn recommendations(access_token: &str) -> Result<Vec<Video>, String> {
let body = json!({
"context": {
"client": {
"clientName": TV_CLIENT_NAME,
"clientVersion": TV_CLIENT_VERSION,
"deviceMake": "Samsung",
"deviceModel": "SmartTV",
"osName": "Tizen",
"osVersion": "4.0.0.2",
"platform": "TV",
"hl": "en",
"gl": "US",
"utcOffsetMinutes": 0,
},
"request": {"internalExperimentFlags": [], "useSsl": true},
"user": {"lockedSafetyMode": false},
},
"contentCheckOk": true,
"racyCheckOk": true,
"browseId": RECOMMENDATIONS_BROWSE_ID,
});
let auth = format!("Bearer {access_token}");
let (status, text) = post_json(
BROWSE_URL,
&body,
&[
("Authorization", &auth),
("User-Agent", TV_USER_AGENT),
("X-YouTube-Client-Name", TV_CLIENT_ID),
("X-YouTube-Client-Version", TV_CLIENT_VERSION),
],
)?;
if std::env::var("MEDIAS_YT_DEBUG").is_ok_and(|v| !v.is_empty())
&& let Some(home) = std::env::var_os("HOME")
{
let path = std::path::Path::new(&home).join("medias").join("innertube-browse.json");
let _ = std::fs::write(&path, &text);
eprintln!("innertube: HTTP {status}, {} bytes -> {}", text.len(), path.display());
}
let root: Value = serde_json::from_str(&text).map_err(|e| format!("innertube json: {e}"))?;
Ok(parse_recommendations(&root))
}
#[cfg(feature = "youtube-tv")]
fn parse_recommendations(root: &Value) -> Vec<Video> {
let mut videos = Vec::new();
let mut seen = HashSet::new();
let Some(shelves) = root
.pointer("/contents/tvBrowseRenderer/content/tvSurfaceContentRenderer/content/sectionListRenderer/contents")
.and_then(Value::as_array)
else {
return videos;
};
for shelf in shelves {
let items = shelf
.pointer("/shelfRenderer/content/horizontalListRenderer/items")
.or_else(|| shelf.pointer("/shelfRenderer/content/verticalListRenderer/items"))
.and_then(Value::as_array);
let Some(items) = items else { continue };
for item in items {
if let Some(v) = parse_tile(item)
&& seen.insert(v.id.clone())
{
videos.push(v);
}
}
}
videos
}
#[cfg(feature = "youtube-tv")]
fn parse_tile(item: &Value) -> Option<Video> {
let tile = item.get("tileRenderer")?;
let id = tile.pointer("/onSelectCommand/watchEndpoint/videoId")?.as_str()?.to_string();
if id.is_empty() {
return None;
}
let meta = tile.pointer("/metadata/tileMetadataRenderer");
let title = meta.and_then(|m| text_of(m.get("title"))).unwrap_or_default();
let channel = meta.and_then(channel_from_lines).unwrap_or_default();
let thumbs = thumbs_from(tile.pointer("/header/tileHeaderRenderer/thumbnail/thumbnails"));
Some(Video { id, title, channel, duration: 0, thumbs })
}
#[cfg(feature = "youtube-tv")]
fn channel_from_lines(meta: &Value) -> Option<String> {
let lines = meta.get("lines")?.as_array()?;
lines.iter().find_map(|line| {
let items = line.pointer("/lineRenderer/items")?.as_array()?;
items
.iter()
.find_map(|it| text_of(it.pointer("/lineItemRenderer/text")).filter(|t| !t.is_empty()))
})
}
fn text_of(node: Option<&Value>) -> Option<String> {
let node = node?;
if let Some(s) = node.get("simpleText").and_then(Value::as_str) {
return Some(s.to_string());
}
let runs = node.get("runs")?.as_array()?;
let text: String = runs.iter().filter_map(|r| r.get("text").and_then(Value::as_str)).collect();
(!text.is_empty()).then_some(text)
}
fn thumbs_from(node: Option<&Value>) -> Thumbs {
let mut thumbs = Thumbs::default();
let Some(urls) = node.and_then(Value::as_array) else {
return thumbs;
};
let urls: Vec<String> = urls
.iter()
.filter_map(|x| x.get("url").and_then(Value::as_str))
.map(normalize_url)
.collect();
if let Some(u) = urls.first() {
thumbs.default = u.clone();
}
if let Some(u) = urls.get(urls.len() / 2) {
thumbs.medium = u.clone();
}
if let Some(u) = urls.last() {
thumbs.high = u.clone();
}
thumbs
}
fn normalize_url(u: &str) -> String {
if let Some(rest) = u.strip_prefix("//") {
format!("https://{rest}")
} else {
u.to_string()
}
}
const NEXT_URL: &str = "https://www.youtube.com/youtubei/v1/next?prettyPrint=false";
const WEB_CLIENT_VERSION: &str = "2.20250925.01.00";
pub fn related(video_id: &str) -> Result<Vec<Video>, String> {
let body = json!({
"context": {"client": {"clientName": "WEB", "clientVersion": WEB_CLIENT_VERSION, "hl": "en", "gl": "US"}},
"videoId": video_id,
});
let headers = [("X-YouTube-Client-Name", "1"), ("X-YouTube-Client-Version", WEB_CLIENT_VERSION)];
let (status, text) = post_json(NEXT_URL, &body, &headers)?;
let root: Value = serde_json::from_str(&text).map_err(|e| format!("next json (HTTP {status}): {e}"))?;
Ok(parse_related(&root))
}
pub fn from_history(seeds: &[String], watched: &HashSet<String>) -> Vec<Video> {
let lists = std::thread::scope(|scope| {
let handles: Vec<_> = seeds.iter().map(|id| scope.spawn(move || related(id))).collect();
handles
.into_iter()
.map(|handle| match handle.join() {
Ok(Ok(videos)) => videos,
Ok(Err(error)) => {
eprintln!("youtube: related: {error}");
Vec::new()
}
Err(_) => Vec::new(),
})
.collect()
});
super::interleave(lists, watched)
}
fn parse_related(root: &Value) -> Vec<Video> {
let Some(results) = root
.pointer("/contents/twoColumnWatchNextResults/secondaryResults/secondaryResults/results")
.and_then(Value::as_array)
else {
return Vec::new();
};
results.iter().filter_map(|item| parse_lockup(item).or_else(|| parse_compact(item))).collect()
}
fn parse_lockup(item: &Value) -> Option<Video> {
let lockup = item.get("lockupViewModel")?;
if lockup.get("contentType").and_then(Value::as_str) != Some("LOCKUP_CONTENT_TYPE_VIDEO") {
return None;
}
let id = lockup.get("contentId")?.as_str()?.to_string();
let meta = lockup.pointer("/metadata/lockupMetadataViewModel")?;
let title = meta.pointer("/title/content").and_then(Value::as_str).unwrap_or_default().to_string();
let channel = meta
.pointer("/metadata/contentMetadataViewModel/metadataRows/0/metadataParts/0/text/content")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let image = lockup.pointer("/contentImage/thumbnailViewModel");
let duration = image
.and_then(|i| i.get("overlays"))
.and_then(Value::as_array)
.and_then(|overlays| {
overlays.iter().find_map(|o| {
o.pointer("/thumbnailBottomOverlayViewModel/badges/0/thumbnailBadgeViewModel/text")
.and_then(Value::as_str)
})
})
.map(parse_clock)
.unwrap_or(0);
let thumbs = thumbs_from(image.and_then(|i| i.pointer("/image/sources")));
Some(Video { id, title, channel, duration, thumbs })
}
fn parse_compact(item: &Value) -> Option<Video> {
let compact = item.get("compactVideoRenderer")?;
Some(Video {
id: compact.get("videoId")?.as_str()?.to_string(),
title: text_of(compact.get("title")).unwrap_or_default(),
channel: text_of(compact.get("longBylineText")).or_else(|| text_of(compact.get("shortBylineText")))
.unwrap_or_default(),
duration: text_of(compact.get("lengthText")).map(|t| parse_clock(&t)).unwrap_or(0),
thumbs: thumbs_from(compact.pointer("/thumbnail/thumbnails")),
})
}
fn parse_clock(text: &str) -> u32 {
let mut seconds = 0u32;
for part in text.trim().split(':') {
let Ok(n) = part.parse::<u32>() else { return 0 };
seconds = seconds * 60 + n;
}
seconds
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Streams {
pub video: String,
pub audio: Vec<Audio>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Audio {
pub url: String,
pub lang: String,
pub title: String,
}
#[derive(Debug, Clone)]
pub struct StreamPrefs {
pub max_height: u32,
pub codecs: Vec<String>,
pub audio_langs: Vec<String>,
}
impl StreamPrefs {
pub fn from_env() -> StreamPrefs {
let max_height = std::env::var("MEDIAS_YT_MAX_HEIGHT").ok().and_then(|v| v.parse().ok()).unwrap_or(1080);
let codecs = std::env::var("MEDIAS_YT_CODECS").unwrap_or_else(|_| "av01,vp9,avc1".into());
let langs = std::env::var("MEDIAS_YT_AUDIO_LANGS").unwrap_or_else(|_| locale_langs());
StreamPrefs { max_height, codecs: parse_codecs(&codecs), audio_langs: parse_langs(&langs) }
}
fn codec_rank(&self, codec: &str) -> Option<usize> {
self.codecs.iter().position(|c| c == codec)
}
}
fn parse_codecs(list: &str) -> Vec<String> {
list.split(',').map(|c| c.trim().to_ascii_lowercase()).filter(|c| !c.is_empty()).collect()
}
fn locale_langs() -> String {
let var = |name: &str| std::env::var(name).ok().filter(|v| !v.is_empty());
var("LANGUAGE").map(|v| v.replace(':', ","))
.or_else(|| var("LC_ALL")).or_else(|| var("LC_MESSAGES")).or_else(|| var("LANG"))
.unwrap_or_default()
}
fn parse_langs(list: &str) -> Vec<String> {
let mut langs: Vec<String> = Vec::new();
for item in list.split(',') {
let lang = primary_lang(item.trim());
if !lang.is_empty() && lang != "c" && lang != "posix" && !langs.contains(&lang) {
langs.push(lang);
}
}
langs
}
fn primary_lang(tag: &str) -> String {
tag.split(['_', '-', '.', '@']).next().unwrap_or("").to_ascii_lowercase()
}
struct PlayerClient {
name: &'static str,
id: &'static str,
version: &'static str,
user_agent: &'static str,
client: Value,
}
fn player_clients() -> [PlayerClient; 2] {
[
PlayerClient {
name: "ios",
id: "5",
version: "20.20.7",
user_agent: "com.google.ios.youtube/20.20.7 (iPhone16,2; U; CPU iOS 18_5_0 like Mac OS X)",
client: json!({
"clientName": "IOS", "clientVersion": "20.20.7", "deviceMake": "Apple",
"deviceModel": "iPhone16,2", "osName": "iOS", "osVersion": "18.5.0.22F76",
"platform": "MOBILE", "hl": "en", "gl": "US",
}),
},
PlayerClient {
name: "android",
id: "3",
version: "20.10.38",
user_agent: "com.google.android.youtube/20.10.38 (Linux; U; Android 15) gzip",
client: json!({
"clientName": "ANDROID", "clientVersion": "20.10.38", "androidSdkVersion": "32",
"osName": "Android", "osVersion": "15", "platform": "MOBILE", "hl": "en", "gl": "US",
}),
},
]
}
pub fn streams(video_id: &str, prefs: &StreamPrefs) -> Result<Streams, String> {
let mut errors = Vec::new();
for client in player_clients() {
let body = json!({
"context": {"client": client.client},
"videoId": video_id,
"params": TESTSUITE_PARAMS,
"contentCheckOk": true,
"racyCheckOk": true,
});
let headers = [
("User-Agent", client.user_agent),
("X-YouTube-Client-Name", client.id),
("X-YouTube-Client-Version", client.version),
("Origin", "https://www.youtube.com"),
];
let result = post_json(PLAYER_URL, &body, &headers)
.and_then(|(_, text)| serde_json::from_str::<Value>(&text).map_err(|e| format!("player json: {e}")))
.and_then(|root| select(&root, prefs));
match result {
Ok(streams) => return Ok(streams),
Err(error) => errors.push(format!("{}: {error}", client.name)),
}
}
Err(errors.join("; "))
}
pub fn select(root: &Value, prefs: &StreamPrefs) -> Result<Streams, String> {
let status = root.pointer("/playabilityStatus/status").and_then(Value::as_str).unwrap_or("");
if status != "OK" {
let reason = root.pointer("/playabilityStatus/reason").and_then(Value::as_str).unwrap_or("unplayable");
return Err(format!("{status}: {reason}"));
}
let data = root.get("streamingData").ok_or("no streamingData")?;
let hls = data.get("hlsManifestUrl").and_then(Value::as_str);
let live = root.pointer("/videoDetails/isLive").and_then(Value::as_bool).unwrap_or(false);
if live && let Some(hls) = hls {
return Ok(Streams { video: hls.to_string(), audio: Vec::new() });
}
let adaptive: Vec<&Value> =
data.get("adaptiveFormats").and_then(Value::as_array).map(|a| a.iter().filter(|f| playable(f)).collect())
.unwrap_or_default();
let video = adaptive
.iter()
.copied()
.filter(|f| mime(f).starts_with("video/") && !is_hdr(f))
.filter_map(|f| {
let height = f.get("height").and_then(Value::as_u64)? as u32;
let rank = prefs.codec_rank(&codec(f))?;
(height > 0 && height <= prefs.max_height).then_some((height, std::cmp::Reverse(rank), bitrate(f), f))
})
.max_by_key(|&(height, rank, rate, _)| (height, rank, rate))
.map(|(.., f)| f);
let audio = audio_tracks(&adaptive, &prefs.audio_langs);
if let Some(video) = video
&& !audio.is_empty()
{
return Ok(Streams { video: url(video)?, audio });
}
let muxed = data
.get("formats")
.and_then(Value::as_array)
.and_then(|a| a.iter().filter(|f| playable(f)).max_by_key(|f| f.get("height").and_then(Value::as_u64)));
if let Some(muxed) = muxed {
return Ok(Streams { video: url(muxed)?, audio: Vec::new() });
}
if let Some(hls) = hls {
return Ok(Streams { video: hls.to_string(), audio: Vec::new() });
}
Err("no playable stream URLs".into())
}
fn audio_content(format: &Value) -> Option<String> {
let query = format.get("url")?.as_str()?.split_once('?')?.1;
let xtags = query.split('&').find_map(|kv| kv.strip_prefix("xtags="))?;
let xtags = xtags.replace("%3D", "=").replace("%3A", ":").replace("%3d", "=").replace("%3a", ":");
xtags.split(':').find_map(|tag| tag.strip_prefix("acont=")).map(str::to_string)
}
fn audio_tracks(adaptive: &[&Value], langs: &[String]) -> Vec<Audio> {
let mut best: Vec<(String, &Value)> = Vec::new();
for &f in adaptive.iter().filter(|f| mime(f).starts_with("audio/")) {
let id = f.pointer("/audioTrack/id").and_then(Value::as_str).unwrap_or("").to_string();
match best.iter_mut().find(|(k, _)| *k == id) {
Some((_, b)) if (!is_drc(f), bitrate(f)) > (!is_drc(b), bitrate(b)) => *b = f,
Some(_) => {}
None => best.push((id, f)),
}
}
let tagged = best.iter().any(|(_, f)| audio_content(f).as_deref() == Some("original"));
let original = |f: &Value| {
best.len() == 1 || if tagged { audio_content(f).as_deref() == Some("original") } else { is_default_track(f) }
};
let mut tracks: Vec<(usize, Audio)> = best
.iter()
.filter_map(|&(ref id, f)| {
let lang = id.split('.').next().unwrap_or("").to_string();
let rank = if original(f) { 0 } else { 1 + langs.iter().position(|l| *l == primary_lang(&lang))? };
let name = f.pointer("/audioTrack/displayName").and_then(Value::as_str).unwrap_or("");
let name = name.trim_end_matches(" original").trim();
let kind = match audio_content(f).as_deref() {
Some("dubbed-auto") => "auto-dubbed".to_string(),
Some(kind) => kind.to_string(),
None if rank == 0 => "original".to_string(),
None => String::new(),
};
let title = if name.is_empty() {
if rank == 0 { "Original".to_string() } else { kind }
} else if kind.is_empty() || name.to_ascii_lowercase().contains(&kind) {
name.to_string()
} else {
format!("{name} ({kind})")
};
Some((rank, Audio { url: url(f).ok()?, lang, title }))
})
.collect();
tracks.sort_by_key(|(rank, _)| *rank);
tracks.into_iter().map(|(_, audio)| audio).collect()
}
fn playable(format: &Value) -> bool {
format.get("url").and_then(Value::as_str).is_some()
&& format.get("type").and_then(Value::as_str) != Some("FORMAT_STREAM_TYPE_OTF")
}
fn url(format: &Value) -> Result<String, String> {
format.get("url").and_then(Value::as_str).map(str::to_string).ok_or_else(|| "format without url".into())
}
fn mime(format: &Value) -> &str {
format.get("mimeType").and_then(Value::as_str).unwrap_or("")
}
fn bitrate(format: &Value) -> u64 {
format.get("bitrate").and_then(Value::as_u64).unwrap_or(0)
}
fn codec(format: &Value) -> String {
let codecs = mime(format).split("codecs=\"").nth(1).unwrap_or("");
let family = codecs.split([',', '"', '.']).next().unwrap_or("").to_ascii_lowercase();
if family == "vp09" { "vp9".into() } else { family }
}
fn is_hdr(format: &Value) -> bool {
format
.pointer("/colorInfo/transferCharacteristics")
.and_then(Value::as_str)
.is_some_and(|t| t.contains("2084") || t.contains("B67"))
}
fn is_default_track(format: &Value) -> bool {
match format.get("audioTrack") {
Some(track) => track.get("audioIsDefault").and_then(Value::as_bool).unwrap_or(false),
None => true,
}
}
fn is_drc(format: &Value) -> bool {
format.get("isDrc").and_then(Value::as_bool).unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "youtube-tv")]
#[test]
fn parses_tiles_from_a_tv_shelf_response() {
let body = r#"{"contents":{"tvBrowseRenderer":{"content":{"tvSurfaceContentRenderer":
{"content":{"sectionListRenderer":{"contents":[
{"shelfRenderer":{"content":{"horizontalListRenderer":{"items":[
{"tileRenderer":{
"onSelectCommand":{"watchEndpoint":{"videoId":"vid123"}},
"metadata":{"tileMetadataRenderer":{
"title":{"simpleText":"Cool Video"},
"lines":[{"lineRenderer":{"items":[{"lineItemRenderer":
{"text":{"runs":[{"text":"Some Channel"}]}}}]}}]}},
"header":{"tileHeaderRenderer":{"thumbnail":{"thumbnails":[
{"url":"//i.ytimg.com/s.jpg","width":320,"height":180}]}}}}},
{"tileRenderer":{"onSelectCommand":{"watchEndpoint":{"videoId":"vid123"}},
"metadata":{"tileMetadataRenderer":{"title":{"simpleText":"Dup"}}}}}
]}}}}
]}}}}}}}"#;
let videos = parse_recommendations(&serde_json::from_str(body).unwrap());
assert_eq!(videos.len(), 1); let v = &videos[0];
assert_eq!((v.id.as_str(), v.title.as_str(), v.channel.as_str()), ("vid123", "Cool Video", "Some Channel"));
assert_eq!(v.thumbs.high, "https://i.ytimg.com/s.jpg");
}
#[cfg(feature = "youtube-tv")]
#[test]
fn missing_feed_yields_no_videos() {
assert!(parse_recommendations(&json!({"contents": {}})).is_empty());
}
#[test]
#[ignore]
fn history_feed_returns_unwatched_related_videos() {
let seeds = ["V_uq3v6xK-4".to_string(), "WXELsIk4o0g".to_string()];
let watched: HashSet<String> = seeds.iter().cloned().collect();
let videos = from_history(&seeds, &watched);
assert!(videos.len() >= 10, "only {} videos", videos.len());
assert!(videos.iter().all(|v| !watched.contains(&v.id) && v.id.len() == 11 && !v.title.is_empty()));
let unique: HashSet<&str> = videos.iter().map(|v| v.id.as_str()).collect();
assert_eq!(unique.len(), videos.len());
}
#[test]
fn parses_related_lockups_and_compact_renderers() {
let body = json!({"contents": {"twoColumnWatchNextResults": {"secondaryResults": {"secondaryResults": {"results": [
{"lockupViewModel": {"contentType": "LOCKUP_CONTENT_TYPE_VIDEO", "contentId": "fsLh-NYhOoU",
"metadata": {"lockupMetadataViewModel": {"title": {"content": "Formula"},
"metadata": {"contentMetadataViewModel": {"metadataRows": [
{"metadataParts": [{"text": {"content": "3Blue1Brown"}}]},
{"metadataParts": [{"text": {"content": "1.5M"}}]}]}}}},
"contentImage": {"thumbnailViewModel": {
"image": {"sources": [{"url": "https://i.ytimg.com/a.jpg"}, {"url": "https://i.ytimg.com/b.jpg"}]},
"overlays": [{"thumbnailBottomOverlayViewModel": {"badges": [
{"thumbnailBadgeViewModel": {"text": "1:00:24"}}]}}]}}}},
{"lockupViewModel": {"contentType": "LOCKUP_CONTENT_TYPE_PLAYLIST", "contentId": "RDxyz"}},
{"compactVideoRenderer": {"videoId": "abcdefghijk", "title": {"simpleText": "Old"},
"longBylineText": {"runs": [{"text": "Chan"}]}, "lengthText": {"simpleText": "12:34"}}},
{"continuationItemRenderer": {}}
]}}}}});
let videos = parse_related(&body);
assert_eq!(videos.len(), 2); assert_eq!((videos[0].id.as_str(), videos[0].title.as_str(), videos[0].channel.as_str()),
("fsLh-NYhOoU", "Formula", "3Blue1Brown"));
assert_eq!((videos[0].duration, videos[0].thumbs.high.as_str()), (3624, "https://i.ytimg.com/b.jpg"));
assert_eq!((videos[1].id.as_str(), videos[1].channel.as_str(), videos[1].duration), ("abcdefghijk", "Chan", 754));
assert_eq!(parse_clock("LIVE"), 0);
}
fn prefs(max_height: u32, codecs: &str) -> StreamPrefs {
StreamPrefs { max_height, codecs: parse_codecs(codecs), audio_langs: Vec::new() }
}
fn player(adaptive: Value) -> Value {
json!({"playabilityStatus": {"status": "OK"}, "streamingData": {"adaptiveFormats": adaptive,
"formats": [{"itag": 18, "url": "muxed", "mimeType": "video/mp4; codecs=\"avc1.42001E, mp4a.40.2\"", "height": 360}],
"hlsManifestUrl": "hls"}})
}
fn sample() -> Value {
player(json!([
{"url": "av1-2160", "mimeType": "video/mp4; codecs=\"av01.0.12M.08\"", "height": 2160, "bitrate": 9},
{"url": "av1-1080", "mimeType": "video/mp4; codecs=\"av01.0.08M.08\"", "height": 1080, "bitrate": 5},
{"url": "vp9-1080", "mimeType": "video/webm; codecs=\"vp9\"", "height": 1080, "bitrate": 6},
{"url": "avc-720", "mimeType": "video/mp4; codecs=\"avc1.4d401f\"", "height": 720, "bitrate": 3},
{"url": "hdr-1080", "mimeType": "video/webm; codecs=\"vp09.02.51.10\"", "height": 1080, "bitrate": 8,
"colorInfo": {"transferCharacteristics": "COLOR_TRANSFER_CHARACTERISTICS_SMPTEST2084"}},
{"url": "otf-1080", "mimeType": "video/mp4; codecs=\"av01.0.08M.08\"", "height": 1080, "bitrate": 9,
"type": "FORMAT_STREAM_TYPE_OTF"},
{"signatureCipher": "s=x&url=y", "mimeType": "video/mp4; codecs=\"av01.0.08M.08\"", "height": 1080},
{"url": "de?xtags=acont%3Ddubbed%3Alang%3Dde", "mimeType": "audio/webm; codecs=\"opus\"", "bitrate": 200,
"audioTrack": {"id": "de.3", "displayName": "German", "audioIsDefault": true}},
{"url": "fr?xtags=acont%3Ddubbed-auto%3Alang%3Dfr", "mimeType": "audio/webm; codecs=\"opus\"", "bitrate": 200,
"audioTrack": {"id": "fr.3", "displayName": "French"}},
{"url": "drc?xtags=acont%3Doriginal%3Adrc%3D1%3Alang%3Den-US", "mimeType": "audio/webm; codecs=\"opus\"",
"bitrate": 180, "isDrc": true, "audioTrack": {"id": "en-US.4", "displayName": "English (US) original"}},
{"url": "opus?xtags=acont%3Doriginal%3Alang%3Den-US", "mimeType": "audio/webm; codecs=\"opus\"", "bitrate": 160,
"audioTrack": {"id": "en-US.4", "displayName": "English (US) original"}},
{"url": "aac?xtags=acont%3Doriginal%3Alang%3Den-US", "mimeType": "audio/mp4; codecs=\"mp4a.40.2\"", "bitrate": 128,
"audioTrack": {"id": "en-US.4", "displayName": "English (US) original"}}
]))
}
fn audio(url: &str, lang: &str, title: &str) -> Audio {
Audio { url: url.into(), lang: lang.into(), title: title.into() }
}
fn original() -> Audio {
audio("opus?xtags=acont%3Doriginal%3Alang%3Den-US", "en-US", "English (US) (original)")
}
#[test]
fn selects_highest_allowed_sdr_video_with_default_audio() {
let s = select(&sample(), &prefs(1080, "av01,vp9,avc1")).unwrap();
assert_eq!(s, Streams { video: "av1-1080".into(), audio: vec![original()] });
assert_eq!(select(&sample(), &prefs(2160, "av01,vp9,avc1")).unwrap().video, "av1-2160");
}
#[test]
fn codec_list_restricts_and_orders_video() {
assert_eq!(select(&sample(), &prefs(1080, "vp9,av01")).unwrap().video, "vp9-1080");
assert_eq!(select(&sample(), &prefs(1080, "avc1")).unwrap().video, "avc-720");
assert_eq!(select(&sample(), &prefs(1080, "hev1")).unwrap(), Streams { video: "muxed".into(), audio: Vec::new() });
}
#[test]
fn dubs_in_preferred_languages_follow_the_original() {
let mut p = prefs(1080, "av01");
p.audio_langs = parse_langs("fr_FR.UTF-8,de-AT,C");
assert_eq!(p.audio_langs, ["fr", "de"]);
assert_eq!(select(&sample(), &p).unwrap().audio, [
original(),
audio("fr?xtags=acont%3Ddubbed-auto%3Alang%3Dfr", "fr", "French (auto-dubbed)"),
audio("de?xtags=acont%3Ddubbed%3Alang%3Dde", "de", "German (dubbed)"),
]);
}
#[test]
#[ignore]
fn german_original_wins_over_the_english_auto_dub() {
let mut p = prefs(1080, "av01,vp9,avc1");
let titles = |p: &StreamPrefs| -> Vec<String> {
streams("siOlmWVhefE", p).unwrap().audio.into_iter().map(|a| a.title).collect()
};
assert_eq!(titles(&p), ["German (DE) (original)"]);
p.audio_langs = vec!["en".into()];
assert_eq!(titles(&p), ["German (DE) (original)", "English (US) (auto-dubbed)"]);
}
#[test]
fn untagged_tracks_fall_back_to_the_default_flag() {
let tagged = [json!({"url": "a", "mimeType": "audio/mp4", "audioTrack": {"id": "de.4", "displayName": "Deutsch", "audioIsDefault": true}}),
json!({"url": "b", "mimeType": "audio/mp4", "audioTrack": {"id": "en.3", "displayName": "Englisch"}})];
let refs: Vec<&Value> = tagged.iter().collect();
assert_eq!(audio_tracks(&refs, &[]), [audio("a", "de", "Deutsch (original)")]);
let single = json!({"url": "c", "mimeType": "audio/mp4"});
assert_eq!(audio_tracks(&[&single], &[]), [audio("c", "", "Original")]);
}
#[test]
fn live_uses_hls_and_unplayable_reports_the_reason() {
let mut live = sample();
live["videoDetails"] = json!({"isLive": true});
assert_eq!(select(&live, &prefs(1080, "av01")).unwrap().video, "hls");
let blocked = json!({"playabilityStatus": {"status": "LOGIN_REQUIRED", "reason": "Sign in"}});
assert_eq!(select(&blocked, &prefs(1080, "av01")).unwrap_err(), "LOGIN_REQUIRED: Sign in");
}
}