//! A minimal loopback HTTP server for rewritten HLS playlists. //! //! When a streaming quality cap is set we hand the player a filtered master //! playlist rather than YouTube's. That playlist has to be reachable over //! `http://`: Safari's native HLS is backed by AVFoundation, which cannot read //! `blob:` or custom-scheme URLs — only a real HTTP one. Hence ~80 lines of //! server rather than a one-line Blob URL. //! //! It binds to 127.0.0.1 on an ephemeral port and only ever serves playlists it //! was explicitly handed, addressed by an unguessable token. use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::Mutex; /// Playlists are small; keeping the last few is plenty, and it stops a long /// session from growing the map without bound. const MAX_ENTRIES: usize = 8; #[derive(Clone)] pub struct PlaylistServer { port: u16, entries: Arc>>, } impl PlaylistServer { pub fn start() -> Result { let listener = std::net::TcpListener::bind("127.0.0.1:0") .map_err(|e| format!("Cannot bind playlist server: {e}"))?; listener .set_nonblocking(true) .map_err(|e| format!("Cannot configure playlist server: {e}"))?; let port = listener .local_addr() .map_err(|e| format!("Cannot read playlist server port: {e}"))? .port(); let server = PlaylistServer { port, entries: Arc::new(Mutex::new(Vec::new())), }; let entries = server.entries.clone(); tauri::async_runtime::spawn(async move { let Ok(listener) = TcpListener::from_std(listener) else { return; }; loop { let Ok((stream, _)) = listener.accept().await else { continue; }; let entries = entries.clone(); tauri::async_runtime::spawn(async move { let _ = serve(stream, entries).await; }); } }); Ok(server) } /// Publishes a playlist and returns the URL to hand the player. pub async fn publish(&self, body: String) -> String { let token = token(); let mut entries = self.entries.lock().await; entries.push((token.clone(), body)); if entries.len() > MAX_ENTRIES { entries.remove(0); } format!("http://127.0.0.1:{}/{token}.m3u8", self.port) } } /// Not cryptographic, just unguessable enough that another local process /// cannot stumble onto a playlist by iterating short paths. fn token() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let addr = Box::into_raw(Box::new(0u8)) as usize; // Reclaim the allocation used purely as an address source. unsafe { drop(Box::from_raw(addr as *mut u8)) }; format!("{nanos:x}{addr:x}") } async fn serve( mut stream: tokio::net::TcpStream, entries: Arc>>, ) -> std::io::Result<()> { let mut buf = vec![0u8; 2048]; let n = stream.read(&mut buf).await?; let request = String::from_utf8_lossy(&buf[..n]); // "GET /.m3u8 HTTP/1.1" let path = request .lines() .next() .and_then(|l| l.split_whitespace().nth(1)) .unwrap_or(""); let wanted = path.trim_start_matches('/').trim_end_matches(".m3u8"); let body = { let entries = entries.lock().await; entries .iter() .find(|(t, _)| t == wanted) .map(|(_, b)| b.clone()) }; let response = match body { Some(body) => format!( "HTTP/1.1 200 OK\r\nContent-Type: application/vnd.apple.mpegurl\r\n\ Content-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n{}", body.len(), body ), None => "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".into(), }; stream.write_all(response.as_bytes()).await?; stream.flush().await }