use std::{
fmt::{self, Display, Formatter},
ops::{Deref, DerefMut},
str::{self, Utf8Error},
};
use anyhow::{Context, Result, bail};
use getrandom::getrandom;
use log::{debug, error, info};
use super::{OfflineError, Passthrough, cache::Cache, map_if_offline};
use crate::config::Config;
use crate::{
constants,
http::{Agent, Connection, Method, StatusError, Url},
};
pub enum Stream {
Variant(Connection),
Passthrough(Url),
None,
}
impl Stream {
pub fn new(agent: &Agent) -> Result<Self> {
let cfg = Config::get();
if let Some(url) = cfg.force_playlist_url.clone() {
info!("Using forced playlist URL");
return Ok(Self::Variant(Connection::new(url, agent.text())));
}
let cache = Cache::new(
cfg.playlist_cache_dir.as_ref(),
&cfg.channel,
cfg.quality.as_ref(),
);
if let Some(conn) = cache.as_ref().and_then(|c| c.get(agent)) {
if cfg.write_cache_only {
info!("Playlist URL is already cached, exiting...");
return Ok(Self::None);
}
info!("Using cached playlist URL");
return Ok(Self::Variant(conn));
} else if cfg.use_cache_only {
bail!("Playlist URL not found in cache");
}
info!("Fetching playlist for channel {}", cfg.channel);
let (multivariant_url, playlist) = if let Some(channel) = cfg.channel.strip_prefix("kick:")
{
fetch_kick_playlist(channel, agent)?
} else if let Some(servers) = &cfg.servers {
fetch_proxy_playlist(!cfg.no_low_latency, servers, &cfg.codecs, agent)?
} else {
let response = fetch_twitch_gql(
cfg.client_id.as_deref(),
cfg.auth_token.as_deref(),
&cfg.channel,
agent,
)?;
fetch_twitch_playlist(
&response,
!cfg.no_low_latency,
&cfg.codecs,
&cfg.channel,
agent,
)?
};
let Some(url) = choose_stream(&playlist, cfg.quality.as_ref(), cfg.print_streams) else {
print_streams(&playlist);
return Ok(Self::None);
};
if let Some(cache) = &cache {
cache.create(&url);
if cfg.write_cache_only {
info!("Playlist cache written, exiting...");
return Ok(Self::None);
}
}
match cfg.passthrough {
Passthrough::Disabled => Ok(Self::Variant(Connection::new(url, agent.text()))),
Passthrough::Variant => Ok(Self::Passthrough(url)),
Passthrough::Multivariant => Ok(Self::Passthrough(multivariant_url)),
}
}
}
fn fetch_twitch_gql(
client_id: Option<&str>,
auth_token: Option<&str>,
channel: &str,
agent: &Agent,
) -> Result<String> {
const GQL_LEN_WITHOUT_CHANNEL: usize = 267;
let mut client_id_buf = ArrayString::<30>::new();
let client_id = choose_client_id(&mut client_id_buf, client_id, auth_token, agent)?;
let mut request = agent.text();
request.text_fmt(
Method::Post,
&constants::TWITCH_GQL_ENDPOINT.into(),
format_args!(
"Content-Type: text/plain;charset=UTF-8\r\n\
X-Device-ID: {device_id}\r\n\
Client-ID: {client_id}\r\n\
{auth_token_head}{auth_token}{auth_token_tail}\
Content-Length: {content_length}\r\n\
\r\n\
{{\
\"extensions\":{{\
\"persistedQuery\":{{\
\"sha256Hash\":\"ed230aa1e33e07eebb8928504583da78a5173989fadfb1ac94be06a04f3cdbe9\",\
\"version\":1\
}}\
}},\
\"operationName\":\"PlaybackAccessToken\",\
\"variables\":{{\
\"isLive\":true,\
\"isVod\":false,\
\"login\":\"{channel}\",\
\"playerType\":\"site\",\
\"platform\":\"site\",\
\"vodID\":\"\"\
}}\
}}",
device_id = ArrayString::<32>::random()?,
content_length = GQL_LEN_WITHOUT_CHANNEL + channel.len(),
auth_token_head = if auth_token.is_some() { "Authorization: OAuth " } else { "" },
auth_token_tail = if auth_token.is_some() { "\r\n" } else { "" },
auth_token = auth_token.unwrap_or_default(),
)
)?;
let mut response = request.take();
response.retain(|c| c != '\\');
debug!("GQL response: {response}");
if response.contains(r#"streamPlaybackAccessToken":null"#) {
return Err(OfflineError.into());
}
Ok(response)
}
fn fetch_twitch_playlist(
gql_response: &str,
low_latency: bool,
codecs: &str,
channel: &str,
agent: &Agent,
) -> Result<(Url, String)> {
let url = format!(
"{base_url}{channel}.m3u8\
?allow_source=true\
&allow_audio_only=true\
&cdm=wv\
&fast_bread={low_latency}\
&player_backend=mediaplayer\
&playlist_include_framerate=true\
&enable_score=true\
&multigroup_video=false\
&include_unavailable=false\
&reassignments_supported=true\
&supported_codecs={codecs}\
&transcode_mode=cbr_v1\
&p={p}\
&play_session_id={play_session_id}\
&sig={sig}\
&token={token}\
&player_version={player_version}\
&warp={low_latency}\
&platform=web",
base_url = constants::TWITCH_HLS_BASE,
p = {
let mut buf = [0u8; 4];
getrandom(&mut buf)?;
u32::from_be_bytes(buf) % 9_999_999
},
play_session_id = ArrayString::<32>::random()?,
sig = {
extract(gql_response, r#""signature":""#, r#"","authorization""#)
.context("Failed to find signature in GQL response")?
},
token = {
let start = gql_response.find(r#"{"adblock""#).ok_or(OfflineError)?;
let end = gql_response.find(r#"","signature""#).ok_or(OfflineError)?;
&gql_response[start..end]
},
player_version = constants::PLAYER_VERSION,
)
.into();
let mut request = agent.text();
request.text(Method::Get, &url).map_err(map_if_offline)?;
Ok((url, request.take()))
}
fn fetch_proxy_playlist(
low_latency: bool,
servers: &[Url],
codecs: &str,
agent: &Agent,
) -> Result<(Url, String), OfflineError> {
let mut request = agent.text();
for server in servers {
info!(
"Using playlist proxy: {}://{}",
server.scheme,
server.host().unwrap_or("<unknown>"),
);
let url = format!(
"{server}?allow_source=true\
&allow_audio_only=true\
&fast_bread={low_latency}\
&warp={low_latency}\
&supported_codecs={codecs}\
&platform=web",
)
.into();
match request.text_no_retry(Method::Get, &url) {
Ok(()) => {
let playlist = request.take();
if playlist.is_empty() {
return Err(OfflineError);
}
return Ok((url, playlist));
}
Err(e) if StatusError::is_not_found(&e) => error!("Server returned stream offline"),
Err(e) => error!("{e}"),
}
}
Err(OfflineError)
}
fn fetch_kick_playlist(channel: &str, agent: &Agent) -> Result<(Url, String)> {
let mut request = agent.text();
let url = format!("{}/{channel}/livestream", constants::KICK_CHANNELS_ENDPOINT).into();
request.text(Method::Get, &url).map_err(map_if_offline)?;
let response = &request.take();
request
.text(
Method::Get,
&extract(response, r#""playback_url":""#, r#"","thumbnail""#)
.context("Failed to find kick playlist URL")?
.replace('\\', "")
.into(),
)
.map_err(map_if_offline)?;
Ok((url, request.take()))
}
#[derive(PartialEq, Eq)]
struct PlaylistItem<'a> {
name: &'a str,
url: &'a str,
resolution: Option<(u16, u16)>,
}
impl<'a> PlaylistItem<'a> {
pub fn parse(media: Option<&'a str>, stream_inf: &'a str, url: &'a str) -> Option<Self> {
let name = media
.map_or_else(
|| stream_inf.split_once("IVS-NAME=\""),
|media| media.split_once("NAME=\""),
)
.map(|s| s.1.split('"'))
.and_then(|mut s| s.next())
.map(|s| s.strip_suffix(" (source)").unwrap_or(s))?;
let resolution = stream_inf
.split_once("RESOLUTION=")
.and_then(|(_, tail)| tail.split_once(','))
.and_then(|(head, _)| head.split_once('x'))
.and_then(|(width, height)| {
if let (Ok(width), Ok(height)) = (width.parse(), height.parse()) {
Some((width, height))
} else {
None
}
});
Some(Self {
name,
url,
resolution,
})
}
}
impl PartialOrd for PlaylistItem<'_> {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for PlaylistItem<'_> {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.resolution
.unwrap_or_default()
.cmp(&other.resolution.unwrap_or_default())
}
}
struct PlaylistIter<'a> {
lines: std::str::Lines<'a>,
media: Option<&'a str>,
stream_inf: Option<&'a str>,
}
impl<'a> Iterator for PlaylistIter<'a> {
type Item = PlaylistItem<'a>;
fn next(&mut self) -> Option<Self::Item> {
for line in self.lines.by_ref() {
if line.starts_with("#EXT-X-MEDIA") {
self.media = Some(line);
} else if line.starts_with("#EXT-X-STREAM-INF") {
self.stream_inf = Some(line);
} else if line.starts_with("http")
&& let Some(stream_inf) = self.stream_inf.take()
&& let Some(item) = PlaylistItem::parse(self.media.take(), stream_inf, line)
{
return Some(item);
}
}
None
}
}
impl<'a> PlaylistIter<'a> {
fn new(playlist: &'a str) -> Self {
PlaylistIter {
lines: playlist.lines(),
media: Option::default(),
stream_inf: Option::default(),
}
}
}
fn choose_stream(playlist: &str, quality: Option<&String>, should_print: bool) -> Option<Url> {
debug!("Multivariant playlist:\n{playlist}");
let (Some(quality), false) = (quality, should_print) else {
return None;
};
let mut iter = PlaylistIter::new(playlist);
if quality == "best" {
return iter.max().map(|it| it.url.into());
}
iter.find(|it| it.name == quality).map(|it| it.url.into())
}
fn print_streams(playlist: &str) {
let items = PlaylistIter::new(playlist).collect::<Vec<_>>();
let Some((best, _)) = items.iter().enumerate().max_by_key(|it| it.1) else {
println!();
return;
};
print!("Available streams: ");
for (i, item) in items.iter().enumerate() {
if i != 0 {
print!(", ");
}
print!("{}", item.name);
if i == best {
print!(" (best)");
}
}
println!();
}
fn choose_client_id<'a>(
buf: &'a mut ArrayString<30>,
client_id: Option<&'a str>,
auth_token: Option<&str>,
agent: &Agent,
) -> Result<&'a str> {
if let Some(client_id) = client_id {
Ok(client_id)
} else if let Some(auth_token) = auth_token {
let mut request = agent.text();
let response = request.text_fmt(
Method::Get,
&constants::TWITCH_OAUTH_ENDPOINT.into(),
format_args!("Authorization: OAuth {auth_token}\r\n\r\n"),
)?;
response
.split_once(r#""client_id":""#)
.context("Failed to parse client ID in GQL response")?
.1
.chars()
.take(30)
.zip(buf.iter_mut())
.for_each(|(src, dst)| *dst = src as u8);
Ok(buf.as_str()?)
} else {
Ok(constants::DEFAULT_CLIENT_ID)
}
}
fn extract<'a>(data: &'a str, start: &'a str, end: &'a str) -> Option<&'a str> {
let start = data.find(start)? + start.len();
let end = data.find(end)?;
data.get(start..end)
}
struct ArrayString<const N: usize>([u8; N]);
impl<const N: usize> Deref for ArrayString<{ N }> {
type Target = [u8];
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl<const N: usize> DerefMut for ArrayString<{ N }> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl<const N: usize> Display for ArrayString<{ N }> {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
for chunk in self.0.utf8_chunks() {
f.write_str(chunk.valid())?;
}
Ok(())
}
}
impl<const N: usize> ArrayString<{ N }> {
const fn new() -> Self {
Self([0u8; N])
}
fn random() -> Result<Self> {
const ALPHANUMERIC: &[u8] = b"0123456789\
ABCDEFGHIJKLMNOPQRSTUVWXYZ\
abcdefghijklmnopqrstuvwxyz";
let mut buf = [0u8; N];
getrandom(&mut buf)?;
for r in &mut buf {
*r = ALPHANUMERIC[(*r as usize) % ALPHANUMERIC.len()];
}
Ok(Self(buf))
}
const fn as_str(&self) -> Result<&str, Utf8Error> {
str::from_utf8(&self.0)
}
}