diff --git a/Cargo.lock b/Cargo.lock index 850cac0..361d7b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,12 +140,6 @@ dependencies = [ "libc", ] -[[package]] -name = "once_cell" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" - [[package]] name = "openssl-probe" version = "0.1.5" @@ -355,7 +349,6 @@ dependencies = [ "anyhow", "curl", "log", - "once_cell", "pico-args", "rand", "time", diff --git a/Cargo.toml b/Cargo.toml index c0d9a77..6e50392 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,6 @@ static-openssl = ["curl/static-ssl"] anyhow = "1.0.79" curl = { version = "0.4.44", git = "https://github.com/alexcrichton/curl-rust.git", rev = "0c1dddf" } log = { version = "0.4.20", features = ["std"] } -once_cell = "1.19.0" pico-args = { version = "0.5.0", features = ["eq-separator"] } rand = "0.8.5" time = { version = "0.3.31", features = ["local-offset", "formatting", "macros"] } diff --git a/src/args.rs b/src/args.rs index d4c4248..66ddc17 100644 --- a/src/args.rs +++ b/src/args.rs @@ -5,6 +5,44 @@ use pico_args::Arguments; use crate::constants; +#[derive(Debug)] +#[allow(clippy::module_name_repetitions)] //nothing else to name it +pub struct HttpArgs { + pub force_https: bool, + pub force_ipv4: bool, + pub retries: u64, + pub timeout: Duration, +} + +impl Default for HttpArgs { + fn default() -> Self { + Self { + retries: 3, + timeout: Duration::from_secs(10), + force_https: bool::default(), + force_ipv4: bool::default(), + } + } +} + +#[derive(Debug)] +#[allow(clippy::module_name_repetitions)] //nothing else to name it +pub struct HlsArgs { + pub codecs: String, + pub channel: String, + pub quality: String, +} + +impl Default for HlsArgs { + fn default() -> Self { + Self { + codecs: "av1,h265,h264".to_owned(), + channel: String::default(), + quality: String::default(), + } + } +} + #[derive(Clone, Debug)] #[allow(clippy::module_name_repetitions)] //nothing else to name it pub struct PlayerArgs { @@ -17,58 +55,30 @@ pub struct PlayerArgs { impl Default for PlayerArgs { fn default() -> Self { Self { - path: String::default(), args: "-".to_owned(), + path: String::default(), quiet: bool::default(), no_kill: bool::default(), } } } -#[derive(Debug)] -#[allow(clippy::struct_field_names)] //.player_args -#[allow(clippy::struct_excessive_bools)] +#[derive(Default, Debug)] pub struct Args { + pub hls: HlsArgs, pub player: PlayerArgs, pub servers: Option>, pub debug: bool, pub passthrough: bool, - pub force_https: bool, - pub force_ipv4: bool, pub client_id: Option, pub auth_token: Option, pub never_proxy: Option>, - pub codecs: String, - pub http_retries: u64, - pub http_timeout: Duration, - pub channel: String, - pub quality: String, -} - -impl Default for Args { - fn default() -> Self { - Self { - player: PlayerArgs::default(), - servers: Option::default(), - debug: bool::default(), - passthrough: bool::default(), - force_https: bool::default(), - force_ipv4: bool::default(), - client_id: Option::default(), - auth_token: Option::default(), - never_proxy: Option::default(), - codecs: "av1,h265,h264".to_owned(), - http_retries: 3, - http_timeout: Duration::from_secs(10), - channel: String::default(), - quality: String::default(), - } - } } impl Args { - pub fn parse() -> Result { + pub fn parse() -> Result<(Self, HttpArgs)> { let mut args = Self::default(); + let mut http_args = HttpArgs::default(); let mut parser = Arguments::from_env(); if parser.contains("-h") || parser.contains("--help") { println!(include_str!("usage")); @@ -92,22 +102,22 @@ impl Args { None => default_config_path()?, }; - args.parse_config(&config_path)?; + args.parse_config(&mut http_args, &config_path)?; } - args.merge_cli(&mut parser)?; + args.merge_cli(&mut parser, &mut http_args)?; if let Some(never_proxy) = &args.never_proxy { - if never_proxy.iter().any(|a| a.eq(&args.channel)) { + if never_proxy.iter().any(|a| a.eq(&args.hls.channel)) { args.servers = None; } } ensure!(!args.player.path.is_empty(), "Player must be set"); - ensure!(!args.quality.is_empty(), "Quality must be set"); - Ok(args) + ensure!(!args.hls.quality.is_empty(), "Quality must be set"); + Ok((args, http_args)) } - fn parse_config(&mut self, path: &str) -> Result<()> { + fn parse_config(&mut self, http: &mut HttpArgs, path: &str) -> Result<()> { if !Path::new(path).is_file() { return Ok(()); } @@ -128,15 +138,15 @@ impl Args { "quiet" => self.player.quiet = split.1.parse()?, "passthrough" => self.passthrough = split.1.parse()?, "no-kill" => self.player.no_kill = split.1.parse()?, - "force-https" => self.force_https = split.1.parse()?, - "force-ipv4" => self.force_ipv4 = split.1.parse()?, + "force-https" => http.force_https = split.1.parse()?, + "force-ipv4" => http.force_ipv4 = split.1.parse()?, "client-id" => self.client_id = Some(split.1.into()), "auth-token" => self.auth_token = Some(split.1.into()), "never-proxy" => self.never_proxy = Some(split_comma(split.1)?), - "codecs" => self.codecs = split.1.into(), - "http-retries" => self.http_retries = split.1.parse()?, - "http-timeout" => self.http_timeout = parse_duration(split.1)?, - "quality" => self.quality = split.1.into(), + "codecs" => self.hls.codecs = split.1.into(), + "http-retries" => http.retries = split.1.parse()?, + "http-timeout" => http.timeout = parse_duration(split.1)?, + "quality" => self.hls.quality = split.1.into(), _ => bail!("Unknown key in config: {}", split.0), } } else { @@ -147,7 +157,7 @@ impl Args { Ok(()) } - fn merge_cli(&mut self, p: &mut Arguments) -> Result<()> { + fn merge_cli(&mut self, p: &mut Arguments, http: &mut HttpArgs) -> Result<()> { merge_opt_opt(&mut self.servers, p.opt_value_from_fn("-s", split_comma)?); merge_opt(&mut self.player.path, p.opt_value_from_str("-p")?); merge_opt(&mut self.player.args, p.opt_value_from_str("-a")?); @@ -158,31 +168,28 @@ impl Args { ); merge_switch(&mut self.passthrough, p.contains("--passthrough")); merge_switch(&mut self.player.no_kill, p.contains("--no-kill")); - merge_switch(&mut self.force_https, p.contains("--force-https")); - merge_switch(&mut self.force_ipv4, p.contains("--force-ipv4")); + merge_switch(&mut http.force_https, p.contains("--force-https")); + merge_switch(&mut http.force_ipv4, p.contains("--force-ipv4")); merge_opt_opt(&mut self.client_id, p.opt_value_from_str("--client-id")?); merge_opt_opt(&mut self.auth_token, p.opt_value_from_str("--auth-token")?); merge_opt_opt( &mut self.never_proxy, p.opt_value_from_fn("--never-proxy", split_comma)?, ); - merge_opt(&mut self.codecs, p.opt_value_from_str("--codecs")?); - merge_opt( - &mut self.http_retries, - p.opt_value_from_str("--http-retries")?, - ); + merge_opt(&mut self.hls.codecs, p.opt_value_from_str("--codecs")?); + merge_opt(&mut http.retries, p.opt_value_from_str("--http-retries")?); merge_opt( - &mut self.http_timeout, + &mut http.timeout, p.opt_value_from_fn("--http-timeout", parse_duration)?, ); - self.channel = p + self.hls.channel = p .free_from_str::() .context("missing channel argument")? .to_lowercase() .replace("twitch.tv/", ""); - merge_opt(&mut self.quality, p.opt_free_from_str()?); + merge_opt(&mut self.hls.quality, p.opt_free_from_str()?); Ok(()) } diff --git a/src/hls.rs b/src/hls.rs index 9477cf2..8158758 100644 --- a/src/hls.rs +++ b/src/hls.rs @@ -16,8 +16,9 @@ use rand::{ use url::Url; use crate::{ + args::HlsArgs, constants, - http::{self, TextRequest}, + http::{self, Agent, TextRequest}, }; #[derive(Debug)] @@ -164,12 +165,12 @@ pub struct MediaPlaylist { } impl MediaPlaylist { - pub fn new(url: &Url) -> Result { + pub fn new(url: &Url, agent: &Agent) -> Result { let mut playlist = Self { header_url: SegmentHeaderUrl::default(), urls: PrefetchUrls::default(), duration: SegmentDuration::default(), - request: TextRequest::get(url)?, + request: agent.get(url)?, }; playlist.header_url = playlist.reload()?.parse()?; @@ -223,7 +224,12 @@ struct PlaybackAccessToken { } impl PlaybackAccessToken { - fn new(client_id: &Option, auth_token: &Option, channel: &str) -> Result { + fn new( + client_id: &Option, + auth_token: &Option, + channel: &str, + agent: &Agent, + ) -> Result { #[rustfmt::skip] let gql = concat!( "{", @@ -243,12 +249,12 @@ impl PlaybackAccessToken { "}", "}").replace("{channel}", channel); - let mut request = TextRequest::post(&constants::TWITCH_GQL_ENDPOINT.parse()?, &gql)?; + let mut request = agent.post(&constants::TWITCH_GQL_ENDPOINT.parse()?, &gql)?; request.header("Content-Type: text/plain;charset=UTF-8")?; request.header(&format!("X-Device-ID: {}", &Self::gen_id()))?; request.header(&format!( "Client-Id: {}", - Self::choose_client_id(client_id, auth_token)? + Self::choose_client_id(client_id, auth_token, agent)? ))?; if let Some(auth_token) = auth_token { @@ -281,12 +287,16 @@ impl PlaybackAccessToken { }) } - fn choose_client_id(client_id: &Option, auth_token: &Option) -> Result { + fn choose_client_id( + client_id: &Option, + auth_token: &Option, + agent: &Agent, + ) -> Result { //--client-id > (if auth token) client id from twitch > default let client_id = if let Some(client_id) = client_id { client_id.to_owned() } else if let Some(auth_token) = auth_token { - let mut request = TextRequest::get(&constants::TWITCH_OAUTH_ENDPOINT.parse()?)?; + let mut request = agent.get(&constants::TWITCH_OAUTH_ENDPOINT.parse()?)?; request.header(&format!("Authorization: OAuth {auth_token}"))?; request @@ -314,14 +324,13 @@ impl PlaybackAccessToken { pub fn fetch_twitch_playlist( client_id: &Option, auth_token: &Option, - codecs: &str, - channel: &str, - quality: &str, + args: &HlsArgs, + agent: &Agent, ) -> Result { - info!("Fetching playlist for channel {channel} (Twitch)"); - let access_token = PlaybackAccessToken::new(client_id, auth_token, channel)?; + info!("Fetching playlist for channel {} (Twitch)", args.channel); + let access_token = PlaybackAccessToken::new(client_id, auth_token, &args.channel, agent)?; let url = Url::parse_with_params( - &format!("{}{channel}.m3u8", constants::TWITCH_HLS_BASE), + &format!("{}{}.m3u8", constants::TWITCH_HLS_BASE, args.channel), &[ ("acmb", "e30="), ("allow_source", "true"), @@ -331,7 +340,7 @@ pub fn fetch_twitch_playlist( ("playlist_include_framerate", "true"), ("player_backend", "mediaplayer"), ("reassignments_supported", "true"), - ("supported_codecs", codecs), + ("supported_codecs", &args.codecs), ("transcode_mode", "cbr_v1"), ( "p", @@ -346,29 +355,24 @@ pub fn fetch_twitch_playlist( )?; parse_variant_playlist( - &TextRequest::get(&url)?.text().map_err(map_if_offline)?, - quality, + &agent.get(&url)?.text().map_err(map_if_offline)?, + &args.quality, ) } -pub fn fetch_proxy_playlist( - servers: &[String], - codecs: &str, - channel: &str, - quality: &str, -) -> Result { - info!("Fetching playlist for channel {} (proxy)", channel); +pub fn fetch_proxy_playlist(servers: &[String], args: &HlsArgs, agent: &Agent) -> Result { + info!("Fetching playlist for channel {} (proxy)", args.channel); let servers = servers .iter() .map(|s| { Url::parse_with_params( - &s.replace("[channel]", channel), + &s.replace("[channel]", &args.channel), &[ ("allow_source", "true"), ("allow_audio_only", "true"), ("fast_bread", "true"), ("warp", "true"), - ("supported_codecs", codecs), + ("supported_codecs", &args.codecs), ], ) }) @@ -384,7 +388,7 @@ pub fn fetch_proxy_playlist( s.host_str().unwrap_or("") ); - let mut request = match TextRequest::get(s) { + let mut request = match agent.get(s) { Ok(request) => request, Err(e) => { error!("{e}"); @@ -410,7 +414,7 @@ pub fn fetch_proxy_playlist( }) .ok_or(Error::Offline)?; - parse_variant_playlist(&playlist, quality) + parse_variant_playlist(&playlist, &args.quality) } fn parse_variant_playlist(master_playlist: &str, quality: &str) -> Result { diff --git a/src/http.rs b/src/http.rs index 969a534..a5e8fa8 100644 --- a/src/http.rs +++ b/src/http.rs @@ -2,14 +2,15 @@ use std::{ fmt, io::{self, Write}, str, + sync::Arc, }; use anyhow::{ensure, Result}; use curl::easy::{Easy2, Handler, InfoType, IpResolve, List, WriteError}; -use log::debug; +use log::{debug, LevelFilter}; use url::Url; -use crate::{constants, ARGS}; +use crate::{args::HttpArgs, constants}; #[derive(Debug)] pub enum Error { @@ -28,20 +29,46 @@ impl fmt::Display for Error { } } +//Arc wrapper +#[derive(Clone)] +pub struct Agent { + args: Arc, +} + +impl Agent { + pub fn new(args: HttpArgs) -> Self { + Self { + args: Arc::new(args), + } + } + + pub fn get(&self, url: &Url) -> Result { + TextRequest::get(url, self.args.clone()) + } + + pub fn post(&self, url: &Url, data: &str) -> Result { + TextRequest::post(url, data, self.args.clone()) + } + + pub fn writer(&self, writer: T, url: &Url) -> Result> { + WriterRequest::get(writer, url, self.args.clone()) + } +} + pub struct TextRequest { request: Request>, } impl TextRequest { - pub fn get(url: &Url) -> Result { - let mut request = Request::new(Vec::new(), url)?; + pub fn get(url: &Url, args: Arc) -> Result { + let mut request = Request::new(Vec::new(), url, args)?; request.handle.get(true)?; Ok(Self { request }) } - pub fn post(url: &Url, data: &str) -> Result { - let mut request = Request::new(Vec::new(), url)?; + pub fn post(url: &Url, data: &str, args: Arc) -> Result { + let mut request = Request::new(Vec::new(), url, args)?; request.handle.post(true)?; request.handle.post_fields_copy(data.as_bytes())?; @@ -74,8 +101,8 @@ where } impl WriterRequest { - pub fn get(writer: T, url: &Url) -> Result { - let mut request = Request::new(writer, url)?; + pub fn get(writer: T, url: &Url, args: Arc) -> Result { + let mut request = Request::new(writer, url, args)?; request.handle.get(true)?; request.perform()?; @@ -93,24 +120,28 @@ where T: Write, { handle: Easy2>, + args: Arc, } impl Request { - pub fn new(writer: T, url: &Url) -> Result { + pub fn new(writer: T, url: &Url, args: Arc) -> Result { let mut request = Self { handle: Easy2::new(RequestHandler { writer, error: Option::default(), }), + args, }; - let args = ARGS.get().unwrap(); - if args.force_ipv4 { + if request.args.force_ipv4 { request.handle.ip_resolve(IpResolve::V4)?; } - request.handle.verbose(args.debug)?; - request.handle.timeout(args.http_timeout)?; + request + .handle + .verbose(log::max_level() == LevelFilter::Debug)?; + + request.handle.timeout(request.args.timeout)?; request.handle.tcp_nodelay(true)?; request.handle.accept_encoding("")?; request.handle.useragent(constants::USER_AGENT)?; @@ -127,12 +158,11 @@ impl Request { } pub fn perform(&mut self) -> Result<()> { - let retries_arg = ARGS.get().unwrap().http_retries; let mut retries = 0; loop { match self.handle.perform() { Ok(()) => break, - Err(_) if retries < retries_arg => retries += 1, + Err(_) if retries < self.args.retries => retries += 1, Err(e) => return Err(e.into()), } } @@ -164,7 +194,7 @@ impl Request { } pub fn url(&mut self, url: &Url) -> Result<()> { - if ARGS.get().unwrap().force_https { + if self.args.force_https { ensure!( url.scheme() == "https", "URL protocol is not HTTPS and --force-https is enabled: {url}" diff --git a/src/main.rs b/src/main.rs index 33ae8c7..fd0b908 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,23 +13,15 @@ use std::{ use anyhow::Result; use log::{debug, info}; -use once_cell::sync::OnceCell; use args::Args; use hls::MediaPlaylist; +use http::Agent; use logger::Logger; use player::Player; use worker::Worker; -static ARGS: OnceCell = OnceCell::new(); - -fn main_loop(mut playlist: MediaPlaylist, player: Player) -> Result<()> { - let mut worker = Worker::spawn( - player, - playlist.urls.take_newest()?, - playlist.header_url.0.take(), - )?; - +fn main_loop(mut playlist: MediaPlaylist, mut worker: Worker) -> Result<()> { loop { let time = Instant::now(); if let Err(e) = playlist.reload() { @@ -48,22 +40,15 @@ fn main_loop(mut playlist: MediaPlaylist, player: Player) -> Result<()> { } fn main() -> Result<()> { - let args = ARGS.get_or_try_init(Args::parse)?; + let (args, http_args) = Args::parse()?; Logger::init(args.debug)?; - debug!("{:?}", args); + debug!("{:?} {:?}", args, http_args); + let agent = Agent::new(http_args); let playlist_url = match args.servers.as_ref().map_or_else( - || { - hls::fetch_twitch_playlist( - &args.client_id, - &args.auth_token, - &args.codecs, - &args.channel, - &args.quality, - ) - }, - |servers| hls::fetch_proxy_playlist(servers, &args.codecs, &args.channel, &args.quality), + || hls::fetch_twitch_playlist(&args.client_id, &args.auth_token, &args.hls, &agent), + |servers| hls::fetch_proxy_playlist(servers, &args.hls, &agent), ) { Ok(playlist_url) => playlist_url, Err(e) => match e.downcast_ref::() { @@ -83,9 +68,15 @@ fn main() -> Result<()> { return Player::passthrough(&args.player, &playlist_url); } - let playlist = MediaPlaylist::new(&playlist_url)?; - let player = Player::spawn(&args.player)?; - match main_loop(playlist, player) { + let mut playlist = MediaPlaylist::new(&playlist_url, &agent)?; + let worker = Worker::spawn( + Player::spawn(&args.player)?, + playlist.urls.take_newest()?, + playlist.header_url.0.take(), + &agent, + )?; + + match main_loop(playlist, worker) { Ok(()) => Ok(()), Err(e) => { if matches!(e.downcast_ref::(), Some(hls::Error::Offline)) { diff --git a/src/worker.rs b/src/worker.rs index 438f2f7..899d922 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -7,7 +7,7 @@ use anyhow::{ensure, Context, Result}; use log::debug; use url::Url; -use crate::{http::WriterRequest, player::Player}; +use crate::{http::Agent, player::Player}; pub struct Worker { //Option to call take() because handle.join() consumes self. @@ -17,21 +17,27 @@ pub struct Worker { } impl Worker { - pub fn spawn(player: Player, initial_url: Url, header_url: Option) -> Result { + pub fn spawn( + player: Player, + initial_url: Url, + header_url: Option, + agent: &Agent, + ) -> Result { let (url_tx, url_rx): (Sender, Receiver) = mpsc::channel(); let (init_tx, init_rx): (SyncSender<()>, Receiver<()>) = mpsc::sync_channel(1); + let agent = agent.clone(); let handle = thread::Builder::new() .name("worker".to_owned()) .spawn(move || -> Result<()> { debug!("Starting with URL: {initial_url}"); let mut request = if let Some(header_url) = header_url { - let mut request = WriterRequest::get(player, &header_url)?; + let mut request = agent.writer(player, &header_url)?; request.call(&initial_url)?; request } else { - WriterRequest::get(player, &initial_url)? + agent.writer(player, &initial_url)? }; init_tx.send(())?;