use std::{fmt, time::Duration};
use anyhow::{anyhow, Context, Result};
use log::{debug, error, info};
use percent_encoding::{percent_encode, AsciiSet, CONTROLS};
use url::Url;
use crate::http::Request;
#[derive(Debug)]
pub enum Error {
Unchanged,
InvalidPrefetchUrl,
Advertisement,
Discontinuity,
}
impl std::error::Error for Error {}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::Unchanged => write!(f, "Media playlist is the same as previous"),
Self::InvalidPrefetchUrl => write!(f, "Invalid or missing prefetch URLs"),
Self::Advertisement => write!(f, "Encountered an embedded advertisement segment"),
Self::Discontinuity => write!(f, "Encountered a discontinuity"),
}
}
}
pub struct MediaPlaylist {
pub prefetch_urls: Vec<String>, pub duration: Duration,
request: Request,
}
impl MediaPlaylist {
pub fn new(playlist_url: &str) -> Result<Self> {
let mut request = Request::get(playlist_url)?;
request.set_accept_header(
"application/x-mpegURL, application/vnd.apple.mpegurl, application/json, text/plain",
)?;
let mut media_playlist = Self {
prefetch_urls: vec![String::default(); 2],
duration: Duration::default(),
request,
};
media_playlist.reload()?;
Ok(media_playlist)
}
pub fn reload(&mut self) -> Result<()> {
let playlist = self.request.read_string()?;
debug!("Playlist:\n{playlist}");
if playlist.contains("Amazon")
|| playlist.contains("stitched-ad")
|| playlist.contains("X-TV-TWITCH-AD")
{
return Err(Error::Advertisement.into());
}
if playlist.contains("#EXT-X-DISCONTINUITY") {
return Err(Error::Discontinuity.into());
}
let prefetch_urls = Self::parse_prefetch_urls(&playlist)?;
if prefetch_urls[0] == self.prefetch_urls[0] || prefetch_urls[1] == self.prefetch_urls[1] {
return Err(Error::Unchanged.into());
}
self.prefetch_urls = prefetch_urls;
self.duration = Self::parse_duration(&playlist)?;
Ok(())
}
fn parse_prefetch_urls(playlist: &str) -> Result<Vec<String>> {
let prefetch_urls = playlist
.lines()
.rev()
.filter(|s| s.starts_with("#EXT-X-TWITCH-PREFETCH"))
.map(|s| s.replace("#EXT-X-TWITCH-PREFETCH:", ""))
.collect::<Vec<String>>();
if prefetch_urls.len() != 2 {
return Err(Error::InvalidPrefetchUrl.into());
}
for url in &prefetch_urls {
Url::parse(url).or(Err(Error::InvalidPrefetchUrl))?;
}
Ok(prefetch_urls)
}
fn parse_duration(playlist: &str) -> Result<Duration> {
Ok(Duration::try_from_secs_f32(
playlist
.lines()
.rev()
.find(|s| s.starts_with("#EXTINF"))
.context("Malformed media playlist while parsing #EXTINF")?
.replace("#EXTINF:", "")
.split(',')
.next()
.context("Invalid #EXTINF")?
.parse()
.context("Failed to parse #EXTINF")?,
)?)
}
}
pub struct MasterPlaylist {
servers: Vec<Url>,
quality: String,
channel: String,
}
impl MasterPlaylist {
pub fn new(servers: &str, channel: &str, quality: &str) -> Result<Self> {
let channel = channel.to_lowercase().replace("twitch.tv/", "");
let servers: Result<Vec<Url>, _> = servers
.replace("[channel]", &channel)
.split(',')
.map(|s| {
Url::parse_with_params(
s,
&[
("player", "twitchweb"),
("type", "any"),
("allow_source", "true"),
("allow_audio_only", "true"),
("allow_spectre", "false"),
("fast_bread", "true"),
],
)
})
.collect();
Ok(Self {
servers: servers.context("Invalid server URL")?,
quality: quality.to_owned(),
channel,
})
}
pub fn fetch(&self) -> Result<String> {
info!("Fetching playlist for channel {}", self.channel);
let playlist = self
.servers
.iter()
.find_map(|s| {
let scheme = s.scheme();
let host = s.host_str().expect("Somehow invalid host?");
let request = if s.path() == "/[ttvlol]" {
const ENCODE_SET: &AsciiSet = &CONTROLS.add(b'?').add(b'=').add(b'&');
info!("Using server {scheme}://{host} (TTVLOL API)");
let mut url = s.clone();
url.set_path(&format!("/playlist/{}.m3u8", &self.channel));
Request::get_with_header(
&percent_encode(url.as_str().as_bytes(), ENCODE_SET).to_string(),
"X-Donate-To: https://ttv.lol/donate",
)
} else {
info!("Using server {scheme}://{host}");
Request::get(s.as_str())
};
match request {
Ok(mut res) => match res.read_string() {
Ok(res) => Some(res),
Err(e) => {
error!("{e}");
None
}
},
Err(e) => {
error!("{e}");
None
}
}
})
.ok_or_else(|| anyhow!("No servers available"))?;
Self::parse_variant_playlist(&self.quality, &playlist)
}
fn parse_variant_playlist(quality: &str, playlist: &str) -> Result<String> {
Ok(playlist
.lines()
.skip_while(|s| {
!(s.contains("#EXT-X-MEDIA") && (s.contains(quality) || quality == "best"))
})
.nth(2)
.context("Invalid quality or malformed master playlist")?
.to_owned())
}
}