diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 00000000..8032a19e --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,378 @@ +name: Beta Release (unsigned) + +# Unsigned dev-build pipeline for this fork. Builds installers for all +# supported platforms on every `v*` tag push and attaches them to an +# automatically created BETA prerelease. No signing secrets required: +# `tauri.beta.conf.json` disables updater artifacts, so the build works +# without TAURI_SIGNING_PRIVATE_KEY. +# +# NOTE: binaries are UNSIGNED — Windows SmartScreen and macOS Gatekeeper +# will show warnings. That is expected for beta builds; see the release +# notes that this workflow generates. + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + prerelease_suffix: + description: 'Release title suffix (used only with workflow_dispatch)' + required: false + default: 'beta' + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + static-checks: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Tauri command consistency check + run: node scripts/check-tauri-commands.mjs + + - name: Frontend typecheck + build + run: bun run build + + build: + needs: static-checks + strategy: + fail-fast: false + matrix: + include: + # macOS Apple Silicon + - platform: macos-latest + target: aarch64-apple-darwin + os_name: macos + arch: aarch64 + friendly_name: Mac-Apple-Silicon + yt_dlp_url: https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos + yt_dlp_name: youwee-yt-dlp-aarch64-apple-darwin + + # macOS Intel (yt-dlp_macos is a Universal Binary) + - platform: macos-15 + target: x86_64-apple-darwin + os_name: macos + arch: x86_64 + friendly_name: Mac-Intel + yt_dlp_url: https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos + yt_dlp_name: youwee-yt-dlp-x86_64-apple-darwin + + # Linux x86_64 + - platform: ubuntu-22.04 + target: x86_64-unknown-linux-gnu + os_name: linux + arch: x86_64 + friendly_name: Linux + yt_dlp_url: https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux + yt_dlp_name: youwee-yt-dlp-x86_64-unknown-linux-gnu + + # Windows x86_64 + - platform: windows-latest + target: x86_64-pc-windows-msvc + os_name: windows + arch: x86_64 + friendly_name: Windows + yt_dlp_url: https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe + yt_dlp_name: youwee-yt-dlp-x86_64-pc-windows-msvc.exe + + runs-on: ${{ matrix.platform }} + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + key: ${{ matrix.target }} + + - name: Install Linux dependencies + if: matrix.os_name == 'linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + librsvg2-dev \ + patchelf \ + libssl-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Download yt-dlp binary (Unix) + if: matrix.os_name != 'windows' + run: | + mkdir -p src-tauri/bin + curl -L -o "src-tauri/bin/${{ matrix.yt_dlp_name }}" "${{ matrix.yt_dlp_url }}" + chmod +x "src-tauri/bin/${{ matrix.yt_dlp_name }}" + ls -la src-tauri/bin/ + + - name: Download yt-dlp binary (Windows) + if: matrix.os_name == 'windows' + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path src-tauri/bin + Invoke-WebRequest -Uri "${{ matrix.yt_dlp_url }}" -OutFile "src-tauri/bin/${{ matrix.yt_dlp_name }}" + Get-ChildItem src-tauri/bin/ + + # Unsigned beta build: config overlay disables updater artifacts so no + # signing key is needed. + - name: Build Tauri app (unsigned) + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tauriScript: bun run tauri + args: --target ${{ matrix.target }} --config src-tauri/tauri.beta.conf.json + + - name: Fix Linux deep-link desktop entry + if: matrix.os_name == 'linux' + run: | + bash scripts/fix-deb-deep-link-desktop-entry.sh \ + "src-tauri/target/${{ matrix.target }}/release/bundle/deb" + + - name: Rename artifacts with friendly names (macOS) + if: matrix.os_name == 'macos' + run: | + cd src-tauri/target/${{ matrix.target }}/release/bundle + cd dmg + for f in *.dmg; do + mv "$f" "Youwee-${{ matrix.friendly_name }}.dmg" + done + cd .. + ls -la dmg + + - name: Rename artifacts with friendly names (Linux) + if: matrix.os_name == 'linux' + run: | + cd src-tauri/target/${{ matrix.target }}/release/bundle + if [ -d "deb" ]; then + cd deb + for f in *.deb; do + mv "$f" "Youwee-${{ matrix.friendly_name }}.deb" + done + cd .. + fi + if [ -d "appimage" ]; then + cd appimage + for f in *.AppImage; do + mv "$f" "Youwee-${{ matrix.friendly_name }}.AppImage" + done + cd .. + fi + ls -laR + + - name: Rename artifacts with friendly names (Windows) + if: matrix.os_name == 'windows' + shell: pwsh + run: | + cd src-tauri/target/${{ matrix.target }}/release/bundle + if (Test-Path "msi") { + cd msi + Get-ChildItem *.msi | ForEach-Object { + Rename-Item $_.Name "Youwee-${{ matrix.friendly_name }}.msi" + } + cd .. + } + if (Test-Path "nsis") { + cd nsis + Get-ChildItem *.exe | ForEach-Object { + Rename-Item $_.Name "Youwee-${{ matrix.friendly_name }}-Setup.exe" + } + cd .. + } + Get-ChildItem -Recurse + + - name: Upload artifacts + uses: actions/upload-artifact@v6 + with: + name: youwee-beta-${{ matrix.os_name }}-${{ matrix.arch }} + path: | + src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg + src-tauri/target/${{ matrix.target }}/release/bundle/deb/*.deb + src-tauri/target/${{ matrix.target }}/release/bundle/appimage/*.AppImage + src-tauri/target/${{ matrix.target }}/release/bundle/msi/*.msi + src-tauri/target/${{ matrix.target }}/release/bundle/nsis/*.exe + if-no-files-found: error + + extension: + runs-on: ubuntu-latest + needs: static-checks + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build and package extension (unsigned Chromium zip) + run: bun run ext:package + + - name: Upload extension artifact + uses: actions/upload-artifact@v6 + with: + name: youwee-beta-extension + path: extensions/youwee-webext/dist/packages/Youwee-Extension-Chromium.zip + if-no-files-found: error + + release: + needs: [build, extension] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Download all artifacts + uses: actions/download-artifact@v7 + with: + path: artifacts + merge-multiple: true + + - name: List artifacts + run: find artifacts -type f | sort + + - name: Generate SHA256 checksums + run: | + cd artifacts + echo "# SHA256 Checksums for Youwee $GITHUB_REF_NAME (beta)" > ../SHA256SUMS.txt + echo "# Generated: $(date -u)" >> ../SHA256SUMS.txt + echo "" >> ../SHA256SUMS.txt + find . -type f \( \ + -name "*.dmg" -o \ + -name "*.msi" -o \ + -name "*.exe" -o \ + -name "*.deb" -o \ + -name "*.AppImage" -o \ + -name "Youwee-Extension-*.zip" \ + \) | sort | while read file; do + filename=$(basename "$file") + hash=$(sha256sum "$file" | cut -d' ' -f1) + echo "$hash $filename" >> ../SHA256SUMS.txt + done + cd .. + echo "Generated SHA256SUMS.txt:" + cat SHA256SUMS.txt + + - name: Create beta release body + run: | + VERSION="${GITHUB_REF_NAME#v}" + # Commits since the previous tag (or the last 40 when this is the first tag) + PREV_TAG="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)" + if [ -n "$PREV_TAG" ]; then + CHANGELOG="$(git log --no-decorate --format='- %s (%h)' "${PREV_TAG}..HEAD" | head -40)" + else + CHANGELOG="$(git log --no-decorate --format='- %s (%h)' -40 | head -40)" + fi + + cat > RELEASE_BODY.md << HEADER_EOF + # Youwee ${VERSION} + + Automated beta build off the round4-fixes branch. Found something broken? Open an issue. + + ## What's in this beta + HEADER_EOF + # NOTE: do not use ${CHANGELOG:+echo ...} here — a redirect inside a + # ${var:+word} alternative is not parsed as a redirection, so the + # changelog would leak to the job log instead of the file. + if [ -n "$CHANGELOG" ]; then + printf '%s\n' "$CHANGELOG" >> RELEASE_BODY.md + fi + + cat >> RELEASE_BODY.md << FOOTER_EOF + + ## Downloads + + | Platform | File | Notes | + |----------|------|-------| + | **Windows** | \`Youwee-Windows-Setup.exe\` | NSIS installer (x64) | + | **Windows** | \`Youwee-Windows.msi\` | MSI installer (x64) | + | **Mac (Apple Silicon)** | \`Youwee-Mac-Apple-Silicon.dmg\` | M1/M2/M3/M4 | + | **Mac (Intel)** | \`Youwee-Mac-Intel.dmg\` | Intel Macs | + | **Linux** | \`Youwee-Linux.deb\` | Debian/Ubuntu | + | **Linux** | \`Youwee-Linux.AppImage\` | Portable, may require FUSE | + | **Extension (Chromium)** | \`Youwee-Extension-Chromium.zip\` | Unzip → \`chrome://extensions\` → Load unpacked | + + ## ⚠️ Unsigned build — SmartScreen / Gatekeeper WILL warn + + These binaries are **not code-signed** (no signing certificate in this fork). + + **Windows (SmartScreen):** When launching the installer you may see + *"Windows protected your PC"*. Click **More info → Run anyway**. + + **macOS (Gatekeeper):** The app may be blocked with *"cannot be opened because the + developer cannot be verified"*. Right-click the app → **Open → Open**, or run: + \`xattr -cr /Applications/Youwee.app\` + + If the app "refuses to open" or shows a blank window after a warning was dismissed, + it is very likely the OS trust prompt — not a crash. + + ## Verify downloads + \`\`\`bash + sha256sum -c SHA256SUMS.txt + \`\`\` + + ## Requirements + - **Windows**: Windows 10 or later + - **macOS**: 10.15 (Catalina) or later + - **Linux**: Ubuntu 20.04 or equivalent + - Engines (yt-dlp / FFmpeg / gallery-dl / Deno) auto-download in **Settings → Engines** + FOOTER_EOF + echo "Generated RELEASE_BODY.md:" + cat RELEASE_BODY.md + + + - name: Create beta release + uses: softprops/action-gh-release@v2 + with: + name: Youwee ${{ github.ref_name }} (beta) + tag_name: ${{ github.ref_name }} + draft: false + prerelease: true + generate_release_notes: true + body_path: RELEASE_BODY.md + files: | + artifacts/**/*.dmg + artifacts/**/*.deb + artifacts/**/*.AppImage + artifacts/**/*.msi + artifacts/**/*.exe + artifacts/**/Youwee-Extension-*.zip + SHA256SUMS.txt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e4883950..c94271f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,9 +1,12 @@ -name: Build & Release +name: Build & Release (signed) + +# NOTE (fork): tag-push triggering is DISABLED here because this workflow +# requires TAURI_SIGNING_PRIVATE_KEY / FIREFOX_AMO_* secrets that this fork +# does not have. Unsigned builds & releases are handled by +# `beta-release.yml` (triggers on `v*` tags). Re-enable the tag trigger +# below once signing secrets are configured. on: - push: - tags: - - 'v*' workflow_dispatch: inputs: release: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9ef0a25..26c1e8b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,9 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 + - name: Tauri command consistency check + run: node scripts/check-tauri-commands.mjs + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: diff --git a/.gitignore b/.gitignore index 0620bcd9..39af8824 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ flatpak/*.flatpak # Python (old version) *.py +!src-tauri/extractors/*.py __pycache__/ *.pyc venv/ diff --git a/package.json b/package.json index 4ac840ce..c812e8bc 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "youwee", "private": true, - "version": "0.20.3", + "version": "0.20.4", "type": "module", "scripts": { "dev": "vite", @@ -9,6 +9,7 @@ "build": "tsc -b && vite build", "build:sdk": "tsc -p sdk-js/tsconfig.json", "lint": "biome check .", + "check:commands": "node scripts/check-tauri-commands.mjs", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", diff --git a/scripts/check-tauri-commands.mjs b/scripts/check-tauri-commands.mjs new file mode 100644 index 00000000..e9b4201d --- /dev/null +++ b/scripts/check-tauri-commands.mjs @@ -0,0 +1,145 @@ +#!/usr/bin/env node +/** + * Tauri command consistency smoke check. + * + * Catches at CI time the exact bug class that shipped twice in this fork: + * 1. A frontend `invoke('some_command')` with no matching entry in the + * `invoke_handler!` list in src-tauri/src/lib.rs — fails silently at + * runtime ("it randomly stopped working"). + * 2. A `#[tauri::command]` defined in Rust but never registered — dead code + * that will fail the moment the frontend starts calling it. + * + * Usage: node scripts/check-tauri-commands.mjs + * Exit code 0 = consistent, 1 = drift detected. + */ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); + +function walk(dir, exts, out = []) { + for (const entry of readdirSync(dir)) { + if (entry === 'node_modules' || entry === 'dist' || entry === '.git') continue; + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) walk(full, exts, out); + else if (exts.some((e) => entry.endsWith(e))) out.push(full); + } + return out; +} + +// --- 1. Registered commands from invoke_handler! --- +const libRs = readFileSync(join(repoRoot, 'src-tauri/src/lib.rs'), 'utf8'); +const handlerStart = libRs.indexOf('generate_handler!['); +if (handlerStart === -1) { + console.error('FATAL: generate_handler![ not found in src-tauri/src/lib.rs'); + process.exit(1); +} +const handlerBlock = libRs.slice(handlerStart, libRs.indexOf('])', handlerStart)); +const registered = new Set(); +for (const m of handlerBlock.matchAll(/commands::(\w+)/g)) registered.add(m[1]); +for (const m of handlerBlock.matchAll(/^\s{12}(\w+),/gm)) registered.add(m[1]); + +// --- 2. #[tauri::command] definitions across Rust modules --- +const rustFiles = walk(join(repoRoot, 'src-tauri/src'), ['.rs']); +const defined = new Set(); +const definedIn = new Map(); +for (const f of rustFiles) { + const src = readFileSync(f, 'utf8'); + for (const m of src.matchAll( + /#\[tauri::command\]\s*(?:pub(?:\s\(crate\))?\s+)?(?:async\s+)?fn\s+(\w+)/g, + )) { + defined.add(m[1]); + if (!definedIn.has(m[1])) definedIn.set(m[1], relative(repoRoot, f)); + } +} + +// --- 3. Frontend invoke('...') literals --- +const tsFiles = walk(join(repoRoot, 'src'), ['.ts', '.tsx']); +const called = new Set(); +const calledIn = new Map(); +// Dynamic template-literal calls: invoke(`prefix_${expr}`) — the exact +// command name is built at runtime, so the static PREFIX is checked against +// the registered command family instead (e.g. `rollback_${engine}` requires +// at least one rollback_* command in invoke_handler!). +const dynamicPrefixes = []; +for (const f of tsFiles) { + const src = readFileSync(f, 'utf8'); + const rel = relative(repoRoot, f); + for (const m of src.matchAll( + /\binvoke(?:First|AndIgnore)?(?:<[^>]*>)?\s*\(\s*['"`](\w+)['"`]/g, + )) { + // Skip dynamic invocation via string concatenation (`invoke('x_' + id)`) + // — cannot be verified statically; not a drift signal. + const tail = src.slice(m.index + m[0].length, m.index + m[0].length + 2); + if (/^\s*\+/.test(tail)) continue; + called.add(m[1]); + if (!calledIn.has(m[1])) calledIn.set(m[1], rel); + } + for (const m of src.matchAll(/\binvoke(?:First|AndIgnore)?(?:<[^>]*>)?\s*\(\s*`([^`\\]*)`/g)) { + const tpl = m[1]; + const interpolation = tpl.indexOf('${'); + // A template literal without interpolation is a plain string — already + // matched by the quoted-literal pass above. + if (interpolation === -1) continue; + const prefix = tpl.slice(0, interpolation); + // Only a non-empty word-char prefix is statically checkable. + if (!prefix || !/^\w+$/.test(prefix)) continue; + dynamicPrefixes.push({ prefix, at: rel }); + } +} + +let failed = false; + +// Frontend calls with no registered command -> guaranteed runtime failure. +for (const cmd of [...called].sort()) { + if (!registered.has(cmd)) { + failed = true; + console.error(`✗ invoke('${cmd}') at ${calledIn.get(cmd)} — NOT registered in invoke_handler!`); + } +} + +// Defined but never registered -> silent failure waiting to happen. +for (const cmd of [...defined].sort()) { + if (!registered.has(cmd)) { + failed = true; + console.error( + `✗ #[tauri::command] '${cmd}' (${definedIn.get(cmd)}) — defined but missing from invoke_handler!`, + ); + } +} + +// Dynamic template-literal families (`prefix_${expr}`) must resolve to at +// least one registered command, otherwise the whole family is dead drift. +const dynamicSorted = [...dynamicPrefixes].sort((a, b) => + a.prefix === b.prefix ? a.at.localeCompare(b.at) : a.prefix.localeCompare(b.prefix), +); +for (const { prefix, at } of dynamicSorted) { + const family = [...registered].filter((c) => c.startsWith(prefix)).sort(); + if (family.length === 0) { + failed = true; + console.error( + `✗ dynamic invoke(\`${prefix}_\${…}\`) at ${at} — no registered command starts with '${prefix}'`, + ); + } +} + +// Informational: registered but never invoked (kept as a warning list only). +const uncalled = [...registered].filter((c) => !called.has(c)).sort(); + +console.log(`Registered commands: ${registered.size}`); +console.log(`#[tauri::command] definitions: ${defined.size}`); +console.log(`Frontend invoke() literals: ${called.size}`); +console.log(`Dynamic invoke() template-literal prefixes: ${dynamicPrefixes.length}`); +for (const { prefix, at } of dynamicSorted) { + const family = [...registered].filter((c) => c.startsWith(prefix)).sort(); + console.log(` - ${prefix}* → ${family.join(', ')} (${at})`); +} +console.log(`Registered but never invoked from TS (informational): ${uncalled.length}`); + +if (failed) { + console.error('\nTauri command drift detected — fix the mismatches above.'); + process.exit(1); +} +console.log('\n✓ All frontend invoke() calls resolve to registered Tauri commands.'); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 2474004b..9a0c289a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6427,7 +6427,7 @@ dependencies = [ [[package]] name = "youwee" -version = "0.20.3" +version = "0.20.4" dependencies = [ "base64 0.23.0", "chrono", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fbf8c4a6..8bd29031 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "youwee" -version = "0.20.3" +version = "0.20.4" description = "Youwee - Modern YouTube Video Downloader" authors = ["locnguyen"] license = "MIT" diff --git a/src-tauri/extractors/asurascans.py b/src-tauri/extractors/asurascans.py new file mode 100644 index 00000000..b12a75f8 --- /dev/null +++ b/src-tauri/extractors/asurascans.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- + +# asurascans.com extractor for gallery-dl (external module). +# +# asurascans.com is an Astro.js SPA (not WordPress/Madara anymore): the whole +# chapter payload lives in an HTML-escaped JSON blob inside an +# tag. This module parses that blob and yields the +# page image URLs using the same folder/filename layout gallery-dl uses for +# other manga sources. +# +# Loaded by the app through `gallery-dl -X ` (or the +# `extractor.module-sources` config key). No fork/rebuild of gallery-dl is +# needed. The module stays in the app data dir so it can be hot-patched +# without shipping a new release. +# +# Notes (verified live 2026-08-26 against the installed gallery-dl 1.32.9): +# - Free chapters download without any auth. +# - Premium (Asura+) chapters are gated behind an API + access_token cookie; +# they surface a clear error instead of a silent failure. +# - Some premium batches arrive tile-scrambled (tiles/tileCols/tileRows); +# unsupported for now -> clear error. + +import html as _html +import json +import re + +from gallery_dl import exception, text +from gallery_dl.extractor.common import Extractor, Message + +BROWSER_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" +) +PROPS_RE = re.compile(r']*\bprops="([^"]*)"') + + +def _unwrap(value): + """Recursively unwrap Astro island serialization. + + Astro encodes prop values as tagged tuples: + [0, value] -> value + [1|2, [...]] -> list of (recursively unwrapped) values + Plain strings/numbers/bools pass through untouched. + """ + if isinstance(value, list): + if len(value) == 2 and isinstance(value[0], int) and value[0] in (0, 1, 2, 3): + tag, payload = value[0], value[1] + if tag == 0: + return _unwrap(payload) + if tag in (1, 2) and isinstance(payload, list): + return [_unwrap(item) for item in payload] + return _unwrap(payload) + return [_unwrap(item) for item in value] + if isinstance(value, dict): + return {key: _unwrap(item) for key, item in value.items()} + return value + + +class AsuraScansChapterExtractor(Extractor): + """Download a single asurascans.com chapter.""" + + category = "asurascans" + subcategory = "chapter" + directory_fmt = ("{category}", "{seriesName}", "{chapterNumber:>03}") + filename_fmt = "{num:>03}.{extension}" + archive_fmt = "c{chapterNumber}_{num}" + pattern = ( + r"https?://(?:www\.)?asurascans\.com/" + r"(?:comics|series|manga)/([^/?#]+)/chapter/(\d+)" + ) + test = [] + + def __init__(self, match): + Extractor.__init__(self, match) + self.slug = match.group(1) + self.chapter_number = int(match.group(2)) + self.headers = { + "User-Agent": BROWSER_UA, + "Referer": "https://asurascans.com/", + } + + def _chapter_props(self, url): + page = self.request(url, headers=self.headers).text + match = PROPS_RE.search(page) + if not match: + raise exception.StopExtraction( + "Could not find chapter data on the page - the site layout " + "may have changed" + ) + return _unwrap(json.loads(_html.unescape(match.group(1)))) + + def items(self): + props = self._chapter_props(self.url) + pages = props.get("pages") or [] + + if not pages: + if props.get("isPremium") or props.get("isLocked"): + raise exception.StopExtraction( + "This chapter is premium (Asura+). Import your Asura+ " + "login cookies in the app's cookie settings and retry" + ) + raise exception.StopExtraction("No page images found on the chapter page") + + for page in pages: + if isinstance(page, dict) and page.get("tiles"): + raise exception.StopExtraction( + "This chapter uses tile-scrambled images, which are not " + "supported yet" + ) + + chapter_number = props.get("chapterNumber") or self.chapter_number + data = { + "category": self.category, + "subcategory": self.subcategory, + "seriesName": props.get("seriesName") or self.slug, + "seriesSlug": props.get("seriesSlug") or self.slug, + "chapterNumber": chapter_number, + "chapterName": props.get("chapterName") or chapter_number, + "count": len(pages), + } + + yield Message.Directory, "", data + + for num, page in enumerate(pages, 1): + url = page.get("url") if isinstance(page, dict) else page + if not url: + continue + file_data = dict(data) + file_data["num"] = num + text.nameext_from_url(url, file_data) + yield Message.Url, url, file_data diff --git a/src-tauri/src/commands/backup.rs b/src-tauri/src/commands/backup.rs new file mode 100644 index 00000000..2740cf64 --- /dev/null +++ b/src-tauri/src/commands/backup.rs @@ -0,0 +1,818 @@ +//! Download history backup, restore and preview commands. +//! +//! P0-6 "Download history export, import, and backup": +//! portable, versioned JSON backups of the download history plus an optional +//! settings blob supplied by the frontend. Imports are fully validated before +//! any write and applied inside a single SQLite transaction, so a corrupt or +//! incompatible backup can never partially modify existing data. +//! +//! This module is intentionally NOT registered in `lib.rs` by the P0-6 +//! implementer; the mainline registers these commands: +//! +//! ```ignore +//! commands::export_backup, +//! commands::export_backup_with_settings, +//! commands::preview_backup, +//! commands::import_backup, +//! ``` + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::database::get_db; +use chrono::{Local, Utc}; +use rusqlite::{params, Connection}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// Current backup JSON schema version. Bump when the file layout changes; +/// older versions should be migrated here (or rejected with a clear error). +pub const BACKUP_SCHEMA_VERSION: u32 = 1; + +const BACKUP_PREFIX: &str = "youwee-backup"; +const PREVIEW_ROW_LIMIT: usize = 10; +const REDACTED_PLACEHOLDER: &str = "[redacted]"; + +const HISTORY_COLUMNS: &str = "id, url, title, thumbnail, filepath, filesize, duration, quality, format, source, downloaded_at, summary, time_range, media_id, canonical_url"; + +// --------------------------------------------------------------------------- +// Backup schema types +// --------------------------------------------------------------------------- + +/// One history row as stored inside a backup file. `status` is derived from +/// the row's own data ("completed" / "summary_only" / "pending") because the +/// `history` table has no explicit status column. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupHistoryRow { + pub id: String, + pub url: String, + pub title: String, + pub thumbnail: Option, + pub filepath: String, + pub filesize: Option, + pub duration: Option, + pub quality: Option, + pub format: Option, + pub source: Option, + pub downloaded_at: i64, + pub summary: Option, + pub time_range: Option, + pub media_id: Option, + pub canonical_url: Option, + /// Derived status, kept in the file for readability on any OS. + #[serde(default = "default_status")] + pub status: String, +} + +fn default_status() -> String { + "pending".to_string() +} + +/// Top-level backup file structure. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupFile { + pub schema_version: u32, + pub exported_at: String, + pub app_version: String, + #[serde(default)] + pub os: String, + #[serde(default)] + pub secrets: bool, + pub history: Vec, + /// Opaque settings blob provided by the frontend (localStorage settings). + /// Secret keys are redacted unless the user opted in (`secrets: true`). + pub settings: Option, +} + +// --------------------------------------------------------------------------- +// Command result types (serialized camelCase for the frontend) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupExportResult { + pub path: String, + pub count: usize, + pub secrets: bool, + pub exported_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupPreviewRow { + pub id: String, + pub url: String, + pub title: String, + pub status: String, + pub source: Option, + pub filepath: String, + pub downloaded_at: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupPreview { + pub valid: bool, + pub schema_version: u32, + pub count: usize, + pub errors: Vec, + pub rows: Vec, + pub additions: usize, + pub conflicts: usize, + pub skipped: usize, + pub secrets: bool, + pub has_settings: bool, + pub exported_at: Option, + pub app_version: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BackupImportResult { + pub total: usize, + pub added: usize, + pub updated: usize, + pub skipped: usize, + pub policy: String, + pub secrets: bool, + pub settings: Option, +} + +// --------------------------------------------------------------------------- +// Conflict policy +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConflictPolicy { + Skip, + Replace, + Merge, + Duplicate, +} + +impl ConflictPolicy { + fn parse(raw: Option) -> Result { + match raw.as_deref().unwrap_or("skip") { + "skip" => Ok(ConflictPolicy::Skip), + "replace" => Ok(ConflictPolicy::Replace), + "merge" => Ok(ConflictPolicy::Merge), + "duplicate" => Ok(ConflictPolicy::Duplicate), + other => Err(format!( + "Invalid conflict policy \"{other}\". Expected one of: skip, replace, merge, duplicate" + )), + } + } + + fn name(self) -> &'static str { + match self { + ConflictPolicy::Skip => "skip", + ConflictPolicy::Replace => "replace", + ConflictPolicy::Merge => "merge", + ConflictPolicy::Duplicate => "duplicate", + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// The `history` table has no status column; derive one from the row's data. +fn derive_status(filepath: &str, summary: &Option) -> String { + if !filepath.trim().is_empty() { + "completed".to_string() + } else if summary.as_deref().map(str::trim).is_some_and(|s| !s.is_empty()) { + "summary_only".to_string() + } else { + "pending".to_string() + } +} + +fn row_from_sqlite(row: &rusqlite::Row) -> rusqlite::Result { + let filepath: String = row.get(4)?; + let summary: Option = row.get(11)?; + let status = derive_status(&filepath, &summary); + Ok(BackupHistoryRow { + id: row.get(0)?, + url: row.get(1)?, + title: row.get(2)?, + thumbnail: row.get(3)?, + filepath, + filesize: row.get(5)?, + duration: row.get(6)?, + quality: row.get(7)?, + format: row.get(8)?, + source: row.get(9)?, + downloaded_at: row.get(10)?, + summary, + time_range: row.get(12)?, + media_id: row.get(13)?, + canonical_url: row.get(14)?, + status, + }) +} + +fn load_all_history_rows(conn: &Connection) -> Result, String> { + let query = format!("SELECT {HISTORY_COLUMNS} FROM history"); + let mut stmt = conn + .prepare(&query) + .map_err(|e| format!("Failed to prepare history read: {e}"))?; + let rows = stmt + .query_map([], row_from_sqlite) + .map_err(|e| format!("Failed to read history: {e}"))?; + let mut out = Vec::new(); + for row in rows { + out.push(row.map_err(|e| format!("Failed to parse history row: {e}"))?); + } + Ok(out) +} + +/// Validate schema version and required fields. Returns a list of problems +/// (empty == valid). +fn validate_backup(backup: &BackupFile) -> Vec { + let mut errors = Vec::new(); + + if backup.schema_version != BACKUP_SCHEMA_VERSION { + if backup.schema_version > BACKUP_SCHEMA_VERSION { + errors.push(format!( + "Backup schema version {} is newer than the version this app supports ({}). Update youwee and try again.", + backup.schema_version, BACKUP_SCHEMA_VERSION + )); + } else { + errors.push(format!( + "Backup schema version {} is no longer supported (current version is {}).", + backup.schema_version, BACKUP_SCHEMA_VERSION + )); + } + } + + if backup.exported_at.trim().is_empty() { + errors.push("Backup is missing the required \"exportedAt\" field.".to_string()); + } + if backup.app_version.trim().is_empty() { + errors.push("Backup is missing the required \"appVersion\" field.".to_string()); + } + + let mut seen_ids = HashSet::new(); + for (index, row) in backup.history.iter().enumerate() { + if row.id.trim().is_empty() { + errors.push(format!("History entry #{} is missing an id.", index + 1)); + } else if !seen_ids.insert(row.id.clone()) { + errors.push(format!( + "History entry #{} has a duplicate id ({}) inside the backup.", + index + 1, + row.id + )); + } + if row.url.trim().is_empty() { + errors.push(format!("History entry #{} is missing a URL.", index + 1)); + } + if row.title.trim().is_empty() { + errors.push(format!("History entry #{} is missing a title.", index + 1)); + } + } + + errors +} + +/// Read + parse + validate a backup file. Returns Err(errors) for anything +/// that must not be imported. +fn read_backup_file(path: &str) -> Result> { + // Reject oversized files before reading (protects against OOM from + // accidentally pointing at a multi-GB file). + if let Ok(meta) = fs::metadata(path) { + if meta.len() > 50 * 1024 * 1024 { + return Err(vec!["Backup file is larger than 50 MB and was rejected.".to_string()]); + } + } + let raw = fs::read_to_string(path).map_err(|e| vec![format!("Failed to read backup file: {e}")])?; + + let parsed: Value = serde_json::from_str(&raw) + .map_err(|e| vec![format!("Backup is not valid JSON: {e}")])?; + + let mut backup: BackupFile = serde_json::from_value(parsed) + .map_err(|e| vec![format!("Backup does not match the expected schema: {e}")])?; + + // Normalize derived status so matching never depends on untrusted file values. + for row in backup.history.iter_mut() { + row.status = derive_status(&row.filepath, &row.summary); + } + + let errors = validate_backup(&backup); + if errors.is_empty() { + Ok(backup) + } else { + Err(errors) + } +} + +/// Find an existing row with the same url + derived status (most recent wins). +fn find_matching_existing<'a>( + existing: &'a [BackupHistoryRow], + row: &BackupHistoryRow, +) -> Option<&'a BackupHistoryRow> { + existing + .iter() + .filter(|candidate| candidate.url == row.url && candidate.status == row.status) + .max_by_key(|candidate| candidate.downloaded_at) +} + +/// Data equality used by the "merge" policy (id and derived status excluded). +fn data_equal(a: &BackupHistoryRow, b: &BackupHistoryRow) -> bool { + a.url == b.url + && a.title == b.title + && a.thumbnail == b.thumbnail + && a.filepath == b.filepath + && a.filesize == b.filesize + && a.duration == b.duration + && a.quality == b.quality + && a.format == b.format + && a.source == b.source + && a.downloaded_at == b.downloaded_at + && a.summary == b.summary + && a.time_range == b.time_range + && a.media_id == b.media_id + && a.canonical_url == b.canonical_url +} + +/// Preview counts (additions / conflicts / skipped) for a given policy. +fn compute_conflict_counts( + existing: &[BackupHistoryRow], + backup: &BackupFile, + policy: ConflictPolicy, +) -> (usize, usize, usize) { + let mut additions = 0usize; + let mut conflicts = 0usize; + let mut skipped = 0usize; + + for row in &backup.history { + match policy { + ConflictPolicy::Duplicate => additions += 1, + ConflictPolicy::Skip => { + if find_matching_existing(existing, row).is_some() { + skipped += 1; + } else { + additions += 1; + } + } + ConflictPolicy::Replace => { + if find_matching_existing(existing, row).is_some() { + conflicts += 1; + } else { + additions += 1; + } + } + ConflictPolicy::Merge => match find_matching_existing(existing, row) { + Some(matched) if data_equal(matched, row) => skipped += 1, + Some(_) => conflicts += 1, + None => additions += 1, + }, + } + } + + (additions, conflicts, skipped) +} + +fn insert_row(conn: &Connection, row: &BackupHistoryRow, force_new_id: bool) -> Result<(), String> { + let id = if force_new_id { + uuid::Uuid::new_v4().to_string() + } else { + let exists: i64 = conn + .query_row( + "SELECT COUNT(*) FROM history WHERE id = ?1", + params![&row.id], + |r| r.get(0), + ) + .map_err(|e| format!("Failed to check history id: {e}"))?; + if exists == 0 { + row.id.clone() + } else { + uuid::Uuid::new_v4().to_string() + } + }; + + conn.execute( + "INSERT INTO history (id, url, title, thumbnail, filepath, filesize, duration, quality, format, source, downloaded_at, summary, time_range, media_id, canonical_url) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)", + params![ + id, + &row.url, + &row.title, + &row.thumbnail, + &row.filepath, + &row.filesize, + &row.duration, + &row.quality, + &row.format, + &row.source, + &row.downloaded_at, + &row.summary, + &row.time_range, + &row.media_id, + &row.canonical_url, + ], + ) + .map_err(|e| format!("Failed to insert history row: {e}"))?; + + Ok(()) +} + +fn update_row(conn: &Connection, id: &str, row: &BackupHistoryRow) -> Result<(), String> { + conn.execute( + "UPDATE history SET url = ?2, title = ?3, thumbnail = ?4, filepath = ?5, filesize = ?6, duration = ?7, quality = ?8, format = ?9, source = ?10, downloaded_at = ?11, summary = ?12, time_range = ?13, media_id = ?14, canonical_url = ?15 WHERE id = ?1", + params![ + id, + &row.url, + &row.title, + &row.thumbnail, + &row.filepath, + &row.filesize, + &row.duration, + &row.quality, + &row.format, + &row.source, + &row.downloaded_at, + &row.summary, + &row.time_range, + &row.media_id, + &row.canonical_url, + ], + ) + .map_err(|e| format!("Failed to update history row: {e}"))?; + Ok(()) +} + +/// Recursively replace values of sensitive keys with a placeholder. +const SENSITIVE_KEY_PARTS: &[&str] = &[ + "token", + "password", + "passwd", + "secret", + "api_key", + "apikey", + "api-key", +]; + +fn is_sensitive_key(key: &str) -> bool { + let lower = key.to_ascii_lowercase(); + SENSITIVE_KEY_PARTS + .iter() + .any(|part| lower.contains(part)) +} + +fn redact_settings(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut redacted = serde_json::Map::with_capacity(map.len()); + for (key, child) in map { + if is_sensitive_key(key) { + redacted.insert(key.clone(), Value::String(REDACTED_PLACEHOLDER.to_string())); + } else { + redacted.insert(key.clone(), redact_settings(child)); + } + } + Value::Object(redacted) + } + Value::Array(items) => Value::Array(items.iter().map(redact_settings).collect()), + other => other.clone(), + } +} + +fn build_backup_file( + app: &tauri::AppHandle, + include_secrets: bool, + settings: Option, +) -> Result<(BackupFile, Vec), String> { + let conn = get_db()?; + let rows = load_all_history_rows(&conn)?; + + let settings = settings.map(|settings| { + if include_secrets { + settings + } else { + redact_settings(&settings) + } + }); + + let backup = BackupFile { + schema_version: BACKUP_SCHEMA_VERSION, + exported_at: Utc::now().to_rfc3339(), + app_version: app.package_info().version.to_string(), + os: std::env::consts::OS.to_string(), + secrets: include_secrets, + history: rows.clone(), + settings, + }; + + Ok((backup, rows)) +} + +fn write_backup_file(dest_dir: &str, backup: &BackupFile) -> Result { + let dir = Path::new(dest_dir); + fs::create_dir_all(dir).map_err(|e| format!("Failed to create backup folder: {e}"))?; + + let stamp = Local::now().format("%Y%m%d-%H%M%S"); + let file_name = format!("{BACKUP_PREFIX}-{stamp}.json"); + let final_path = dir.join(&file_name); + let temp_path = dir.join(format!("{file_name}.tmp")); + + let content = serde_json::to_string_pretty(backup) + .map_err(|e| format!("Failed to serialize backup: {e}"))?; + fs::write(&temp_path, content).map_err(|e| format!("Failed to write backup file: {e}"))?; + fs::rename(&temp_path, &final_path) + .map_err(|e| format!("Failed to finalize backup file: {e}"))?; + + Ok(final_path) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +/// Export download history (no settings) to a versioned JSON backup file in +/// `dest_dir`. Never writes cookie values or tokens; `include_secrets` only +/// controls the optional settings blob (see `export_backup_with_settings`). +#[tauri::command] +pub fn export_backup( + app: tauri::AppHandle, + include_secrets: bool, + dest_dir: String, +) -> Result { + let (backup, rows) = build_backup_file(&app, include_secrets, None)?; + let path = write_backup_file(&dest_dir, &backup)?; + Ok(BackupExportResult { + path: path.to_string_lossy().into_owned(), + count: rows.len(), + secrets: include_secrets, + exported_at: backup.exported_at, + }) +} + +/// Like `export_backup`, but embeds a settings blob supplied by the frontend +/// (serialized localStorage settings). When `include_secrets` is false, keys +/// that look sensitive (tokens, passwords, API keys) are redacted; when true, +/// the blob is embedded as-is. +#[tauri::command] +pub fn export_backup_with_settings( + app: tauri::AppHandle, + include_secrets: bool, + dest_dir: String, + settings_json: Option, +) -> Result { + let settings = match settings_json { + Some(raw) => Some( + serde_json::from_str(&raw).map_err(|e| format!("Failed to parse settings blob: {e}"))?, + ), + None => None, + }; + let (backup, rows) = build_backup_file(&app, include_secrets, settings)?; + let path = write_backup_file(&dest_dir, &backup)?; + Ok(BackupExportResult { + path: path.to_string_lossy().into_owned(), + count: rows.len(), + secrets: include_secrets, + exported_at: backup.exported_at, + }) +} + +/// Validate a backup file and return a preview (first N rows + per-policy +/// conflict counts) WITHOUT writing anything. Invalid or corrupt files return +/// `Ok` with `valid == false` and a list of errors. +#[tauri::command] +pub fn preview_backup( + path: String, + conflict_policy: Option, +) -> Result { + let policy = ConflictPolicy::parse(conflict_policy)?; + + let backup = match read_backup_file(&path) { + Ok(backup) => backup, + Err(errors) => { + return Ok(BackupPreview { + valid: false, + schema_version: 0, + count: 0, + errors, + rows: Vec::new(), + additions: 0, + conflicts: 0, + skipped: 0, + secrets: false, + has_settings: false, + exported_at: None, + app_version: None, + }); + } + }; + + let conn = get_db()?; + let existing = load_all_history_rows(&conn)?; + let (additions, conflicts, skipped) = compute_conflict_counts(&existing, &backup, policy); + + let rows = backup + .history + .iter() + .take(PREVIEW_ROW_LIMIT) + .map(|row| BackupPreviewRow { + id: row.id.clone(), + url: row.url.clone(), + title: row.title.clone(), + status: row.status.clone(), + source: row.source.clone(), + filepath: row.filepath.clone(), + downloaded_at: row.downloaded_at, + }) + .collect(); + + Ok(BackupPreview { + valid: true, + schema_version: backup.schema_version, + count: backup.history.len(), + errors: Vec::new(), + rows, + additions, + conflicts, + skipped, + secrets: backup.secrets, + has_settings: backup.settings.is_some(), + exported_at: Some(backup.exported_at), + app_version: Some(backup.app_version), + }) +} + +/// Re-validate a backup file and apply it with the chosen conflict policy +/// inside a single transaction. Any validation or write error aborts the +/// whole import, so existing data is never partially modified. +/// +/// Policies: "skip" (skip rows whose url+status already exist), "replace" +/// (update matching rows), "merge" (insert missing + update changed), and +/// "duplicate" (always insert with fresh ids). +#[tauri::command] +pub fn import_backup( + path: String, + conflict_policy: Option, +) -> Result { + let policy = ConflictPolicy::parse(conflict_policy)?; + let backup = read_backup_file(&path).map_err(|errors| errors.join("\n"))?; + + let mut conn = get_db()?; + let existing = load_all_history_rows(&conn)?; + + let tx = conn + .transaction() + .map_err(|e| format!("Failed to start import transaction: {e}"))?; + + let mut added = 0usize; + let mut updated = 0usize; + let mut skipped = 0usize; + + for row in &backup.history { + match policy { + ConflictPolicy::Duplicate => { + insert_row(&tx, row, true)?; + added += 1; + } + ConflictPolicy::Skip => { + if find_matching_existing(&existing, row).is_some() { + skipped += 1; + } else { + insert_row(&tx, row, false)?; + added += 1; + } + } + ConflictPolicy::Replace => { + if let Some(matched) = find_matching_existing(&existing, row) { + update_row(&tx, &matched.id, row)?; + updated += 1; + } else { + insert_row(&tx, row, false)?; + added += 1; + } + } + ConflictPolicy::Merge => match find_matching_existing(&existing, row) { + Some(matched) if data_equal(matched, row) => skipped += 1, + Some(matched) => { + update_row(&tx, &matched.id, row)?; + updated += 1; + } + None => { + insert_row(&tx, row, false)?; + added += 1; + } + }, + } + } + + tx.commit() + .map_err(|e| format!("Failed to commit import transaction: {e}"))?; + + Ok(BackupImportResult { + total: backup.history.len(), + added, + updated, + skipped, + policy: policy.name().to_string(), + secrets: backup.secrets, + settings: backup.settings, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_row(id: &str, url: &str) -> BackupHistoryRow { + BackupHistoryRow { + id: id.to_string(), + url: url.to_string(), + title: "Sample video".to_string(), + thumbnail: None, + filepath: "/tmp/sample.mp4".to_string(), + filesize: Some(1024), + duration: Some(60), + quality: Some("1080".to_string()), + format: Some("mp4".to_string()), + source: Some("youtube".to_string()), + downloaded_at: 1_700_000_000, + summary: None, + time_range: None, + media_id: None, + canonical_url: None, + status: "completed".to_string(), + } + } + + #[test] + fn derive_status_maps_filepath_and_summary() { + assert_eq!(derive_status("", &None), "pending"); + assert_eq!(derive_status("", &Some("hello".to_string())), "summary_only"); + assert_eq!(derive_status(" ", &Some("".to_string())), "pending"); + assert_eq!(derive_status("/a/b.mp4", &None), "completed"); + } + + #[test] + fn matching_requires_same_url_and_status() { + let existing = vec![sample_row("a", "https://example.com/v")]; + let same = sample_row("b", "https://example.com/v"); + assert!(find_matching_existing(&existing, &same).is_some()); + + let different_url = sample_row("c", "https://example.com/other"); + assert!(find_matching_existing(&existing, &different_url).is_none()); + + let mut different_status = sample_row("d", "https://example.com/v"); + different_status.filepath = "".to_string(); + different_status.status = "pending".to_string(); + assert!(find_matching_existing(&existing, &different_status).is_none()); + } + + #[test] + fn validation_rejects_wrong_schema_and_duplicate_ids() { + let mut backup = BackupFile { + schema_version: 2, + exported_at: "2026-08-27T00:00:00Z".to_string(), + app_version: "0.20.3".to_string(), + os: "windows".to_string(), + secrets: false, + history: vec![sample_row("dup", "https://example.com/1"), sample_row("dup", "https://example.com/2")], + settings: None, + }; + let errors = validate_backup(&backup); + assert!(errors.iter().any(|e| e.contains("newer"))); + assert!(errors.iter().any(|e| e.contains("duplicate id"))); + + backup.schema_version = BACKUP_SCHEMA_VERSION; + backup.history[1].id = "other".to_string(); + assert!(validate_backup(&backup).is_empty()); + } + + #[test] + fn redaction_hides_tokens_passwords_and_api_keys() { + let value = serde_json::json!({ + "download": { "quality": "1080", "telegramBotToken": "123:ABC", "nested": { "api_key": "sk-secret", "keep": "value" } }, + "proxy": { "password": "pw", "host": "1.2.3.4" }, + "list": [{"token": "x"}, {"safe": 1}] + }); + let redacted = redact_settings(&value); + assert_eq!(redacted["download"]["telegramBotToken"], "[redacted]"); + assert_eq!(redacted["download"]["nested"]["api_key"], "[redacted]"); + assert_eq!(redacted["proxy"]["password"], "[redacted]"); + assert_eq!(redacted["list"][0]["token"], "[redacted]"); + assert_eq!(redacted["download"]["quality"], "1080"); + assert_eq!(redacted["download"]["nested"]["keep"], "value"); + assert_eq!(redacted["list"][1]["safe"], 1); + } + + #[test] + fn data_equal_ignores_id_and_status() { + let a = sample_row("a", "https://example.com/v"); + let mut b = sample_row("b", "https://example.com/v"); + b.status = "completed".to_string(); + assert!(data_equal(&a, &b)); + + b.title = "Changed".to_string(); + assert!(!data_equal(&a, &b)); + } +} diff --git a/src-tauri/src/commands/cli_shortcut.rs b/src-tauri/src/commands/cli_shortcut.rs index ac951a82..27ecdc0c 100644 --- a/src-tauri/src/commands/cli_shortcut.rs +++ b/src-tauri/src/commands/cli_shortcut.rs @@ -323,7 +323,7 @@ fn create_symlink(exe: &Path, link: &Path) -> Result<(), String> { if let Ok(metadata) = std::fs::symlink_metadata(link) { if !metadata.file_type().is_symlink() { return Err(format!( - "{} already exists and is not a Youwee symlink. Remove it manually before reinstalling.", + "{} already exists and is not a youwee symlink. Remove it manually before reinstalling.", link.display() )); } diff --git a/src-tauri/src/commands/dependencies.rs b/src-tauri/src/commands/dependencies.rs index 53e0889a..176dba3c 100644 --- a/src-tauri/src/commands/dependencies.rs +++ b/src-tauri/src/commands/dependencies.rs @@ -1,12 +1,13 @@ use crate::services::{ check_deno_internal, check_deno_update_internal, check_ffmpeg_internal, check_ffmpeg_update_internal, check_gallerydl_internal, get_all_ytdlp_versions, - get_channel_api_url, get_deno_download_url, get_ffmpeg_download_info, get_ffmpeg_path, - get_ffmpeg_source, get_latest_ffmpeg_release_info, get_ytdlp_channel, - get_ytdlp_channel_download_url, get_ytdlp_download_info, get_ytdlp_source, - get_ytdlp_version_internal, parse_ffmpeg_version, set_ffmpeg_source, set_ytdlp_channel, - set_ytdlp_source, system_ffmpeg_upgrade_message, system_ytdlp_upgrade_message, verify_sha256, - write_app_ffmpeg_release_version, DenoUpdateInfo, FfmpegUpdateInfo, + get_channel_api_url, get_deno_download_url, get_deno_path, get_ffmpeg_download_info, + get_ffmpeg_path, get_ffmpeg_source, get_gallerydl_path, get_latest_ffmpeg_release_info, + get_ytdlp_channel, get_ytdlp_channel_download_url, get_ytdlp_download_info, get_ytdlp_path, + get_ytdlp_source, get_ytdlp_version_internal, parse_ffmpeg_version, set_ffmpeg_source, + set_ytdlp_channel, set_ytdlp_source, system_ffmpeg_upgrade_message, + system_ytdlp_upgrade_message, verify_sha256, write_app_ffmpeg_release_version, + DenoUpdateInfo, FfmpegUpdateInfo, }; use crate::types::{ BackendError, DenoStatus, DependencySource, FfmpegStatus, GalleryDlStatus, YtdlpAllVersions, @@ -49,6 +50,42 @@ pub struct BrowserProfile { pub display_name: String, // Shown to user: "Loc Nguyen" or fallback to folder_name } +/// Per-engine startup/upgrade compatibility test result (binary runs: --version probe) +#[derive(Clone, Serialize)] +pub struct EngineCompatResult { + pub engine: String, + pub ok: bool, + pub version: Option, + pub error: Option, +} + +/// gallery-dl update info for app-managed installs +#[derive(Clone, Serialize)] +pub struct GalleryDlUpdateInfo { + pub has_update: bool, + pub current_version: Option, + pub latest_version: Option, + pub release_url: Option, +} + +/// Whether a previous working version backup exists for an engine +#[derive(Clone, Serialize)] +pub struct EngineBackupInfo { + pub available: bool, + pub version: Option, +} + +/// Aggregate previous-version backup availability for all engines +#[derive(Clone, Serialize)] +pub struct EngineBackupsStatus { + pub ytdlp: EngineBackupInfo, + pub ytdlp_stable: EngineBackupInfo, + pub ytdlp_nightly: EngineBackupInfo, + pub ffmpeg: EngineBackupInfo, + pub deno: EngineBackupInfo, + pub gallerydl: EngineBackupInfo, +} + #[tauri::command] pub async fn get_ytdlp_version(app: AppHandle) -> Result { get_ytdlp_version_internal(&app).await @@ -120,6 +157,7 @@ pub async fn update_ytdlp(app: AppHandle) -> Result { .map_err(|e| format!("Failed to create bin directory: {}", e))?; let binary_path = bin_dir.join(filename); + let engine_key = "ytdlp"; let client = reqwest::Client::builder() .user_agent("Youwee/0.4.0") @@ -180,7 +218,11 @@ pub async fn update_ytdlp(app: AppHandle) -> Result { // Verify checksum if !verify_sha256(&bytes, &expected_hash) { - return Err("Security error: SHA256 checksum verification failed.".to_string()); + return Err(BackendError::new( + "CHECKSUM_VERIFICATION_FAILED", + "Security error: SHA256 checksum verification failed.", + ) + .to_wire_string()); } // Write binary @@ -202,20 +244,17 @@ pub async fn update_ytdlp(app: AppHandle) -> Result { .map_err(|e| format!("Failed to set permissions: {}", e))?; } + // Preserve the last working version before replacing it + backup_engine_binary(&app, engine_key).await?; + tokio::fs::rename(&temp_path, &binary_path) .await .map_err(|e| format!("Failed to rename binary: {}", e))?; - // Get version - let mut cmd = Command::new(&binary_path); - cmd.args(["--version"]); - cmd.hide_window(); - let output = cmd - .output() - .await - .map_err(|e| format!("Failed to verify update: {}", e))?; + // Verify the installed binary runs; on failure the previous version is restored + let version = verify_installed_engine(&app, engine_key).await?; - Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + Ok(version) } // ============ yt-dlp Channel Commands ============ @@ -364,6 +403,13 @@ pub async fn download_ytdlp_channel(app: AppHandle, channel: String) -> Result "ytdlp", + YtdlpChannel::Stable => "ytdlp_stable", + YtdlpChannel::Nightly => "ytdlp_nightly", + }; + // Get download URL for the channel let (download_url, checksum_filename) = get_ytdlp_channel_download_url(&channel_enum).ok_or("Cannot download bundled channel")?; @@ -464,7 +510,11 @@ pub async fn download_ytdlp_channel(app: AppHandle, channel: String) -> Result Result Result { // Verify checksum if !verify_sha256(&bytes, &expected_hash) { let _ = tokio::fs::remove_file(&temp_path).await; - return Err("Security error: SHA256 checksum verification failed.".to_string()); + return Err(BackendError::new( + "CHECKSUM_VERIFICATION_FAILED", + "Security error: SHA256 checksum verification failed.", + ) + .to_wire_string()); } // Emit: Extracting @@ -712,12 +763,33 @@ pub async fn download_ffmpeg(app: AppHandle) -> Result { #[cfg(not(windows))] let ffmpeg_binary = "ffmpeg"; + #[cfg(unix)] let ffmpeg_path = bin_dir.join(ffmpeg_binary); + #[cfg(not(unix))] + let _ffmpeg_path = bin_dir.join(ffmpeg_binary); + + // Preserve the last working version before replacing it + backup_engine_binary(&app, "ffmpeg").await?; match info.archive_type { - "tar.gz" => extract_tar_gz(&bytes, &bin_dir, ffmpeg_binary).await?, - "tar.xz" => extract_tar_xz(&bytes, &bin_dir, ffmpeg_binary).await?, - "zip" => extract_zip(&bytes, &bin_dir, ffmpeg_binary).await?, + "tar.gz" => { + if let Err(e) = extract_tar_gz(&bytes, &bin_dir, ffmpeg_binary).await { + let _ = rollback_engine_binary(&app, "ffmpeg").await; + return Err(format!("Failed to extract FFmpeg archive: {}", e)); + } + } + "tar.xz" => { + if let Err(e) = extract_tar_xz(&bytes, &bin_dir, ffmpeg_binary).await { + let _ = rollback_engine_binary(&app, "ffmpeg").await; + return Err(format!("Failed to extract FFmpeg archive: {}", e)); + } + } + "zip" => { + if let Err(e) = extract_zip(&bytes, &bin_dir, ffmpeg_binary).await { + let _ = rollback_engine_binary(&app, "ffmpeg").await; + return Err(format!("Failed to extract FFmpeg archive: {}", e)); + } + } _ => return Err("Unsupported archive type".to_string()), } @@ -748,31 +820,9 @@ pub async fn download_ffmpeg(app: AppHandle) -> Result { }, ); - let mut cmd = Command::new(&ffmpeg_path); - cmd.args(["-version"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - cmd.hide_window(); - let output = cmd - .output() - .await - .map_err(|e| format!("Failed to verify FFmpeg installation: {}", e))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let details = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - format!("exit code {}", output.status) - }; - - return Err(format!("Failed to verify FFmpeg installation: {}", details)); - } - - let binary_version = parse_ffmpeg_version(&String::from_utf8_lossy(&output.stdout)); + // Verify the installed binary runs; on failure the previous version is restored + let verified_output = verify_installed_engine(&app, "ffmpeg").await?; + let binary_version = parse_ffmpeg_version(&verified_output); let latest_release = get_latest_ffmpeg_release_info().await.ok(); if let Some(release) = latest_release { @@ -920,10 +970,19 @@ pub async fn download_deno(app: AppHandle) -> Result { #[cfg(not(windows))] let deno_binary = "deno"; + #[cfg(unix)] let deno_path = bin_dir.join(deno_binary); + #[cfg(not(unix))] + let _deno_path = bin_dir.join(deno_binary); + + // Preserve the last working version before replacing it + backup_engine_binary(&app, "deno").await?; // Extract deno from zip (deno zip contains just the binary directly) - extract_deno_zip(&bytes, &bin_dir, deno_binary).await?; + if let Err(e) = extract_deno_zip(&bytes, &bin_dir, deno_binary).await { + let _ = rollback_engine_binary(&app, "deno").await; + return Err(format!("Failed to extract Deno archive: {}", e)); + } // Clean up temp file let _ = tokio::fs::remove_file(&temp_path).await; @@ -952,19 +1011,11 @@ pub async fn download_deno(app: AppHandle) -> Result { }, ); - let mut cmd = Command::new(&deno_path); - cmd.args(["--version"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - cmd.hide_window(); - let output = cmd - .output() - .await - .map_err(|e| format!("Failed to verify Deno installation: {}", e))?; + // Verify the installed binary runs; on failure the previous version is restored + let verified_output = verify_installed_engine(&app, "deno").await?; // Parse version from "deno 2.1.2 (...)" format - let version_output = String::from_utf8_lossy(&output.stdout); - let version = version_output + let version = verified_output .lines() .next() .map(|l| { @@ -1364,3 +1415,607 @@ pub async fn get_browser_profiles(browser: String) -> Result Ok(profiles) } + + +// ============ Engine Backup, Rollback, Compatibility & gallery-dl Manager ============ + +const ENGINE_BACKUP_DIR: &str = ".previous"; + +/// Map an engine key to its app-managed binary file name (bin/) +fn engine_file_name(engine: &str) -> Option<&'static str> { + #[cfg(windows)] + let name = match engine { + "ytdlp" => "yt-dlp.exe", + "ytdlp_stable" => "yt-dlp-stable.exe", + "ytdlp_nightly" => "yt-dlp-nightly.exe", + "ffmpeg" => "ffmpeg.exe", + "deno" => "deno.exe", + "gallerydl" => "gallery-dl.exe", + _ => return None, + }; + #[cfg(not(windows))] + let name = match engine { + "ytdlp" => "yt-dlp", + "ytdlp_stable" => "yt-dlp-stable", + "ytdlp_nightly" => "yt-dlp-nightly", + "ffmpeg" => "ffmpeg", + "deno" => "deno", + "gallerydl" => "gallery-dl", + _ => return None, + }; + Some(name) +} + +fn engine_version_args(engine: &str) -> &'static [&'static str] { + if engine == "ffmpeg" { + &["-version"] + } else { + &["--version"] + } +} + +/// Run ` --version` (or `-version` for FFmpeg) and return the raw output. +async fn probe_binary_version(path: &std::path::Path, engine: &str) -> Result { + let mut cmd = Command::new(path); + cmd.args(engine_version_args(engine)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.hide_window(); + + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to run {}: {}", engine, e))?; + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if !output.status.success() { + let detail = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + format!("exit code {}", output.status) + }; + return Err(detail); + } + if stdout.is_empty() { + return Err("binary produced no version output".to_string()); + } + Ok(stdout) +} + +async fn app_bin_dir(app: &AppHandle) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + let bin_dir = app_data_dir.join("bin"); + tokio::fs::create_dir_all(&bin_dir) + .await + .map_err(|e| format!("Failed to create bin directory: {}", e))?; + Ok(bin_dir) +} + +/// Copy the current binary to `bin/.previous/` before it is replaced, so a failed +/// update can always be undone. A failure here aborts the update (never delete the last +/// working version). The captured version is stored next to the backup for the UI. +async fn backup_engine_binary(app: &AppHandle, engine: &str) -> Result<(), String> { + let Some(file_name) = engine_file_name(engine) else { + return Err(format!("Unknown engine: {}", engine)); + }; + + let bin_dir = app_bin_dir(app).await?; + let binary_path = bin_dir.join(file_name); + if !binary_path.exists() { + return Ok(()); // First install - nothing to preserve + } + + let previous_dir = bin_dir.join(ENGINE_BACKUP_DIR); + tokio::fs::create_dir_all(&previous_dir) + .await + .map_err(|e| format!("Failed to create previous-version directory: {}", e))?; + + let backup_path = previous_dir.join(file_name); + tokio::fs::copy(&binary_path, &backup_path) + .await + .map_err(|e| format!("Failed to back up previous {}: {}", engine, e))?; + + // Best-effort version capture for the UI + let version = probe_binary_version(&binary_path, engine).await.unwrap_or_default(); + let _ = tokio::fs::write(previous_dir.join(format!("{}.version", file_name)), version).await; + + Ok(()) +} + +/// Restore the last working version from `bin/.previous/`. +async fn rollback_engine_binary(app: &AppHandle, engine: &str) -> Result { + let Some(file_name) = engine_file_name(engine) else { + return Err(format!("Unknown engine: {}", engine)); + }; + + let bin_dir = app_bin_dir(app).await?; + let previous_dir = bin_dir.join(ENGINE_BACKUP_DIR); + let backup_path = previous_dir.join(file_name); + + if !backup_path.exists() { + return Err(BackendError::new( + "ENGINE_ROLLBACK_NOT_AVAILABLE", + format!("No previous version is available for {}.", engine), + ) + .to_wire_string()); + } + + // Preserve the currently-installed binary before restoring (swap semantics) + // so a manual rollback never destroys a working newer version. + let binary_path = bin_dir.join(file_name); + if binary_path.exists() { + let current_backup = previous_dir.join(format!("current.{}", file_name)); + tokio::fs::copy(&binary_path, ¤t_backup).await.ok(); + let _ = tokio::fs::write( + previous_dir.join(format!("current.{}.version", file_name)), + probe_binary_version(&binary_path, engine).await.unwrap_or_default(), + ) + .await; + } + + // The backup must run before it is allowed to replace the working binary + let version = probe_binary_version(&backup_path, engine).await.map_err(|detail| { + BackendError::new( + "ENGINE_ROLLBACK_VERIFY_FAILED", + format!("The previous version is not runnable: {}", detail), + ) + .to_wire_string() + })?; + + let temp_path = bin_dir.join(format!("{}.rollback.tmp", file_name)); + tokio::fs::copy(&backup_path, &temp_path) + .await + .map_err(|e| format!("Failed to stage previous version: {}", e))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = tokio::fs::metadata(&temp_path) + .await + .map_err(|e| format!("Failed to get file metadata: {}", e))? + .permissions(); + perms.set_mode(0o755); + tokio::fs::set_permissions(&temp_path, perms) + .await + .map_err(|e| format!("Failed to set permissions: {}", e))?; + } + + tokio::fs::rename(&temp_path, &binary_path) + .await + .map_err(|e| format!("Failed to restore previous version: {}", e))?; + + Ok(version) +} + +/// Verify a freshly installed binary runs; on failure restore the previous version so an +/// update can never leave the user without a working engine. +async fn verify_installed_engine(app: &AppHandle, engine: &str) -> Result { + let Some(file_name) = engine_file_name(engine) else { + return Err(format!("Unknown engine: {}", engine)); + }; + let bin_dir = app_bin_dir(app).await?; + let binary_path = bin_dir.join(file_name); + + match probe_binary_version(&binary_path, engine).await { + Ok(version) => Ok(version), + Err(detail) => { + let restored = rollback_engine_binary(app, engine).await; + let message = match restored { + Ok(previous_version) => format!( + "Update failed: the installed binary did not run ({}) and the previous version {} was restored.", + detail, previous_version + ), + Err(rollback_error) => format!( + "Update failed: the installed binary did not run ({}) and restoring the previous version also failed ({}).", + detail, rollback_error + ), + }; + Err(BackendError::new("ENGINE_VERSION_CHECK_FAILED", message).to_wire_string()) + } + } +} + +/// Startup/upgrade compatibility test: run --version for every engine's resolved binary. +#[tauri::command] +pub async fn check_engine_compat(app: AppHandle) -> Result, String> { + let ytdlp_path = get_ytdlp_path(&app).await.map(|(path, _)| path); + let entries = [ + ("ytdlp", ytdlp_path), + ("ffmpeg", get_ffmpeg_path(&app).await), + ("deno", get_deno_path(&app).await), + ("gallerydl", get_gallerydl_path(&app)), + ]; + + let mut results = Vec::new(); + for (engine, path) in entries { + let result = match path { + Some(path) if path.exists() => match probe_binary_version(&path, engine).await { + Ok(version) => EngineCompatResult { + engine: engine.to_string(), + ok: true, + version: Some(version), + error: None, + }, + Err(error) => EngineCompatResult { + engine: engine.to_string(), + ok: false, + version: None, + error: Some(error), + }, + }, + _ => EngineCompatResult { + engine: engine.to_string(), + ok: false, + version: None, + error: Some("binary not found".to_string()), + }, + }; + results.push(result); + } + + Ok(results) +} + +/// Report whether a previous working version backup exists for each engine. +#[tauri::command] +pub async fn check_engine_backups(app: AppHandle) -> Result { + let Ok(app_data_dir) = app.path().app_data_dir() else { + return Ok(EngineBackupsStatus { + ytdlp: EngineBackupInfo { available: false, version: None }, + ytdlp_stable: EngineBackupInfo { available: false, version: None }, + ytdlp_nightly: EngineBackupInfo { available: false, version: None }, + ffmpeg: EngineBackupInfo { available: false, version: None }, + deno: EngineBackupInfo { available: false, version: None }, + gallerydl: EngineBackupInfo { available: false, version: None }, + }); + }; + let previous_dir = app_data_dir.join("bin").join(ENGINE_BACKUP_DIR); + + let read_backup = |engine: &str| -> EngineBackupInfo { + let Some(file_name) = engine_file_name(engine) else { + return EngineBackupInfo { available: false, version: None }; + }; + let backup_path = previous_dir.join(file_name); + if !backup_path.exists() { + return EngineBackupInfo { available: false, version: None }; + } + let version = std::fs::read_to_string(previous_dir.join(format!("{}.version", file_name))) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + EngineBackupInfo { + available: true, + version, + } + }; + + Ok(EngineBackupsStatus { + ytdlp: read_backup("ytdlp"), + ytdlp_stable: read_backup("ytdlp_stable"), + ytdlp_nightly: read_backup("ytdlp_nightly"), + ffmpeg: read_backup("ffmpeg"), + deno: read_backup("deno"), + gallerydl: read_backup("gallerydl"), + }) +} + +/// One-click rollback for yt-dlp (channel: bundled | stable | nightly). +/// The channel is optional: when omitted, the currently configured channel is used. +#[tauri::command] +pub async fn rollback_ytdlp(app: AppHandle, channel: Option) -> Result { + let channel = match channel { + Some(channel) => channel, + None => get_ytdlp_channel(&app).await.as_str().to_string(), + }; + let engine = match channel.to_lowercase().as_str() { + "stable" => "ytdlp_stable", + "nightly" => "ytdlp_nightly", + _ => "ytdlp", + }; + rollback_engine_binary(&app, engine).await +} + +/// One-click rollback for FFmpeg. +#[tauri::command] +pub async fn rollback_ffmpeg(app: AppHandle) -> Result { + rollback_engine_binary(&app, "ffmpeg").await +} + +/// One-click rollback for Deno. +#[tauri::command] +pub async fn rollback_deno(app: AppHandle) -> Result { + rollback_engine_binary(&app, "deno").await +} + +/// One-click rollback for gallery-dl. +#[tauri::command] +pub async fn rollback_gallerydl(app: AppHandle) -> Result { + rollback_engine_binary(&app, "gallerydl").await +} + +/// Check the latest gallery-dl release (app-managed installs only). +#[tauri::command] +pub async fn check_gallerydl_update(app: AppHandle) -> Result { + let current_status = check_gallerydl_internal(&app).await?; + + if !current_status.installed { + return Ok(GalleryDlUpdateInfo { + has_update: false, + current_version: None, + latest_version: None, + release_url: None, + }); + } + + let current_version = current_status.version.clone(); + + // System gallery-dl is managed outside the app + if current_status.is_system { + return Ok(GalleryDlUpdateInfo { + has_update: false, + current_version, + latest_version: None, + release_url: Some( + "System gallery-dl - update via package manager".to_string(), + ), + }); + } + + let client = reqwest::Client::builder() + .user_agent("Youwee/0.6.0") + .timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .get("https://api.github.com/repos/mikf/gallery-dl/releases/latest") + .send() + .await + .map_err(|e| { + if e.is_timeout() { + "Request timed out. Please try again later.".to_string() + } else if e.is_connect() { + "Unable to connect. Please check your internet connection.".to_string() + } else { + format!("Failed to check for updates: {}", e) + } + })?; + + let status = response.status(); + if status == reqwest::StatusCode::FORBIDDEN || status == reqwest::StatusCode::TOO_MANY_REQUESTS + { + return Err("GitHub API rate limit exceeded. Please try again later.".to_string()); + } + if !status.is_success() { + return Err(format!("GitHub API error: {}", status)); + } + + let release: GitHubRelease = response + .json() + .await + .map_err(|e| format!("Failed to parse release info: {}", e))?; + + let latest_version = release.tag_name; + let normalize_version = |v: &str| { + v.trim() + .trim_start_matches('v') + .trim_start_matches("gallery-dl ") + .to_string() + }; + let has_update = current_version + .as_ref() + .map(|cv| normalize_version(cv) != normalize_version(&latest_version)) + .unwrap_or(false); + + Ok(GalleryDlUpdateInfo { + has_update, + current_version, + latest_version: Some(latest_version.clone()), + release_url: Some(format!( + "https://github.com/mikf/gallery-dl/releases/tag/{}", + latest_version + )), + }) +} + +/// Download/update the app-managed gallery-dl binary from the official GitHub release, +/// verifying the SHA-256 checksum before replacing the executable. +#[tauri::command] +pub async fn download_gallerydl(app: AppHandle) -> Result { + #[cfg(windows)] + let (download_url, checksum_filename) = ( + "https://github.com/mikf/gallery-dl/releases/latest/download/gallery-dl.exe", + "gallery-dl.exe", + ); + #[cfg(not(windows))] + let (download_url, checksum_filename) = ( + "https://github.com/mikf/gallery-dl/releases/latest/download/gallery-dl.bin", + "gallery-dl.bin", + ); + + // Emit: Starting (checksum fetch) + let _ = app.emit( + "gallerydl-download-progress", + DownloadProgress { + stage: "checksum".to_string(), + percent: 0, + downloaded: 0, + total: 0, + }, + ); + + let bin_dir = app_bin_dir(&app).await?; + + let client = reqwest::Client::builder() + .user_agent("Youwee/0.6.0") + .timeout(std::time::Duration::from_secs(300)) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + // Download checksums + let checksums_response = client + .get("https://github.com/mikf/gallery-dl/releases/latest/download/SHA2-256SUMS") + .send() + .await + .map_err(|e| format!("Failed to download checksums: {}", e))?; + + if !checksums_response.status().is_success() { + return Err(format!( + "Failed to download checksums: HTTP {}", + checksums_response.status() + )); + } + + let checksums_text = checksums_response + .text() + .await + .map_err(|e| format!("Failed to read checksums: {}", e))?; + + let expected_hash = checksums_text + .lines() + .find_map(|line| { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 && parts[1] == checksum_filename { + Some(parts[0].to_string()) + } else { + None + } + }) + .ok_or_else(|| format!("Checksum not found for {}", checksum_filename))?; + + // Emit: Downloading + let _ = app.emit( + "gallerydl-download-progress", + DownloadProgress { + stage: "downloading".to_string(), + percent: 0, + downloaded: 0, + total: 0, + }, + ); + + let response = client + .get(download_url) + .send() + .await + .map_err(|e| format!("Failed to download gallery-dl: {}", e))?; + + if !response.status().is_success() { + return Err(format!("Download failed with status: {}", response.status())); + } + + let total_size = response.content_length().unwrap_or(0); + let mut downloaded: u64 = 0; + let mut last_percent: u8 = 0; + + let temp_path = bin_dir.join("gallerydl_download.tmp"); + let mut file = tokio::fs::File::create(&temp_path) + .await + .map_err(|e| format!("Failed to create temp file: {}", e))?; + + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Download error: {}", e))?; + file.write_all(&chunk) + .await + .map_err(|e| format!("Failed to write chunk: {}", e))?; + + downloaded += chunk.len() as u64; + let percent = if total_size > 0 { + ((downloaded as f64 / total_size as f64) * 100.0) as u8 + } else { + 0 + }; + + // Only emit every 5% to avoid spamming + if percent >= last_percent + 5 || percent == 100 { + last_percent = percent; + let _ = app.emit( + "gallerydl-download-progress", + DownloadProgress { + stage: "downloading".to_string(), + percent, + downloaded, + total: total_size, + }, + ); + } + } + + file.flush() + .await + .map_err(|e| format!("Failed to flush file: {}", e))?; + drop(file); + + let bytes = tokio::fs::read(&temp_path) + .await + .map_err(|e| format!("Failed to read downloaded file: {}", e))?; + + // Emit: Verifying checksum + let _ = app.emit( + "gallerydl-download-progress", + DownloadProgress { + stage: "verifying".to_string(), + percent: 100, + downloaded, + total: total_size, + }, + ); + + if !verify_sha256(&bytes, &expected_hash) { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(BackendError::new( + "CHECKSUM_VERIFICATION_FAILED", + "Security error: SHA256 checksum verification failed.", + ) + .to_wire_string()); + } + + // Preserve the last working version before replacing it + backup_engine_binary(&app, "gallerydl").await?; + + #[cfg(windows)] + let binary_name = "gallery-dl.exe"; + #[cfg(not(windows))] + let binary_name = "gallery-dl"; + let binary_path = bin_dir.join(binary_name); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = tokio::fs::metadata(&temp_path) + .await + .map_err(|e| format!("Failed to get file metadata: {}", e))? + .permissions(); + perms.set_mode(0o755); + tokio::fs::set_permissions(&temp_path, perms) + .await + .map_err(|e| format!("Failed to set permissions: {}", e))?; + } + + tokio::fs::rename(&temp_path, &binary_path) + .await + .map_err(|e| format!("Failed to install gallery-dl: {}", e))?; + + // Emit: Complete + let _ = app.emit( + "gallerydl-download-progress", + DownloadProgress { + stage: "complete".to_string(), + percent: 100, + downloaded, + total: total_size, + }, + ); + + // Verify the installed binary runs; on failure the previous version is restored + let version = verify_installed_engine(&app, "gallerydl").await?; + Ok(version) +} diff --git a/src-tauri/src/commands/download.rs b/src-tauri/src/commands/download.rs index ab23a1ba..71a3b279 100644 --- a/src-tauri/src/commands/download.rs +++ b/src-tauri/src/commands/download.rs @@ -12,6 +12,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use crate::utils::{normalize_url, validate_url}; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter}; use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::ShellExt; @@ -25,9 +26,11 @@ use crate::database::ensure_collection_for_download_in_db; use crate::database::update_history_download; use crate::services::{ add_safe_filename_args, build_cookie_args, build_proxy_args, build_site_header_args, - build_youtube_extractor_args, build_ytdlp_advanced_args, enqueue_post_download_workflow, + build_youtube_extractor_args, build_ytdlp_advanced_args, build_ytdlp_raw_args, + enqueue_post_download_workflow, get_deno_path, get_ffmpeg_path, get_ytdlp_path, get_ytdlp_source, is_upcoming_live_error, - redact_ytdlp_advanced_args, resolve_download_workflow_snapshot, run_ytdlp_with_stderr, + redact_download_args_for_log, resolve_download_workflow_snapshot, + run_ytdlp_with_stderr, system_ytdlp_not_found_message, YtdlpAdvancedOption, }; use crate::types::{ @@ -40,6 +43,19 @@ use crate::utils::{ pub static CANCEL_FLAG: AtomicBool = AtomicBool::new(false); +/// Per-item cancellation registry (P0-7). An id inserted here is treated as +/// cancelled by the poll loops, which kill only that item's process instead of +/// every active download. +pub static CANCEL_ITEM_IDS: Mutex> = Mutex::new(Vec::new()); + +fn is_item_cancelled(id: &str) -> bool { + CANCEL_FLAG.load(Ordering::SeqCst) + || CANCEL_ITEM_IDS + .lock() + .map(|ids| ids.iter().any(|existing| existing == id)) + .unwrap_or(false) +} + const RECENT_OUTPUT_LIMIT: usize = 30; fn extract_time_range(download_sections: &Option) -> Option { @@ -854,6 +870,32 @@ fn kill_all_download_processes() { cmd2.spawn().ok(); } } +/// Kill only one download's process tree (yt-dlp + its ffmpeg children) by PID, +/// without touching other concurrent downloads. +fn kill_process_tree_pid(pid: u32) { + #[cfg(windows)] + { + use crate::utils::CommandExt as _; + use std::process::Command as StdCommand; + let mut cmd = StdCommand::new("taskkill"); + cmd.args(["/F", "/T", "/PID", &pid.to_string()]); + cmd.hide_window(); + cmd.spawn().ok(); + } + #[cfg(unix)] + { + use std::process::Command as StdCommand; + StdCommand::new("pkill") + .args(["-9", "-P", &pid.to_string()]) + .spawn() + .ok(); + StdCommand::new("kill") + .args(["-9", &pid.to_string()]) + .spawn() + .ok(); + } +} + fn push_recent_output(buffer: &mut VecDeque, line: &str) { let trimmed = line.trim(); @@ -966,165 +1008,102 @@ fn build_download_error_message(exit_code: Option, recent_lines: &[String]) } } -#[tauri::command] -pub async fn download_video( - app: AppHandle, - id: String, - url: String, - output_path: String, - quality: String, - format: String, - download_playlist: bool, - playlist_index: Option, - playlist_total: Option, - number_playlist_items: Option, - queue_index: Option, - queue_total: Option, - number_queue_items: Option, - filename_metadata_enabled: Option, - filename_metadata_fields: Option>, - split_embedded_chapters: Option, - number_chapter_files: Option, - auto_organize_collections: Option, - playlist_collection_name: Option, - video_codec: String, - preferred_fps: Option, - audio_bitrate: String, - playlist_limit: Option, - subtitle_mode: String, - subtitle_langs: String, - subtitle_embed: bool, - subtitle_format: String, - log_stderr: Option, - _use_bun_runtime: Option, // Deprecated - now auto uses deno - use_actual_player_js: Option, - history_id: Option, - // Cookie settings - cookie_mode: Option, - cookie_browser: Option, - cookie_browser_profile: Option, - cookie_file_path: Option, - cookie_skip_patterns: Option>, - // Embed settings - embed_metadata: Option, - embed_thumbnail: Option, - // Proxy settings - proxy_url: Option, - // Live stream settings - live_from_start: Option, - skip_live: Option, - // Speed limit settings - speed_limit: Option, - // External downloader settings - use_aria2: Option, - aria2_args: Option, - // Vetted yt-dlp advanced options - ytdlp_advanced_options_enabled: Option, - ytdlp_advanced_options: Option>, - // SponsorBlock settings - sponsorblock_remove: Option, // comma-separated categories to remove - sponsorblock_mark: Option, // comma-separated categories to mark as chapters - // Download sections (time range) - download_sections: Option, // e.g. "*10:30-14:30" for partial download - // Title (optional, passed from frontend for display purposes) - title: Option, - // Thumbnail URL (optional, passed from frontend for non-YouTube sites) - thumbnail: Option, - // Source/extractor name (optional, from yt-dlp extractor e.g. "BiliBili", "TikTok") - source: Option, - // Legacy snapshot of plugin ids enabled when the job was queued - post_download_plugins: Option>, - // Snapshot of workflow steps by trigger at queue time - plugin_workflow_snapshots: Option>>, - // Full workflow step snapshot used for post-processing - post_download_workflow_steps: Option>, - // When false, caller is responsible for firing the final download.failed workflow. - emit_failed_workflow: Option, - // Caller context used in plugin payload - download_kind: Option, -) -> Result<(), String> { - CANCEL_FLAG.store(false, Ordering::SeqCst); - validate_url(&url).map_err(|e| BackendError::from_message(e).to_wire_string())?; - let url = normalize_url(&url); - let post_download_plugins = post_download_plugins.unwrap_or_default(); - let mut plugin_workflow_snapshots = plugin_workflow_snapshots.unwrap_or_default(); - if !plugin_workflow_snapshots.contains_key("download.completed") { - let completed_steps = resolve_download_workflow_snapshot( - &app, - "download.completed", - post_download_workflow_steps, - &post_download_plugins, - ); - plugin_workflow_snapshots.insert("download.completed".to_string(), completed_steps); - } - let emit_failed_workflow = emit_failed_workflow.unwrap_or(true); - let download_kind = download_kind.unwrap_or_else(|| "download".to_string()); +/// Inputs for the pure yt-dlp argument builder. Mirrors the `download_video` +/// parameters that affect the command line. `deno_path`, `ffmpeg_path` and +/// `filepath_tmp` are optional: when omitted they are resolved/computed by the +/// caller (or by `preview_download_command` via the app handle). +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadArgsRequest { + pub id: String, + pub url: String, + pub output_path: String, + pub quality: String, + pub format: String, + pub download_playlist: bool, + pub playlist_index: Option, + pub playlist_total: Option, + pub number_playlist_items: bool, + pub queue_index: Option, + pub queue_total: Option, + pub number_queue_items: bool, + pub filename_metadata_enabled: bool, + pub filename_metadata_fields: Vec, + pub split_embedded_chapters: bool, + pub number_chapter_files: bool, + pub video_codec: String, + pub preferred_fps: Option, + pub audio_bitrate: String, + pub playlist_limit: Option, + pub subtitle_mode: String, + pub subtitle_langs: String, + pub subtitle_embed: bool, + pub subtitle_format: String, + pub use_actual_player_js: bool, + pub cookie_mode: Option, + pub cookie_browser: Option, + pub cookie_browser_profile: Option, + pub cookie_file_path: Option, + pub cookie_skip_patterns: Vec, + pub embed_metadata: bool, + pub embed_thumbnail: bool, + pub proxy_url: Option, + pub live_from_start: bool, + pub speed_limit: Option, + pub use_aria2: bool, + pub aria2_args: Option, + pub ytdlp_advanced_options_enabled: bool, + pub ytdlp_advanced_options: Vec, + pub ytdlp_raw_args: Option, + pub sponsorblock_remove: Option, + pub sponsorblock_mark: Option, + pub download_sections: Option, + #[serde(default)] + pub filepath_tmp: Option, + #[serde(default)] + pub deno_path: Option, + #[serde(default)] + pub ffmpeg_path: Option, +} - if skip_live.unwrap_or(false) { - if let Some(live_status) = skipped_live_status( - &app, - &url, - cookie_mode.as_deref(), - cookie_browser.as_deref(), - cookie_browser_profile.as_deref(), - cookie_file_path.as_deref(), - cookie_skip_patterns.as_deref(), - proxy_url.as_deref(), - ) - .await? - { - add_log_internal( - "info", - &format!( - "Skipped live video due to --skip-live (status: {})", - live_status - ), - None, - Some(&url), - ) - .ok(); - return Err(BackendError::new( - crate::types::code::YT_SKIPPED_LIVE, - format!("Skipped live video (status: {})", live_status), - ) - .with_retryable(false) - .with_param("liveStatus", live_status) - .to_wire_string()); - } - } +/// Result of building a download command line. +pub struct BuiltDownloadArgs { + pub args: Vec, + pub skipped_options: Vec, + pub youtube_player_client: Option, +} - let should_log_stderr = log_stderr.unwrap_or(true); - let sanitized_path = sanitize_output_path(&output_path) - .map_err(|e| BackendError::from_message(e).to_wire_string())?; - let format_string = - build_format_string(&quality, &format, &video_codec, preferred_fps.as_deref()); - let number_playlist_items = number_playlist_items.unwrap_or(false); - let number_queue_items = number_queue_items.unwrap_or(false); - let filename_metadata_enabled = filename_metadata_enabled.unwrap_or(false); - let filename_metadata_fields = filename_metadata_fields.unwrap_or_default(); - let split_embedded_chapters = split_embedded_chapters.unwrap_or(false); - let number_chapter_files = number_chapter_files.unwrap_or(true); +/// Pure yt-dlp argument builder shared by `download_video` and +/// `preview_download_command`. Everything is assembled into an argv array — +/// never a shell string. Expert raw arguments are tokenized and allowlisted +/// here, so invalid options fail BEFORE the download starts. +pub fn build_download_args(req: &DownloadArgsRequest) -> Result { + let sanitized_path = + sanitize_output_path(&req.output_path).map_err(BackendError::from_message)?; + let format_string = build_format_string( + &req.quality, + &req.format, + &req.video_codec, + req.preferred_fps.as_deref(), + ); let has_filename_metadata = - build_filename_metadata_prefix(filename_metadata_enabled, &filename_metadata_fields) + build_filename_metadata_prefix(req.filename_metadata_enabled, &req.filename_metadata_fields) .is_some(); let output_template = build_output_template( &sanitized_path, - number_playlist_items, - playlist_index, - playlist_total, - number_queue_items, - queue_index, - queue_total, - filename_metadata_enabled, - &filename_metadata_fields, + req.number_playlist_items, + req.playlist_index, + req.playlist_total, + req.number_queue_items, + req.queue_index, + req.queue_total, + req.filename_metadata_enabled, + &req.filename_metadata_fields, ); - - // Use a temp file to capture the final filepath from yt-dlp. - // On Windows with non-UTF-8 locales (e.g. Chinese/GBK), stdout is encoded - // in the system ANSI code page which cannot represent all Unicode characters - // (such as ⧸ U+29F8 used by yt-dlp to replace / in filenames). - // --print-to-file always writes UTF-8, so we get the exact filepath. - let filepath_tmp = std::env::temp_dir().join(format!("youwee-fp-{}.txt", id)); + let filepath_tmp = req + .filepath_tmp + .clone() + .unwrap_or_else(|| std::env::temp_dir().join(format!("youwee-fp-{}.txt", req.id))); let mut args = vec![ "--newline".to_string(), @@ -1154,35 +1133,35 @@ pub async fn download_video( args.push("unknown".to_string()); } - if split_embedded_chapters { + if req.split_embedded_chapters { args.push("--split-chapters".to_string()); args.push("-o".to_string()); args.push(format!( "chapter:{}", build_chapter_output_template( &sanitized_path, - number_playlist_items, - playlist_index, - playlist_total, - number_queue_items, - queue_index, - queue_total, - number_chapter_files, + req.number_playlist_items, + req.playlist_index, + req.playlist_total, + req.number_queue_items, + req.queue_index, + req.queue_total, + req.number_chapter_files, ) )); } // Auto use Deno runtime for YouTube (required for JS extractor) // Use --js-runtimes instead of --extractor-args (handles spaces in path correctly) - if url.contains("youtube.com") || url.contains("youtu.be") { - if let Some(deno_path) = get_deno_path(&app).await { + if req.url.contains("youtube.com") || req.url.contains("youtu.be") { + if let Some(deno_path) = req.deno_path.as_ref() { args.push("--js-runtimes".to_string()); args.push(format!("deno:{}", deno_path.to_string_lossy())); } } // Add FFmpeg location if available - if let Some(ffmpeg_path) = get_ffmpeg_path(&app).await { + if let Some(ffmpeg_path) = req.ffmpeg_path.as_ref() { if let Some(parent) = ffmpeg_path.parent() { args.push("--ffmpeg-location".to_string()); args.push(parent.to_string_lossy().to_string()); @@ -1190,92 +1169,82 @@ pub async fn download_video( } // Subtitle settings - if subtitle_mode != "off" { + if req.subtitle_mode != "off" { args.push("--write-subs".to_string()); - if subtitle_mode == "auto" { + if req.subtitle_mode == "auto" { args.push("--write-auto-subs".to_string()); + // Honor the user's selected languages in auto mode too; only fall + // back to "all" when nothing specific was chosen (upstream #116). + let langs = req.subtitle_langs.trim(); + let user_selected = !langs.is_empty() + && !langs.eq_ignore_ascii_case("all") + && langs != "*"; args.push("--sub-langs".to_string()); - args.push("all".to_string()); + if user_selected { + args.push(langs.to_string()); + } else { + args.push("all".to_string()); + } } else { args.push("--sub-langs".to_string()); - args.push(subtitle_langs.clone()); + args.push(req.subtitle_langs.clone()); } args.push("--sub-format".to_string()); - args.push(subtitle_format.clone()); - if subtitle_embed { + args.push(req.subtitle_format.clone()); + if req.subtitle_embed { args.push("--embed-subs".to_string()); } } - args.extend(build_site_header_args(&url)); + args.extend(build_site_header_args(&req.url)); args.extend(build_cookie_args( - &url, - cookie_mode.as_deref(), - cookie_browser.as_deref(), - cookie_browser_profile.as_deref(), - cookie_file_path.as_deref(), - cookie_skip_patterns.as_deref(), + &req.url, + req.cookie_mode.as_deref(), + req.cookie_browser.as_deref(), + req.cookie_browser_profile.as_deref(), + req.cookie_file_path.as_deref(), + Some(req.cookie_skip_patterns.as_slice()), )); // Proxy settings - if let Some(proxy) = proxy_url.as_ref() { + if let Some(proxy) = req.proxy_url.as_ref() { if !proxy.is_empty() { args.push("--proxy".to_string()); args.push(proxy.clone()); } } - let ytdlp_advanced_options = ytdlp_advanced_options.unwrap_or_default(); let advanced_args = build_ytdlp_advanced_args( - &url, - ytdlp_advanced_options_enabled.unwrap_or(false), - &ytdlp_advanced_options, - ) - .map_err(|e| e.to_wire_string())?; - if !advanced_args.skipped_options.is_empty() { - add_log_internal( - "info", - &format!( - "Skipped yt-dlp advanced option(s) for Bilibili because Youwee uses app-managed headers: {}", - advanced_args.skipped_options.join(", ") - ), - None, - Some(&url), - ) - .ok(); - } + &req.url, + req.ytdlp_advanced_options_enabled, + &req.ytdlp_advanced_options, + )?; + let skipped_options = advanced_args.skipped_options.clone(); + let youtube_player_client = advanced_args.youtube_player_client.clone(); args.extend(advanced_args.args); // Merge YouTube extractor settings into a single --extractor-args value. // See: https://github.com/yt-dlp/yt-dlp/issues/14680 - let is_youtube_url = url.contains("youtube.com") || url.contains("youtu.be"); + let is_youtube_url = req.url.contains("youtube.com") || req.url.contains("youtu.be"); if is_youtube_url { if let Some(extractor_args) = build_youtube_extractor_args( - use_actual_player_js.unwrap_or(false), + req.use_actual_player_js, advanced_args.youtube_player_client.as_deref(), ) { args.push("--extractor-args".to_string()); args.push(extractor_args); } - } else if advanced_args.youtube_player_client.is_some() { - add_log_internal( - "info", - "Skipped YouTube player client preset because the download URL is not YouTube.", - None, - Some(&url), - ) - .ok(); } // Live stream settings - if live_from_start.unwrap_or(false) { + if req.live_from_start { args.push("--live-from-start".to_string()); args.push("--no-part".to_string()); } // Speed limit settings - if let Some(limit) = speed_limit.as_ref() { + if let Some(limit) = req.speed_limit.as_ref() { if !limit.is_empty() { args.push("--limit-rate".to_string()); args.push(limit.clone()); @@ -1283,10 +1252,10 @@ pub async fn download_video( } // External downloader settings (aria2c) - if use_aria2.unwrap_or(false) { + if req.use_aria2 { args.push("--downloader".to_string()); args.push("aria2c".to_string()); - if let Some(raw_args) = aria2_args.as_ref() { + if let Some(raw_args) = req.aria2_args.as_ref() { if let Some(normalized_args) = normalize_aria2_args(raw_args) { args.push("--downloader-args".to_string()); args.push(normalized_args); @@ -1294,13 +1263,13 @@ pub async fn download_video( } } - // Force overwrite to avoid HTTP 416 errors from stale .part files - args.push("--force-overwrites".to_string()); + // upstream always passes --force-overwrites, which wipes .part files and + // kills resume. yt-dlp handles the stale-part 416 case itself. // Playlist handling - if !download_playlist { + if !req.download_playlist { args.push("--no-playlist".to_string()); - } else if let Some(limit) = playlist_limit { + } else if let Some(limit) = req.playlist_limit { if limit > 0 { args.push("--playlist-end".to_string()); args.push(limit.to_string()); @@ -1309,32 +1278,32 @@ pub async fn download_video( // Audio formats let is_audio_format = - format == "mp3" || format == "m4a" || format == "opus" || quality == "audio"; + req.format == "mp3" || req.format == "m4a" || req.format == "opus" || req.quality == "audio"; if is_audio_format { args.push("-x".to_string()); args.push("--audio-format".to_string()); - match format.as_str() { + match req.format.as_str() { "mp3" => args.push("mp3".to_string()), "m4a" => args.push("m4a".to_string()), "opus" => args.push("opus".to_string()), _ => args.push("mp3".to_string()), } args.push("--audio-quality".to_string()); - match audio_bitrate.as_str() { + match req.audio_bitrate.as_str() { "128" => args.push("128K".to_string()), _ => args.push("0".to_string()), } } else { args.push("--merge-output-format".to_string()); - args.push(format.clone()); + args.push(req.format.clone()); } // Embed metadata and thumbnail - if embed_metadata.unwrap_or(false) { + if req.embed_metadata { args.push("--embed-metadata".to_string()); } - if embed_thumbnail.unwrap_or(false) { + if req.embed_thumbnail { args.push("--embed-thumbnail".to_string()); // Convert thumbnail to jpg for better compatibility with MP4 container args.push("--convert-thumbnails".to_string()); @@ -1342,13 +1311,13 @@ pub async fn download_video( } // SponsorBlock settings - if let Some(ref remove_cats) = sponsorblock_remove { + if let Some(ref remove_cats) = req.sponsorblock_remove { if !remove_cats.is_empty() { args.push("--sponsorblock-remove".to_string()); args.push(remove_cats.clone()); } } - if let Some(ref mark_cats) = sponsorblock_mark { + if let Some(ref mark_cats) = req.sponsorblock_mark { if !mark_cats.is_empty() { args.push("--sponsorblock-mark".to_string()); args.push(mark_cats.clone()); @@ -1356,15 +1325,273 @@ pub async fn download_video( } // Download sections (time range) - if let Some(ref sections) = download_sections { + if let Some(ref sections) = req.download_sections { if !sections.is_empty() { args.push("--download-sections".to_string()); args.push(sections.clone()); } } + // Expert raw arguments (tokenized + allowlisted). Appended last so they can + // override app-managed retry/rate defaults, but never the URL position. + if let Some(raw) = req.ytdlp_raw_args.as_deref() { + args.extend(build_ytdlp_raw_args(raw)?); + } + args.push("--".to_string()); - args.push(url.clone()); + args.push(req.url.clone()); + + Ok(BuiltDownloadArgs { + args, + skipped_options, + youtube_player_client, + }) +} + +/// Serialized result of `preview_download_command`. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewDownloadCommandResult { + pub args: Vec, + pub display: String, +} + +#[tauri::command] +pub async fn download_video( + app: AppHandle, + id: String, + url: String, + output_path: String, + quality: String, + format: String, + download_playlist: bool, + playlist_index: Option, + playlist_total: Option, + number_playlist_items: Option, + queue_index: Option, + queue_total: Option, + number_queue_items: Option, + filename_metadata_enabled: Option, + filename_metadata_fields: Option>, + split_embedded_chapters: Option, + number_chapter_files: Option, + auto_organize_collections: Option, + playlist_collection_name: Option, + video_codec: String, + preferred_fps: Option, + audio_bitrate: String, + playlist_limit: Option, + subtitle_mode: String, + subtitle_langs: String, + subtitle_embed: bool, + subtitle_format: String, + log_stderr: Option, + _use_bun_runtime: Option, // Deprecated - now auto uses deno + use_actual_player_js: Option, + history_id: Option, + // Cookie settings + cookie_mode: Option, + cookie_browser: Option, + cookie_browser_profile: Option, + cookie_file_path: Option, + cookie_skip_patterns: Option>, + // Embed settings + embed_metadata: Option, + embed_thumbnail: Option, + // Proxy settings + proxy_url: Option, + // Live stream settings + live_from_start: Option, + skip_live: Option, + // Speed limit settings + speed_limit: Option, + // External downloader settings + use_aria2: Option, + aria2_args: Option, + // Vetted yt-dlp advanced options + ytdlp_advanced_options_enabled: Option, + ytdlp_advanced_options: Option>, + // Expert raw yt-dlp arguments (tokenized + allowlisted server-side) + ytdlp_raw_args: Option, + // SponsorBlock settings + sponsorblock_remove: Option, // comma-separated categories to remove + sponsorblock_mark: Option, // comma-separated categories to mark as chapters + // Download sections (time range) + download_sections: Option, // e.g. "*10:30-14:30" for partial download + // Title (optional, passed from frontend for display purposes) + title: Option, + // Thumbnail URL (optional, passed from frontend for non-YouTube sites) + thumbnail: Option, + // Source/extractor name (optional, from yt-dlp extractor e.g. "BiliBili", "TikTok") + source: Option, + // Legacy snapshot of plugin ids enabled when the job was queued + post_download_plugins: Option>, + // Snapshot of workflow steps by trigger at queue time + plugin_workflow_snapshots: Option>>, + // Full workflow step snapshot used for post-processing + post_download_workflow_steps: Option>, + // When false, caller is responsible for firing the final download.failed workflow. + emit_failed_workflow: Option, + // Caller context used in plugin payload + download_kind: Option, + // Incognito: skip history writes and redact logs for this download (P0-5) + incognito: Option, +) -> Result<(), String> { + CANCEL_FLAG.store(false, Ordering::SeqCst); + CANCEL_ITEM_IDS + .lock() + .unwrap() + .retain(|existing| existing != &id); + validate_url(&url).map_err(|e| BackendError::from_message(e).to_wire_string())?; + let url = normalize_url(&url); + let post_download_plugins = post_download_plugins.unwrap_or_default(); + let mut plugin_workflow_snapshots = plugin_workflow_snapshots.unwrap_or_default(); + if !plugin_workflow_snapshots.contains_key("download.completed") { + let completed_steps = resolve_download_workflow_snapshot( + &app, + "download.completed", + post_download_workflow_steps, + &post_download_plugins, + ); + plugin_workflow_snapshots.insert("download.completed".to_string(), completed_steps); + } + let emit_failed_workflow = emit_failed_workflow.unwrap_or(true); + let download_kind = download_kind.unwrap_or_else(|| "download".to_string()); + + if skip_live.unwrap_or(false) { + if let Some(live_status) = skipped_live_status( + &app, + &url, + cookie_mode.as_deref(), + cookie_browser.as_deref(), + cookie_browser_profile.as_deref(), + cookie_file_path.as_deref(), + cookie_skip_patterns.as_deref(), + proxy_url.as_deref(), + ) + .await? + { + add_log_internal( + "info", + &format!( + "Skipped live video due to --skip-live (status: {})", + live_status + ), + None, + Some(&url), + ) + .ok(); + return Err(BackendError::new( + crate::types::code::YT_SKIPPED_LIVE, + format!("Skipped live video (status: {})", live_status), + ) + .with_retryable(false) + .with_param("liveStatus", live_status) + .to_wire_string()); + } + } + + let should_log_stderr = log_stderr.unwrap_or(true); + let sanitized_path = sanitize_output_path(&output_path) + .map_err(|e| BackendError::from_message(e).to_wire_string())?; + let number_playlist_items = number_playlist_items.unwrap_or(false); + let number_queue_items = number_queue_items.unwrap_or(false); + let filename_metadata_enabled = filename_metadata_enabled.unwrap_or(false); + let filename_metadata_fields = filename_metadata_fields.unwrap_or_default(); + let split_embedded_chapters = split_embedded_chapters.unwrap_or(false); + let number_chapter_files = number_chapter_files.unwrap_or(true); + // Use a temp file to capture the final filepath from yt-dlp. + // On Windows with non-UTF-8 locales (e.g. Chinese/GBK), stdout is encoded + // in the system ANSI code page which cannot represent all Unicode characters + // (such as ⧸ U+29F8 used by yt-dlp to replace / in filenames). + // --print-to-file always writes UTF-8, so we get the exact filepath. + let filepath_tmp = std::env::temp_dir().join(format!("youwee-fp-{}.txt", id)); + + // Resolve runtime paths that the pure arg builder cannot (async, app-scoped). + let deno_path = if url.contains("youtube.com") || url.contains("youtu.be") { + get_deno_path(&app).await + } else { + None + }; + let ffmpeg_path = get_ffmpeg_path(&app).await; + + let args_request = DownloadArgsRequest { + id: id.clone(), + url: url.clone(), + output_path, + quality: quality.clone(), + format: format.clone(), + download_playlist, + playlist_index, + playlist_total, + number_playlist_items, + queue_index, + queue_total, + number_queue_items, + filename_metadata_enabled, + filename_metadata_fields, + split_embedded_chapters, + number_chapter_files, + video_codec, + preferred_fps, + audio_bitrate, + playlist_limit, + subtitle_mode, + subtitle_langs, + subtitle_embed, + subtitle_format, + use_actual_player_js: use_actual_player_js.unwrap_or(false), + cookie_mode, + cookie_browser, + cookie_browser_profile, + cookie_file_path, + cookie_skip_patterns: cookie_skip_patterns.unwrap_or_default(), + embed_metadata: embed_metadata.unwrap_or(false), + embed_thumbnail: embed_thumbnail.unwrap_or(false), + proxy_url, + live_from_start: live_from_start.unwrap_or(false), + speed_limit, + use_aria2: use_aria2.unwrap_or(false), + aria2_args, + ytdlp_advanced_options_enabled: ytdlp_advanced_options_enabled.unwrap_or(false), + ytdlp_advanced_options: ytdlp_advanced_options.unwrap_or_default(), + ytdlp_raw_args, + sponsorblock_remove, + sponsorblock_mark, + download_sections: download_sections.clone(), + filepath_tmp: Some(filepath_tmp.clone()), + deno_path, + ffmpeg_path, + }; + + let incognito_enabled = incognito.unwrap_or(false); + let built_args = build_download_args(&args_request).map_err(|e| e.to_wire_string())?; + + if !built_args.skipped_options.is_empty() { + add_log_internal( + "info", + &format!( + "Skipped yt-dlp advanced option(s) for Bilibili because Youwee uses app-managed headers: {}", + built_args.skipped_options.join(", ") + ), + None, + if incognito_enabled { None } else { Some(&url) }, + ) + .ok(); + } + + let is_youtube_url = url.contains("youtube.com") || url.contains("youtu.be"); + if built_args.youtube_player_client.is_some() && !is_youtube_url { + add_log_internal( + "info", + "Skipped YouTube player client preset because the download URL is not YouTube.", + None, + if incognito_enabled { None } else { Some(&url) }, + ) + .ok(); + } + + let args = built_args.args; // Get binary info for logging let binary_info = get_ytdlp_path(&app).await; @@ -1373,14 +1600,21 @@ pub async fn download_video( .map(|(p, is_bundled)| format!("{} (bundled: {})", p.display(), is_bundled)) .unwrap_or_else(|| "sidecar".to_string()); - // Log command with binary path - let command_args_for_log = redact_ytdlp_advanced_args(&args); + // Log command with binary path. Incognito downloads never write the URL + // column and their URL argument is masked in the command string (P0-5). + let command_args_for_log = redact_download_args_for_log(&args, incognito_enabled); let command_str = format!( "[{}] yt-dlp {}", binary_path_str, command_args_for_log.join(" ") ); - add_log_internal("command", &command_str, None, Some(&url)).ok(); + add_log_internal( + "command", + &command_str, + None, + if incognito_enabled { None } else { Some(&url) }, + ) + .ok(); let trigger_source = source.clone().or_else(|| detect_source(&url)); let trigger_time_range = extract_time_range(&download_sections); @@ -1481,6 +1715,7 @@ pub async fn download_video( auto_organize_collections.unwrap_or(false), playlist_collection_name.clone(), split_embedded_chapters, + incognito_enabled, ) .await; } @@ -1585,10 +1820,10 @@ pub async fn download_video( }; while let Some(event) = rx.recv().await { - if CANCEL_FLAG.load(Ordering::SeqCst) { + if is_item_cancelled(&id) { + kill_process_tree_pid(child.pid()); child.kill().ok(); - kill_all_download_processes(); - return Err(BackendError::from_message("Download cancelled").to_wire_string()); + return Err(download_cancelled_error().to_wire_string()); } match event { @@ -1776,7 +2011,7 @@ pub async fn download_video( return Err(error.to_wire_string()); } CommandEvent::Terminated(status) => { - if CANCEL_FLAG.load(Ordering::SeqCst) { + if is_item_cancelled(&id) { add_log_internal( "info", "Download cancelled by user", @@ -1784,9 +2019,7 @@ pub async fn download_video( Some(&url), ) .ok(); - return Err( - BackendError::from_message("Download cancelled").to_wire_string() - ); + return Err(download_cancelled_error().to_wire_string()); } // Primary filepath source: read from --print-to-file temp file (UTF-8) @@ -1844,11 +2077,17 @@ pub async fn download_video( quality_display.clone().unwrap_or_else(|| quality.clone()), format.clone() ); - add_log_internal("success", &success_msg, Some(&details), Some(&url)) - .ok(); + add_log_internal( + "success", + &success_msg, + Some(&details), + if incognito_enabled { None } else { Some(&url) }, + ) + .ok(); // Save each emitted output to history. The first file remains the // queue representative; split chapters are extra history rows. + // Incognito downloads are excluded from history entirely (P0-5). let mut progress_history_id = None; for (index, filepath) in output_paths.iter().enumerate() { let time_range = extract_time_range(&download_sections); @@ -1868,45 +2107,49 @@ pub async fn download_video( if index == 0 { if let Some(ref hist_id) = history_id { - update_history_download( - hist_id.clone(), - filepath.clone(), - file_filesize, - quality_display.clone(), - Some(format.clone()), - time_range, - ) - .ok(); + if !incognito_enabled { + update_history_download( + hist_id.clone(), + filepath.clone(), + file_filesize, + quality_display.clone(), + Some(format.clone()), + time_range, + ) + .ok(); + assign_history_auto_collections( + hist_id, + &auto_collection_names, + ); + progress_history_id = Some(hist_id.clone()); + continue; + } + } + } + + if !incognito_enabled { + let history_row_id = add_history_internal( + url.clone(), + entry_title, + thumbnail.clone().or_else(|| generate_thumbnail_url(&url)), + filepath.clone(), + file_filesize, + None, + quality_display.clone(), + Some(format.clone()), + source.clone().or_else(|| detect_source(&url)), + time_range, + ) + .ok(); + if let Some(ref hist_id) = history_row_id { assign_history_auto_collections( hist_id, &auto_collection_names, ); - progress_history_id = Some(hist_id.clone()); - continue; } - } - - let history_row_id = add_history_internal( - url.clone(), - entry_title, - thumbnail.clone().or_else(|| generate_thumbnail_url(&url)), - filepath.clone(), - file_filesize, - None, - quality_display.clone(), - Some(format.clone()), - source.clone().or_else(|| detect_source(&url)), - time_range, - ) - .ok(); - if let Some(ref hist_id) = history_row_id { - assign_history_auto_collections( - hist_id, - &auto_collection_names, - ); - } - if index == 0 { - progress_history_id = history_row_id; + if index == 0 { + progress_history_id = history_row_id; + } } } @@ -1965,7 +2208,7 @@ pub async fn download_video( } return Ok(()); } else { - if CANCEL_FLAG.load(Ordering::SeqCst) { + if is_item_cancelled(&id) { let error = download_cancelled_error(); add_log_internal("info", error.message(), None, Some(&url)).ok(); return Err(error.to_wire_string()); @@ -2129,6 +2372,7 @@ pub async fn download_video( auto_organize_collections.unwrap_or(false), playlist_collection_name, split_embedded_chapters, + incognito_enabled, ) .await } @@ -2157,6 +2401,7 @@ async fn handle_tokio_download( auto_organize_collections: bool, playlist_collection_name: Option, split_embedded_chapters: bool, + incognito: bool, ) -> Result<(), String> { let stdout = process .stdout @@ -2212,7 +2457,7 @@ async fn handle_tokio_download( } let line = decode_process_output(&line_buf); - if CANCEL_FLAG.load(Ordering::SeqCst) { + if is_item_cancelled(&stderr_id) { break; } push_recent_output_shared(&stderr_recent_output, &line); @@ -2276,9 +2521,15 @@ async fn handle_tokio_download( stderr_app.emit("download-progress", progress).ok(); } - // Log stderr if enabled + // Log stderr if enabled (URL column skipped for incognito, P0-5) if should_log_stderr && !line.trim().is_empty() { - add_log_internal("stderr", line.trim(), None, Some(&stderr_url)).ok(); + add_log_internal( + "stderr", + line.trim(), + None, + if incognito { None } else { Some(&stderr_url) }, + ) + .ok(); } } })) @@ -2304,10 +2555,12 @@ async fn handle_tokio_download( } let line = decode_process_output(&stdout_line_buf); - if CANCEL_FLAG.load(Ordering::SeqCst) { + if is_item_cancelled(&id) { + if let Some(pid) = process.id() { + kill_process_tree_pid(pid); + } process.kill().await.ok(); - kill_all_download_processes(); - return Err(BackendError::from_message("Download cancelled").to_wire_string()); + return Err(download_cancelled_error().to_wire_string()); } push_recent_output_shared(&recent_output, &line); @@ -2508,7 +2761,13 @@ async fn handle_tokio_download( quality_display.clone().unwrap_or_else(|| quality.clone()), format.clone() ); - add_log_internal("success", &success_msg, Some(&details), Some(&url)).ok(); + add_log_internal( + "success", + &success_msg, + Some(&details), + if incognito { None } else { Some(&url) }, + ) + .ok(); let mut progress_history_id = None; for (index, filepath) in output_paths.iter().enumerate() { @@ -2529,39 +2788,43 @@ async fn handle_tokio_download( if index == 0 { if let Some(ref hist_id) = history_id { - update_history_download( - hist_id.clone(), - filepath.clone(), - file_filesize, - quality_display.clone(), - Some(format.clone()), - time_range, - ) - .ok(); - assign_history_auto_collections(hist_id, &auto_collection_names); - progress_history_id = Some(hist_id.clone()); - continue; + if !incognito { + update_history_download( + hist_id.clone(), + filepath.clone(), + file_filesize, + quality_display.clone(), + Some(format.clone()), + time_range, + ) + .ok(); + assign_history_auto_collections(hist_id, &auto_collection_names); + progress_history_id = Some(hist_id.clone()); + continue; + } } } - let history_row_id = add_history_internal( - url.clone(), - entry_title, - thumbnail.clone().or_else(|| generate_thumbnail_url(&url)), - filepath.clone(), - file_filesize, - None, - quality_display.clone(), - Some(format.clone()), - source.clone().or_else(|| detect_source(&url)), - time_range, - ) - .ok(); - if let Some(ref hist_id) = history_row_id { - assign_history_auto_collections(hist_id, &auto_collection_names); - } - if index == 0 { - progress_history_id = history_row_id; + if !incognito { + let history_row_id = add_history_internal( + url.clone(), + entry_title, + thumbnail.clone().or_else(|| generate_thumbnail_url(&url)), + filepath.clone(), + file_filesize, + None, + quality_display.clone(), + Some(format.clone()), + source.clone().or_else(|| detect_source(&url)), + time_range, + ) + .ok(); + if let Some(ref hist_id) = history_row_id { + assign_history_auto_collections(hist_id, &auto_collection_names); + } + if index == 0 { + progress_history_id = history_row_id; + } } } @@ -2620,7 +2883,7 @@ async fn handle_tokio_download( } Ok(()) } else { - if CANCEL_FLAG.load(Ordering::SeqCst) { + if is_item_cancelled(&id) { let error = download_cancelled_error(); add_log_internal("info", error.message(), None, Some(&url)).ok(); return Err(error.to_wire_string()); @@ -2721,3 +2984,44 @@ fn generate_thumbnail_url(url: &str) -> Option { None } } +// --------------------------------------------------------------------------- +// Command preview: reproduce the exact yt-dlp invocation for one item without +// starting it (P0-2 diagnostics). Returns the full arg vector plus a redacted +// display string. Registered by the mainline handler list in lib.rs. +// --------------------------------------------------------------------------- +#[tauri::command] +pub async fn preview_download_command( + app: AppHandle, + mut request: DownloadArgsRequest, +) -> Result { + if request.deno_path.is_none() { + request.deno_path = if request.url.contains("youtube.com") || request.url.contains("youtu.be") + { + get_deno_path(&app).await + } else { + None + }; + } + if request.ffmpeg_path.is_none() { + request.ffmpeg_path = get_ffmpeg_path(&app).await; + } + + let built = build_download_args(&request).map_err(|e| e.to_wire_string())?; + let redacted = redact_download_args_for_log(&built.args, true); + let display = format!("yt-dlp {}", redacted.join(" ")); + Ok(PreviewDownloadCommandResult { + args: redacted, + display, + }) +} + +/// Requests cancellation of a single download by id (P0-7). The poll loops pick +/// up the id and kill only that item's process. NOTE: registered by the +/// mainline handler list in lib.rs, not by this module. +#[tauri::command] +pub fn cancel_download_item(id: String) { + let mut ids = CANCEL_ITEM_IDS.lock().unwrap(); + if !ids.iter().any(|existing| existing == &id) { + ids.push(id); + } +} diff --git a/src-tauri/src/commands/gallery.rs b/src-tauri/src/commands/gallery.rs index 45462672..042bdda7 100644 --- a/src-tauri/src/commands/gallery.rs +++ b/src-tauri/src/commands/gallery.rs @@ -1,9 +1,11 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, HashSet, VecDeque}; use std::path::PathBuf; use std::process::Stdio; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{LazyLock, Mutex}; use tauri::{AppHandle, Manager}; -use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; use crate::database::add_history_internal; @@ -14,12 +16,52 @@ use crate::utils::{normalize_url, sanitize_output_path, validate_url, CommandExt const RECENT_OUTPUT_LIMIT: usize = 30; +/// Child PIDs of gallery-dl processes spawned by THIS app, keyed by download +/// id — a stop request only kills the process trees that belong to it. +static GALLERY_CHILD_PIDS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +/// Download ids that a stop request has targeted (cleared when the run ends). +static GALLERY_STOP_IDS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashSet::new())); + #[derive(serde::Serialize)] pub struct GalleryDownloadResult { pub filepath: String, pub history_id: Option, } +const DEFAULT_GALLERY_UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"; + +/// asurascans.com gallery-dl external extractor, embedded and hot-deployed to +/// `/extractors/asurascans.py` (loaded via `gallery-dl -X`). +const ASURASCANS_EXTRACTOR: &str = include_str!("../../extractors/asurascans.py"); + +#[derive(serde::Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GalleryDownloadOptions { + pub retries: Option, + pub timeout: Option, + pub range: Option, + pub filename: Option, + pub flat_output: Option, + pub cbz: Option, + pub rate_limit: Option, + pub filesize_min: Option, + pub filesize_max: Option, + pub sleep: Option, +} + +#[derive(serde::Serialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct GalleryProbe { + pub title: Option, + pub thumbnail: Option, + pub count: Option, + pub category: Option, + pub subcategory: Option, + pub error: Option, +} + fn push_recent_output(buffer: &mut VecDeque, line: &str) { let trimmed = line.trim(); if trimmed.is_empty() { @@ -31,23 +73,28 @@ fn push_recent_output(buffer: &mut VecDeque, line: &str) { buffer.push_back(trimmed.to_string()); } -fn kill_gallery_processes() { - #[cfg(unix)] - { - use std::process::Command as StdCommand; - StdCommand::new("pkill") - .args(["-9", "-f", "gallery-dl"]) - .spawn() - .ok(); - } +/// Kill one gallery-dl process tree by PID (the app's own child only). +fn kill_process_tree_pid(pid: u32) { #[cfg(windows)] { use std::process::Command as StdCommand; let mut cmd = StdCommand::new("taskkill"); - cmd.args(["/F", "/IM", "gallery-dl.exe"]); + cmd.args(["/F", "/T", "/PID", &pid.to_string()]); cmd.hide_window(); cmd.spawn().ok(); } + #[cfg(unix)] + { + use std::process::Command as StdCommand; + StdCommand::new("pkill") + .args(["-9", "-P", &pid.to_string()]) + .spawn() + .ok(); + StdCommand::new("kill") + .args(["-9", &pid.to_string()]) + .spawn() + .ok(); + } } fn archive_file_path(app: &AppHandle) -> Result { @@ -58,9 +105,137 @@ fn archive_file_path(app: &AppHandle) -> Result { Ok(app_data_dir.join("archive").join("gallery-dl.txt")) } +/// Make sure the external-extractor dir exists and holds the current +/// asurascans.py (hot-patched: any drift is overwritten on the next run). +async fn ensure_extractors_dir(app: &AppHandle) -> Result { + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("Failed to get app data directory: {}", e))?; + let extractors_dir = app_data_dir.join("extractors"); + tokio::fs::create_dir_all(&extractors_dir) + .await + .map_err(|e| format!("Failed to create extractors directory: {}", e))?; + let target = extractors_dir.join("asurascans.py"); + let up_to_date = tokio::fs::read_to_string(&target) + .await + .map(|existing| existing == ASURASCANS_EXTRACTOR) + .unwrap_or(false); + if !up_to_date { + tokio::fs::write(&target, ASURASCANS_EXTRACTOR) + .await + .map_err(|e| format!("Failed to write asurascans extractor: {}", e))?; + } + Ok(extractors_dir) +} + +fn push_opt(args: &mut Vec, flag: &str, value: Option<&str>) { + if let Some(value) = value { + if !value.is_empty() { + args.push(flag.to_string()); + args.push(value.to_string()); + } + } +} + +fn thumb_cache_key(url: &str) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + hasher.finish() +} + +fn thumb_extension(url: &str) -> &'static str { + let lower = url.to_ascii_lowercase(); + if lower.contains(".webp") { + "webp" + } else if lower.contains(".png") { + "png" + } else if lower.contains(".gif") { + "gif" + } else { + "jpg" + } +} + +/// Download a probe thumbnail into the shared gallery-thumbs cache dir. +async fn cache_probe_thumbnail( + app: &AppHandle, + url: &str, + proxy_url: Option<&str>, +) -> Option { + let cache_dir = app.path().app_cache_dir().ok()?.join("gallery-thumbs"); + std::fs::create_dir_all(&cache_dir).ok()?; + let ext = thumb_extension(url); + let thumb_path = cache_dir.join(format!("queue-{}.{}", thumb_cache_key(url), ext)); + if thumb_path.exists() { + return Some(thumb_path.to_string_lossy().into_owned()); + } + let mut builder = reqwest::Client::builder() + .user_agent(DEFAULT_GALLERY_UA) + .timeout(std::time::Duration::from_secs(10)); + if let Some(proxy) = proxy_url { + if !proxy.is_empty() { + if let Ok(proxy) = reqwest::Proxy::all(proxy) { + builder = builder.proxy(proxy); + } + } + } + let client = builder.build().ok()?; + let mut request = client.get(url); + if url.to_ascii_lowercase().contains("pximg.net") { + request = request.header("Referer", "https://www.pixiv.net/"); + } + let response = request.send().await.ok()?; + if !response.status().is_success() { + return None; + } + let bytes = response.bytes().await.ok()?; + tokio::fs::write(&thumb_path, bytes).await.ok()?; + Some(thumb_path.to_string_lossy().into_owned()) +} + +fn first_meta_string( + meta: &serde_json::Map, + keys: &[&str], +) -> Option { + for key in keys { + if let Some(value) = meta.get(*key) { + if let Some(s) = value.as_str() { + if !s.is_empty() { + return Some(s.to_string()); + } + } + } + } + None +} + #[tauri::command] -pub async fn stop_gallery_download() -> Result<(), String> { - kill_gallery_processes(); +pub async fn stop_gallery_download(id: Option) -> Result<(), String> { + { + let mut stop_ids = GALLERY_STOP_IDS.lock().unwrap_or_else(|e| e.into_inner()); + let mut pids = GALLERY_CHILD_PIDS.lock().unwrap_or_else(|e| e.into_inner()); + match id { + Some(target) => { + stop_ids.insert(target.clone()); + if let Some(pid) = pids.remove(&target) { + kill_process_tree_pid(pid); + } + } + None => { + // Stop-everything: mark every tracked download and kill each tree. + for key in pids.keys() { + stop_ids.insert(key.clone()); + } + for pid in pids.values() { + kill_process_tree_pid(*pid); + } + pids.clear(); + } + } + } Ok(()) } @@ -77,6 +252,10 @@ pub async fn download_gallery( cookie_skip_patterns: Option>, proxy_url: Option, source: Option, + thumbnail: Option, + options: Option, + incognito: Option, + id: Option, ) -> Result { validate_url(&url).map_err(|e| BackendError::from_message(e).to_wire_string())?; let url = normalize_url(&url); @@ -99,13 +278,38 @@ pub async fn download_gallery( .map_err(|e| format!("Failed to create archive directory: {}", e))?; } + let extractors_dir = ensure_extractors_dir(&app).await?; + let mut args = vec![ "--destination".to_string(), sanitized_path.clone(), "--download-archive".to_string(), archive_path.to_string_lossy().to_string(), + "-X".to_string(), + extractors_dir.to_string_lossy().to_string(), ]; + // Hardening defaults. Per-item options below override these, and each + // flag is emitted exactly once (the resolved value wins — no duplicates). + let retries = options + .as_ref() + .and_then(|o| o.retries) + .filter(|v| *v > 0) + .unwrap_or(8); + args.push("--retries".to_string()); + args.push(retries.to_string()); + let http_timeout = options + .as_ref() + .and_then(|o| o.timeout) + .filter(|v| *v > 0) + .unwrap_or(60); + args.push("--http-timeout".to_string()); + args.push(http_timeout.to_string()); + args.push("--sleep-429".to_string()); + args.push("30".to_string()); + args.push("--user-agent".to_string()); + args.push(DEFAULT_GALLERY_UA.to_string()); + args.extend(build_cookie_args( &url, cookie_mode.as_deref(), @@ -122,6 +326,27 @@ pub async fn download_gallery( } } + if let Some(opts) = options { + push_opt(&mut args, "--range", opts.range.as_deref()); + push_opt(&mut args, "--filename", opts.filename.as_deref()); + if opts.flat_output == Some(true) { + args.push("-o".to_string()); + args.push("directory=".to_string()); + } + if opts.cbz == Some(true) { + args.push("--cbz".to_string()); + } + push_opt(&mut args, "--limit-rate", opts.rate_limit.as_deref()); + push_opt(&mut args, "--filesize-min", opts.filesize_min.as_deref()); + push_opt(&mut args, "--filesize-max", opts.filesize_max.as_deref()); + if let Some(value) = opts.sleep { + if value > 0.0 { + args.push("--sleep".to_string()); + args.push(value.to_string()); + } + } + } + args.push(url.clone()); let command_str = format!("[{}] gallery-dl {}", binary_path.display(), args.join(" ")); @@ -133,9 +358,44 @@ pub async fn download_gallery( .stderr(Stdio::piped()); cmd.hide_window(); + let stop_key = id.unwrap_or_else(|| url.clone()); + // Forget stale stop requests left over from a previous run with this key + // (mirrors download_video clearing CANCEL_ITEM_IDS at start), so only a + // stop that lands during THIS run can cancel it. + GALLERY_STOP_IDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&stop_key); let mut child = cmd.spawn().map_err(|e| { BackendError::from_message(format!("Failed to start gallery-dl: {}", e)).to_wire_string() })?; + if let Some(pid) = child.id() { + GALLERY_CHILD_PIDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(stop_key.clone(), pid); + } + // A stop request may have landed in the window between spawn() and the PID + // registration above: stop_gallery_download found no PID yet and could only + // set the flag. Honor it now — kill the fresh child and re-arm the flag so + // the post-wait handling below reports this run as stopped, not failed. + let stop_during_spawn = GALLERY_STOP_IDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&stop_key); + if stop_during_spawn { + if let Some(pid) = child.id() { + kill_process_tree_pid(pid); + } + GALLERY_CHILD_PIDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&stop_key); + GALLERY_STOP_IDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert(stop_key.clone()); + } let stdout = child.stdout.take(); let stderr = child.stderr.take(); @@ -167,9 +427,18 @@ pub async fn download_gallery( recent }); + let child_pid = child.id(); let status = child.wait().await.map_err(|e| { BackendError::from_message(format!("gallery-dl process error: {}", e)).to_wire_string() })?; + GALLERY_CHILD_PIDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&stop_key); + let was_stopped = GALLERY_STOP_IDS + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&stop_key); let mut recent_lines: Vec = stdout_task.await.unwrap_or_default().into_iter().collect(); recent_lines.extend(stderr_task.await.unwrap_or_default().into_iter()); @@ -190,6 +459,11 @@ pub async fn download_gallery( .or_else(|| recent_lines.last().cloned()) .unwrap_or_else(|| "Unknown error".to_string()); + if was_stopped { + return Err(BackendError::from_message("Gallery download stopped".to_string()) + .with_param("exitCode", status.code().unwrap_or(-1)) + .to_wire_string()); + } return Err(BackendError::from_message(format!( "Gallery download failed (exit code {}): {}", status.code().unwrap_or(-1), @@ -199,25 +473,231 @@ pub async fn download_gallery( .to_wire_string()); } - let title = source.clone().unwrap_or_else(|| url.clone()); - let history_id = add_history_internal( - url.clone(), - title, - None, - sanitized_path.clone(), - None, - None, - None, - Some("gallery".to_string()), - source.or(Some("gallery-dl".to_string())), - None, - ) - .ok(); - - add_log_internal("success", "Gallery download completed", None, Some(&url)).ok(); + let history_id = if incognito.unwrap_or(false) { + add_log_internal("success", "Gallery download completed (incognito)", None, None).ok(); + None + } else { + let title = source.clone().unwrap_or_else(|| url.clone()); + let id = add_history_internal( + url.clone(), + title, + thumbnail, + sanitized_path.clone(), + None, + None, + None, + Some("gallery".to_string()), + source.or(Some("gallery-dl".to_string())), + None, + ) + .ok(); + add_log_internal("success", "Gallery download completed", None, Some(&url)).ok(); + id + }; Ok(GalleryDownloadResult { filepath: sanitized_path, history_id, }) } + +/// Probe a gallery URL without downloading: runs `gallery-dl -J --no-download` +/// and returns the title/count/category plus a cached thumbnail (first image). +/// Never blocks queueing: any failure returns a probe with `error` set. +#[tauri::command] +pub async fn probe_gallery( + app: AppHandle, + url: String, + cookie_mode: Option, + cookie_browser: Option, + cookie_browser_profile: Option, + cookie_file_path: Option, + cookie_skip_patterns: Option>, + proxy_url: Option, +) -> Result { + if validate_url(&url).is_err() { + return Ok(GalleryProbe { + error: Some("Invalid URL".to_string()), + ..Default::default() + }); + } + let url = normalize_url(&url); + + let Some(binary_path) = get_gallerydl_path(&app) else { + return Ok(GalleryProbe { + error: Some(system_gallerydl_not_found_message()), + ..Default::default() + }); + }; + + let extractors_dir = match ensure_extractors_dir(&app).await { + Ok(dir) => dir, + Err(e) => { + return Ok(GalleryProbe { + error: Some(e), + ..Default::default() + }) + } + }; + + let mut args = vec![ + "-J".to_string(), + "--no-download".to_string(), + "-X".to_string(), + extractors_dir.to_string_lossy().to_string(), + ]; + args.extend(build_cookie_args( + &url, + cookie_mode.as_deref(), + cookie_browser.as_deref(), + cookie_browser_profile.as_deref(), + cookie_file_path.as_deref(), + cookie_skip_patterns.as_deref(), + )); + if let Some(proxy) = proxy_url.as_ref() { + if !proxy.is_empty() { + args.push("--proxy".to_string()); + args.push(proxy.clone()); + } + } + args.push(url.clone()); + + let mut cmd = Command::new(&binary_path); + cmd.args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.hide_window(); + + let mut child = match cmd.spawn() { + Ok(child) => child, + Err(e) => { + return Ok(GalleryProbe { + error: Some(format!("Failed to start gallery-dl: {}", e)), + ..Default::default() + }) + } + }; + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + + let wait = async { + let status = child.wait().await; + let mut out = String::new(); + if let Some(mut stdout) = stdout { + let _ = stdout.read_to_string(&mut out).await; + } + let mut err = String::new(); + if let Some(mut stderr) = stderr { + let _ = stderr.read_to_string(&mut err).await; + } + (status, out, err) + }; + + let (status, out, err) = + match tokio::time::timeout(std::time::Duration::from_secs(15), wait).await { + Ok(inner) => inner, + Err(_) => { + let _ = child.kill().await; + return Ok(GalleryProbe { + error: Some("Preview timed out".to_string()), + ..Default::default() + }); + } + }; + if status.is_err() { + return Ok(GalleryProbe { + error: Some("Failed to run gallery-dl".to_string()), + ..Default::default() + }); + } + + // gallery-dl can exit non-zero while still printing valid JSON; parse stdout regardless. + let parsed: Result = serde_json::from_str(out.trim()); + let Ok(value) = parsed else { + let reason = err.trim(); + return Ok(GalleryProbe { + error: Some(if reason.is_empty() { + "No preview available".to_string() + } else { + reason.to_string() + }), + ..Default::default() + }); + }; + + let mut probe = GalleryProbe::default(); + let mut thumb_candidates: Vec = Vec::new(); + + if let Some(entries) = value.as_array() { + for entry in entries { + let Some(items) = entry.as_array() else { continue }; + if items.len() < 2 { + continue; + } + let kind = items[0].as_i64().unwrap_or(-1); + if kind == 2 { + // Directory message -> general gallery metadata + if let Some(meta) = items[1].as_object() { + probe.title = + first_meta_string(meta, &["seriesName", "title", "manga", "name", "description"]); + probe.count = meta.get("count").and_then(|v| v.as_u64()).map(|v| v as u32); + probe.category = meta + .get("category") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + probe.subcategory = meta + .get("subcategory") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } else if kind == 3 { + // Url message -> [3, [0, url] | url, meta] + let url_val = &items[1]; + let url_str = if let Some(arr) = url_val.as_array() { + arr.get(1) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + } else { + url_val.as_str().map(|s| s.to_string()) + }; + if let Some(url_str) = url_str { + let mut thumb: Option = None; + if let Some(meta) = items.get(2).and_then(|v| v.as_object()) { + thumb = first_meta_string(meta, &["thumbnail", "preview_file_url"]).or_else( + || { + meta.get("preview") + .and_then(|v| v.as_object()) + .and_then(|p| p.get("url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }, + ); + if thumb.is_none() { + thumb = meta + .get("file") + .and_then(|v| v.as_object()) + .and_then(|f| f.get("url")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + if thumb.is_none() { + thumb = meta + .get("file_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + thumb_candidates.push(thumb.unwrap_or_else(|| url_str.clone())); + } + } + } + } + + if probe.thumbnail.is_none() { + if let Some(first) = thumb_candidates.first() { + probe.thumbnail = cache_probe_thumbnail(&app, first, proxy_url.as_deref()).await; + } + } + + Ok(probe) +} diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index 80aa954f..fe66a761 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -5,12 +5,14 @@ use crate::database::{ assign_history_tags_in_db, clear_history_from_db, create_collection_in_db, delete_collection_from_db, delete_history_from_db, find_duplicate_downloads_in_history_db, get_collections_from_db, get_history_count_from_db, get_history_entries_by_ids_from_db, - get_history_from_db, get_tags_from_db, remove_history_from_collection_in_db, + get_history_from_db, get_tags_from_db, list_gallery_items_from_db, + remove_history_from_collection_in_db, remove_history_tag_from_db, rename_collection_in_db, update_history_filepath_and_title, update_history_filepath_and_title_by_id, update_history_summary, }; use crate::types::{ DownloadDuplicateIdentity, DownloadDuplicateMatch, HistoryAdvancedFilters, HistoryCollection, + GalleryLibraryItem, HistoryEntry, HistorySort, HistoryTag, }; @@ -61,6 +63,44 @@ pub fn get_history( get_history_from_db(limit, offset, source, search, filters, sort) } +/// List completed gallery downloads for the Gallery library browser. +#[tauri::command] +pub fn list_gallery_items() -> Result, String> { + list_gallery_items_from_db() +} + +/// Generate (or return the cached) 420px thumbnail for a gallery cover image. +#[tauri::command] +pub fn get_gallery_thumbnail(app: tauri::AppHandle, filepath: String) -> Result { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + use tauri::Manager; + + let mut hasher = DefaultHasher::new(); + filepath.hash(&mut hasher); + let key = hasher.finish(); + + let cache_dir = app + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to resolve cache dir: {}", e))? + .join("gallery-thumbs"); + std::fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create thumbnail dir: {}", e))?; + + let thumb_path = cache_dir.join(format!("{}.jpg", key)); + if thumb_path.exists() { + return Ok(thumb_path.to_string_lossy().into_owned()); + } + + let img = image::open(&filepath).map_err(|e| format!("Failed to open image: {}", e))?; + let thumb = img.thumbnail(420, 420); + thumb + .save(&thumb_path) + .map_err(|e| format!("Failed to save thumbnail: {}", e))?; + Ok(thumb_path.to_string_lossy().into_owned()) +} + #[tauri::command] pub fn get_history_entries_by_ids(ids: Vec) -> Result, String> { get_history_entries_by_ids_from_db(ids) diff --git a/src-tauri/src/commands/metadata.rs b/src-tauri/src/commands/metadata.rs index f10f5eb0..da7a8c27 100644 --- a/src-tauri/src/commands/metadata.rs +++ b/src-tauri/src/commands/metadata.rs @@ -59,6 +59,7 @@ pub struct ExtractDataRowsInput { pub source: ExportSource, pub text: String, pub limit: Option, + pub lang: Option, pub detail_mode: bool, pub cookie_mode: Option, pub cookie_browser: Option, @@ -452,8 +453,9 @@ async fn run_export_ytdlp( async fn run_export_youtube_keyword( query: &str, limit: Option, + lang: Option, ) -> Result, String> { - let response = search_youtube_videos_internal(query.to_string(), limit, None, None).await?; + let response = search_youtube_videos_internal(query.to_string(), limit, None, None, lang).await?; Ok(response .videos .into_iter() @@ -486,7 +488,7 @@ pub async fn extract_data_rows( let effective_source = detect_export_source(&line, &input.source); let export_result = if matches!(effective_source, ExportSource::YoutubeKeyword) { - run_export_youtube_keyword(&line, input.limit) + run_export_youtube_keyword(&line, input.limit, input.lang.clone()) .await .map(|rows| (rows, Some(line.clone()))) } else { diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ce5a51ee..6dce77d8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ mod ai; mod assets; +mod backup; mod channels; mod cli; mod cli_shortcut; @@ -22,6 +23,7 @@ mod youtube_search; pub use ai::*; pub use assets::*; +pub use backup::*; pub use channels::*; pub use cli::*; pub use cli_shortcut::*; diff --git a/src-tauri/src/commands/processing/jobs.rs b/src-tauri/src/commands/processing/jobs.rs index 67983853..2a36ca6a 100644 --- a/src-tauri/src/commands/processing/jobs.rs +++ b/src-tauri/src/commands/processing/jobs.rs @@ -1,15 +1,16 @@ use super::*; -/// Execute FFmpeg command with progress tracking -#[tauri::command] -pub async fn execute_ffmpeg_command( +/// Shared FFmpeg execution core used by both `execute_ffmpeg_command` +/// (single job with frontend-provided ids/paths) and `execute_ffmpeg_batch` +/// (queue-driven batch runs that derive ids/paths server-side). +async fn run_ffmpeg_job( app: AppHandle, job_id: String, command_args: Vec, input_path: String, output_path: String, ) -> Result<(), String> { - println!("[FFMPEG] Starting execute_ffmpeg_command"); + println!("[FFMPEG] Starting ffmpeg job"); println!("[FFMPEG] Job ID: {}", job_id); println!("[FFMPEG] Args: {:?}", command_args); println!("[FFMPEG] Input: {}", input_path); @@ -207,6 +208,49 @@ pub async fn execute_ffmpeg_command( } } +/// Execute FFmpeg command with progress tracking (single job, frontend-managed). +#[tauri::command] +pub async fn execute_ffmpeg_command( + app: AppHandle, + job_id: String, + command_args: Vec, + input_path: String, + output_path: String, +) -> Result<(), String> { + run_ffmpeg_job(app, job_id, command_args, input_path, output_path).await +} + +/// Batch-mode FFmpeg execution: the frontend supplies only the generated +/// command args and the input file; the output path is derived from the input +/// (same directory, `_processed.`) and a job id is minted locally. +/// Registered in `lib.rs` so `invoke('execute_ffmpeg_batch')` resolves. +#[tauri::command] +pub async fn execute_ffmpeg_batch( + app: AppHandle, + command_args: Vec, + input_path: String, +) -> Result<(), String> { + let input = PathBuf::from(&input_path); + let stem = input + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("output") + .to_string(); + let ext = input + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("mp4") + .to_string(); + let output_path = input + .with_file_name(format!("{}_processed.{}", stem, ext)) + .to_string_lossy() + .to_string(); + + let job_id = uuid::Uuid::new_v4().to_string(); + println!("[FFMPEG] Batch job {} for {}", job_id, input_path); + run_ffmpeg_job(app, job_id, command_args, input_path, output_path).await +} + #[tauri::command] pub async fn cancel_ffmpeg(job_id: String) -> Result<(), String> { let mut jobs = ACTIVE_JOBS.lock().await; diff --git a/src-tauri/src/commands/video.rs b/src-tauri/src/commands/video.rs index 5230a5c0..361bd13a 100644 --- a/src-tauri/src/commands/video.rs +++ b/src-tauri/src/commands/video.rs @@ -1471,6 +1471,182 @@ pub async fn get_available_subtitles( Ok(subtitles) } +/// Download a single subtitle track's content and return it as a string. +/// +/// Used by the Subtitle Download Dialog to load a subtitle file into the +/// editor without touching the download queue. The frontend calls +/// `invoke('download_subtitle_content', ...)`; the command must stay +/// registered in `lib.rs` or the dialog fails at runtime. +#[tauri::command] +pub async fn download_subtitle_content( + app: AppHandle, + url: String, + lang: String, + is_auto: Option, + format: Option, + cookie_mode: Option, + cookie_browser: Option, + cookie_browser_profile: Option, + cookie_file_path: Option, + cookie_skip_patterns: Option>, + proxy_url: Option, +) -> Result { + validate_url(&url).map_err(|e| BackendError::from_message(e).to_wire_string())?; + let url = normalize_url(&url); + let is_auto = is_auto.unwrap_or(false); + // yt-dlp accepts a preference list; keep the requested format first and + // fall back to vtt/best so the dialog still works on sites without srt. + let fmt = match format.as_deref() { + Some(f) if !f.trim().is_empty() => f.trim().to_string(), + _ => "srt".to_string(), + }; + let sub_format = format!("{}/vtt/best", fmt); + + let temp_dir = std::env::temp_dir().join(format!("youwee-subs-{}", Uuid::new_v4())); + tokio::fs::create_dir_all(&temp_dir) + .await + .map_err(|e| format!("Failed to create temp directory: {}", e))?; + + let mut args = vec![ + "--skip-download".to_string(), + "--no-warnings".to_string(), + "--no-playlist".to_string(), + "--sub-langs".to_string(), + lang.clone(), + "--sub-format".to_string(), + sub_format, + "--write-subs".to_string(), + ]; + if is_auto { + args.push("--write-auto-subs".to_string()); + } + + // Deno runtime for YouTube (JS extractor requirement) + if url.contains("youtube.com") || url.contains("youtu.be") { + if let Some(deno_path) = get_deno_path(&app).await { + args.push("--js-runtimes".to_string()); + args.push(format!("deno:{}", deno_path.to_string_lossy())); + } + } + + args.push("--output".to_string()); + args.push( + temp_dir + .join("sub") + .to_string_lossy() + .to_string(), + ); + args.push("--".to_string()); + args.push(url.clone()); + + let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + let result = run_ytdlp_with_stderr_and_cookies( + &app, + &args_ref, + cookie_mode.as_deref(), + cookie_browser.as_deref(), + cookie_browser_profile.as_deref(), + cookie_file_path.as_deref(), + cookie_skip_patterns.as_deref(), + proxy_url.as_deref(), + ) + .await; + + let cleanup = |dir: &std::path::Path| { + let _ = std::fs::remove_dir_all(dir); + }; + + let run = match result { + Ok(run) => run, + Err(e) => { + cleanup(&temp_dir); + return Err(e); + } + }; + if !run.success { + cleanup(&temp_dir); + let message = parse_ytdlp_error(&run.stderr) + .map(|e| e.to_wire_string()) + .unwrap_or_else(|| { + let stderr = run.stderr.trim(); + if stderr.is_empty() { + "Failed to download subtitle.".to_string() + } else { + format!("Failed to download subtitle: {}", stderr) + } + }); + return Err(message); + } + + // yt-dlp writes `..(srt|vtt|...)` — pick the file whose + // name contains the requested language tag, otherwise the only subtitle + // file present (yt-dlp may normalize the lang code, e.g. en-US). + let mut found: Option = None; + let mut fallback: Option = None; + let mut entries = match tokio::fs::read_dir(&temp_dir).await { + Ok(entries) => entries, + Err(e) => { + cleanup(&temp_dir); + return Err(format!("Failed to read temp directory: {}", e)); + } + }; + while let Some(entry) = entries.next_entry().await.ok().flatten() { + let path = entry.path(); + if !path.is_file() { + continue; + } + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default() + .to_lowercase(); + let is_sub = name.ends_with(".srt") + || name.ends_with(".vtt") + || name.ends_with(".ass") + || name.ends_with(".ssa") + || name.ends_with(".srv3") + || name.ends_with(".ttml"); + if !is_sub { + continue; + } + if fallback.is_none() { + fallback = Some(path.clone()); + } + let lang_l = lang.to_lowercase(); + if name.contains(&format!(".{}.", lang_l)) || name.contains(&format!(".{}", lang_l)) { + found = Some(path); + break; + } + } + + let file_path = found.or(fallback); + let Some(file_path) = file_path else { + cleanup(&temp_dir); + return Err(if is_auto { + "No auto-generated subtitle was found for the selected language.".to_string() + } else { + "No subtitle file was downloaded for the selected language.".to_string() + }); + }; + + let content = match std::fs::read_to_string(&file_path) { + Ok(content) => content, + Err(e) => { + cleanup(&temp_dir); + return Err(format!("Failed to read downloaded subtitle: {}", e)); + } + }; + + cleanup(&temp_dir); + + if content.trim().is_empty() { + return Err("The downloaded subtitle file is empty.".to_string()); + } + + Ok(content) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/youtube_search.rs b/src-tauri/src/commands/youtube_search.rs index a88b30d6..ca55e7f0 100644 --- a/src-tauri/src/commands/youtube_search.rs +++ b/src-tauri/src/commands/youtube_search.rs @@ -7,6 +7,7 @@ pub async fn search_youtube_videos( limit: Option, filters: Option, continuation: Option, + lang: Option, ) -> Result { - search_youtube_videos_internal(query, limit, filters, continuation).await + search_youtube_videos_internal(query, limit, filters, continuation, lang).await } diff --git a/src-tauri/src/database/history.rs b/src-tauri/src/database/history.rs index 80280b53..1566f3c9 100644 --- a/src-tauri/src/database/history.rs +++ b/src-tauri/src/database/history.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use super::get_db; use crate::types::{ DownloadDuplicateIdentity, DownloadDuplicateMatch, HistoryAdvancedFilters, HistoryCollection, + GalleryLibraryItem, HistoryEntry, HistoryFilterMatchMode, HistoryMediaType, HistorySearchScope, HistorySort, HistoryTag, }; @@ -887,6 +888,112 @@ pub fn add_history_with_summary( Ok(id) } +const GALLERY_COVER_EXTS: &[&str] = &["jpg", "jpeg", "png", "webp", "gif", "avif", "bmp", "jfif"]; + +/// Total size of a gallery folder (recursive walk, capped to stay responsive). +fn gallery_dir_size(path: &std::path::Path) -> Option { + let mut total: u64 = 0; + let mut stack: Vec = vec![path.to_path_buf()]; + let mut visited: usize = 0; + while let Some(dir) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + stack.push(entry_path); + } else if let Ok(meta) = entry.metadata() { + total += meta.len(); + } + visited += 1; + if visited >= 100_000 { + return Some(total); + } + } + } + Some(total) +} + +/// First image file inside a gallery folder, used as the library card cover. +fn gallery_cover_image(path: &std::path::Path) -> Option { + let entries = std::fs::read_dir(path).ok()?; + for entry in entries.flatten() { + let entry_path = entry.path(); + if !entry_path.is_file() { + continue; + } + let ext = entry_path + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()) + .unwrap_or_default(); + if GALLERY_COVER_EXTS.contains(&ext.as_str()) { + return entry_path.to_str().map(|s| s.to_string()); + } + } + None +} + +/// List completed gallery downloads (history rows tagged with format = 'gallery'). +pub fn list_gallery_items_from_db() -> Result, String> { + let conn = get_db()?; + let mut stmt = conn + .prepare( + "SELECT id, url, title, thumbnail, filepath, source, downloaded_at + FROM history + WHERE format = 'gallery' + ORDER BY downloaded_at DESC", + ) + .map_err(|e| format!("Failed to prepare gallery listing: {}", e))?; + + let rows = stmt + .query_map([], |row| { + let filepath: String = row.get(4)?; + let downloaded_at: i64 = row.get(6)?; + let dt = chrono::DateTime::from_timestamp(downloaded_at, 0) + .map(|d| d.to_rfc3339()) + .unwrap_or_default(); + let file_exists = std::path::Path::new(&filepath).exists(); + let item_path = std::path::Path::new(&filepath); + let folder_name = item_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + let file_size = if item_path.is_dir() { + gallery_dir_size(item_path) + } else { + std::fs::metadata(item_path).ok().map(|m| m.len()) + }; + let cover_image = if item_path.is_dir() { + gallery_cover_image(item_path) + } else { + None + }; + Ok(GalleryLibraryItem { + id: row.get(0)?, + url: row.get(1)?, + title: row.get(2)?, + thumbnail: row.get(3)?, + filepath, + source: row.get(5)?, + downloaded_at: dt, + file_exists, + folder_name, + file_size, + cover_image, + }) + }) + .map_err(|e| format!("Failed to query gallery items: {}", e))?; + + let mut items = Vec::new(); + for row in rows { + items.push(row.map_err(|e| format!("Failed to read gallery item: {}", e))?); + } + Ok(items) +} + pub fn get_history_from_db( limit: Option, offset: Option, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 50b45024..0ffdf935 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -//! Youwee - Modern YouTube Video Downloader +//! youwee - Modern YouTube Video Downloader //! //! This is the main entry point for the Tauri application. //! The codebase is organized into the following modules: @@ -323,14 +323,18 @@ pub fn run() { // Download commands commands::download_video, commands::stop_download, + commands::cancel_download_item, commands::download_gallery, commands::stop_gallery_download, + commands::probe_gallery, + commands::preview_download_command, // Video info commands commands::get_video_basic_info, commands::get_video_info, commands::get_playlist_entries, commands::search_youtube_videos, commands::get_available_subtitles, + commands::download_subtitle_content, commands::get_video_transcript, // yt-dlp commands commands::get_ytdlp_version, @@ -350,6 +354,18 @@ pub fn run() { commands::set_ffmpeg_source_cmd, commands::check_ffmpeg_update, commands::download_ffmpeg, + commands::check_engine_compat, + commands::check_engine_backups, + commands::rollback_ytdlp, + commands::rollback_ffmpeg, + commands::rollback_deno, + commands::rollback_gallerydl, + commands::check_gallerydl_update, + commands::download_gallerydl, + commands::export_backup, + commands::export_backup_with_settings, + commands::preview_backup, + commands::import_backup, commands::get_ffmpeg_path_for_ytdlp, // Deno commands commands::check_deno, @@ -369,6 +385,8 @@ pub fn run() { // History commands commands::add_history, commands::get_history, + commands::list_gallery_items, + commands::get_gallery_thumbnail, commands::get_history_entries_by_ids, commands::find_duplicate_downloads, commands::delete_history, @@ -412,6 +430,7 @@ pub fn run() { commands::generate_processing_command, commands::generate_quick_action_command, commands::execute_ffmpeg_command, + commands::execute_ffmpeg_batch, commands::cancel_ffmpeg, commands::get_processing_history, commands::save_processing_job, @@ -542,7 +561,7 @@ fn setup_tray(app: &tauri::App) -> Result<(), Box> { let tray_builder = TrayIconBuilder::with_id("main-tray") .icon(icon) - .tooltip("Youwee") + .tooltip("youwee") .menu(&menu) .on_menu_event(move |_tray, event| { let id = event.id().as_ref(); @@ -689,7 +708,7 @@ fn tray_text(key: &str) -> &'static str { ("fr", "check_all") => "Verifier les chaines suivies maintenant", ("fr", "settings") => "Parametres", ("fr", "check_update") => "Verifier les mises a jour...", - ("fr", "open") => "Ouvrir Youwee", + ("fr", "open") => "Ouvrir youwee", ("fr", "browser_extension") => "Extension navigateur", ("fr", "quit") => "Quitter", // Japanese diff --git a/src-tauri/src/services/plugin/bridge.rs b/src-tauri/src/services/plugin/bridge.rs index 7e4d2cd5..72935f9f 100644 --- a/src-tauri/src/services/plugin/bridge.rs +++ b/src-tauri/src/services/plugin/bridge.rs @@ -115,6 +115,7 @@ struct YoutubeSearchVideosRequest { limit: Option, filters: Option, continuation: Option, + lang: Option, } #[derive(Debug, Serialize)] @@ -394,6 +395,7 @@ async fn handle_bridge_request( request.limit, request.filters, request.continuation, + request.lang.clone(), ) .await?; Ok(( diff --git a/src-tauri/src/services/telegram.rs b/src-tauri/src/services/telegram.rs index c73e4eda..4b123c2b 100644 --- a/src-tauri/src/services/telegram.rs +++ b/src-tauri/src/services/telegram.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Mutex; +use std::time::Duration; use reqwest::Client; use serde::{Deserialize, Serialize}; @@ -9,6 +10,24 @@ use tauri::{AppHandle, Emitter}; const TELEGRAM_API_BASE: &str = "https://api.telegram.org"; const LONG_POLL_TIMEOUT_SECS: u64 = 30; const MAX_BACKOFF_SECS: u64 = 60; +/// Connect timeout so a dead/unreachable network fails fast and the polling +/// loop can back off instead of hanging forever (upstream issue #118). +const CONNECT_TIMEOUT_SECS: u64 = 15; +/// Generous total request timeout: must exceed the 30s long-poll window plus +/// network latency, while still guaranteeing a wedged connection eventually +/// errors out so the loop can retry. +const REQUEST_TIMEOUT_SECS: u64 = LONG_POLL_TIMEOUT_SECS + 90; + +/// Shared HTTP client for every Telegram API call. `Client::new()` has no +/// timeouts at all, which let a dead connection block the polling loop +/// indefinitely (bot appeared frozen / "doesn't work"). +fn telegram_client() -> Client { + Client::builder() + .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS)) + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) + .build() + .unwrap_or_else(|_| Client::new()) +} static TELEGRAM_CONFIG: Mutex = Mutex::new(TelegramConfig { enabled: false, @@ -213,7 +232,7 @@ pub fn set_config(app: AppHandle, config: TelegramConfig) { let bot_token = sanitized.bot_token.clone(); let command_app = app.clone(); tauri::async_runtime::spawn(async move { - if let Err(error) = set_my_commands(&Client::new(), &bot_token).await { + if let Err(error) = set_my_commands(&telegram_client(), &bot_token).await { if set_status(TelegramStatusState::Error, Some(error)) { crate::rebuild_tray_menu(&command_app); } @@ -244,7 +263,7 @@ pub async fn send_reply(chat_id: String, text: String) -> Result<(), String> { return Err("Telegram is not configured.".to_string()); } - send_message_with_keyboard(&Client::new(), &config.bot_token, &chat_id, &text).await + send_message_with_keyboard(&telegram_client(), &config.bot_token, &chat_id, &text).await } fn sanitize_config(config: TelegramConfig) -> TelegramConfig { @@ -282,7 +301,7 @@ fn replace_polling_task(handle: tauri::async_runtime::JoinHandle<()>) { } async fn run_polling_loop(app: AppHandle, config: TelegramConfig, generation: u64) { - let client = Client::new(); + let client = telegram_client(); let allowed_chat_ids: HashSet = config.allowed_chat_ids.iter().cloned().collect(); let mut backoff_secs = 2; diff --git a/src-tauri/src/services/youtube_search.rs b/src-tauri/src/services/youtube_search.rs index a94c95df..8c9cecfd 100644 --- a/src-tauri/src/services/youtube_search.rs +++ b/src-tauri/src/services/youtube_search.rs @@ -251,14 +251,60 @@ async fn fetch_search_page( query: &str, filters: Option<&YoutubeSearchFilters>, continuation: Option<&str>, + lang: &str, ) -> Result { + // Map app UI language to a YouTube "hl"/"gl" pair. Fall back to en/US so + // search results never come back in an unexpected (e.g. Vietnamese) locale. + let (hl, gl) = match lang { + "ar" => ("ar", "SA"), + "fa" => ("fa", "IR"), + "zh-CN" | "zh" => ("zh-Hans", "CN"), + "zh-TW" => ("zh-Hant", "TW"), + "ja" => ("ja", "JP"), + "ko" => ("ko", "KR"), + "ru" => ("ru", "RU"), + "tr" => ("tr", "TR"), + "th" => ("th", "TH"), + "vi" => ("vi", "VN"), + "pt" => ("pt", "BR"), + "es" => ("es", "ES"), + "fr" => ("fr", "FR"), + "de" => ("de", "DE"), + "it" => ("it", "IT"), + "id" => ("id", "ID"), + "uz" => ("uz", "UZ"), + "ro" => ("ro", "RO"), + other => { + let base = other.split('-').next().unwrap_or("en"); + match base { + "zh" => ("zh-Hans", "CN"), + "ar" => ("ar", "SA"), + "fa" => ("fa", "IR"), + "ja" => ("ja", "JP"), + "ko" => ("ko", "KR"), + "ru" => ("ru", "RU"), + "tr" => ("tr", "TR"), + "th" => ("th", "TH"), + "vi" => ("vi", "VN"), + "pt" => ("pt", "BR"), + "es" => ("es", "ES"), + "fr" => ("fr", "FR"), + "de" => ("de", "DE"), + "it" => ("it", "IT"), + "id" => ("id", "ID"), + "uz" => ("uz", "UZ"), + "ro" => ("ro", "RO"), + _ => ("en", "US"), + } + } + }; let mut body = json!({ "context": { "client": { "clientName": YOUTUBE_WEB_CLIENT_NAME, "clientVersion": YOUTUBE_WEB_CLIENT_VERSION, - "hl": "vi", - "gl": "VN" + "hl": hl, + "gl": gl } } }); @@ -308,7 +354,14 @@ pub async fn search_youtube_videos_internal( limit: Option, filters: Option, continuation: Option, + lang: Option, ) -> Result { + let lang = lang + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("en") + .to_string(); let query = query.trim().to_string(); let initial_continuation = continuation .as_deref() @@ -338,6 +391,7 @@ pub async fn search_youtube_videos_internal( &query, filters.as_ref(), next_continuation.as_deref(), + &lang, ) .await?; for video in page.videos { diff --git a/src-tauri/src/services/ytdlp.rs b/src-tauri/src/services/ytdlp.rs index e7bbf2ad..243eda1e 100644 --- a/src-tauri/src/services/ytdlp.rs +++ b/src-tauri/src/services/ytdlp.rs @@ -335,7 +335,7 @@ pub async fn get_ytdlp_path(app: &AppHandle) -> Option<(PathBuf, bool)> { YtdlpChannel::Bundled => { // Bundled must mean the sidecar shipped with the current app build. // Keep the old app-data binary only as a compatibility fallback for - // installs that predate the Youwee-specific sidecar name. + // installs that predate the youwee-specific sidecar name. if let Some(bundled) = get_bundled_ytdlp_path() { return Some((bundled, true)); } diff --git a/src-tauri/src/services/ytdlp_args.rs b/src-tauri/src/services/ytdlp_args.rs index 6e7f3d3b..b65f2dfe 100644 --- a/src-tauri/src/services/ytdlp_args.rs +++ b/src-tauri/src/services/ytdlp_args.rs @@ -197,14 +197,23 @@ pub fn redact_ytdlp_advanced_args(args: &[String]) -> Vec { for arg in args { if let Some(flag) = redact_next_value_for.take() { redacted.push(match flag { - "--add-headers" => redact_header_value(arg), + "--add-headers" | "--add-header" => redact_header_value(arg), _ => "".to_string(), }); continue; } redacted.push(arg.clone()); - if matches!(arg.as_str(), "--add-headers" | "--user-agent" | "--referer") { + if matches!( + arg.as_str(), + "--add-headers" + | "--add-header" + | "--user-agent" + | "--referer" + | "--cookies" + | "--cookies-from-browser" + | "--proxy" + ) { redact_next_value_for = Some(arg); } } @@ -212,6 +221,248 @@ pub fn redact_ytdlp_advanced_args(args: &[String]) -> Vec { redacted } +/// Masks the query string (and any embedded credentials) of a URL before it is +/// written to logs or shown in the diagnostics panel. The scheme, host and path +/// are preserved so the command stays recognizable. The marker is composed +/// manually instead of going through `Url::set_query`, which would +/// percent-encode the angle brackets into `%3Credacted%3E`. +pub fn redact_url_for_log(url: &str) -> String { + let Ok(parsed) = reqwest::Url::parse(url) else { + return url.split(['?', '&']).next().unwrap_or(url).to_string(); + }; + let has_query = parsed.query().map_or(false, |q| !q.is_empty()); + if parsed.username().is_empty() && parsed.password().is_none() && !has_query { + return parsed.to_string(); + } + let host = match parsed.port() { + Some(port) => format!("{}:{port}", parsed.host_str().unwrap_or_default()), + None => parsed.host_str().unwrap_or_default().to_string(), + }; + let mut redacted = format!("{}://{}{}", parsed.scheme(), host, parsed.path()); + if has_query { + redacted.push_str("?"); + } + redacted +} + +/// Redacts a full yt-dlp arg vector for logs / diagnostics. When `redact_url` is +/// set, the trailing URL argument (position after `--`) is masked too. +pub fn redact_download_args_for_log(args: &[String], redact_url: bool) -> Vec { + let mut redacted = redact_ytdlp_advanced_args(args); + if redact_url { + if let Some(last) = redacted.last_mut() { + *last = redact_url_for_log(last); + } + } + redacted +} + +/// Flags that may be passed through the expert raw-arguments textarea. +/// Everything else starting with `-` is rejected so the app-managed download +/// pipeline (output paths, cookies, proxy, retries, post-processing) cannot be +/// silently overridden by raw input. +const SAFE_RAW_ARG_ALLOWLIST: &[&str] = &[ + "--limit-rate", + "--retries", + "--fragment-retries", + "--extractor-retries", + "--file-access-retries", + "--concurrent-fragments", + "--socket-timeout", + "--http-chunk-size", + "--throttled-rate", + "--sleep-requests", + "--sleep-interval", + "--max-sleep-interval", + "--user-agent", + "--referer", + "--add-header", + "--geo-bypass", + "--geo-bypass-country", + "--match-filters", + "--format-sort", + "--impersonate", +]; + +/// Flags that are dangerous or app-managed. Defense-in-depth: even if a flag is +/// ever added to the allowlist above, these are still rejected. +const RAW_ARG_BLOCKLIST: &[&str] = &[ + "--exec", + "--exec-before-download", + "--downloader", + "--downloader-args", + "--external-downloader", + "--external-downloader-args", + "--config-location", + "--load-info-json", + "--write-info-json", + "--print", + "--print-to-file", + "--cookies", + "--cookies-from-browser", + "--proxy", + "--ffmpeg-location", + "--js-runtimes", + "--extractor-args", + "--plugin-dirs", + "--cache-dir", + "--output", + "-o", + "--format", + "-f", + "--playlist-start", + "--playlist-end", + "--download-archive", + "--write-pages", + "--skip-download", + "--simulate", + "--dump-json", + "--flat-playlist", + "--lazy-playlist", + "--postprocessor-args", + "--ppa", + "--remux-video", + "--recode-video", + "--convert-subs", + "--convert-thumbnails", + "--xattrs", + "--embed-metadata", + "--embed-thumbnail", + "--split-chapters", + "--download-sections", + "--force-overwrites", + "--no-overwrites", + "--newline", + "--progress", + "--no-warnings", +]; + +/// Tokenizes an expert raw-arguments string (whitespace-split, quote-aware) and +/// validates every flag against the safe allowlist. Returns the argv tokens so +/// they can be appended to the download command as an array (never a shell +/// string). Invalid or dangerous flags produce a validation error BEFORE the +/// download starts. +pub fn build_ytdlp_raw_args(raw: &str) -> Result, BackendError> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(Vec::new()); + } + if trimmed.len() > 4000 { + return Err(validation_error( + "Raw yt-dlp arguments are too long (max 4000 characters).", + )); + } + + let tokens = tokenize_raw_args(trimmed)?; + + let mut args = Vec::with_capacity(tokens.len()); + for token in &tokens { + if token.chars().any(|ch| ch.is_control()) { + return Err(validation_error( + "Raw yt-dlp arguments cannot contain control characters.", + )); + } + if token == "--" { + return Err(validation_error( + "Raw yt-dlp arguments cannot contain the '--' separator.", + )); + } + if token.starts_with('-') { + if RAW_ARG_BLOCKLIST.contains(&token.as_str()) { + return Err(validation_error(format!( + "Raw yt-dlp flag '{}' is blocked for safety.", + token + ))); + } + if !SAFE_RAW_ARG_ALLOWLIST.contains(&token.as_str()) { + return Err(validation_error(format!( + "Raw yt-dlp flag '{}' is not in the safe allowlist.", + token + ))); + } + } + args.push(token.clone()); + } + + Ok(args) +} + +/// Whitespace-splits a raw argument string while honoring single and double +/// quotes (with backslash escapes inside double quotes). +fn tokenize_raw_args(raw: &str) -> Result, BackendError> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut chars = raw.chars().peekable(); + let mut in_token = false; + + while let Some(ch) = chars.next() { + match ch { + ' ' | '\t' | '\r' => { + if in_token { + tokens.push(std::mem::take(&mut current)); + in_token = false; + } + } + '"' => { + in_token = true; + let mut closed = false; + while let Some(inner) = chars.next() { + match inner { + '"' => { + closed = true; + break; + } + '\\' => { + if let Some(escaped) = chars.next() { + current.push(escaped); + } + } + c => current.push(c), + } + } + if !closed { + return Err(validation_error( + "Unterminated double quote in raw yt-dlp arguments.", + )); + } + } + '\'' => { + in_token = true; + let mut closed = false; + while let Some(inner) = chars.next() { + match inner { + '\'' => { + closed = true; + break; + } + c => current.push(c), + } + } + if !closed { + return Err(validation_error( + "Unterminated single quote in raw yt-dlp arguments.", + )); + } + } + c if c.is_control() => { + return Err(validation_error( + "Raw yt-dlp arguments cannot contain control characters.", + )); + } + c => { + in_token = true; + current.push(c); + } + } + } + + if in_token { + tokens.push(current); + } + + Ok(tokens) +} + fn push_flag_value(args: &mut Vec, flag: &str, value: String) { args.push(flag.to_string()); args.push(value); @@ -581,4 +832,109 @@ mod tests { ] ); } + + #[test] + fn build_ytdlp_raw_args_tokenizes_quoted_values() { + let args = build_ytdlp_raw_args( + "--limit-rate 5M --retries 5 --add-header \"X-Test: hello world\" --match-filters '!is_live'", + ) + .expect("safe raw args should tokenize"); + + assert_eq!( + args, + vec![ + "--limit-rate", + "5M", + "--retries", + "5", + "--add-header", + "X-Test: hello world", + "--match-filters", + "!is_live", + ] + ); + } + + #[test] + fn build_ytdlp_raw_args_rejects_dangerous_flags() { + for bad in ["--exec", "--downloader-args", "--config-location", "--load-info-json"] { + let err = build_ytdlp_raw_args(&format!("--limit-rate 5M {bad} echo pwned")) + .expect_err("dangerous raw flags must be rejected"); + assert!( + err.message().contains("blocked for safety"), + "unexpected message: {}", + err.message() + ); + } + } + + #[test] + fn build_ytdlp_raw_args_rejects_non_allowlisted_flags() { + // A flag in neither the allowlist nor the blocklist falls through to + // the allowlist rejection. (--output would hit the blocklist first.) + let err = build_ytdlp_raw_args("--write-subs") + .expect_err("app-managed flags must be rejected"); + assert!(err.message().contains("not in the safe allowlist")); + + let err2 = build_ytdlp_raw_args("-f best") + .expect_err("short app-managed flags must be rejected"); + assert!(err2.message().contains("blocked for safety")); + } + + #[test] + fn build_ytdlp_raw_args_rejects_unterminated_quotes() { + let err = build_ytdlp_raw_args("--limit-rate \"5M") + .expect_err("unterminated quotes must be rejected"); + assert!(err.message().contains("Unterminated")); + + let err2 = build_ytdlp_raw_args("--retries '3") + .expect_err("unterminated single quotes must be rejected"); + assert!(err2.message().contains("Unterminated")); + } + + #[test] + fn build_ytdlp_raw_args_accepts_empty_input() { + assert!(build_ytdlp_raw_args("").unwrap().is_empty()); + assert!(build_ytdlp_raw_args(" ").unwrap().is_empty()); + } + + #[test] + fn redact_download_args_for_log_redacts_secrets_and_url() { + let redacted = redact_download_args_for_log( + &[ + "--cookies".to_string(), + "C:\\secrets\\cookies.txt".to_string(), + "--proxy".to_string(), + "http://user:pass@127.0.0.1:8080".to_string(), + "--add-header".to_string(), + "X-Api-Key:abc".to_string(), + "--".to_string(), + "https://example.com/watch?v=secret123&t=10".to_string(), + ], + true, + ); + + assert_eq!( + redacted, + vec![ + "--cookies", + "", + "--proxy", + "", + "--add-header", + "X-Api-Key:", + "--", + "https://example.com/watch?" + ] + ); + } + + #[test] + fn redact_url_for_log_keeps_scheme_host_path() { + let redacted = redact_url_for_log("https://example.com/watch?v=abc&token=secret"); + assert_eq!(redacted, "https://example.com/watch?"); + + let plain = redact_url_for_log("https://example.com/video/123"); + assert_eq!(plain, "https://example.com/video/123"); + } } diff --git a/src-tauri/src/types/history.rs b/src-tauri/src/types/history.rs index dc5c4542..ec86cba4 100644 --- a/src-tauri/src/types/history.rs +++ b/src-tauri/src/types/history.rs @@ -17,6 +17,23 @@ pub struct HistoryCollection { pub item_count: Option, } +/// Gallery library item (a completed gallery-dl download stored in history) +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct GalleryLibraryItem { + pub id: String, + pub url: String, + pub title: String, + pub thumbnail: Option, + pub filepath: String, + pub source: Option, + pub downloaded_at: String, + pub file_exists: bool, + pub folder_name: String, + pub file_size: Option, + pub cover_image: Option, +} + /// History entry structure #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct HistoryEntry { diff --git a/src-tauri/src/utils/filename.rs b/src-tauri/src/utils/filename.rs index 451aed23..63c88651 100644 --- a/src-tauri/src/utils/filename.rs +++ b/src-tauri/src/utils/filename.rs @@ -39,7 +39,7 @@ mod tests { #[test] fn calc_trim_filenames_respects_long_output_paths() { - let long_path = "G:\\Youwee\\Downloads\\very-long-folder-name"; + let long_path = "G:\\youwee\\Downloads\\very-long-folder-name"; assert!(calc_trim_filenames_bytes(long_path) < DEFAULT_TRIM_FILENAMES); } @@ -50,7 +50,7 @@ mod tests { #[test] fn calc_trim_filenames_uses_byte_length_for_non_ascii_paths() { - let path = "G:\\下载\\Youwee\\very-long-non-ascii-output-directory-name"; + let path = "G:\\下载\\youwee\\very-long-non-ascii-output-directory-name"; assert!(path.as_bytes().len() > path.chars().count()); let expected = (WINDOWS_MAX_PATH @@ -64,7 +64,7 @@ mod tests { #[test] fn add_safe_filename_args_uses_path_aware_trim() { let mut args = Vec::new(); - add_safe_filename_args(&mut args, Some("G:\\Youwee")); + add_safe_filename_args(&mut args, Some("G:\\youwee")); assert!(args.contains(&"--trim-filenames".to_string())); let trim_index = args diff --git a/src-tauri/tauri.beta.conf.json b/src-tauri/tauri.beta.conf.json new file mode 100644 index 00000000..a4763334 --- /dev/null +++ b/src-tauri/tauri.beta.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "createUpdaterArtifacts": false + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 62064507..4e5f0bb1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "productName": "Youwee", - "version": "0.20.3", + "version": "0.20.4", "identifier": "com.vanloctech.youwee", "build": { "frontendDist": "../dist", diff --git a/src/App.tsx b/src/App.tsx index ce92802a..826521ca 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,3 +1,4 @@ +import { DirectionProvider } from '@radix-ui/react-direction'; import { invoke } from '@tauri-apps/api/core'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -35,6 +36,7 @@ import { useTelegramRemoteCommands } from '@/hooks/useTelegramRemoteCommands'; import { useTrayDownloadStatus } from '@/hooks/useTrayDownloadStatus'; import { useTrayEvents } from '@/hooks/useTrayEvents'; import { useYtdlpAutoUpdateToast } from '@/hooks/useYtdlpAutoUpdateToast'; +import { runDevCommandSmoke } from '@/lib/dev-command-smoke'; import { ChannelsPage, DownloadPage, @@ -99,6 +101,14 @@ function AppContent() { onOpenDependencies: openDependenciesSettings, }); + useEffect(() => { + // Dev-only: probe safe startup commands and warn on any failure + // (catches unregistered/signature-drifted Tauri commands early). + if (import.meta.env.DEV) { + runDevCommandSmoke(); + } + }, []); + useEffect(() => { const locale = i18n.resolvedLanguage || i18n.language || 'en'; const direction = @@ -237,42 +247,52 @@ function UpdaterWrapper({ children }: { children: React.ReactNode }) { return {children}; } +function AppDirectionProvider({ children }: { children: React.ReactNode }) { + const { i18n } = useTranslation(); + const baseLanguage = (i18n.resolvedLanguage || i18n.language || 'en').toLowerCase().split('-')[0]; + // Mirrors RTL_LANGUAGES in src/i18n/index.ts (ar, fa). + const dir = baseLanguage === 'ar' || baseLanguage === 'fa' ? 'rtl' : 'ltr'; + return {children}; +} + export function App() { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/src/assets/fonts/nunito/OFL.txt b/src/assets/fonts/nunito/OFL.txt new file mode 100644 index 00000000..c8210f08 --- /dev/null +++ b/src/assets/fonts/nunito/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/components/BrowserCookieErrorDialog.tsx b/src/components/BrowserCookieErrorDialog.tsx index 29f5563a..fb278b63 100644 --- a/src/components/BrowserCookieErrorDialog.tsx +++ b/src/components/BrowserCookieErrorDialog.tsx @@ -34,7 +34,7 @@ export function BrowserCookieErrorDialog({ @@ -74,7 +74,7 @@ export function BrowserCookieErrorDialog({
diff --git a/src/components/DenoDialog.tsx b/src/components/DenoDialog.tsx index 8f60f0fd..beebe469 100644 --- a/src/components/DenoDialog.tsx +++ b/src/components/DenoDialog.tsx @@ -83,7 +83,7 @@ export function DenoDialog({ onDismiss }: DenoDialogProps) {
{denoDownloading ? ( ) : denoSuccess ? ( diff --git a/src/components/FFmpegDialog.tsx b/src/components/FFmpegDialog.tsx index 9825aae5..ca82ef50 100644 --- a/src/components/FFmpegDialog.tsx +++ b/src/components/FFmpegDialog.tsx @@ -33,7 +33,7 @@ export function FFmpegDialog({ onDismiss }: FFmpegDialogProps) { @@ -89,7 +89,7 @@ export function FFmpegDialog({ onDismiss }: FFmpegDialogProps) {
{ffmpegDownloading ? ( ) : ffmpegSuccess ? ( @@ -111,7 +111,7 @@ export function FFmpegDialog({ onDismiss }: FFmpegDialogProps) { Skip for Now diff --git a/src/components/FFmpegRequiredDialog.tsx b/src/components/FFmpegRequiredDialog.tsx index fac34c38..6df0a10c 100644 --- a/src/components/FFmpegRequiredDialog.tsx +++ b/src/components/FFmpegRequiredDialog.tsx @@ -53,7 +53,7 @@ export function FFmpegRequiredDialog({ @@ -115,14 +115,14 @@ export function FFmpegRequiredDialog({ {ffmpegDownloading ? (
) : ffmpegSuccess ? (
@@ -139,14 +139,14 @@ export function FFmpegRequiredDialog({
{/* Primary action */} {/* Secondary actions */}
diff --git a/src/components/UpdateDialog.tsx b/src/components/UpdateDialog.tsx index 5d28d62a..7a560316 100644 --- a/src/components/UpdateDialog.tsx +++ b/src/components/UpdateDialog.tsx @@ -81,7 +81,7 @@ export function UpdateDialog({ @@ -111,7 +111,7 @@ export function UpdateDialog({
-
+

{status === 'error' ? t('update.error') @@ -146,7 +146,7 @@ export function UpdateDialog({ {t('update.description')}

-
+
@@ -195,9 +195,12 @@ export function UpdateDialog({ {status === 'error' && (
-

- {error || t('update.errorGeneric')} -

+

{t('update.errorGeneric')}

+ {error && error !== t('update.errorGeneric') && ( +

+ {error} +

+ )}
)}
@@ -218,7 +221,7 @@ export function UpdateDialog({ className="sm:w-2/3 h-12 rounded-xl font-bold bg-foreground text-background hover:bg-foreground/90 shadow-xl shadow-foreground/10" > {t('update.updateNow')} - + )} @@ -229,7 +232,7 @@ export function UpdateDialog({ className="w-full h-12 rounded-xl font-semibold opacity-80" disabled > - + {t('update.downloading')} )} @@ -240,7 +243,7 @@ export function UpdateDialog({ className="w-full h-12 rounded-xl font-bold bg-green-500 text-white hover:bg-green-600 shadow-xl shadow-green-500/20" > {t('update.restartNow')} - + )} @@ -257,7 +260,7 @@ export function UpdateDialog({ onClick={onRetry} className="sm:w-2/3 h-12 rounded-xl font-bold bg-foreground text-background hover:bg-foreground/90" > - + {t('update.retry')} diff --git a/src/components/download/DuplicateDownloadDialog.tsx b/src/components/download/DuplicateDownloadDialog.tsx index 06b6ad2c..2e8d9e16 100644 --- a/src/components/download/DuplicateDownloadDialog.tsx +++ b/src/components/download/DuplicateDownloadDialog.tsx @@ -117,7 +117,7 @@ export function DuplicateDownloadDialog({ review, onResolve }: DuplicateDownload }} > - +
@@ -209,12 +209,12 @@ export function DuplicateDownloadDialog({ review, onResolve }: DuplicateDownload
- + diff --git a/src/components/download/GalleryQueueItem.tsx b/src/components/download/GalleryQueueItem.tsx index 0d852b7e..024b42bc 100644 --- a/src/components/download/GalleryQueueItem.tsx +++ b/src/components/download/GalleryQueueItem.tsx @@ -1,10 +1,38 @@ -import { CheckCircle2, Clock, FolderOpen, Globe, Loader2, X, XCircle } from 'lucide-react'; +import { + ArrowUpToLine, + Ban, + CheckCircle2, + Clock, + CopyPlus, + Eye, + EyeOff, + FolderOpen, + Globe, + Loader2, + Pause, + Play, + RotateCcw, + X, + XCircle, +} from 'lucide-react'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { useGalleryDl } from '@/contexts/gallerydl-context'; import { openFileLocation } from '@/lib/open-file-location'; import type { DownloadItem } from '@/lib/types'; import { cn } from '@/lib/utils'; +const ERROR_CLASS_BADGE_STYLES: Record = { + transient: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400', + auth: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + geo: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + unavailable: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', + disk: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', + config: 'bg-rose-500/10 text-rose-600 dark:text-rose-400', + unsupported: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400', + unknown: 'bg-muted/50 text-muted-foreground', +}; + interface GalleryQueueItemProps { item: DownloadItem; isFocused?: boolean; @@ -19,10 +47,20 @@ export function GalleryQueueItem({ onRemove, }: GalleryQueueItemProps) { const { t } = useTranslation('gallery'); + const { + retryFailedDownload, + pauseItem, + resumeItem, + cancelItem, + duplicateItem, + moveItemToTop, + toggleItemIncognito, + } = useGalleryDl(); const isActive = item.status === 'downloading' || item.status === 'fetching'; const isCompleted = item.status === 'completed'; const isError = item.status === 'error'; const isPending = item.status === 'pending'; + const isPaused = item.status === 'paused'; const handleOpenFolder = useCallback(async () => { if (!item.completedFilepath) return; @@ -57,6 +95,14 @@ export function GalleryQueueItem({ )}
+ + {/* P0-5: incognito indicator */} + {item.incognito && ( +
+ + {t('download:queue.incognito')} +
+ )}
@@ -82,17 +128,20 @@ export function GalleryQueueItem({ className={cn( 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium', isPending && 'bg-muted text-muted-foreground', + isPaused && 'bg-amber-500/10 text-amber-600 dark:text-amber-400', isActive && 'bg-primary/10 text-primary', isCompleted && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', isError && 'bg-red-500/10 text-red-600 dark:text-red-400', )} > {isPending && } + {isPaused && } {isActive && } {isCompleted && } {isError && } {isPending && t('queue.status.pending')} + {isPaused && t('download:queue.status.paused')} {isActive && t('queue.status.downloading')} {isCompleted && t('queue.status.completed')} {isError && t('queue.status.failed')} @@ -104,6 +153,20 @@ export function GalleryQueueItem({ {item.error} )} + + {/* P0-7: error class badge + suggested action */} + {isError && item.errorClass && ( + + {t(`download:queue.errorClass.${item.errorClass}`)} + · + {t(`download:queue.errorClassAction.${item.errorClass}`)} + + )}
{!isActive && !isError && ( @@ -120,6 +183,97 @@ export function GalleryQueueItem({ )}
)} + + {/* P0-7: per-item controls cluster */} +
+ {!isActive && isError && ( + + )} + {!isActive && (isPending || isError) && ( + + )} + {isPaused && ( + + )} + {(isPending || isPaused || isError || isActive) && ( + + )} + {!isActive && ( + + )} + + {!isActive && ( + + )} +
+ )} +
+ + + + {selectedIds.size > 0 && ( + )} - {hasCompleted && ( - - {completionRate}% + + +
+
+ {t('queue.title')} + + {completedCount}/{totalCount} + {pendingCount > 0 && ( + + {t('queue.pending', { count: pendingCount })} + + )} + {hasCompleted && ( + + {completionRate}% + + )} +
+ {hasCompleted && ( + )}
- {hasCompleted && ( - + + {/* Remove (per-item history delete) */} + + + {/* Hover reveal: filename + size */} +
+

+ {filename} +

+ {size &&

{size}

} +
+ + {/* Indeterminate activity overlay for active items */} + {isActive && ( +
+
+
+ )} +
+ + {/* P0-7: per-item controls cluster */} +
+ {!isActive && isError && ( + + + {t('download:queue.retry')} + + )} + {!isActive && (isPending || isError) && ( + + )} + {isPaused && ( + + )} + {(isPending || isPaused || isError || isActive) && ( + + )} + {!isActive && ( + + )} + + {!isActive && ( + )}
- -
- {items.map((item) => ( - - ))} + {/* Body */} +
+

+ {item.title} +

+
+ {item.extractor && ( + + + {item.extractor} + + )} + {isError && item.errorClass && ( + + {t(`download:queue.errorClass.${item.errorClass}`)} + · + {t(`download:queue.errorClassAction.${item.errorClass}`)} + + )} + {isCompleted && item.completedFilepath && ( + + )} + {isError && item.error && ( + + {item.error} + + )}
- +
); } diff --git a/src/components/download/GallerySettingsPanel.tsx b/src/components/download/GallerySettingsPanel.tsx index 2dfccc5b..09f554de 100644 --- a/src/components/download/GallerySettingsPanel.tsx +++ b/src/components/download/GallerySettingsPanel.tsx @@ -1,6 +1,7 @@ import { FolderOpen, Settings2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { @@ -10,17 +11,29 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; -interface GallerySettings { +export interface GallerySettingsPanelSettings { outputPath: string; concurrentDownloads: number; + range: string; + filenameTemplate: string; + flatOutput: boolean; + cbzOutput: boolean; + rateLimit: string; + minFileSize: string; + maxFileSize: string; + sleep: string; + retries: number; + timeout: number; } interface GallerySettingsPanelProps { - settings: GallerySettings; + settings: GallerySettingsPanelSettings; disabled?: boolean; onSelectFolder: () => Promise; onConcurrentChange: (concurrent: number) => void; + onSettingsChange: (patch: Partial) => void; } export function GallerySettingsPanel({ @@ -28,6 +41,7 @@ export function GallerySettingsPanel({ disabled, onSelectFolder, onConcurrentChange, + onSettingsChange, }: GallerySettingsPanelProps) { const { t } = useTranslation('gallery'); const outputFolderName = settings.outputPath @@ -85,6 +99,130 @@ export function GallerySettingsPanel({ + +
+ + onSettingsChange({ range: e.target.value })} + placeholder={t('settings.rangeHint')} + disabled={disabled} + className="h-8 text-xs" + /> +
+ +
+ + onSettingsChange({ filenameTemplate: e.target.value })} + placeholder={t('settings.filenameHint')} + disabled={disabled} + className="h-8 text-xs font-mono" + /> +
+ +
+
+ + onSettingsChange({ flatOutput: checked })} + disabled={disabled} + /> +
+
+ + onSettingsChange({ cbzOutput: checked })} + disabled={disabled} + /> +
+
+ +
+
+ + onSettingsChange({ minFileSize: e.target.value })} + placeholder="10k" + disabled={disabled} + className="h-8 text-xs font-mono" + /> +
+
+ + onSettingsChange({ maxFileSize: e.target.value })} + placeholder="50M" + disabled={disabled} + className="h-8 text-xs font-mono" + /> +
+
+ +
+
+ + onSettingsChange({ rateLimit: e.target.value })} + placeholder="1M" + disabled={disabled} + className="h-8 text-xs font-mono" + /> +
+
+ + onSettingsChange({ sleep: e.target.value })} + placeholder="0.5" + disabled={disabled} + className="h-8 text-xs font-mono" + /> +
+
+ +
+
+ + onSettingsChange({ retries: Number(e.target.value) || 0 })} + disabled={disabled} + className="h-8 text-xs" + /> +
+
+ + onSettingsChange({ timeout: Number(e.target.value) || 0 })} + disabled={disabled} + className="h-8 text-xs" + /> +
+
diff --git a/src/components/download/GalleryUrlInput.tsx b/src/components/download/GalleryUrlInput.tsx index c718e9aa..ecaab768 100644 --- a/src/components/download/GalleryUrlInput.tsx +++ b/src/components/download/GalleryUrlInput.tsx @@ -215,7 +215,7 @@ export function GalleryUrlInput({ {!isExpanded ? (
- + @@ -274,7 +274,7 @@ export function GalleryUrlInput({ )} /> {urlCount > 0 && ( -
+
{urlCount !== 1 ? t('urlInput.urlCount_plural', { count: urlCount }) diff --git a/src/components/download/QueueItem.tsx b/src/components/download/QueueItem.tsx index c55283b2..37618e50 100644 --- a/src/components/download/QueueItem.tsx +++ b/src/components/download/QueueItem.tsx @@ -1,4 +1,6 @@ import { + ArrowUpToLine, + Ban, Check, CheckCircle2, ChevronDown, @@ -6,15 +8,22 @@ import { CircleSlash, Clock, Copy, + CopyPlus, + Eye, + EyeOff, FolderOpen, HardDrive, Lightbulb, ListVideo, Loader2, MonitorPlay, + Pause, Pencil, + Play, RefreshCw, + RotateCcw, Scissors, + SlidersHorizontal, Sparkles, X, XCircle, @@ -22,8 +31,11 @@ import { import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SchedulePopover } from '@/components/download/SchedulePopover'; +import { YtDlpOptionsEditor } from '@/components/download/YtDlpOptionsEditor'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { SimpleMarkdown } from '@/components/ui/simple-markdown'; import { useAI } from '@/contexts/AIContext'; +import { useDownload } from '@/contexts/download-context'; import type { ScheduleConfig } from '@/hooks/useSchedule'; import { openFileLocation } from '@/lib/open-file-location'; import type { DownloadItem, ItemDownloadSettings } from '@/lib/types'; @@ -104,6 +116,17 @@ function getFolderName(path: string): string { return segments.at(-1) || path; } +const ERROR_CLASS_BADGE_STYLES: Record = { + transient: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400', + auth: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + geo: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + unavailable: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', + disk: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', + config: 'bg-rose-500/10 text-rose-600 dark:text-rose-400', + unsupported: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400', + unknown: 'bg-muted/50 text-muted-foreground', +}; + interface QueueItemProps { item: DownloadItem; isFocused?: boolean; @@ -129,6 +152,16 @@ export function QueueItem({ }: QueueItemProps) { const { t } = useTranslation('download'); const ai = useAI(); + const { + pauseItem, + resumeItem, + retryFailedDownload, + cancelItem, + duplicateItem, + moveItemToTop, + updateItemAdvancedOptions, + toggleItemIncognito, + } = useDownload(); const [showFullSummary, setShowFullSummary] = useState(false); const [showTimeRange, setShowTimeRange] = useState(false); const [timeStart, setTimeStart] = useState(''); @@ -178,6 +211,7 @@ export function QueueItem({ const isCompleted = item.status === 'completed'; const isError = item.status === 'error'; const isPending = item.status === 'pending'; + const isPaused = item.status === 'paused'; const isSkipped = item.status === 'skipped'; const retryState = item.retryState; const isUpcomingLiveError = item.errorCode === 'YT_UPCOMING_LIVE'; @@ -324,7 +358,7 @@ export function QueueItem({ {isActive && (
{/* Progress Bar at bottom */} -
+
{/* Live stream: indeterminate shimmer progress bar */} {item.isLive && item.progress === 0 ? ( @@ -391,7 +425,7 @@ export function QueueItem({ {/* Playlist Badge */} {item.isPlaylist && showPlaylistBadge && ( -
+
{t('queue.playlist')}
@@ -399,7 +433,7 @@ export function QueueItem({ {/* Live Badge */} {item.isLive && ( -
+
{t('queue.live')}
@@ -407,7 +441,7 @@ export function QueueItem({ {/* Playlist Progress */} {item.playlistIndex && item.playlistTotal && ( -
+
{item.playlistIndex}/{item.playlistTotal}
)} @@ -434,6 +468,7 @@ export function QueueItem({ 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium', isPending && 'bg-muted text-muted-foreground', isActive && 'bg-primary/10 text-primary', + isPaused && 'bg-slate-500/10 text-slate-600 dark:text-slate-400', isCompleted && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', isError && 'bg-red-500/10 text-red-600 dark:text-red-400', isSkipped && 'bg-amber-500/10 text-amber-600 dark:text-amber-400', @@ -441,6 +476,7 @@ export function QueueItem({ > {isPending && } {isActive && } + {isPaused && } {isCompleted && } {isError && } {isSkipped && } @@ -450,6 +486,7 @@ export function QueueItem({ (item.status === 'fetching' ? t('queue.status.fetching') : t('queue.status.downloading'))} + {isPaused && t('queue.status.paused')} {isCompleted && t('queue.status.completed')} {isError && t('queue.status.failed')} {isSkipped && t('queue.status.skipped')} @@ -467,6 +504,26 @@ export function QueueItem({ )} + {/* P0-5: incognito indicator */} + {item.incognito && ( + + + {t('queue.incognito')} + + )} + + {/* P0-2: advanced options indicator */} + {itemSettings?.ytdlpAdvancedOptionsEnabled && + itemSettings.ytdlpAdvancedOptions.length > 0 && ( + + + {t('queue.advancedBadge')} + + )} + {/* Settings badges for pending/downloading items */} {(isPending || isActive) && itemSettings && ( <> @@ -518,6 +575,20 @@ export function QueueItem({ )} + {/* P0-7: error class badge + suggested action */} + {isError && item.errorClass && ( + + {t(`queue.errorClass.${item.errorClass}`)} + · + {t(`queue.errorClassAction.${item.errorClass}`)} + + )} + {/* Failed Hint - View Logs */} {isError && ( @@ -781,6 +852,128 @@ export function QueueItem({ )}
+ {/* P0-7: per-item controls cluster (hover reveal, matches remove button style) */} +
+ {!isActive && isError && ( + + )} + {!isActive && (isPending || isError) && ( + + )} + {isPaused && ( + + )} + {(isPending || isPaused || isError || isActive) && ( + + )} + {!isActive && ( + + )} + + {!isActive && ( + + )} + {(isPending || isError) && itemSettings && ( + + + + + +
+

{t('queue.advancedOptions')}

+
+
+ updateItemAdvancedOptions(item.id, patch)} + /> +
+
+
+ )} +
+ {/* Remove Button */} )} diff --git a/src/components/download/SettingsPanel.tsx b/src/components/download/SettingsPanel.tsx index 98714a79..015fc086 100644 --- a/src/components/download/SettingsPanel.tsx +++ b/src/components/download/SettingsPanel.tsx @@ -92,6 +92,7 @@ const subtitleFormatOptions: { value: SubtitleFormat; label: string }[] = [ const commonLanguageCodes = [ 'en', 'ar', + 'fa', 'vi', 'ja', 'ko', @@ -670,7 +671,7 @@ export function SettingsPanel({ type="button" onClick={onSelectFolder} disabled={disabled} - className="w-full h-7 px-2.5 rounded-md border bg-background text-[11px] flex items-center gap-2 text-left hover:bg-muted/50 transition-colors" + className="w-full h-7 px-2.5 rounded-md border bg-background text-[11px] flex items-center gap-2 text-start hover:bg-muted/50 transition-colors" > diff --git a/src/components/download/ThumbnailStatusBadge.tsx b/src/components/download/ThumbnailStatusBadge.tsx index f087edfc..4c688b36 100644 --- a/src/components/download/ThumbnailStatusBadge.tsx +++ b/src/components/download/ThumbnailStatusBadge.tsx @@ -3,7 +3,7 @@ import { CheckCircle2, XCircle } from 'lucide-react'; export function ThumbnailCompletedBadge() { return (
-
+
@@ -13,7 +13,7 @@ export function ThumbnailCompletedBadge() { export function ThumbnailFailedBadge() { return (
-
+
diff --git a/src/components/download/UniversalQueueItem.tsx b/src/components/download/UniversalQueueItem.tsx index b42f98a6..df518fe7 100644 --- a/src/components/download/UniversalQueueItem.tsx +++ b/src/components/download/UniversalQueueItem.tsx @@ -1,4 +1,6 @@ import { + ArrowUpToLine, + Ban, Check, CheckCircle2, ChevronDown, @@ -6,15 +8,22 @@ import { CircleSlash, Clock, Copy, + CopyPlus, + Eye, + EyeOff, FolderOpen, Globe, HardDrive, Lightbulb, Loader2, MonitorPlay, + Pause, Pencil, + Play, RefreshCw, + RotateCcw, Scissors, + SlidersHorizontal, Sparkles, X, XCircle, @@ -22,8 +31,11 @@ import { import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { SchedulePopover } from '@/components/download/SchedulePopover'; +import { YtDlpOptionsEditor } from '@/components/download/YtDlpOptionsEditor'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { SimpleMarkdown } from '@/components/ui/simple-markdown'; import { useAI } from '@/contexts/AIContext'; +import { useUniversal } from '@/contexts/universal-context'; import type { ScheduleConfig } from '@/hooks/useSchedule'; import { openFileLocation } from '@/lib/open-file-location'; import type { DownloadItem, ItemUniversalSettings } from '@/lib/types'; @@ -104,6 +116,17 @@ function getFolderName(path: string): string { return segments.at(-1) || path; } +const ERROR_CLASS_BADGE_STYLES: Record = { + transient: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400', + auth: 'bg-amber-500/10 text-amber-600 dark:text-amber-400', + geo: 'bg-violet-500/10 text-violet-600 dark:text-violet-400', + unavailable: 'bg-slate-500/10 text-slate-600 dark:text-slate-400', + disk: 'bg-orange-500/10 text-orange-600 dark:text-orange-400', + config: 'bg-rose-500/10 text-rose-600 dark:text-rose-400', + unsupported: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400', + unknown: 'bg-muted/50 text-muted-foreground', +}; + interface UniversalQueueItemProps { item: DownloadItem; isFocused?: boolean; @@ -127,6 +150,16 @@ export function UniversalQueueItem({ }: UniversalQueueItemProps) { const { t } = useTranslation('universal'); const ai = useAI(); + const { + pauseItem, + resumeItem, + retryFailedDownload, + cancelItem, + duplicateItem, + moveItemToTop, + updateItemAdvancedOptions, + toggleItemIncognito, + } = useUniversal(); const [showFullSummary, setShowFullSummary] = useState(false); const [thumbError, setThumbError] = useState(false); const [showTimeRange, setShowTimeRange] = useState(false); @@ -159,6 +192,7 @@ export function UniversalQueueItem({ const isCompleted = item.status === 'completed'; const isError = item.status === 'error'; const isPending = item.status === 'pending'; + const isPaused = item.status === 'paused'; const isSkipped = item.status === 'skipped'; const retryState = item.retryState; const isFetchingMeta = isPending && !item.thumbnail && item.title === item.url && !item.extractor; @@ -327,7 +361,7 @@ export function UniversalQueueItem({ {isActive && (
{/* Progress Bar at bottom */} -
+
{/* Live stream: indeterminate shimmer progress bar */} {item.isLive && item.progress === 0 ? ( @@ -432,6 +466,7 @@ export function UniversalQueueItem({ 'inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full font-medium', isPending && 'bg-muted text-muted-foreground', isActive && 'bg-primary/10 text-primary', + isPaused && 'bg-slate-500/10 text-slate-600 dark:text-slate-400', isCompleted && 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400', isError && 'bg-red-500/10 text-red-600 dark:text-red-400', isSkipped && 'bg-amber-500/10 text-amber-600 dark:text-amber-400', @@ -439,6 +474,7 @@ export function UniversalQueueItem({ > {isPending && } {isActive && } + {isPaused && } {isCompleted && } {isError && } {isSkipped && } @@ -448,6 +484,7 @@ export function UniversalQueueItem({ (item.status === 'fetching' ? t('queue.status.fetching') : t('queue.status.downloading'))} + {isPaused && t('download:queue.status.paused')} {isCompleted && t('queue.status.completed')} {isError && t('queue.status.failed')} {isSkipped && t('queue.status.skipped')} @@ -465,6 +502,26 @@ export function UniversalQueueItem({ )} + {/* P0-5: incognito indicator */} + {item.incognito && ( + + + {t('download:queue.incognito')} + + )} + + {/* P0-2: advanced options indicator */} + {itemSettings?.ytdlpAdvancedOptionsEnabled && + itemSettings.ytdlpAdvancedOptions.length > 0 && ( + + + {t('download:queue.advancedBadge')} + + )} + {/* Settings badges for pending/downloading items */} {(isPending || isActive) && itemSettings && ( <> @@ -516,6 +573,20 @@ export function UniversalQueueItem({ )} + {/* P0-7: error class badge + suggested action */} + {isError && item.errorClass && ( + + {t(`download:queue.errorClass.${item.errorClass}`)} + · + {t(`download:queue.errorClassAction.${item.errorClass}`)} + + )} + {/* Failed Hint - View Logs */} {isError && ( @@ -779,6 +850,128 @@ export function UniversalQueueItem({ )}
+ {/* P0-7: per-item controls cluster (hover reveal, matches remove button style) */} +
+ {!isActive && isError && ( + + )} + {!isActive && (isPending || isError) && ( + + )} + {isPaused && ( + + )} + {(isPending || isPaused || isError || isActive) && ( + + )} + {!isActive && ( + + )} + + {!isActive && ( + + )} + {(isPending || isError) && itemSettings && ( + + + + + +
+

{t('download:queue.advancedOptions')}

+
+
+ updateItemAdvancedOptions(item.id, patch)} + /> +
+
+
+ )} +
+ {/* Remove Button */} ))} diff --git a/src/components/download/YoutubeKeywordSearch.tsx b/src/components/download/YoutubeKeywordSearch.tsx index ffec7408..d37d6220 100644 --- a/src/components/download/YoutubeKeywordSearch.tsx +++ b/src/components/download/YoutubeKeywordSearch.tsx @@ -27,6 +27,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import i18n from '@/i18n'; import { extractBackendError, localizeBackendError } from '@/lib/backend-error'; import type { YoutubeSearchDurationFilter, @@ -177,7 +178,7 @@ function SearchResultGridItem({ onClick={onToggle} disabled={disabled || isAdded} className={cn( - 'group relative flex flex-col text-left transition-all duration-300 rounded-xl focus:outline-none', + 'group relative flex flex-col text-start transition-all duration-300 rounded-xl focus:outline-none', isAdded ? 'cursor-not-allowed opacity-70 grayscale-[0.4]' : 'cursor-pointer', )} > @@ -208,7 +209,7 @@ function SearchResultGridItem({ {!isAdded && (
@@ -224,7 +225,7 @@ function SearchResultGridItem({ {/* Subtle top gradient for hover checkbox visibility */} {!isAdded && !selected && ( -
+
)} {/* Added Overlay */} @@ -240,7 +241,7 @@ function SearchResultGridItem({ {/* Duration */} {video.duration && !isAdded && ( - + {video.duration} )} @@ -349,6 +350,7 @@ export function YoutubeKeywordSearch({ limit: clampLimit(limit), filters, continuation: nextContinuation || null, + lang: i18n.language || 'en', }); setVideos((current) => @@ -502,21 +504,21 @@ export function YoutubeKeywordSearch({ className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center" >
- + handleQueryChange(e.target.value)} disabled={disabled || isSearching} placeholder={t('urlInput.keyword.placeholder')} - className="pl-10 pr-10 h-12 rounded-xl bg-background border-border/60 focus:bg-background text-base sm:text-sm shadow-sm" + className="ps-10 pe-10 h-12 rounded-xl bg-background border-border/60 focus:bg-background text-base sm:text-sm shadow-sm" /> {query ? ( @@ -716,7 +718,7 @@ export function YoutubeKeywordSearch({

{t('urlInput.keyword.emptyTitle')}

-
+
@@ -774,7 +776,7 @@ export function YoutubeKeywordSearch({
{/* Selection Status */} -
+
{t('urlInput.keyword.selectedCount', { selected: selectedVideos.length, @@ -784,7 +786,7 @@ export function YoutubeKeywordSearch({
{/* Mobile Selection Status */} -
+
{selectedVideos.length}/{videos.length} @@ -833,7 +835,7 @@ export function YoutubeKeywordSearch({
{/* Load More & Add */} -
+
@@ -855,9 +857,9 @@ export function YoutubeKeywordSearch({ className="h-9 px-5 rounded-full text-sm font-semibold shadow-md bg-primary hover:bg-primary/90 text-primary-foreground transition-all active:scale-95" > {isAdding ? ( - + ) : ( - + )} {t('urlInput.keyword.addSelected')} {t('urlInput.keyword.addSelected').split(' ')[0]} diff --git a/src/components/download/YtDlpOptionsEditor.tsx b/src/components/download/YtDlpOptionsEditor.tsx new file mode 100644 index 00000000..6da6b066 --- /dev/null +++ b/src/components/download/YtDlpOptionsEditor.tsx @@ -0,0 +1,590 @@ +import { invoke } from '@tauri-apps/api/core'; +import { + AlertTriangle, + BookmarkPlus, + Check, + Copy, + Loader2, + Plus, + SlidersHorizontal, + Terminal, + Trash2, + X, +} from 'lucide-react'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/components/ui/toast'; +import { buildDownloadVideoInvokeArgs, buildSponsorBlockArgs } from '@/contexts/DownloadContext'; +import { useDownload } from '@/contexts/download-context'; +import type { + DownloadItem, + ItemDownloadSettings, + ItemUniversalSettings, + YtdlpAdvancedOption, + YtdlpAdvancedOptionId, +} from '@/lib/types'; +import { cn } from '@/lib/utils'; +import { + getYtdlpAdvancedOptionDefinition, + sanitizeYtdlpAdvancedOptions, + YTDLP_ADVANCED_OPTION_DEFINITIONS, + type YtdlpAdvancedOptionDefinition, +} from '@/lib/ytdlp-advanced-options'; +import { + deleteYtdlpPreset, + loadYtdlpPresets, + saveYtdlpPreset, + YTDLP_PRESET_LIMITS, + type YtdlpPreset, + type YtdlpPresetSettings, +} from '@/lib/ytdlp-presets'; + +/** + * P0-2: validated key/value editor for per-item yt-dlp advanced options, + * plus an expert raw-arguments textarea (behind a warning gate) and a + * command diagnostics preview. Mirrors the global settings editor in + * DownloadSection and the round-4 GallerySettingsPanel popover pattern. + */ + +export type YtDlpOptionsPatch = Partial< + Pick +>; + +interface PreviewCommandResult { + args: string[]; + display: string; +} + +interface YtDlpOptionsEditorProps { + item: DownloadItem; + settings?: ItemDownloadSettings | ItemUniversalSettings; + disabled?: boolean; + onChange: (patch: YtDlpOptionsPatch) => void; +} + +function formatYtdlpOptionName(definition: YtdlpAdvancedOptionDefinition): string { + if (definition.id === 'youtubePlayerClient') { + return '--extractor-args youtube:player-client'; + } + return definition.ytDlpFlag; +} + +export function YtDlpOptionsEditor({ + item, + settings, + disabled, + onChange, +}: YtDlpOptionsEditorProps) { + const { t } = useTranslation(['download', 'settings']); + const toast = useToast(); + // Global settings + network config are needed to reproduce the exact command + // in the diagnostics preview (quality/format/cookies/proxy/... live there). + const { settings: globalSettings, cookieSettings, proxySettings } = useDownload(); + + const enabled = settings?.ytdlpAdvancedOptionsEnabled === true; + const options = useMemo( + () => sanitizeYtdlpAdvancedOptions(settings?.ytdlpAdvancedOptions), + [settings?.ytdlpAdvancedOptions], + ); + const rawArgs = settings?.rawArgs ?? ''; + + // Raw-args warning gate (one-time acknowledgement per editor session). + const [rawArgsAcknowledged, setRawArgsAcknowledged] = useState(false); + const [showRawArgsWarning, setShowRawArgsWarning] = useState(false); + + // Command preview state. + const [isPreviewing, setIsPreviewing] = useState(false); + const [preview, setPreview] = useState(null); + const [previewCopied, setPreviewCopied] = useState(false); + + // Preset state. + const [presets, setPresets] = useState(() => loadYtdlpPresets()); + const [selectedPresetId, setSelectedPresetId] = useState(''); + const [presetName, setPresetName] = useState(''); + + const selectedOptionIds = useMemo(() => new Set(options.map((option) => option.id)), [options]); + const availableDefinitions = useMemo( + () => + YTDLP_ADVANCED_OPTION_DEFINITIONS.filter( + (definition) => definition.repeatable || !selectedOptionIds.has(definition.id), + ), + [selectedOptionIds], + ); + + const updateOption = useCallback( + (index: number, updates: Partial) => { + onChange({ + ytdlpAdvancedOptions: options.map((option, optionIndex) => + optionIndex === index ? { ...option, ...updates } : option, + ), + }); + }, + [onChange, options], + ); + + const removeOption = useCallback( + (index: number) => { + onChange({ ytdlpAdvancedOptions: options.filter((_, optionIndex) => optionIndex !== index) }); + }, + [onChange, options], + ); + + const addOption = useCallback( + (id: YtdlpAdvancedOptionId) => { + const definition = getYtdlpAdvancedOptionDefinition(id); + if (!definition) return; + const nextOption: YtdlpAdvancedOption = { id }; + if (definition.valueType === 'select') { + nextOption.value = definition.options?.[0] || ''; + } + onChange({ ytdlpAdvancedOptions: [...options, nextOption] }); + }, + [onChange, options], + ); + + const renderOptionValue = ( + option: YtdlpAdvancedOption, + definition: YtdlpAdvancedOptionDefinition, + index: number, + ) => { + if (definition.valueType === 'boolean') return null; + + if (definition.valueType === 'select') { + return ( + + ); + } + + if (definition.valueType === 'header') { + return ( +
+ updateOption(index, { value: event.currentTarget.value })} + placeholder={t('download.ytdlpAdvanced.placeholder.headerName')} + className="h-7 bg-background text-xs" + /> + updateOption(index, { secondaryValue: event.currentTarget.value })} + placeholder={t('download.ytdlpAdvanced.placeholder.headerValue')} + className="h-7 bg-background text-xs" + /> +
+ ); + } + + return ( + updateOption(index, { value: event.currentTarget.value })} + placeholder={ + definition.placeholderKey ? t(`settings:${definition.placeholderKey}`) : undefined + } + className="h-7 w-full bg-background text-xs" + /> + ); + }; + + const handleRawArgsOpen = useCallback(() => { + if (rawArgsAcknowledged) return; + setShowRawArgsWarning(true); + }, [rawArgsAcknowledged]); + + const confirmRawArgs = useCallback(() => { + setRawArgsAcknowledged(true); + setShowRawArgsWarning(false); + }, []); + + const handlePreview = useCallback(async () => { + if (isPreviewing) return; + setIsPreviewing(true); + setPreview(null); + try { + const itemSettings = settings as ItemDownloadSettings | undefined; + const payload = buildDownloadVideoInvokeArgs({ + item, + itemSettings, + settings: globalSettings, + cookieSettings, + proxySettings, + sponsorBlockArgs: buildSponsorBlockArgs(globalSettings), + logStderr: true, + override: { + ytdlpAdvancedOptionsEnabled: enabled, + ytdlpAdvancedOptions: options, + rawArgs, + }, + }); + const result = await invoke('preview_download_command', { + request: payload, + }); + // Redaction: only the server-redacted display string is ever rendered. + setPreview(result.display); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (/not found|unknown command/i.test(message)) { + toast.warning({ title: t('ytdlpEditor.previewUnavailable') }); + } else { + toast.error({ title: t('ytdlpEditor.previewFailed') }); + } + } finally { + setIsPreviewing(false); + } + }, [ + enabled, + isPreviewing, + item, + options, + rawArgs, + settings, + globalSettings, + cookieSettings, + proxySettings, + t, + toast, + ]); + + const handleCopyPreview = useCallback(async () => { + if (!preview) return; + try { + await navigator.clipboard.writeText(preview); + setPreviewCopied(true); + window.setTimeout(() => setPreviewCopied(false), 2000); + } catch { + // ignore clipboard failures + } + }, [preview]); + + const handleSavePreset = useCallback(() => { + const saved = saveYtdlpPreset(presetName, { + ytdlpAdvancedOptionsEnabled: enabled, + ytdlpAdvancedOptions: options, + rawArgs, + } as YtdlpPresetSettings); + if (!saved) { + toast.warning({ title: t('ytdlpEditor.presetMax') }); + return; + } + setPresets(loadYtdlpPresets()); + setSelectedPresetId(saved.id); + setPresetName(''); + toast.success({ title: t('ytdlpEditor.presetSaved') }); + }, [enabled, options, presetName, rawArgs, t, toast]); + + const handleApplyPreset = useCallback( + (presetId: string) => { + setSelectedPresetId(presetId); + const preset = presets.find((entry) => entry.id === presetId); + if (!preset) return; + onChange({ + ytdlpAdvancedOptionsEnabled: preset.settings.ytdlpAdvancedOptionsEnabled, + ytdlpAdvancedOptions: preset.settings.ytdlpAdvancedOptions, + rawArgs: preset.settings.rawArgs, + }); + toast.success({ title: t('ytdlpEditor.presetApplied') }); + }, + [onChange, presets, t, toast], + ); + + const handleDeletePreset = useCallback(() => { + if (!selectedPresetId) return; + deleteYtdlpPreset(selectedPresetId); + setPresets(loadYtdlpPresets()); + setSelectedPresetId(''); + toast.success({ title: t('ytdlpEditor.presetDeleted') }); + }, [selectedPresetId, t, toast]); + + const hasRawArgs = rawArgs.trim().length > 0; + + return ( +
+ {/* Enable toggle */} +
+
+ +

+ {t('ytdlpEditor.description')} +

+
+ onChange({ ytdlpAdvancedOptionsEnabled: checked })} + disabled={disabled} + /> +
+ + {enabled && ( + <> + {/* Add-option dropdown */} +
+ +
+ + {/* Option rows */} + {options.length > 0 && ( +
+ {options.map((option, index) => { + const definition = getYtdlpAdvancedOptionDefinition(option.id); + if (!definition) return null; + return ( +
+
+
+
+

+ {formatYtdlpOptionName(definition)} +

+ {definition.securityLevel === 'advanced' && ( + + {t('download.ytdlpAdvanced.advancedBadge')} + + )} +
+

+ {t(`settings:${definition.descriptionKey}`)} +

+
+ +
+
{renderOptionValue(option, definition, index)}
+
+ ); + })} +
+ )} + + {/* Expert raw arguments */} +
+ + {rawArgsAcknowledged && ( + <> +