diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ead9ea0..5cc5c93 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2688,6 +2688,12 @@ async fn ensure_ytdlp(app: tauri::AppHandle) { ytdlp::ensure(app).await; } +/// Throttled update check for a process that remains alive in the tray. +#[tauri::command] +async fn check_ytdlp_update(app: tauri::AppHandle) { + ytdlp::check_update(app).await; +} + /// Run yt-dlp to resolve a videoId into metadata JSON. #[tauri::command] fn resolve_stream_ytdlp(app: tauri::AppHandle, video_id: String) -> Result { @@ -3715,6 +3721,7 @@ pub fn run() { .manage(lastfm::LastfmState::default()) .invoke_handler(tauri::generate_handler![ ensure_ytdlp, + check_ytdlp_update, resolve_stream_ytdlp, get_stream_base_url, start_login, diff --git a/src-tauri/src/ytdlp.rs b/src-tauri/src/ytdlp.rs index 7d154c9..1dff9a5 100644 --- a/src-tauri/src/ytdlp.rs +++ b/src-tauri/src/ytdlp.rs @@ -120,6 +120,10 @@ fn emit_state(app: &tauri::AppHandle, phase: &str, message: Option) { ); } +// Setup, manual retries and the long-session timer can overlap. One lock keeps +// them from updating or replacing the managed binary concurrently. +static ENSURE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// Idempotent "make yt-dlp available" entry point. Called from the /// frontend on every launch (so the webview's event listener is /// guaranteed to be mounted before any state event fires) and safe to @@ -128,8 +132,7 @@ fn emit_state(app: &tauri::AppHandle, phase: &str, message: Option) { /// Emits `ytdlp-state` events: `downloading` → `ready` | `error`. pub async fn ensure(app: tauri::AppHandle) { // Serialize concurrent calls (StrictMode double-mount, retry spam). - static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - let _guard = LOCK.lock().await; + let _guard = ENSURE_LOCK.lock().await; let managed = managed_path(&app); @@ -176,6 +179,16 @@ pub async fn ensure(app: tauri::AppHandle) { } } +/// Check an existing managed copy for an update without repeating first-run +/// setup, readiness events, or macOS prewarming and legacy cleanup. +pub async fn check_update(app: tauri::AppHandle) { + let _guard = ENSURE_LOCK.lock().await; + let managed = managed_path(&app); + if managed.exists() { + maybe_self_update(&managed).await; + } +} + /// True when a bare `yt-dlp --version` spawn succeeds (PATH install). async fn probe_path_install() -> bool { let mut cmd = tokio::process::Command::new("yt-dlp"); diff --git a/src/lib/ytdlp.ts b/src/lib/ytdlp.ts index 2a0b010..f0bd1df 100644 --- a/src/lib/ytdlp.ts +++ b/src/lib/ytdlp.ts @@ -9,6 +9,10 @@ type YtdlpState = { }; const TOAST_ID = "ytdlp-setup"; +// `ensure_ytdlp` does no network work until its 72-hour stamp expires. Polling +// hourly keeps a tray-resident process close to that cadence without turning +// a launch-time check into a second scheduler on the Rust side. +const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000; /** * Mount once in AppShell. Kicks off `ensure_ytdlp` on the Rust side @@ -28,6 +32,15 @@ export function useYtdlpSetup(): void { useEffect(() => { let cancelled = false; let dispose: (() => void) | undefined; + let updateTimer: number | undefined; + + const runYtdlpCommand = ( + command: "ensure_ytdlp" | "check_ytdlp_update", + ) => { + void invoke(command).catch((err) => { + console.error(`[ytdlp] ${command} failed:`, err); + }); + }; void listen("ytdlp-state", (e) => { const { phase, message } = e.payload; @@ -51,7 +64,7 @@ export function useYtdlpSetup(): void { action: { label: "Retry", onClick: () => { - void invoke("ensure_ytdlp"); + runYtdlpCommand("ensure_ytdlp"); }, }, }); @@ -63,14 +76,20 @@ export function useYtdlpSetup(): void { } dispose = un; // Listener is live — safe to start the Rust side now. - void invoke("ensure_ytdlp").catch((err) => { - console.error("[ytdlp] ensure_ytdlp failed:", err); - }); + runYtdlpCommand("ensure_ytdlp"); + // Closing the main window hides this process to the tray by default, + // so it may not launch again for weeks. Keep checking while it lives; + // the Rust-side stamp makes every early poll a local no-op. + updateTimer = window.setInterval( + () => runYtdlpCommand("check_ytdlp_update"), + UPDATE_POLL_INTERVAL_MS, + ); }); return () => { cancelled = true; dispose?.(); + if (updateTimer !== undefined) window.clearInterval(updateTimer); }; }, []); }