use std::collections::HashSet;
use std::time::Duration;
use serde_json::{Value, json};
use crate::youtube::model::{Thumbs, Video};
#[cfg(feature = "youtube-tv")]
use crate::youtube::model::Playlist;
#[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 const FEED_TARGET: usize = 36;
#[cfg(feature = "youtube-tv")]
pub const LIST_TARGET: usize = 200;
#[cfg(feature = "youtube-tv")]
const MAX_REQUESTS: usize = 8;
#[cfg(feature = "youtube-tv")]
pub const HISTORY_BROWSE_ID: &str = "FEhistory";
#[cfg(feature = "youtube-tv")]
const PLAYLISTS_BROWSE_ID: &str = "FEplaylist_aggregation";
#[cfg(feature = "youtube-tv")]
const SURFACE: &str = "/contents/tvBrowseRenderer/content/tvSurfaceContentRenderer/content";
#[cfg(feature = "youtube-tv")]
fn tv_browse(access_token: &str, request: Value, name: &str, n: usize) -> Result<Value, String> {
let mut 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,
});
if let (Some(body), Some(request)) = (body.as_object_mut(), request.as_object()) {
body.extend(request.clone());
}
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 file = if n == 0 { format!("innertube-{name}.json") } else { format!("innertube-{name}-{n}.json") };
let path = std::path::Path::new(&home).join("medias").join(file);
let _ = std::fs::write(&path, &text);
eprintln!("innertube: HTTP {status}, {} bytes -> {}", text.len(), path.display());
}
serde_json::from_str(&text).map_err(|e| format!("innertube json (HTTP {status}): {e}"))
}
#[cfg(feature = "youtube-tv")]
pub fn recommendations(access_token: &str) -> Result<Vec<Video>, String> {
Ok(tv_list(access_token, RECOMMENDATIONS_BROWSE_ID, FEED_TARGET)?.videos)
}
#[cfg(feature = "youtube-tv")]
pub fn tv_videos(access_token: &str, browse_id: &str) -> Result<Vec<Video>, String> {
Ok(tv_list(access_token, browse_id, LIST_TARGET)?.videos)
}
#[cfg(feature = "youtube-tv")]
pub fn tv_playlists(access_token: &str) -> Result<Vec<Playlist>, String> {
Ok(tv_list(access_token, PLAYLISTS_BROWSE_ID, LIST_TARGET)?.playlists)
}
#[cfg(feature = "youtube-tv")]
fn tv_list(access_token: &str, browse_id: &str, target: usize) -> Result<Feed, String> {
let root = tv_browse(access_token, json!({"browseId": browse_id}), browse_id, 0)?;
let mut feed = Feed::default();
let mut next = feed.add_page(&root);
let mut requests = 1;
while feed.len() < target && requests < MAX_REQUESTS {
let Some(token) = next.take() else { break };
match tv_browse(access_token, json!({"continuation": token}), browse_id, requests) {
Ok(page) => next = feed.add_page(&page),
Err(error) => eprintln!("innertube: {browse_id} continuation: {error}"),
}
requests += 1;
}
if feed.len() < target {
let tokens: Vec<String> = feed.shelf_tokens.drain(..).take(MAX_REQUESTS.saturating_sub(requests)).collect();
let pages: Vec<_> = std::thread::scope(|scope| {
let handles: Vec<_> = tokens
.iter()
.enumerate()
.map(|(k, t)| {
scope.spawn(move || tv_browse(access_token, json!({"continuation": t}), browse_id, requests + k))
})
.collect();
handles.into_iter().map(|h| h.join().unwrap_or_else(|_| Err("panic".into()))).collect()
});
for page in pages {
match page {
Ok(page) => {
feed.add_page(&page);
}
Err(error) => eprintln!("innertube: {browse_id} shelf continuation: {error}"),
}
}
}
feed.videos.truncate(target);
feed.playlists.truncate(target);
Ok(feed)
}
#[cfg(feature = "youtube-tv")]
#[derive(Default)]
struct Feed {
videos: Vec<Video>,
playlists: Vec<Playlist>,
seen: HashSet<String>,
shelf_tokens: Vec<String>,
}
#[cfg(feature = "youtube-tv")]
impl Feed {
fn len(&self) -> usize {
self.videos.len() + self.playlists.len()
}
fn add_page(&mut self, root: &Value) -> Option<String> {
if let Some(surface) = root.pointer(SURFACE) {
if let Some(list) = surface.get("sectionListRenderer") {
return self.add_sections(list, "contents");
}
let node = surface
.get("gridRenderer")
.or_else(|| surface.pointer("/twoColumnRenderer/rightColumn/playlistVideoListRenderer"))?;
return self.add_list(node);
}
let contents = root.get("continuationContents")?;
if let Some(list) = contents.get("sectionListContinuation") {
return self.add_sections(list, "contents");
}
["gridContinuation", "playlistVideoListContinuation", "horizontalListContinuation"]
.iter()
.find_map(|key| contents.get(*key))
.and_then(|node| self.add_list(node))
}
fn add_list(&mut self, node: &Value) -> Option<String> {
if let Some(items) = node.get("items").or_else(|| node.get("contents")).and_then(Value::as_array) {
self.add_tiles(items);
}
continuation(node)
}
fn add_sections(&mut self, list: &Value, key: &str) -> Option<String> {
for shelf in list.get(key).and_then(Value::as_array).into_iter().flatten() {
let Some(content) = shelf
.pointer("/shelfRenderer/content/horizontalListRenderer")
.or_else(|| shelf.pointer("/shelfRenderer/content/verticalListRenderer"))
.or_else(|| shelf.pointer("/shelfRenderer/content/gridRenderer"))
else {
continue;
};
if let Some(token) = self.add_list(content) {
self.shelf_tokens.push(token);
}
}
continuation(list)
}
fn add_tiles(&mut self, items: &[Value]) {
for item in items {
if let Some(p) = parse_playlist_tile(item) {
if self.seen.insert(p.id.clone()) {
self.playlists.push(p);
}
} else if let Some(v) = parse_tile(item)
&& self.seen.insert(v.id.clone())
{
self.videos.push(v);
}
}
}
}
#[cfg(feature = "youtube-tv")]
fn parse_playlist_tile(item: &Value) -> Option<Playlist> {
let tile = item.get("tileRenderer")?;
if tile.get("contentType").and_then(Value::as_str) != Some("TILE_CONTENT_TYPE_PLAYLIST") {
return None;
}
let id = tile
.get("contentId")
.and_then(Value::as_str)
.or_else(|| {
tile.pointer("/onSelectCommand/browseEndpoint/browseId").and_then(Value::as_str).map(|b| b.strip_prefix("VL").unwrap_or(b))
})
.or_else(|| tile.pointer("/onSelectCommand/watchEndpoint/playlistId").and_then(Value::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 mut texts = meta
.and_then(|m| m.get("lines"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|l| l.pointer("/lineRenderer/items").and_then(Value::as_array))
.flatten()
.filter_map(|it| text_of(it.pointer("/lineItemRenderer/text")))
.chain(
tile.pointer("/header/tileHeaderRenderer/thumbnailOverlays")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|o| text_of(o.pointer("/thumbnailOverlayTimeStatusRenderer/text"))),
);
let count = texts
.find(|t| t.contains("video"))
.and_then(|t| t.split_whitespace().next().map(|n| n.replace(',', "")))
.and_then(|n| n.parse().ok())
.unwrap_or(0);
let thumbs = thumbs_from(tile.pointer("/header/tileHeaderRenderer/thumbnail/thumbnails"));
Some(Playlist { id, title, count, thumbs })
}
#[cfg(feature = "youtube-tv")]
fn continuation(node: &Value) -> Option<String> {
node.pointer("/continuations/0/nextContinuationData/continuation").and_then(Value::as_str).map(str::to_string)
}
#[cfg(all(feature = "youtube-tv", test))]
fn parse_recommendations(root: &Value) -> Vec<Video> {
let mut feed = Feed::default();
feed.add_page(root);
feed.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 parts: Vec<String> = meta
.and_then(|m| m.pointer("/lines/1/lineRenderer/items"))
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|it| text_of(it.pointer("/lineItemRenderer/text")))
.collect();
let (views, age) = views_and_age(&parts);
let header = tile.pointer("/header/tileHeaderRenderer");
let duration = header
.and_then(|h| h.get("thumbnailOverlays"))
.and_then(Value::as_array)
.and_then(|o| o.iter().find_map(|o| text_of(o.pointer("/thumbnailOverlayTimeStatusRenderer/text"))))
.map(|t| parse_clock(&t))
.unwrap_or(0);
let thumbs = thumbs_from(header.and_then(|h| h.pointer("/thumbnail/thumbnails")));
Some(Video { id, title, channel, duration, thumbs, views, age })
}
#[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")));
let parts: Vec<String> = meta
.pointer("/metadata/contentMetadataViewModel/metadataRows/1/metadataParts")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(|p| p.pointer("/text/content").and_then(Value::as_str).map(str::to_string))
.collect();
let (views, age) = views_and_age(&parts);
Some(Video { id, title, channel, duration, thumbs, views, age })
}
fn views_and_age(parts: &[String]) -> (String, String) {
let is_age = |p: &str| p.ends_with(" ago");
let is_views = |p: &str| {
!is_age(p) && (p.contains("view") || p.contains("watching") || p.starts_with(|c: char| c.is_ascii_digit()))
};
let views = match parts.iter().find(|p| is_views(p)) {
Some(p) if p.starts_with(|c: char| c.is_ascii_digit()) && !p.contains(' ') => format!("{p} views"),
Some(p) => p.clone(),
None => String::new(),
};
let age = parts
.iter()
.rev()
.find(|p| !is_views(p) && !p.trim().is_empty() && p.trim() != "•")
.map(|p| long_age(p))
.unwrap_or_default();
(views, age)
}
fn long_age(text: &str) -> String {
let Some(short) = text.strip_suffix(" ago") else { return text.to_string() };
let digits = short.find(|c: char| !c.is_ascii_digit()).unwrap_or(short.len());
let (n, unit) = short.split_at(digits);
let unit = match unit {
"s" => "second",
"m" | "min" => "minute",
"h" => "hour",
"d" => "day",
"w" => "week",
"mo" => "month",
"y" => "year",
_ => return text.to_string(),
};
let Ok(n) = n.parse::<u64>() else { return text.to_string() };
format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" })
}
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")),
views: text_of(compact.get("shortViewCountText")).or_else(|| text_of(compact.get("viewCountText")))
.unwrap_or_default(),
age: text_of(compact.get("publishedTimeText")).unwrap_or_default(),
})
}
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
}
#[cfg(feature = "youtube-tv")]
pub fn tracking_urls(access_token: &str, video_id: &str, cpn: &str) -> Result<(String, String), 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,
}},
"videoId": video_id,
"contentCheckOk": true,
"racyCheckOk": true,
});
let auth = format!("Bearer {access_token}");
let (status, text) = post_json(
PLAYER_URL,
&body,
&[
("Authorization", &auth),
("User-Agent", TV_USER_AGENT),
("X-YouTube-Client-Name", TV_CLIENT_ID),
("X-YouTube-Client-Version", TV_CLIENT_VERSION),
],
)?;
let root: Value = serde_json::from_str(&text).map_err(|e| format!("player json (HTTP {status}): {e}"))?;
let url = |key: &str| {
root.pointer(&format!("/playbackTracking/{key}/baseUrl"))
.and_then(Value::as_str)
.filter(|u| u.starts_with("http"))
.map(|u| format!("{u}&cpn={cpn}"))
};
match (url("videostatsPlaybackUrl"), url("videostatsWatchtimeUrl")) {
(Some(playback), Some(watchtime)) => Ok((playback, watchtime)),
_ => Err(format!("no playbackTracking in player response (HTTP {status})")),
}
}
#[cfg(feature = "youtube-tv")]
pub fn ping(url: &str, access_token: &str, referer: &str) -> Result<u16, String> {
let resp = agent()
.get(url)
.header("Authorization", &format!("Bearer {access_token}"))
.header("Referer", referer)
.header("User-Agent", TV_USER_AGENT)
.call()
.map_err(|e| format!("tracking request: {e}"))?;
Ok(resp.status().as_u16())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Streams {
pub video: String,
pub audio: Vec<Track>,
pub subs: Vec<Track>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Track {
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>,
pub sub_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 audio_langs = std::env::var("MEDIAS_YT_AUDIO_LANGS").unwrap_or_else(|_| locale_langs());
let sub_langs = std::env::var("MEDIAS_YT_SUB_LANGS").unwrap_or_else(|_| audio_langs.clone());
StreamPrefs {
max_height,
codecs: parse_codecs(&codecs),
audio_langs: parse_langs(&audio_langs),
sub_langs: parse_langs(&sub_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);
let audio = audio_tracks(&adaptive_formats(data), &prefs.audio_langs);
let subs = subtitle_tracks(root, &prefs.sub_langs, audio.first().map(|a| a.lang.as_str()));
if live && let Some(hls) = hls {
return Ok(Streams { video: hls.to_string(), audio: Vec::new(), subs });
}
let video = adaptive_formats(data)
.into_iter()
.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);
if let Some(video) = video
&& !audio.is_empty()
{
return Ok(Streams { video: url(video)?, audio, subs });
}
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(), subs });
}
if let Some(hls) = hls {
return Ok(Streams { video: hls.to_string(), audio: Vec::new(), subs });
}
Err("no playable stream URLs".into())
}
fn adaptive_formats(data: &Value) -> Vec<&Value> {
data.get("adaptiveFormats").and_then(Value::as_array).map(|a| a.iter().filter(|f| playable(f)).collect())
.unwrap_or_default()
}
fn subtitle_tracks(root: &Value, langs: &[String], original: Option<&str>) -> Vec<Track> {
let Some(captions) = root.pointer("/captions/playerCaptionsTracklistRenderer/captionTracks").and_then(Value::as_array)
else {
return Vec::new();
};
let lang = |c: &Value| c.get("languageCode").and_then(Value::as_str).unwrap_or("").to_string();
let asr = |c: &Value| c.get("kind").and_then(Value::as_str) == Some("asr");
let spoken = captions.iter().find(|c| asr(c)).map(lang).or(original.map(str::to_string)).map(|l| primary_lang(&l));
let manual: Vec<String> = captions.iter().filter(|c| !asr(c)).map(|c| primary_lang(&lang(c))).collect();
let mut tracks: Vec<(usize, bool, Track)> = captions
.iter()
.filter_map(|c| {
let (lang, asr) = (lang(c), asr(c));
let primary = primary_lang(&lang);
if asr && manual.contains(&primary) {
return None;
}
let rank = match langs.iter().position(|l| *l == primary) {
Some(rank) => rank,
None if spoken.as_deref() == Some(primary.as_str()) => langs.len(),
None => return None,
};
let title = c.pointer("/name/simpleText").or_else(|| c.pointer("/name/runs/0/text"))
.and_then(Value::as_str).unwrap_or(&lang).to_string();
let url = format!("{}&fmt=vtt", c.get("baseUrl")?.as_str()?);
Some((rank, asr, Track { url, lang, title }))
})
.collect();
tracks.sort_by_key(|(rank, asr, _)| (*rank, *asr));
tracks.into_iter().map(|(.., track)| track).collect()
}
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<Track> {
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, Track)> = 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, Track { 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"}]}}}]}},
{"lineRenderer":{"items":[{"lineItemRenderer":{"badge":{}}},
{"lineItemRenderer":{"text":{"simpleText":"93K views"}}},
{"lineItemRenderer":{"text":{"simpleText":"•"}}},
{"lineItemRenderer":{"text":{"simpleText":"4 hours ago"}}}]}}]}},
"header":{"tileHeaderRenderer":{"thumbnail":{"thumbnails":[
{"url":"//i.ytimg.com/s.jpg","width":320,"height":180}]},
"thumbnailOverlays":[{"thumbnailOverlayTimeStatusRenderer":{"text":{"simpleText":"27:13"}}}]}}}},
{"tileRenderer":{"onSelectCommand":{"watchEndpoint":{"videoId":"vid123"}},
"metadata":{"tileMetadataRenderer":{"title":{"simpleText":"Dup"}}}}}
],"continuations":[{"nextContinuationData":{"continuation":"shelf1"}}]}}}}
],"continuations":[{"nextContinuationData":{"continuation":"list1"}}]}}}}}}}"#;
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");
assert_eq!((v.views.as_str(), v.age.as_str(), v.duration), ("93K views", "4 hours ago", 1633));
let root: Value = serde_json::from_str(body).unwrap();
let mut feed = Feed::default();
let next = feed.add_page(&root);
assert_eq!((next.as_deref(), feed.shelf_tokens.as_slice()), (Some("list1"), &["shelf1".to_string()][..]));
}
#[cfg(feature = "youtube-tv")]
#[test]
fn parses_grid_playlists_and_playlist_continuations() {
let grid = json!({"contents": {"tvBrowseRenderer": {"content": {"tvSurfaceContentRenderer": {"content": {
"gridRenderer": {"items": [
{"tileRenderer": {"contentType": "TILE_CONTENT_TYPE_PLAYLIST", "contentId": "PLabc",
"onSelectCommand": {"watchEndpoint": {"videoId": "v1", "playlistId": "PLabc"}},
"metadata": {"tileMetadataRenderer": {"title": {"simpleText": "Music"},
"lines": [{"lineRenderer": {"items": [{"lineItemRenderer": {"text": {"simpleText": "1,204 videos"}}}]}}]}}}},
{"tileRenderer": {"contentType": "TILE_CONTENT_TYPE_PLAYLIST",
"onSelectCommand": {"browseEndpoint": {"browseId": "VLWL"}},
"metadata": {"tileMetadataRenderer": {"title": {"simpleText": "Watch later"}}}}}],
"continuations": [{"nextContinuationData": {"continuation": "g2"}}]}}}}}}});
let mut feed = Feed::default();
assert_eq!(feed.add_page(&grid).as_deref(), Some("g2"));
assert!(feed.videos.is_empty());
let got: Vec<_> = feed.playlists.iter().map(|p| (p.id.as_str(), p.title.as_str(), p.count)).collect();
assert_eq!(got, [("PLabc", "Music", 1204), ("WL", "Watch later", 0)]);
let cont = json!({"continuationContents": {"playlistVideoListContinuation": {"contents": [
{"tileRenderer": {"contentType": "TILE_CONTENT_TYPE_VIDEO",
"onSelectCommand": {"watchEndpoint": {"videoId": "abc", "playlistId": "PLabc"}},
"metadata": {"tileMetadataRenderer": {"title": {"simpleText": "Song"}}}}}]}}});
assert_eq!(feed.add_page(&cont), None);
assert_eq!(feed.videos[0].id, "abc");
}
#[cfg(feature = "youtube-tv")]
#[test]
#[ignore]
fn parses_captured_feed() {
let path = std::env::var("MEDIAS_FEED_JSON").unwrap();
let root: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap();
let videos = parse_recommendations(&root);
assert!(videos.len() >= 10);
assert!(videos.iter().all(|v| !v.age.is_empty() && !v.views.is_empty() && v.duration > 0), "{videos:#?}");
}
#[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 dated = videos.iter().filter(|v| !v.age.is_empty() && !v.views.is_empty()).count();
assert!(dated * 2 >= videos.len(), "{dated} of {} dated: {:?}", videos.len(), videos.first());
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"}}, {"text": {"content": "1y ago"}}]}]}}}},
"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"},
"shortViewCountText": {"simpleText": "5K views"}, "publishedTimeText": {"simpleText": "3 days ago"}}},
{"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!((videos[0].views.as_str(), videos[0].age.as_str()), ("1.5M views", "1 year ago"));
assert_eq!(long_age("8mo ago"), "8 months ago");
assert_eq!(long_age("Streamed 5 hours ago"), "Streamed 5 hours ago");
assert_eq!((videos[1].views.as_str(), videos[1].age.as_str()), ("5K views", "3 days ago"));
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(), sub_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) -> Track {
Track { url: url.into(), lang: lang.into(), title: title.into() }
}
fn original() -> Track {
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()], subs: Vec::new() });
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(), subs: 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]
fn captions_in_preferred_and_spoken_languages() {
let mut root = sample();
let cap = |lang: &str, kind: Option<&str>, name: &str| {
json!({"baseUrl": format!("t?lang={lang}"), "languageCode": lang, "kind": kind, "name": {"runs": [{"text": name}]}})
};
root["captions"] = json!({"playerCaptionsTracklistRenderer": {"captionTracks": [
cap("ar", None, "Arabic"), cap("en", None, "English"), cap("en", Some("asr"), "English (auto-generated)"),
cap("de-DE", None, "German"), cap("fr", None, "French"),
]}});
let sub = |lang: &str, title: &str| Track { url: format!("t?lang={lang}&fmt=vtt"), lang: lang.into(), title: title.into() };
let mut p = prefs(1080, "av01");
assert_eq!(select(&root, &p).unwrap().subs, [sub("en", "English")]);
p.sub_langs = vec!["de".into(), "fr".into()];
assert_eq!(select(&root, &p).unwrap().subs, [sub("de-DE", "German"), sub("fr", "French"), sub("en", "English")]);
root["captions"]["playerCaptionsTracklistRenderer"]["captionTracks"] =
json!([cap("en", Some("asr"), "English (auto-generated)"), cap("ar", None, "Arabic")]);
assert_eq!(select(&root, &p).unwrap().subs, [sub("en", "English (auto-generated)")]);
}
#[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");
}
}