diff --git a/Cargo.lock b/Cargo.lock index f242843..575a0e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,7 @@ dependencies = [ "async-trait", "axum-core", "axum-macros", + "base64", "bytes", "futures-util", "http", @@ -488,8 +489,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower", "tower-layer", "tower-service", @@ -528,6 +531,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -642,6 +651,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -940,6 +955,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.5.8" @@ -3684,6 +3705,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3854,6 +3887,24 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "type-map" version = "0.5.1" @@ -3929,6 +3980,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" diff --git a/Cargo.toml b/Cargo.toml index 3edf2a0..d936adf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ rusqlite = { version = "0.31", features = ["bundled"] } r2d2 = "0.8" r2d2_sqlite = "0.24" notify-rust = { version = "4", default-features = false, features = ["z"] } -axum = { version = "0.7", features = ["macros"] } +axum = { version = "0.7", features = ["macros", "ws"] } tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["io"] } tokio-stream = "0.1" diff --git a/src/web.rs b/src/web.rs index adf4d1c..66d9b25 100644 --- a/src/web.rs +++ b/src/web.rs @@ -109,6 +109,12 @@ pub struct WebState { /// Per-IP failure tracker for [`post_login`]. Each entry is the number of /// recent failures and the instant the lockout (if any) expires. pub login_attempts: Mutex>, + /// Push channel for `/ws/progress` subscribers. A background tokio task + /// ticks every 500 ms while jobs are active and broadcasts a fresh + /// [`ProgressResponse`] snapshot here; the WebSocket handler forwards + /// each message to its client. The `/api/progress` HTTP endpoint stays + /// available as a fallback for clients that can't open a socket. + pub progress_tx: tokio::sync::broadcast::Sender, } /// Failed-login tracking entry. After [`LOGIN_LOCKOUT_AFTER`] failures from @@ -996,6 +1002,68 @@ async fn get_music(State(state): State>) -> impl IntoResponse { Json(serde_json::json!({ "tracks": track_infos })) } +/// `GET /ws/progress` — WebSocket upgrade that streams the same +/// [`ProgressResponse`] payload as the polling HTTP endpoint, pushed +/// from the background broadcast task. Clients should treat the JSON +/// payload identically to `/api/progress`. +/// +/// On socket close (auth lost, network drop, server restart) the JS +/// client falls back to polling `/api/progress` so users with broken +/// WebSocket setups (some reverse proxies, certain mobile carriers) +/// still see updates. +async fn ws_progress( + State(state): State>, + ws: axum::extract::ws::WebSocketUpgrade, +) -> axum::response::Response { + ws.on_upgrade(move |socket| ws_progress_handler(socket, state)) +} + +async fn ws_progress_handler( + mut socket: axum::extract::ws::WebSocket, + state: Arc, +) { + use axum::extract::ws::Message; + // Subscribe before sending the initial snapshot so we don't miss a + // broadcast that fires between the snapshot build and the subscribe. + let mut rx = state.progress_tx.subscribe(); + // Initial snapshot so the UI shows the current state immediately + // (without waiting up to 5s for the next idle broadcast tick). + let initial = { + let mut dl = state.downloader.lock().unwrap(); + dl.poll(); + let queued = dl.pending_snapshots().into_iter() + .map(|(label, url)| QueuedSnapshot { label, url }) + .collect::>(); + ProgressResponse { + jobs: WebState::job_snapshots(&dl), + queued, + max_concurrent: dl.max_concurrent, + } + }; + if let Ok(json) = serde_json::to_string(&initial) { + if socket.send(Message::Text(json)).await.is_err() { return; } + } + loop { + tokio::select! { + msg = rx.recv() => match msg { + Ok(json) => { + if socket.send(Message::Text(json)).await.is_err() { break; } + } + // Subscriber lagged. Resync via the next broadcast tick. + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(_) => break, + }, + // Drain inbound frames (mostly pongs / client closes) so the + // socket doesn't stall. + frame = socket.recv() => match frame { + Some(Ok(Message::Close(_))) | None => break, + Some(Err(_)) => break, + _ => {} + }, + } + } +} + async fn get_progress(State(state): State>) -> impl IntoResponse { let mut dl = state.downloader.lock().unwrap(); dl.poll(); @@ -2021,6 +2089,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { let password_required_initial = db.get_setting("password_hash").ok().flatten().is_some(); let flags = db.get_video_flags().unwrap_or_default(); + // Capacity 16 is plenty — only a small number of browser tabs ever + // subscribe and the broadcast is lossy by design (subscribers that lag + // get a Lagged error and just resubscribe). + let (progress_tx, _initial_rx) = tokio::sync::broadcast::channel::(16); let state = Arc::new(WebState { library: Mutex::new(library), downloader: Mutex::new(downloader), @@ -2037,9 +2109,47 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { last_scheduled_check: Mutex::new(None), password_required_cache: AtomicBool::new(password_required_initial), library_version: AtomicU64::new(1), + progress_tx, login_attempts: Mutex::new(HashMap::new()), }); + // Broadcast progress snapshots to WebSocket subscribers. Ticks fast + // (every 500 ms) while any download is active or queued; slow + // (every 5 s) when idle, just to keep clients refreshed if state + // changed via direct flag/option/folder edits. + let progress_state = state.clone(); + tokio::spawn(async move { + loop { + // Pick interval based on whether there's anything to report on. + let interval_ms = { + let dl = progress_state.downloader.lock().unwrap(); + if dl.any_running() || dl.pending_count() > 0 { 500 } else { 5_000 } + }; + tokio::time::sleep(std::time::Duration::from_millis(interval_ms)).await; + // Skip broadcast when no one's listening — saves the JSON encode. + if progress_state.progress_tx.receiver_count() == 0 { + continue; + } + // Drive the same poll() that /api/progress does so the snapshot + // includes the latest stdout/stderr lines. + let snapshot = { + let mut dl = progress_state.downloader.lock().unwrap(); + dl.poll(); + let queued = dl.pending_snapshots().into_iter() + .map(|(label, url)| QueuedSnapshot { label, url }) + .collect::>(); + ProgressResponse { + jobs: WebState::job_snapshots(&dl), + queued, + max_concurrent: dl.max_concurrent, + } + }; + if let Ok(json) = serde_json::to_string(&snapshot) { + let _ = progress_state.progress_tx.send(json); + } + } + }); + // Background scheduler — ticks every 60 s; runs channel checks when due. let sched_state = state.clone(); tokio::spawn(async move { @@ -2081,6 +2191,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/", get(get_index)) .route("/api/library", get(get_library)) .route("/api/progress", get(get_progress)) + .route("/ws/progress", get(ws_progress)) .route("/api/download", post(post_download)) .route("/api/watched/:id", post(post_watched)) .route("/api/videos/:id/flags/:flag", post(post_video_flag)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index ed4a6cc..e7bb21d 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -106,13 +106,44 @@ aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px} aside.open{transform:translateX(0)} #sidebar-overlay.open{display:block} - #hdr-stats,#sort{display:none} + /* Hide non-essential header chrome on mobile. The remaining + buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings — + are the ones users actually reach for. */ + #hdr-stats,#sort,#agpl-notice{display:none} + header h1{display:none} + header{padding:6px 8px;gap:6px} + header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px} + /* Bigger tap targets on cards. The five-button card-foot reflows + to a comfortable height instead of cramming icons together. */ .grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px} - #details{max-height:160px} - .chapters-pane{display:none} + .card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap} + .card-foot button{font-size:13px;padding:6px 8px;min-height:34px} + .card-foot button:not(.play){min-width:34px} + .card-meta{font-size:10px} + /* Modals go full-screen so the cramped 10px page-margin we use on + desktop doesn't eat the iPhone safe area. */ + .modal-bg{padding:0} + .modal{max-width:100vw;max-height:100vh;border-radius:0;border:none} .modal video{max-height:55vh} + #details{max-height:160px;padding:8px 10px} + .chapters-pane{display:none} section#content{padding:8px} - footer{padding:6px 10px} + /* Download bar wraps onto two rows so the URL input + button + don't squeeze each other off-screen. */ + footer{flex-wrap:wrap;padding:6px 10px;gap:6px} + footer input{flex-basis:100%;order:-1} + /* Bulk-mode actions stack vertically when active. */ + .toolbar{flex-wrap:wrap} + #bulk-actions{flex-wrap:wrap;gap:4px} + #bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px} + /* Job rows: hide the last-line preview on small screens; the + progress bar + label are the only things that fit anyway. */ + .job{padding:4px 10px;font-size:11px} + .job .last-line{display:none} + } + @media(max-width:380px){ + /* Single-column on very narrow phones. */ + .grid{grid-template-columns:1fr;gap:6px} } @@ -1490,7 +1521,7 @@ function renderJobs(jobs,queued,maxConcurrent){ ${j.state} ${esc(j.label)} — ${esc(j.url)} ${j.state==='running'?``:''} - ${esc(j.last_line)} + ${esc(j.last_line)} ${dismiss} `; }).join('')+queuedHtml+limitNote; @@ -1498,25 +1529,62 @@ function renderJobs(jobs,queued,maxConcurrent){ async function clearFinishedJobs(){try{await api('/api/jobs/clear',{method:'POST'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}} async function removeJob(idx){try{await api('/api/jobs/'+idx,{method:'DELETE'});await pollProgress()}catch(e){setStatus('Error: '+e.message)}} +// Progress updates come over a WebSocket (`/ws/progress`) when available +// and fall back to polling `/api/progress` if the upgrade fails (mobile +// carriers / reverse proxies that strip WS sometimes do this). The two +// paths share `applyProgressSnapshot` so the rendering logic is single- +// sourced. let wasRunning=false; +let progressWs=null; +let pollTimer=null; +function applyProgressSnapshot(d){ + if(!d)return; + renderJobs(d.jobs,d.queued,d.max_concurrent); + const run=d.jobs.some(j=>j.state==='running')||(d.queued&&d.queued.length>0); + if(wasRunning&&!run)loadLibrary(); + wasRunning=run; +} async function pollProgress(){ try{ const d=await(await api('/api/progress')).json(); - renderJobs(d.jobs,d.queued,d.max_concurrent); - const run=d.jobs.some(j=>j.state==='running')||(d.queued&&d.queued.length>0); - if(wasRunning&&!run)await loadLibrary(); - wasRunning=run; - // Poll fast while something's happening, slow when idle. Halves request - // count + JSON parses + battery use when the tab sits open all day. - schedulePoll(run?600:5000); + applyProgressSnapshot(d); + schedulePoll(wasRunning?600:5000); }catch{schedulePoll(2000)} } -let pollTimer=null; function schedulePoll(ms){ if(pollTimer)clearTimeout(pollTimer); pollTimer=setTimeout(pollProgress,ms); } -schedulePoll(600); +function startProgressFeed(){ + // Build the ws URL relative to the current page so the same code works + // for http/https + custom port + reverse-proxy mounts. + const proto=location.protocol==='https:'?'wss:':'ws:'; + const url=`${proto}//${location.host}/ws/progress`; + try{ + progressWs=new WebSocket(url); + }catch{ + schedulePoll(600); + return; + } + progressWs.onopen=()=>{ + // Stop the polling timer once the socket's live — pushes are + // authoritative until the connection drops. + if(pollTimer){clearTimeout(pollTimer);pollTimer=null} + }; + progressWs.onmessage=(ev)=>{ + try{applyProgressSnapshot(JSON.parse(ev.data))}catch{} + }; + const onClose=()=>{ + progressWs=null; + // Reconnect attempt after a short backoff; HTTP polling fills in + // the gap so the UI never goes blank. + schedulePoll(600); + setTimeout(()=>{if(!progressWs)startProgressFeed()},5000); + }; + progressWs.onclose=onClose; + progressWs.onerror=onClose; +} +startProgressFeed(); /* ── Keyboard shortcuts ─────────────────────────────────────────── */ // Listed in the ? help dialog below. Skip when the user is typing in a