diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..2784d98d --- /dev/null +++ b/.envrc @@ -0,0 +1,27 @@ +# ============================================================================= +# Youwee development environment +# ============================================================================= +# +# Shell compatibility: bash, zsh (requires direnv + nix-direnv installed) +# +# Setup requirements: +# 1. Install direnv: https://direnv.net/docs/installation.html +# 2. Install nix-direnv: https://github.com/nix-community/nix-direnv +# 3. Add to shell rc: +# eval "$(direnv hook bash)" # or: eval "$(direnv hook zsh)" +# 4. First use: cd into this directory and run: direnv allow +# +# Provides: Rust, Node.js, Bun, yt-dlp, FFmpeg, and all Tauri 2.x +# system dependencies (GTK3, WebKitGTK 4.1, OpenSSL, libayatana-appindicator) +# via Konductor frontend devshell. +# +# Non-Nix users: comment out "use flake" and install dependencies manually +# per README.md prerequisites section. + +# ── Rust toolchain ──────────────────────────────────────────────────── +if [[ -f "$HOME/.cargo/env" ]]; then + source "$HOME/.cargo/env" +fi + +# ── Nix devshell (uncomment to use Nix instead of host deps) ───────── +# use flake diff --git a/README.md b/README.md index edb6e733..0b29a7bf 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,22 @@ > See all releases on the [Releases page](https://github.com/vanloctech/youwee/releases) +#### Linux .deb — optional system dependencies + +The `.deb` package works out of the box with its bundled yt-dlp binary. For best results, +install system `ffmpeg` and a current `yt-dlp` so the app can use them automatically: + +```bash +# Optional: add PPA for a current yt-dlp (Ubuntu/Debian ship older versions) +sudo add-apt-repository -y ppa:tomtomtom/yt-dlp +sudo apt update +sudo apt install ffmpeg yt-dlp +``` + +When system binaries are present, Youwee prefers them over bundled versions. Without them, +the bundled yt-dlp and the app-managed FFmpeg download (Settings > Dependencies) provide +full functionality. + ### Browser Extension (Chromium + Firefox) | Browser | Download | @@ -111,7 +127,7 @@ - [Bun](https://bun.sh/) (v1.3.5 or later) - [Rust](https://www.rust-lang.org/) (v1.70 or later) -- [Tauri CLI](https://tauri.app/v1/guides/getting-started/prerequisites) +- [Tauri 2 prerequisites](https://tauri.app/start/prerequisites/) (system libraries for your platform) #### Steps @@ -130,11 +146,27 @@ bun run tauri dev bun run tauri build ``` +#### Nix devshell (Linux) + +If you use [Nix](https://nixos.org/), a `flake.nix` provides a complete development +environment with all build and runtime dependencies: + +```bash +nix develop # enter devshell (bun, cargo, rustc, pkg-config, WebKitGTK, etc.) +bun install +bun run tauri dev # development +bun run tauri build -b deb # produce .deb (binary is auto-patched for FHS portability) +``` + +The devshell consumes [konductor](https://github.com/braincraftio/konductor) via +`inputsFrom` and adds `yt-dlp`, `ffmpeg`, `deno`, and `glib-networking` for runtime +and WebKitGTK TLS support. + ## Tech Stack - **Frontend**: React 19, TypeScript, Tailwind CSS, shadcn/ui - **Backend**: Rust, Tauri 2.0 -- **Downloader**: yt-dlp (bundled) +- **Downloader**: yt-dlp (bundled on macOS/Windows, system package on Linux) - **Build**: Bun, Vite ## Contributing diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..6fd6f37b --- /dev/null +++ b/flake.lock @@ -0,0 +1,368 @@ +{ + "nodes": { + "catppuccin": { + "inputs": { + "nixpkgs": [ + "konductor", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772153824, + "narHash": "sha256-T65qXmlcD9qFpPTi+mOXsn4dIkO2N8Ls67nqmuzepv0=", + "owner": "catppuccin", + "repo": "nix", + "rev": "4b0f5b7bf7b3eeb484d49524f3c9791864ab9362", + "type": "github" + }, + "original": { + "owner": "catppuccin", + "repo": "nix", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": [ + "konductor", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1769996383, + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": [ + "konductor", + "systems" + ] + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "forgejo-runner-src": { + "flake": false, + "locked": { + "lastModified": 1769493915, + "narHash": "sha256-BkPJp9R/l4t5ZFM2k51MnWl8tPuEdrhQXxgCtu26Q+E=", + "ref": "refs/heads/main", + "rev": "be280cd779bb0906d32546086bbdcd02e9c334c6", + "revCount": 2212, + "type": "git", + "url": "https://git.braincraft.io/BrainCraft/runner" + }, + "original": { + "type": "git", + "url": "https://git.braincraft.io/BrainCraft/runner" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "konductor", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772380125, + "narHash": "sha256-8C+y46xA9bxcchj9GeDPJaRUDApaA3sy2fhJr1bTbUw=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "a07a44a839eb036e950bf397d9b782916f8dcab3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.11", + "repo": "home-manager", + "type": "github" + } + }, + "ixx": { + "inputs": { + "flake-utils": [ + "konductor", + "flake-utils" + ], + "nixpkgs": [ + "konductor", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1760604463, + "narHash": "sha256-15Y6isGV3x4wqqwOhHdHs26P6hF7XAfc6izJ/oA7yBA=", + "owner": "NuschtOS", + "repo": "ixx", + "rev": "2c1be58ea70a7b8c337c1b9d4eb04ca436f5c5d7", + "type": "github" + }, + "original": { + "owner": "NuschtOS", + "repo": "ixx", + "type": "github" + } + }, + "ixx_2": { + "inputs": { + "flake-utils": [ + "konductor", + "nuschtosSearch", + "flake-utils" + ], + "nixpkgs": [ + "konductor", + "nuschtosSearch", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1754860581, + "narHash": "sha256-EM0IE63OHxXCOpDHXaTyHIOk2cNvMCGPqLt/IdtVxgk=", + "owner": "NuschtOS", + "repo": "ixx", + "rev": "babfe85a876162c4acc9ab6fb4483df88fa1f281", + "type": "github" + }, + "original": { + "owner": "NuschtOS", + "ref": "v0.1.1", + "repo": "ixx", + "type": "github" + } + }, + "konductor": { + "inputs": { + "catppuccin": "catppuccin", + "flake-parts": "flake-parts", + "flake-utils": "flake-utils", + "forgejo-runner-src": "forgejo-runner-src", + "home-manager": "home-manager", + "ixx": "ixx", + "nix2container": "nix2container", + "nixlib": "nixlib", + "nixpkgs": "nixpkgs", + "nixpkgs-unstable": "nixpkgs-unstable", + "nixvim": "nixvim", + "nuschtosSearch": "nuschtosSearch", + "rust-overlay": "rust-overlay", + "systems": "systems" + }, + "locked": { + "lastModified": 1772998052, + "narHash": "sha256-CU43kCUxzgmXWqsqYGhaZDZ5r4XrJA9O8Oq3vpSjE9w=", + "owner": "braincraftio", + "repo": "konductor", + "rev": "84a1bcd60a4047b5d20dba6d2293fd096793fac2", + "type": "github" + }, + "original": { + "owner": "braincraftio", + "repo": "konductor", + "type": "github" + } + }, + "nix2container": { + "inputs": { + "nixpkgs": [ + "konductor", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1767430085, + "narHash": "sha256-SiXJ6xv4pS2MDUqfj0/mmG746cGeJrMQGmoFgHLS25Y=", + "ref": "refs/heads/master", + "rev": "66f4b8a47e92aa744ec43acbb5e9185078983909", + "revCount": 222, + "type": "git", + "url": "https://github.com/nlewo/nix2container" + }, + "original": { + "type": "git", + "url": "https://github.com/nlewo/nix2container" + } + }, + "nixlib": { + "locked": { + "lastModified": 1772328832, + "narHash": "sha256-e+/T/pmEkLP6BHhYjx6GmwP5ivonQQn0bJdH9YrRB+Q=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "c185c7a5e5dd8f9add5b2f8ebeff00888b070742", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1772047000, + "narHash": "sha256-7DaQVv4R97cii/Qdfy4tmDZMB2xxtyIvNGSwXBBhSmo=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1267bb4920d0fc06ea916734c11b0bf004bbe17e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1772173633, + "narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixvim": { + "inputs": { + "flake-parts": [ + "konductor", + "flake-parts" + ], + "nixpkgs": [ + "konductor", + "nixpkgs" + ], + "nuschtosSearch": [ + "konductor", + "nuschtosSearch" + ], + "systems": [ + "konductor", + "systems" + ] + }, + "locked": { + "lastModified": 1769049374, + "narHash": "sha256-h0Os2qqNyycDY1FyZgtbn28VF1ySP74/n0f+LDd8j+w=", + "owner": "nix-community", + "repo": "nixvim", + "rev": "b8f76bf5751835647538ef8784e4e6ee8deb8f95", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "nixos-25.11", + "repo": "nixvim", + "type": "github" + } + }, + "nuschtosSearch": { + "inputs": { + "flake-utils": [ + "konductor", + "flake-utils" + ], + "ixx": "ixx_2", + "nixpkgs": [ + "konductor", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1768249818, + "narHash": "sha256-ANfn5OqIxq3HONPIXZ6zuI5sLzX1sS+2qcf/Pa0kQEc=", + "owner": "NuschtOS", + "repo": "search", + "rev": "b6f77b88e9009bfde28e2130e218e5123dc66796", + "type": "github" + }, + "original": { + "owner": "NuschtOS", + "repo": "search", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": [ + "konductor", + "flake-utils" + ], + "konductor": "konductor", + "nixpkgs": [ + "konductor", + "nixpkgs" + ] + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "konductor", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772334676, + "narHash": "sha256-Jrc0J3AH+iNJDlUze3+FJZv2R0BZnhANFnD52V4kyvI=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "9879be11f30fd3bbf848e653a7f991549e8973b5", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..8e8195f5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "Youwee — Modern video downloader built with Tauri 2 and React 19"; + + inputs = { + konductor.url = "github:braincraftio/konductor"; + nixpkgs.follows = "konductor/nixpkgs"; + flake-utils.follows = "konductor/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils, konductor, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + + youweePackages = with pkgs; [ + yt-dlp # Video extraction — resolved via system PATH (source=Auto) + ffmpeg # Post-processing: merge, transcode, embed metadata/subs + deno # JavaScript runtime for yt-dlp YouTube extraction (--js-runtimes) + gallery-dl # Gallery/collection downloads — resolved via system PATH + glib-networking # GnuTLS backend for GIO — required by WebKitGTK for HTTPS + ]; + + in { + devShells.default = pkgs.mkShell { + name = "youwee"; + packages = youweePackages; + inputsFrom = [ konductor.devShells.${system}.frontend ]; + + env = { + KONDUCTOR_SHELL = "youwee"; + GIO_EXTRA_MODULES = "${pkgs.glib-networking}/lib/gio/modules:${pkgs.dconf.lib}/lib/gio/modules"; + }; + }; + } + ); +} diff --git a/scripts/patch-fhs-binary.sh b/scripts/patch-fhs-binary.sh new file mode 100755 index 00000000..84554494 --- /dev/null +++ b/scripts/patch-fhs-binary.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# ============================================================================= +# patch-fhs-binary.sh — Make Tauri binaries portable for FHS Linux distributions +# ============================================================================= +# +# Called by tauri.conf.json beforeBundleCommand after cargo build, before +# .deb/.rpm/.AppImage assembly. Patches the ELF interpreter from nix store +# to standard FHS path and removes RPATH so the binary uses system libraries. +# +# Environment variables (set by Tauri CLI): +# TAURI_ENV_TARGET_TRIPLE — e.g. x86_64-unknown-linux-gnu +# TAURI_ENV_DEBUG — "true" for debug builds, "false" for release +# TAURI_ENV_ARCH — e.g. x86_64, aarch64 +# TAURI_ENV_PLATFORM — e.g. linux, darwin, windows +# +# No-op conditions (exits 0): +# - patchelf not on PATH (non-nix builds, macOS, Windows, CI) +# - binary not found (cross-compilation target mismatch) +# - interpreter already points to FHS path (idempotent) +# +# Fails loudly (exits non-zero, aborts tauri build) if: +# - patchelf is available but patching fails +# ============================================================================= +set -euo pipefail + +# Skip if patchelf is not available (non-nix builds, macOS, Windows) +if ! command -v patchelf >/dev/null 2>&1; then + exit 0 +fi + +# Determine build profile +PROFILE="release" +if [ "${TAURI_ENV_DEBUG:-false}" = "true" ]; then + PROFILE="debug" +fi + +# Determine binary path — Tauri places it at: +# src-tauri/target/// (cross-compilation) +# src-tauri/target// (native) +CARGO_NAME="youwee" +TRIPLE="${TAURI_ENV_TARGET_TRIPLE:-$(rustc -vV 2>/dev/null | sed -n 's/host: //p')}" +BIN="src-tauri/target/${TRIPLE}/${PROFILE}/${CARGO_NAME}" +if [ ! -f "$BIN" ]; then + BIN="src-tauri/target/${PROFILE}/${CARGO_NAME}" +fi +if [ ! -f "$BIN" ]; then + echo "patch-fhs-binary: binary not found, skipping" >&2 + exit 0 +fi + +# Determine correct FHS interpreter for target architecture +ARCH="${TAURI_ENV_ARCH:-$(uname -m)}" +case "$ARCH" in + x86_64) INTERP="/lib64/ld-linux-x86-64.so.2" ;; + aarch64) INTERP="/lib/ld-linux-aarch64.so.1" ;; + armv7l) INTERP="/lib/ld-linux-armhf.so.3" ;; + i686) INTERP="/lib/ld-linux.so.2" ;; + *) + echo "patch-fhs-binary: unknown arch '$ARCH', skipping" >&2 + exit 0 + ;; +esac + +# Check current interpreter — skip if already FHS (idempotent) +CURRENT="$(patchelf --print-interpreter "$BIN" 2>/dev/null || true)" +if [ "$CURRENT" = "$INTERP" ]; then + echo "patch-fhs-binary: $BIN already has FHS interpreter, skipping" + exit 0 +fi + +# Patch interpreter and remove RPATH +patchelf --set-interpreter "$INTERP" --remove-rpath "$BIN" +echo "patch-fhs-binary: patched $BIN" +echo " interpreter: $CURRENT -> $INTERP" +echo " rpath: removed" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7c..2a152394 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,25 @@ fn main() { - tauri_build::build() + // In debug builds, create a minimal yt-dlp sidecar placeholder so + // tauri_build::build() passes externalBin path validation without + // requiring the real per-platform binary that CI downloads for + // release builds. The placeholder is never executed at runtime; + // services/ytdlp.rs rejects files under 1024 bytes and falls + // through to app_data_dir/bin or system PATH. + // + // Release builds skip this so a missing sidecar binary causes a + // build failure, forcing CI to provide the real binary. + let profile = std::env::var("PROFILE").unwrap_or_default(); + if profile != "release" { + let target = std::env::var("TARGET").unwrap_or_default(); + let ext = if target.contains("windows") { ".exe" } else { "" }; + let sidecar_path = std::path::PathBuf::from(format!("bin/yt-dlp-{}{}", target, ext)); + if !sidecar_path.exists() { + if let Some(parent) = sidecar_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(&sidecar_path, b"#!/bin/sh\nexit 1\n"); + } + } + + tauri_build::build() } diff --git a/src-tauri/src/commands/dependencies.rs b/src-tauri/src/commands/dependencies.rs index 42df74da..9dac1bc0 100644 --- a/src-tauri/src/commands/dependencies.rs +++ b/src-tauri/src/commands/dependencies.rs @@ -910,10 +910,85 @@ pub async fn detect_installed_browsers() -> Result, String> Ok(browsers) } +/// Parse Firefox profiles.ini and return profiles sorted with the active profile first. +/// +/// Firefox's profiles.ini has three relevant section types: +/// [Install] — contains `Default=` identifying the active profile +/// [Profile] — contains `Name=`, `Path=`, `IsRelative=1` +/// +/// yt-dlp's `--cookies-from-browser firefox:` expects the `Name` field value. +/// It internally resolves the name to the path via profiles.ini. +/// +/// The bug this fixes: previously we returned profiles in file order, causing the UI +/// to auto-select the first profile (often the unused legacy "default" profile) instead +/// of the active profile where the user's cookies actually live (e.g. "default-release"). +fn parse_firefox_profiles(profiles_ini_path: &str) -> Vec { + let content = match std::fs::read_to_string(profiles_ini_path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + // Find the active profile path from [Install*] section + let mut active_path: Option = None; + for line in content.lines() { + // [Install*] sections have `Default=` pointing to the active profile + if line.starts_with("Default=") && active_path.is_none() { + let path = line.trim_start_matches("Default=").trim(); + if !path.is_empty() && !path.eq_ignore_ascii_case("1") && !path.eq_ignore_ascii_case("0") { + // Distinguish `Default=1` (boolean in [Profile*]) from `Default=` (in [Install*]) + active_path = Some(path.to_string()); + } + } + } + + // Parse [Profile*] sections to collect Name and Path pairs + struct ProfileEntry { + name: String, + path: String, + } + let mut entries: Vec = Vec::new(); + let mut current_name: Option = None; + let mut current_path: Option = None; + + for line in content.lines() { + let line = line.trim(); + if line.starts_with('[') { + // Flush previous section + if let (Some(name), Some(path)) = (current_name.take(), current_path.take()) { + entries.push(ProfileEntry { name, path }); + } + } else if line.starts_with("Name=") { + current_name = Some(line.trim_start_matches("Name=").to_string()); + } else if line.starts_with("Path=") { + current_path = Some(line.trim_start_matches("Path=").to_string()); + } + } + // Flush final section + if let (Some(name), Some(path)) = (current_name, current_path) { + entries.push(ProfileEntry { name, path }); + } + + // Sort: active profile first (matching Install section Default path), then alphabetical + entries.sort_by(|a, b| { + let a_is_active = active_path.as_ref().map(|ap| a.path == *ap).unwrap_or(false); + let b_is_active = active_path.as_ref().map(|ap| b.path == *ap).unwrap_or(false); + match (a_is_active, b_is_active) { + (true, false) => std::cmp::Ordering::Less, + (false, true) => std::cmp::Ordering::Greater, + _ => a.name.cmp(&b.name), + } + }); + + entries.into_iter().map(|e| BrowserProfile { + folder_name: e.name.clone(), + display_name: e.name, + }).collect() +} + #[tauri::command] pub async fn get_browser_profiles(browser: String) -> Result, String> { let mut profiles = Vec::new(); - + // Helper function to read display name from Chrome/Chromium Preferences file fn get_chromium_profile_name(prefs_path: &std::path::Path) -> Option { if let Ok(content) = std::fs::read_to_string(prefs_path) { @@ -956,22 +1031,8 @@ pub async fn get_browser_profiles(browser: String) -> Result "vivaldi" => format!("{}/Library/Application Support/Vivaldi", home), "opera" => format!("{}/Library/Application Support/com.operasoftware.Opera", home), "firefox" => { - // Firefox uses profiles.ini - display name is the same as folder name let profiles_ini = format!("{}/Library/Application Support/Firefox/profiles.ini", home); - if let Ok(content) = std::fs::read_to_string(&profiles_ini) { - for line in content.lines() { - if line.starts_with("Name=") { - let name = line.trim_start_matches("Name="); - if !name.is_empty() { - profiles.push(BrowserProfile { - folder_name: name.to_string(), - display_name: name.to_string(), - }); - } - } - } - } - return Ok(profiles); + return Ok(parse_firefox_profiles(&profiles_ini)); } "safari" => return Ok(profiles), // Safari has no profiles _ => return Ok(profiles), @@ -1030,20 +1091,7 @@ pub async fn get_browser_profiles(browser: String) -> Result "opera" => format!("{}\\Opera Software\\Opera Stable", app_data), "firefox" => { let profiles_ini = format!("{}\\Mozilla\\Firefox\\profiles.ini", app_data); - if let Ok(content) = std::fs::read_to_string(&profiles_ini) { - for line in content.lines() { - if line.starts_with("Name=") { - let name = line.trim_start_matches("Name="); - if !name.is_empty() { - profiles.push(BrowserProfile { - folder_name: name.to_string(), - display_name: name.to_string(), - }); - } - } - } - } - return Ok(profiles); + return Ok(parse_firefox_profiles(&profiles_ini)); } _ => return Ok(profiles), }; @@ -1088,20 +1136,7 @@ pub async fn get_browser_profiles(browser: String) -> Result "opera" => format!("{}/.config/opera", home), "firefox" => { let profiles_ini = format!("{}/.mozilla/firefox/profiles.ini", home); - if let Ok(content) = std::fs::read_to_string(&profiles_ini) { - for line in content.lines() { - if line.starts_with("Name=") { - let name = line.trim_start_matches("Name="); - if !name.is_empty() { - profiles.push(BrowserProfile { - folder_name: name.to_string(), - display_name: name.to_string(), - }); - } - } - } - } - return Ok(profiles); + return Ok(parse_firefox_profiles(&profiles_ini)); } _ => return Ok(profiles), }; diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs index b2d9c2c0..f293a4b0 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download.rs @@ -27,7 +27,8 @@ use crate::services::{ }; use crate::types::{BackendError, DependencySource, DownloadProgress}; use crate::utils::{ - build_format_string, format_size, parse_progress, sanitize_output_path, CommandExt, + build_format_string, format_size, parse_ffmpeg_progress, parse_progress, + sanitize_output_path, CommandExt, }; pub static CANCEL_FLAG: AtomicBool = AtomicBool::new(false); @@ -1098,6 +1099,29 @@ async fn handle_tokio_download( elapsed_time, }; stderr_app.emit("download-progress", progress).ok(); + } else if let Some((size, speed, time)) = parse_ffmpeg_progress(&line) { + // ffmpeg mux/merge progress (e.g. during --download-sections) + let progress = DownloadProgress { + id: stderr_id.clone(), + percent: 0.0, + speed, + eta: String::new(), + status: "muxing".to_string(), + title: None, + playlist_index: None, + playlist_count: None, + filesize: None, + resolution: None, + format_ext: None, + error_message: None, + error_code: None, + error_params: None, + history_id: None, + filepath: None, + downloaded_size: Some(size), + elapsed_time: Some(time), + }; + stderr_app.emit("download-progress", progress).ok(); } // Log stderr if enabled diff --git a/src-tauri/src/services/deno.rs b/src-tauri/src/services/deno.rs index 753d281f..ea0304a0 100644 --- a/src-tauri/src/services/deno.rs +++ b/src-tauri/src/services/deno.rs @@ -5,59 +5,66 @@ use tokio::process::Command; use crate::types::DenoStatus; use crate::utils::CommandExt; -/// Get the Deno binary path (app data or system) +/// Get the Deno binary path. +/// +/// Resolution order: +/// 1. App-managed binary in app_data_dir/bin/ +/// 2. System PATH via which/where (Nix, Homebrew, distro, etc.) +/// 3. ~/.deno/bin/deno fallback (curl installer location) pub async fn get_deno_path(app: &AppHandle) -> Option { - // First check app data directory + // First check app data directory (app-managed download) if let Ok(app_data_dir) = app.path().app_data_dir() { let bin_dir = app_data_dir.join("bin"); #[cfg(windows)] let deno_path = bin_dir.join("deno.exe"); #[cfg(not(windows))] let deno_path = bin_dir.join("deno"); - + if deno_path.exists() { return Some(deno_path); } } - - // Fallback: check if system deno is available + + // System PATH — honors Nix, Homebrew, distro packages, etc. #[cfg(unix)] { - // Check common deno locations - let home = std::env::var("HOME").unwrap_or_default(); - let deno_home = PathBuf::from(&home).join(".deno/bin/deno"); - if deno_home.exists() { - return Some(deno_home); - } - let mut cmd = Command::new("which"); cmd.arg("deno"); cmd.hide_window(); - let output = cmd.output().await.ok()?; - - if output.status.success() { - let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !path_str.is_empty() { - return Some(PathBuf::from(path_str)); + if let Ok(output) = cmd.output().await { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path_str.is_empty() { + return Some(PathBuf::from(path_str)); + } } } + + // Fallback: ~/.deno/bin/deno (curl installer location) + let home = std::env::var("HOME").unwrap_or_default(); + let deno_home = PathBuf::from(&home).join(".deno/bin/deno"); + if deno_home.exists() { + return Some(deno_home); + } } - + #[cfg(windows)] { let mut cmd = Command::new("where"); cmd.arg("deno"); cmd.hide_window(); - let output = cmd.output().await.ok()?; - - if output.status.success() { - let path_str = String::from_utf8_lossy(&output.stdout).lines().next()?.to_string(); - if !path_str.is_empty() { - return Some(PathBuf::from(path_str)); + if let Ok(output) = cmd.output().await { + if output.status.success() { + if let Some(path_str) = String::from_utf8_lossy(&output.stdout).lines().next() { + let path_str = path_str.to_string(); + if !path_str.is_empty() { + return Some(PathBuf::from(path_str)); + } + } } } } - + None } @@ -97,33 +104,23 @@ pub async fn check_deno_internal(app: &AppHandle) -> Result } } - // Check system Deno (including ~/.deno/bin/deno) - let home = std::env::var("HOME").unwrap_or_default(); - let deno_home = PathBuf::from(&home).join(".deno/bin/deno"); - - let (deno_cmd, is_home_deno) = if deno_home.exists() { - (deno_home.to_string_lossy().to_string(), true) - } else { - ("deno".to_string(), false) - }; - - let mut cmd = Command::new(&deno_cmd); + // Check system Deno: PATH first (Nix, Homebrew, distro), then ~/.deno fallback. + // Use "deno" so the OS resolves via PATH. If that fails, try ~/.deno/bin/deno. + let mut cmd = Command::new("deno"); cmd.args(["--version"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()); cmd.hide_window(); let output = cmd.output().await; - - match output { - Ok(output) if output.status.success() => { + + if let Ok(output) = &output { + if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); let version = stdout.lines().next() .map(|l| l.trim_start_matches("deno ").split_whitespace().next().unwrap_or("").to_string()) .unwrap_or_default(); - - let path = if is_home_deno { - Some(deno_home.to_string_lossy().to_string()) - } else { + + let path = { #[cfg(unix)] { let mut cmd = Command::new("which"); @@ -143,21 +140,47 @@ pub async fn check_deno_internal(app: &AppHandle) -> Result #[cfg(not(any(unix, windows)))] { None } }; - - Ok(DenoStatus { + + return Ok(DenoStatus { installed: true, version: Some(version), binary_path: path, is_system: true, - }) + }); + } + } + + // Fallback: ~/.deno/bin/deno (curl installer location, not on PATH) + let home = std::env::var("HOME").unwrap_or_default(); + let deno_home = PathBuf::from(&home).join(".deno/bin/deno"); + if deno_home.exists() { + let mut cmd = Command::new(&deno_home); + cmd.args(["--version"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.hide_window(); + if let Ok(output) = cmd.output().await { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let version = stdout.lines().next() + .map(|l| l.trim_start_matches("deno ").split_whitespace().next().unwrap_or("").to_string()) + .unwrap_or_default(); + return Ok(DenoStatus { + installed: true, + version: Some(version), + binary_path: Some(deno_home.to_string_lossy().to_string()), + is_system: true, + }); + } } - _ => Ok(DenoStatus { - installed: false, - version: None, - binary_path: None, - is_system: false, - }), } + + Ok(DenoStatus { + installed: false, + version: None, + binary_path: None, + is_system: false, + }) } /// Get Deno download URL for current platform diff --git a/src-tauri/src/services/ffmpeg.rs b/src-tauri/src/services/ffmpeg.rs index fb0c4a04..8168303c 100644 --- a/src-tauri/src/services/ffmpeg.rs +++ b/src-tauri/src/services/ffmpeg.rs @@ -70,28 +70,37 @@ fn get_app_ffmpeg_path(app: &AppHandle) -> Option { } } +/// Resolve system binary candidates in correct precedence order: +/// 1. PATH entries (honors the user's environment: Nix, Homebrew, distro, etc.) +/// 2. Well-known fallback paths (for GUI apps that may not inherit shell PATH) fn get_system_binary_candidates(binary_name: &str) -> Vec { - let mut candidates = vec![ - PathBuf::from("/opt/homebrew/bin").join(binary_name), - PathBuf::from("/usr/local/bin").join(binary_name), - PathBuf::from("/usr/bin").join(binary_name), - ]; + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + let mut push_unique = |path: PathBuf| { + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + candidates.push(path); + } + }; + // PATH first — this is the OS-level contract for binary resolution. + // Nix, Homebrew, distro packages, and user installs all express + // their binaries through PATH. if let Ok(path_var) = std::env::var("PATH") { for dir in std::env::split_paths(&path_var) { - candidates.push(dir.join(binary_name)); + push_unique(dir.join(binary_name)); } } - let mut unique = Vec::new(); - let mut seen = HashSet::new(); - for path in candidates { - let key = path.to_string_lossy().to_string(); - if seen.insert(key) { - unique.push(path); - } - } - unique + // Well-known fallback locations for environments where PATH may not + // include these (e.g., macOS GUI apps launched from Finder/Dock that + // don't inherit the user's shell PATH). + push_unique(PathBuf::from("/opt/homebrew/bin").join(binary_name)); + push_unique(PathBuf::from("/usr/local/bin").join(binary_name)); + push_unique(PathBuf::from("/usr/bin").join(binary_name)); + + candidates } fn get_system_ffmpeg_path() -> Option { diff --git a/src-tauri/src/services/gallerydl.rs b/src-tauri/src/services/gallerydl.rs index 95925c4a..087e13bf 100644 --- a/src-tauri/src/services/gallerydl.rs +++ b/src-tauri/src/services/gallerydl.rs @@ -27,28 +27,41 @@ pub fn system_gallerydl_not_found_message() -> String { } } +/// Resolve system binary candidates in correct precedence order: +/// 1. PATH entries (honors the user's environment: Nix, Homebrew, pipx, distro, etc.) +/// 2. Well-known fallback paths (for GUI apps that may not inherit shell PATH) fn get_system_binary_candidates(binary_name: &str) -> Vec { - let mut candidates = vec![ - PathBuf::from("/opt/homebrew/bin").join(binary_name), - PathBuf::from("/usr/local/bin").join(binary_name), - PathBuf::from("/usr/bin").join(binary_name), - ]; + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + let mut push_unique = |path: PathBuf| { + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + candidates.push(path); + } + }; + // PATH first — this is the OS-level contract for binary resolution. + // Nix, Homebrew, pipx, distro packages, and user installs all express + // their binaries through PATH. if let Ok(path_var) = std::env::var("PATH") { for dir in std::env::split_paths(&path_var) { - candidates.push(dir.join(binary_name)); + push_unique(dir.join(binary_name)); } } - let mut unique = Vec::new(); - let mut seen = HashSet::new(); - for path in candidates { - let key = path.to_string_lossy().to_string(); - if seen.insert(key) { - unique.push(path); - } + // Well-known fallback locations for environments where PATH may not + // include these (e.g., macOS GUI apps launched from Finder/Dock or + // Linux desktop files that don't inherit the user's shell PATH). + if let Ok(home) = std::env::var("HOME") { + // ~/.local/bin — standard XDG location for pipx, pip --user, etc. + push_unique(PathBuf::from(&home).join(".local/bin").join(binary_name)); } - unique + push_unique(PathBuf::from("/opt/homebrew/bin").join(binary_name)); + push_unique(PathBuf::from("/usr/local/bin").join(binary_name)); + push_unique(PathBuf::from("/usr/bin").join(binary_name)); + + candidates } pub fn get_system_gallerydl_path() -> Option { diff --git a/src-tauri/src/services/ytdlp.rs b/src-tauri/src/services/ytdlp.rs index 873a6b68..63649f49 100644 --- a/src-tauri/src/services/ytdlp.rs +++ b/src-tauri/src/services/ytdlp.rs @@ -80,28 +80,41 @@ pub async fn set_ytdlp_source(app: &AppHandle, source: &DependencySource) -> Res Ok(()) } +/// Resolve system binary candidates in correct precedence order: +/// 1. PATH entries (honors the user's environment: Nix, Homebrew, distro, etc.) +/// 2. Well-known fallback paths (for GUI apps that may not inherit shell PATH) fn get_system_binary_candidates(binary_name: &str) -> Vec { - let mut candidates = vec![ - PathBuf::from("/opt/homebrew/bin").join(binary_name), - PathBuf::from("/usr/local/bin").join(binary_name), - PathBuf::from("/usr/bin").join(binary_name), - ]; + let mut candidates = Vec::new(); + let mut seen = HashSet::new(); + + let mut push_unique = |path: PathBuf| { + let key = path.to_string_lossy().to_string(); + if seen.insert(key) { + candidates.push(path); + } + }; + // PATH first — this is the OS-level contract for binary resolution. + // Nix, Homebrew, distro packages, and user installs all express + // their binaries through PATH. if let Ok(path_var) = std::env::var("PATH") { for dir in std::env::split_paths(&path_var) { - candidates.push(dir.join(binary_name)); + push_unique(dir.join(binary_name)); } } - let mut unique = Vec::new(); - let mut seen = HashSet::new(); - for path in candidates { - let key = path.to_string_lossy().to_string(); - if seen.insert(key) { - unique.push(path); - } + // Well-known fallback locations for environments where PATH may not + // include these (e.g., macOS GUI apps launched from Finder/Dock or + // Linux desktop files that don't inherit the user's shell PATH). + if let Ok(home) = std::env::var("HOME") { + // ~/.local/bin — standard XDG location for pipx, pip --user, etc. + push_unique(PathBuf::from(&home).join(".local/bin").join(binary_name)); } - unique + push_unique(PathBuf::from("/opt/homebrew/bin").join(binary_name)); + push_unique(PathBuf::from("/usr/local/bin").join(binary_name)); + push_unique(PathBuf::from("/usr/bin").join(binary_name)); + + candidates } fn get_system_ytdlp_path() -> Option { @@ -185,18 +198,26 @@ fn get_legacy_ytdlp_path(app: &AppHandle) -> Option { }) } -/// Get the bundled yt-dlp path +/// Get the bundled yt-dlp path. +/// Returns None if the file doesn't exist or is an empty placeholder +/// (build.rs creates empty placeholders for dev builds; CI provides the real binary). fn get_bundled_ytdlp_path() -> Option { #[cfg(windows)] let binary_name = "yt-dlp.exe"; #[cfg(not(windows))] let binary_name = "yt-dlp"; - - // Check next to executable first + if let Ok(exe_path) = std::env::current_exe() { if let Some(exe_dir) = exe_path.parent() { let bundled = exe_dir.join(binary_name); if bundled.exists() { + // Reject empty placeholder files created by build.rs for dev builds. + // A real yt-dlp binary is several MB; an empty file is not usable. + if let Ok(meta) = std::fs::metadata(&bundled) { + if meta.len() < 1024 { + return None; + } + } return Some(bundled); } } @@ -290,7 +311,13 @@ pub fn get_channel_api_url(channel: &YtdlpChannel) -> Option<&'static str> { } } -/// Get the path to yt-dlp binary based on current channel setting +/// Get the path to yt-dlp binary based on current channel setting. +/// +/// Resolution order for source=Auto (default): +/// 1. User-updated binary in app_data_dir/bin/ (legacy or channel-specific) +/// 2. Bundled sidecar (real binary, not empty placeholder from dev builds) +/// 3. System PATH (Nix, Homebrew, distro package manager, etc.) +/// /// Returns: (path, is_bundled) pub async fn get_ytdlp_path(app: &AppHandle) -> Option<(PathBuf, bool)> { let source = get_ytdlp_source(app).await; @@ -300,14 +327,14 @@ pub async fn get_ytdlp_path(app: &AppHandle) -> Option<(PathBuf, bool)> { } let channel = get_ytdlp_channel(app).await; - + match channel { YtdlpChannel::Bundled => { // Prefer user-updated legacy binary so bundled update actually takes effect. if let Some(legacy_binary) = get_legacy_ytdlp_path(app) { return Some((legacy_binary, false)); } - // Use bundled version + // Use bundled version (rejects empty placeholders from dev builds) if let Some(bundled) = get_bundled_ytdlp_path() { return Some((bundled, true)); } @@ -325,12 +352,21 @@ pub async fn get_ytdlp_path(app: &AppHandle) -> Option<(PathBuf, bool)> { } } } - + // Final fallback: check app_data_dir/bin/yt-dlp (legacy location) if let Some(legacy_binary) = get_legacy_ytdlp_path(app) { return Some((legacy_binary, false)); } - + + // Last resort for Auto mode: check system PATH. + // This covers Nix devshells, system package managers, and any other + // environment where yt-dlp is available on PATH but not bundled. + if source == DependencySource::Auto { + if let Some(system_path) = get_system_ytdlp_path() { + return Some((system_path, false)); + } + } + None } diff --git a/src-tauri/src/utils/progress.rs b/src-tauri/src/utils/progress.rs index 1be65b08..7b8ea887 100644 --- a/src-tauri/src/utils/progress.rs +++ b/src-tauri/src/utils/progress.rs @@ -92,3 +92,51 @@ pub fn parse_progress( None } + +/// Parse ffmpeg stderr progress output emitted during mux/merge/postprocess. +/// +/// ffmpeg writes lines like: +/// frame= 75 fps=0.0 q=-1.0 Lsize= 133KiB time=00:00:05.00 bitrate= 217.7kbits/s speed= 223x +/// +/// Returns (downloaded_size, speed, elapsed_time) when matched. +/// - downloaded_size: output file size so far (e.g. "133KiB") +/// - speed: processing speed (e.g. "223x") +/// - elapsed_time: mux progress timestamp (e.g. "00:00:05.00") +pub fn parse_ffmpeg_progress(line: &str) -> Option<(String, String, String)> { + // Quick guard: ffmpeg progress lines always contain both "time=" and "speed=" + if !line.contains("time=") || !line.contains("speed=") { + return None; + } + + let mut size = String::new(); + let mut speed = String::new(); + let mut time = String::new(); + + // Extract Lsize= or size= value + if let Some(re) = regex::Regex::new(r"[Ll]?size=\s*([\d.]+\s*\w+)").ok() { + if let Some(caps) = re.captures(line) { + size = caps.get(1).map(|m| m.as_str().trim().to_string()).unwrap_or_default(); + } + } + + // Extract speed= value (e.g. "223x" or "1.5x") + if let Some(re) = regex::Regex::new(r"speed=\s*([\d.]+x)").ok() { + if let Some(caps) = re.captures(line) { + speed = caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_default(); + } + } + + // Extract time= value (e.g. "00:00:05.00") + if let Some(re) = regex::Regex::new(r"time=\s*(\d{2}:\d{2}:\d{2}(?:\.\d+)?)").ok() { + if let Some(caps) = re.captures(line) { + time = caps.get(1).map(|m| m.as_str().to_string()).unwrap_or_default(); + } + } + + // Must have at least time to be a valid ffmpeg progress line + if time.is_empty() { + return None; + } + + Some((size, speed, time)) +} diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json new file mode 100644 index 00000000..5e609254 --- /dev/null +++ b/src-tauri/tauri.linux.conf.json @@ -0,0 +1,13 @@ +{ + "build": { + "beforeBundleCommand": "bash scripts/patch-fhs-binary.sh" + }, + "bundle": { + "externalBin": [], + "linux": { + "deb": { + "recommends": ["ffmpeg", "yt-dlp"] + } + } + } +} diff --git a/src/components/download/QueueItem.tsx b/src/components/download/QueueItem.tsx index 810f927b..6900db78 100644 --- a/src/components/download/QueueItem.tsx +++ b/src/components/download/QueueItem.tsx @@ -299,8 +299,8 @@ export function QueueItem({ {/* Progress Bar at bottom */}
- {/* Live stream: indeterminate shimmer progress bar */} - {item.isLive && item.progress === 0 ? ( + {/* Indeterminate shimmer: live streams or ffmpeg muxing */} + {(item.isLive || item.isMuxing) && item.progress === 0 ? (
- {/* Live stream: show "LIVE • elapsed time" only */} + {/* Live stream: show "LIVE • elapsed time" */} {item.isLive && item.progress === 0 ? (
@@ -345,6 +345,22 @@ export function QueueItem({ )}
+ ) : item.isMuxing && item.progress === 0 ? ( +
+ {t('queue.status.muxing')} + {item.elapsedTime && ( + <> + + {item.elapsedTime} + + )} + {item.speed && ( + <> + + {item.speed} + + )} +
) : ( <> {item.progress.toFixed(0)}% diff --git a/src/components/download/UniversalQueueItem.tsx b/src/components/download/UniversalQueueItem.tsx index 371655f9..1070df70 100644 --- a/src/components/download/UniversalQueueItem.tsx +++ b/src/components/download/UniversalQueueItem.tsx @@ -18,7 +18,7 @@ import { X, XCircle, } from 'lucide-react'; -import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; +import { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SimpleMarkdown } from '@/components/ui/simple-markdown'; import { useAI } from '@/contexts/AIContext'; @@ -78,6 +78,12 @@ function formatFileSize(bytes: number): string { return `${bytes} B`; } +// Extract YouTube video ID from URL for thumbnail fallback +function getYouTubeVideoId(url: string): string | null { + const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&?]+)/); + return match ? match[1] : null; +} + // Helper to format quality display function formatQuality(quality: string): string { const qualityMap: Record = { @@ -115,6 +121,20 @@ export function UniversalQueueItem({ const ai = useAI(); const [showFullSummary, setShowFullSummary] = useState(false); const [thumbError, setThumbError] = useState(false); + + // Reset thumb error when the thumbnail source changes (e.g. metadata fetch completes) + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional re-run when thumbnail changes + useEffect(() => { + setThumbError(false); + }, [item.thumbnail]); + + const videoId = getYouTubeVideoId(item.url); + const thumbnailUrl = + item.thumbnail && !thumbError + ? item.thumbnail + : videoId + ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` + : null; const [showTimeRange, setShowTimeRange] = useState(false); const [timeStart, setTimeStart] = useState(''); const [timeEnd, setTimeEnd] = useState(''); @@ -268,9 +288,9 @@ export function UniversalQueueItem({ > {/* Thumbnail Placeholder */}
- {item.thumbnail && !thumbError ? ( + {thumbnailUrl ? (
- {/* Live stream: indeterminate shimmer progress bar */} - {item.isLive && item.progress === 0 ? ( + {/* Indeterminate shimmer: live streams or ffmpeg muxing */} + {(item.isLive || item.isMuxing) && item.progress === 0 ? (
- {/* Live stream: show "LIVE • elapsed time" only */} + {/* Live stream: show "LIVE • elapsed time" */} {item.isLive && item.progress === 0 ? (
@@ -342,6 +362,22 @@ export function UniversalQueueItem({ )}
+ ) : item.isMuxing && item.progress === 0 ? ( +
+ {t('queue.status.muxing')} + {item.elapsedTime && ( + <> + + {item.elapsedTime} + + )} + {item.speed && ( + <> + + {item.speed} + + )} +
) : ( <> {item.progress.toFixed(0)}% diff --git a/src/components/settings/sections/DependenciesSection.tsx b/src/components/settings/sections/DependenciesSection.tsx index 1c70b4b8..b8c07922 100644 --- a/src/components/settings/sections/DependenciesSection.tsx +++ b/src/components/settings/sections/DependenciesSection.tsx @@ -290,6 +290,11 @@ export function DependenciesSection({ highlightId }: DependenciesSectionProps) { t('dependencies.videoDownloadEngine') )}

+ {ytdlpInfo?.binary_path && ( +

+ {ytdlpInfo.binary_path} +

+ )}
@@ -698,6 +708,11 @@ export function DependenciesSection({ highlightId }: DependenciesSectionProps) { t('dependencies.jsRuntimeForYoutube') )}

+ {denoStatus?.binary_path && ( +

+ {denoStatus.binary_path} +

+ )}
@@ -792,6 +807,11 @@ export function DependenciesSection({ highlightId }: DependenciesSectionProps) { )}

+ {galleryDlStatus?.binary_path && ( +

+ {galleryDlStatus.binary_path} +

+ )}