Compare commits
30 Commits
aacf765666
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f0c6b80ef | ||
|
|
634c349ebb | ||
|
|
bcadf36b71 | ||
|
|
e86bc86793 | ||
|
|
4d5cac7a46 | ||
|
|
bbcb10b3ca | ||
|
|
1554be25d2 | ||
|
|
05887fd7a3 | ||
|
|
63648f9d7c | ||
|
|
0a439dbd71 | ||
|
|
f164569e89 | ||
|
|
f4bdb85841 | ||
|
|
36bd5061a4 | ||
|
|
dde9ed7718 | ||
|
|
8ae5f4f66c | ||
|
|
eada45bd9c | ||
|
|
77407c7e28 | ||
|
|
ac00c54ca3 | ||
|
|
14b0e96c7d | ||
|
|
fd28d4764a | ||
|
|
618fd2d933 | ||
|
|
54f841355b | ||
|
|
5fa6b5b616 | ||
|
|
56aeafbf41 | ||
|
|
52344892d5 | ||
|
|
f23b37a581 | ||
|
|
7c1c9c6a2f | ||
|
|
091cf65dac | ||
|
|
dda4e482c1 | ||
|
|
94fa286f30 |
BIN
app-icon.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
@@ -2,9 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + Vue + Typescript App</title>
|
||||
<title>Stream Capture</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stream-capture",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
BIN
public/placeholder.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,6 +0,0 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,58 +0,0 @@
|
||||
# Feature Request: Real-time Logging & UI Refinement
|
||||
|
||||
## 1. Context & Objective
|
||||
Users currently experience download failures without sufficient context or error details, making troubleshooting difficult.
|
||||
The objective is to introduce a dedicated **Logs System** within the application to capture, display, and persist detailed execution logs from the `yt-dlp` process.
|
||||
Additionally, the application's sidebar layout requires balancing for better UX.
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### 2.1. Backend (Rust) - Logging Infrastructure
|
||||
* **Stream Capture**: The `downloader` module must capture both `STDOUT` (progress) and `STDERR` (errors/warnings) from the `yt-dlp` process.
|
||||
* **Event Emission**: Emit a new Tauri event `download-log` to the frontend in real-time.
|
||||
* Payload: `{ id: String, message: String, level: 'info' | 'error' | 'debug' }`
|
||||
* **Persistence (Optional but Recommended)**: Ideally, write logs to a local file (e.g., `logs/{date}.log` or `logs/{task_id}.log`) for post-mortem analysis. *For this iteration, real-time event emission is priority.*
|
||||
|
||||
### 2.2. Frontend - Logs View
|
||||
* **New Route**: `/logs`
|
||||
* **UI Layout**:
|
||||
* A dedicated **Logs Tab** in the main navigation.
|
||||
* A console-like view displaying a stream of log messages.
|
||||
* Filters: Filter by "Task ID" or "Level" (Error/Info).
|
||||
* **Real-time**: The view must update automatically as new log events arrive.
|
||||
* **Detail**: Failed downloads must show the specific error message returned by `yt-dlp` (e.g., "Sign in required", "Video unavailable").
|
||||
|
||||
### 2.3. UI/UX - Sidebar Refactoring
|
||||
* **Reordering**:
|
||||
* **Top Section**: Home (Downloader), History.
|
||||
* **Bottom Section**: Logs, Settings.
|
||||
* **Removal**: Remove the "Version Number" text from the bottom of the sidebar (it can be moved to the Settings page if needed, or just removed as requested).
|
||||
|
||||
## 3. Implementation Steps
|
||||
|
||||
### Step 1: Rust Backend Updates
|
||||
* Modify `src-tauri/src/downloader.rs`:
|
||||
* Update `download_video` to spawn the process with `Stdio::piped()` for both stdout and stderr.
|
||||
* Use `tokio::select!` or separate tasks to read both streams concurrently.
|
||||
* Emit `download-log` events for every line read.
|
||||
* Ensure the final error message (if exit code != 0) is explicitly captured and returned/logged.
|
||||
|
||||
### Step 2: Frontend Store & Logic
|
||||
* Create `src/stores/logs.ts`:
|
||||
* State: `logs: LogEntry[]`.
|
||||
* Action: `addLog(entry)`.
|
||||
* Listener: Listen for `download-log` events globally.
|
||||
|
||||
### Step 3: Frontend UI
|
||||
* Create `src/views/Logs.vue`:
|
||||
* Display logs in a scrollable container.
|
||||
* Style error logs in red, info in gray/white.
|
||||
* Update `src/App.vue`:
|
||||
* Add the `FileText` (or similar) icon for Logs.
|
||||
* Refactor the sidebar Flexbox layout to push Logs and Settings to the bottom.
|
||||
* Remove the version footer.
|
||||
* Update `src/router/index.ts` to include the new route.
|
||||
|
||||
## 4. Success Criteria
|
||||
* When a download fails, the user can go to the "Logs" tab and see the exact error output from `yt-dlp`.
|
||||
* The sidebar looks balanced with navigation items split between top and bottom.
|
||||
142
spec/prd.md
@@ -1,142 +0,0 @@
|
||||
# Project: StreamCapture (Tauri + Vue 3 YouTube Downloader)
|
||||
|
||||
## 1. Context & Objective
|
||||
You are an expert Full-Stack Rust/TypeScript developer specializing in Tauri v2 application development.
|
||||
Your task is to build a cross-platform (Windows & macOS) desktop application named "StreamCapture" in the current directory.
|
||||
The app is a GUI wrapper for `yt-dlp`, but unlike standard sidecar implementations, it must manage the `yt-dlp` binary externally (in the user's AppData directory) to allow for frequent updates without rebuilding the app.
|
||||
|
||||
**Tech Stack:**
|
||||
- **Core:** Tauri v2 (Rust)
|
||||
- **Frontend:** Vue 3 (Composition API, `<script setup>`)
|
||||
- **Build Tool:** Vite
|
||||
- **Styling:** Tailwind CSS (Mobile-first, Modern UI)
|
||||
- **State Management:** Pinia
|
||||
- **Icons:** Lucide-vue-next
|
||||
- **HTTP Client (Rust):** `reqwest` (for downloading yt-dlp)
|
||||
|
||||
---
|
||||
|
||||
## 2. Core Architecture & Workflow
|
||||
|
||||
### 2.1. External Binary Management (Crucial)
|
||||
Since `yt-dlp` updates frequently, we **do NOT** bundle it inside the binary.
|
||||
1. **Location:** The app must utilize the system's local data directory (e.g., `%APPDATA%/StreamCapture/bin` on Windows, `~/Library/Application Support/StreamCapture/bin` on macOS).
|
||||
2. **Initialization:** On app launch, the Rust backend must check if `yt-dlp` exists in that directory.
|
||||
3. **Auto-Download:** If missing, download the correct binary from the official GitHub releases (`yt-dlp.exe` for Win, `yt-dlp_macos` for Mac).
|
||||
4. **Permissions:** On macOS, apply `chmod +x` (0o755) to the downloaded binary immediately.
|
||||
5. **Execution:** Use Rust's `std::process::Command` to execute this specific binary using its absolute path.
|
||||
|
||||
### 2.2. Configuration Persistence
|
||||
- File: `settings.json` in the app data directory.
|
||||
- Fields:
|
||||
- `download_path`: string (Default: System Download Dir)
|
||||
- `theme`: 'light' | 'dark' | 'system' (Default: system)
|
||||
- `last_updated`: timestamp (for yt-dlp check)
|
||||
|
||||
### 2.3. History Persistence
|
||||
- File: `history.json`.
|
||||
- Stores a list of completed or failed downloads (metadata, status, timestamp, file path).
|
||||
|
||||
---
|
||||
|
||||
## 3. Rust Backend Specifications (`src-tauri/src/lib.rs` & Modules)
|
||||
|
||||
Create specific commands to handle logic securely. Avoid exposing raw shell execution to the frontend.
|
||||
|
||||
### Module: `ytdlp_manager`
|
||||
- **`init_ytdlp()`**: Checks existence. If missing, downloads it. Returns status.
|
||||
- **`update_ytdlp()`**: Runs `yt-dlp -U`.
|
||||
- **`get_version()`**: Returns current version string.
|
||||
|
||||
### Module: `downloader`
|
||||
- **`fetch_metadata(url: String)`**:
|
||||
- Runs `yt-dlp --dump-single-json --flat-playlist [url]`.
|
||||
- **Logic:** If it's a playlist, return a list of video objects (id, title, thumbnail, duration). If single video, return one object.
|
||||
- **Important:** Do NOT download media yet.
|
||||
- **`download_video(url: String, options: DownloadOptions)`**:
|
||||
- **Options Struct:** `{ is_audio_only: bool, quality: String, output_path: String }`
|
||||
- **Process:** Spawns a command. Emits Tauri Events (`download-progress`) to frontend with percentage and speed.
|
||||
|
||||
### Module: `storage`
|
||||
- Helper functions to read/write `settings.json` and `history.json`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Frontend Specifications (Vue 3 + Tailwind)
|
||||
|
||||
### 4.1. UI Layout
|
||||
- **Sidebar:** Navigation (Home/Download, History, Settings).
|
||||
- **Header:** Theme toggle, App status (checking yt-dlp...).
|
||||
- **Main Content:** Dynamic view based on route.
|
||||
|
||||
### 4.2. Views
|
||||
|
||||
#### A. Home View (The Downloader)
|
||||
1. **Input Area:** Large, modern input box for URL. "Analyze" button.
|
||||
2. **Selection Area (Conditional):**
|
||||
- Appears after "Analyze" succeeds.
|
||||
- Shows thumbnail(s) and title(s).
|
||||
- If Playlist: Show list of items with checkboxes (Select All / Select None).
|
||||
3. **Options Panel:**
|
||||
- **Format:** Dropdown (Best Video+Audio, 1080p, 720p, 480p).
|
||||
- **Mode:** Toggle Switch (Video / Audio Only).
|
||||
4. **Action:** Big "Download" button.
|
||||
5. **Progress:** Progress bars for active downloads.
|
||||
|
||||
#### B. History View
|
||||
- Table/List of past downloads.
|
||||
- Columns: Thumbnail (small), Title, Date, Format, Status (Success/Fail).
|
||||
- Actions: "Open File Location", "Delete Record" (Icon), "Clear All" (Button at top).
|
||||
|
||||
#### C. Settings View
|
||||
- **Download Path:** Input field + "Browse" button (use Tauri `dialog` API).
|
||||
- **Yt-dlp:** Show current version. Button "Check for Updates".
|
||||
- **Theme:** Radio buttons (Light/Dark/System).
|
||||
|
||||
### 4.3. State Management (Pinia stores)
|
||||
- `useSettingsStore`: Loads/saves config. Handles theme applying (adding `.dark` class to `html`).
|
||||
- `useQueueStore`: Manages active downloads and progress events.
|
||||
|
||||
---
|
||||
|
||||
## 5. Visual Design Guidelines (Tailwind)
|
||||
- **Theme:**
|
||||
- **Light Mode:** White/Gray-50 background, Zinc-900 text, Primary Blue-600.
|
||||
- **Dark Mode:** Zinc-950 background, Gray-100 text, Primary Blue-500.
|
||||
- **Components:**
|
||||
- Rounded corners (`rounded-xl`).
|
||||
- Subtle shadows (`shadow-sm`, `shadow-md`).
|
||||
- Input fields with focus rings.
|
||||
- Transitions for hover states and page switches.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation Steps for Gemini
|
||||
Please generate the code in the following order. **Ensure all files include `// filepath: ...` comments.**
|
||||
|
||||
1. **Rust Setup:**
|
||||
- `Cargo.toml` (dependencies: reqwest, serde, serde_json, tokio, etc.).
|
||||
- `src-tauri/src/lib.rs`: Main entry with command registration.
|
||||
- `src-tauri/src/ytdlp.rs`: The download/update logic.
|
||||
- `src-tauri/src/commands.rs`: The Tauri commands exposed to frontend.
|
||||
2. **Frontend Setup:**
|
||||
- `package.json`: deps (pinia, vue-router, lucide-vue-next, tailwindcss, autoprefixer, postcss).
|
||||
- `src/style.css`: Tailwind directives.
|
||||
- `src/stores/...`: Pinia stores.
|
||||
- `src/components/...`: Reusable UI components (Input, Button, Card).
|
||||
- `src/views/...`: The main pages.
|
||||
- `src/App.vue`: Main layout.
|
||||
3. **Configuration:**
|
||||
- `tailwind.config.js`: Dark mode config.
|
||||
- `src-tauri/capabilities/default.json`: **CRITICAL**. Configure permissions to allow accessing `app_data_dir` and the network scope.
|
||||
|
||||
## 7. Testing Requirements
|
||||
- Create a basic Rust test in `src-tauri/src/ytdlp.rs` that verifies it can construct the correct path for the binary based on OS.
|
||||
- Ensure Vue components handle "Loading" states gracefully (skeletons or spinners).
|
||||
|
||||
---
|
||||
|
||||
**IMPORTANT NOTE ON PERMISSIONS:**
|
||||
Since we are using `std::process::Command` directly in Rust, we do not need to configure the strict `shell` scope in `tauri.conf.json`, but we MUST ensure the App Data directory is writable.
|
||||
|
||||
Start by generating the Rust backend logic first, as the frontend depends on these commands.
|
||||
@@ -1,67 +0,0 @@
|
||||
# Feature Specification: QuickJS Runtime Integration for yt-dlp
|
||||
|
||||
## 1. Context & Objective
|
||||
`yt-dlp` increasingly relies on a JavaScript runtime to execute complex decryption scripts (e.g., signature extraction) required by YouTube and other platforms. Without a JS runtime, downloads may fail or be throttled.
|
||||
The objective is to automatically manage the **QuickJS** runtime binary (download, update, store) alongside the existing `yt-dlp` binary and configure the download process to utilize it via environment variable injection.
|
||||
|
||||
## 2. Technical Requirements
|
||||
|
||||
### 2.1. Binary Management
|
||||
The application must manage the QuickJS binary lifecycle identically to how it currently handles `yt-dlp`.
|
||||
|
||||
* **Source Repository:** [https://github.com/quickjs-ng/quickjs](https://github.com/quickjs-ng/quickjs)
|
||||
* **Storage Location:** The user's AppData `bin` directory (same as `yt-dlp`).
|
||||
* Windows: `%APPDATA%\StreamCapture\bin\`
|
||||
* macOS: `~/Library/Application Support/StreamCapture/bin/`
|
||||
* **Unified Naming on Disk:**
|
||||
Regardless of the source filename, the binary must be renamed upon saving to ensure consistent invocation:
|
||||
* **Windows:** `qjs.exe`
|
||||
* **macOS:** `qjs`
|
||||
|
||||
### 2.2. Download & Update Logic
|
||||
* **Check:** On app launch (or `init_ytdlp`), check if `qjs` exists.
|
||||
* **Download:** If missing, fetch the appropriate asset from the latest GitHub release of `quickjs-ng/quickjs`.
|
||||
* *Note:* Logic must detect OS/Arch to pick the correct asset (e.g., `windows-x86_64`, `macos-x86_64/arm64`).
|
||||
* **Permissions:** On macOS/Linux, ensure `chmod +x` is applied.
|
||||
* **Update:** When `update_ytdlp` is triggered, also check/redownload the latest `qjs` binary.
|
||||
|
||||
### 2.3. Execution & Environment Injection
|
||||
To enable `yt-dlp` to find the `qjs` binary without hardcoding absolute paths in the flags (which can be fragile), we will modify the **subprocess environment**.
|
||||
|
||||
* **Command Flag:** Always pass `--js-runtime qjs` to the `yt-dlp` command.
|
||||
* **Environment Modification:**
|
||||
Before spawning the `yt-dlp` child process in Rust, dynamically modify its `PATH` environment variable.
|
||||
* **Action:** Prepend the absolute path of the application's `bin` directory to the system `PATH`.
|
||||
* **Result:** When `yt-dlp` looks for `qjs`, it will find it in the injected `PATH`.
|
||||
|
||||
## 3. Implementation Plan
|
||||
|
||||
### Step 1: Refactor `src-tauri/src/ytdlp.rs`
|
||||
* Rename file/module to `src-tauri/src/binary_manager.rs` (Optional, or just expand `ytdlp.rs`).
|
||||
* Add constants/functions for QuickJS:
|
||||
* `get_qjs_binary_name()`
|
||||
* `download_qjs()`: Similar logic to `download_ytdlp` but parsing QuickJS release assets.
|
||||
|
||||
### Step 2: Update `src-tauri/src/commands.rs`
|
||||
* Update `init_ytdlp` to initialize **both** binaries.
|
||||
* Update `update_ytdlp` to update **both** binaries.
|
||||
|
||||
### Step 3: Update `src-tauri/src/downloader.rs`
|
||||
* In `download_video` and `fetch_metadata`:
|
||||
1. Resolve the absolute path to the `bin` directory.
|
||||
2. Read the current system `PATH`.
|
||||
3. Construct a new `PATH` string: `bin_dir + delimiter + system_path`.
|
||||
4. Configure the `Command`:
|
||||
```rust
|
||||
Command::new(...)
|
||||
.env("PATH", new_path)
|
||||
.arg("--js-runtime")
|
||||
.arg("qjs")
|
||||
...
|
||||
```
|
||||
|
||||
## 4. Acceptance Criteria
|
||||
1. **File Existence:** `qjs.exe` (Win) or `qjs` (Mac) appears in the `bin` folder after app startup.
|
||||
2. **Process Execution:** The logs show `yt-dlp` running successfully.
|
||||
3. **Verification:** If a specific video requires JS interpretation (often indicated by slow downloads or errors without JS), it proceeds smoothly.
|
||||
4. **Clean Logs:** No "PhantomJS not found" or "JS engine not found" warnings in the logs.
|
||||
@@ -1,26 +0,0 @@
|
||||
# QuickJS Binary Source Update
|
||||
|
||||
## 1. Context
|
||||
The current `quickjs-ng` runtime is proving too slow for our needs. We need to switch to the original QuickJS runtime provided by Bellard.
|
||||
|
||||
## 2. Source Details
|
||||
* **Base URL:** `https://bellard.org/quickjs/binary_releases/`
|
||||
* **Versioning:** Use `LATEST.json` at the base URL to find the latest version and filename.
|
||||
* Format: `{"version":"2024-01-13","files":{"quickjs-win-x86_64.zip":"quickjs-win-x86_64-2024-01-13.zip", ...}}`
|
||||
* **Target Files:**
|
||||
* **Windows:** Look for key `quickjs-win-x86_64.zip`.
|
||||
* **macOS:** Look for key `quickjs-cosmo-x86_64.zip` (Cosmo builds are generally portable). *Wait, need to confirm if macos specific builds exist or if cosmo is the intended one for unix-like.*
|
||||
* *Correction*: Bellard's page lists `quickjs-macos-x86_64.zip`? Let's check LATEST.json first. Assuming `quickjs-cosmo` might be Linux/Universal. Let's fetch `LATEST.json` to be sure.
|
||||
|
||||
## 3. Implementation Changes
|
||||
* **`src-tauri/src/binary_manager.rs`**:
|
||||
* Update `QJS_REPO_URL` to `https://bellard.org/quickjs/binary_releases`.
|
||||
* Add logic to fetch `LATEST.json` first.
|
||||
* Parse the JSON to get the actual filename for the current OS.
|
||||
* Download the ZIP file.
|
||||
* Extract the binary (`qjs.exe` or `qjs`) from the ZIP.
|
||||
* Rename it to our internal standard (`quickjs.exe` / `quickjs`).
|
||||
|
||||
## 4. Verification
|
||||
* Re-run tests.
|
||||
* Ensure `update_quickjs` flows correctly with the new logic.
|
||||
@@ -1,111 +0,0 @@
|
||||
# Feature Request: Advanced Playlist Parsing & Mix Handling
|
||||
|
||||
## Context
|
||||
We are upgrading the **StreamCapture** application. Currently, the app only supports parsing standard single video URLs.
|
||||
We need to implement robust support for **Standard Playlists** and **YouTube Mixes (Radio)**, while solving performance issues and thumbnail loading errors.
|
||||
|
||||
## Problem Statement
|
||||
1. **Playlists (`/playlist?list=...`)**: Fails to parse entries or hangs because it attempts to resolve full metadata for every video. Thumbnails are missing.
|
||||
2. **Mixes (`/watch?v=...&list=RD...`)**: Currently fails or behaves unpredictably.
|
||||
3. **UI/UX**: Users need a specific choice when encountering a "Mix" link:
|
||||
* **Option A (Default):** Treat it as a single video (ignore the list).
|
||||
* **Option B:** Parse the Mix as a playlist (limit to top 20 items).
|
||||
|
||||
## Technical Requirements
|
||||
|
||||
### 1. Rust Backend Refactoring (`src-tauri/src/ytdlp.rs`)
|
||||
|
||||
Refactor the `fetch_metadata` command to use a unified, efficient parsing strategy.
|
||||
|
||||
#### A. Command Construction
|
||||
For **ALL** metadata fetching, use the `--flat-playlist` flag to prevent deep extraction (which causes the hang).
|
||||
|
||||
**Base Command:**
|
||||
```bash
|
||||
yt-dlp --dump-single-json --flat-playlist --no-warnings [URL]
|
||||
```
|
||||
|
||||
#### B. Handling Different URL Types
|
||||
1. Single Video:
|
||||
- `yt-dlp` returns a single JSON object with `_type: "video"` (or no type).
|
||||
- Action: Wrap it in a list of 1.
|
||||
|
||||
2. Standard Playlist:
|
||||
- `yt-dlp` returns `_type: "playlist"` with an `entries` array.
|
||||
- Action: Map the `entries` to our `Video` struct.
|
||||
|
||||
3. Mix / Radio (Infinite List):
|
||||
- Condition: If the frontend flags this request as a "Mix Playlist scan".
|
||||
- Modification: Add flag --playlist-end 20 to the command.
|
||||
- Reason: Mixes are infinite; we must cap them.
|
||||
|
||||
#### C. Data Normalization & Thumbnail Fallback
|
||||
|
||||
When using `--flat-playlist`, the `entries` often lack full `thumbnail` URLs or return webp formats that might not render immediately.
|
||||
|
||||
- Logic: If the `thumbnail` field is missing or empty in an entry, construct it manually using the ID:
|
||||
- https://i.ytimg.com/vi/{video_id}/mqdefault.jpg
|
||||
|
||||
### 2. Frontend Logic (`src/views/Home.vue` & Stores)
|
||||
|
||||
#### A. URL Detection & User Choice
|
||||
|
||||
Before sending the URL to Rust, analyze the string:
|
||||
|
||||
1. Regex Check: Detect if the URL contains both `v=` AND `list=` (typical for Mixes).
|
||||
|
||||
2. New UI Element:
|
||||
- If a Mix link is detected, show a **Checkbox** or **Toggle** near the "Analyze" button.
|
||||
- Label: "Scan Playlist (Max 20)"
|
||||
- Default State: Unchecked (Off).
|
||||
|
||||
3. Submission Logic:
|
||||
- If Unchecked (Default): Strip the `&list=...` parameter from the URL string before calling Rust. Treat it as a pure single video.
|
||||
- If Checked: Keep the full URL and pass a flag (e.g., `is_mix: true`) to the Rust backend (or handle the logic to request the top 20).
|
||||
|
||||
#### B. Displaying Results
|
||||
|
||||
- Ensure the "Selection Area" can render a list of cards whether it contains 1 video or 20 videos.
|
||||
- If it's a playlist, show a "Select All" / "Deselect All" control.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Step 1: Rust Structs Update
|
||||
|
||||
Update the Serde structs in `ytdlp.rs` to handle the `flat-playlist` JSON structure.
|
||||
|
||||
```rust
|
||||
// Example structure hint
|
||||
struct YtDlpResponse {
|
||||
_type: Option<String>,
|
||||
entries: Option<Vec<VideoEntry>>,
|
||||
// ... fields for single video fallback
|
||||
id: Option<String>,
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
struct VideoEntry {
|
||||
id: String,
|
||||
title: String,
|
||||
duration: Option<f64>,
|
||||
thumbnail: Option<String>, // Might be missing
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Rust Logic Update
|
||||
|
||||
Modify `command::fetch_metadata`.
|
||||
|
||||
- Accept an optional argument `parse_mix_playlist: bool`.
|
||||
- If `true`, append `--playlist-end 20`.
|
||||
- Implement the thumbnail fallback logic (if `thumbnail` is None, use `i.ytimg.com`).
|
||||
|
||||
### Step 3: Frontend Update
|
||||
|
||||
- Add the "Mix Detected" logic in `Home.vue`.
|
||||
- Add the toggle UI.
|
||||
- Update the `analyze` function to handle URL stripping vs. passing through based on the toggle.
|
||||
|
||||
## Deliverables
|
||||
|
||||
Please rewrite the necessary parts of `src-tauri/src/ytdlp.rs`, `src-tauri/src/commands.rs`, and `src/views/Home.vue` to implement this logic.
|
||||
12
splashscreen.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>StreamCapture Loading</title>
|
||||
</head>
|
||||
<body class="font-sans antialiased">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/splash/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
src-tauri/.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/target*/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
|
||||
71
src-tauri/Cargo.lock
generated
@@ -19,6 +19,18 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -1020,6 +1032,18 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -1543,12 +1567,30 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -2110,6 +2152,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libz-rs-sys"
|
||||
version = "0.5.2"
|
||||
@@ -3455,6 +3508,20 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
@@ -3971,13 +4038,15 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "stream-capture"
|
||||
version = "0.1.0"
|
||||
version = "1.2.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "stream-capture"
|
||||
version = "0.1.0"
|
||||
version = "1.2.1"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
@@ -19,6 +19,7 @@ tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
||||
base64 = "0.22"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
anyhow = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -26,3 +27,4 @@ futures-util = "0.3"
|
||||
regex = "1.10"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
zip = "6.0.0"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "splashscreen"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 2.0 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 132 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 171 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 875 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
@@ -5,14 +5,41 @@ use tauri::AppHandle;
|
||||
use anyhow::{Result, anyhow};
|
||||
#[cfg(target_family = "unix")]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use zip::ZipArchive;
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::process_utils::first_non_empty_line;
|
||||
use crate::storage::{self};
|
||||
|
||||
const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
|
||||
// Bellard's QuickJS binary releases URL
|
||||
const QJS_REPO_URL: &str = "https://bellard.org/quickjs/binary_releases";
|
||||
// FFmpeg builds
|
||||
const FFMPEG_GITHUB_API: &str = "https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest";
|
||||
const FFMPEG_EVERMEET_BASE: &str = "https://evermeet.cx/ffmpeg";
|
||||
|
||||
#[derive(serde::Serialize, Clone, Debug)]
|
||||
pub struct RuntimeStatus {
|
||||
pub ffmpeg_source: String,
|
||||
pub ffmpeg_version: String,
|
||||
pub js_runtime_name: String,
|
||||
pub js_runtime_source: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FfmpegLocation {
|
||||
System,
|
||||
Managed(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum JsRuntime {
|
||||
Deno,
|
||||
Node,
|
||||
ManagedQuickJs(PathBuf),
|
||||
}
|
||||
|
||||
pub fn get_ytdlp_binary_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
@@ -60,10 +87,20 @@ pub fn get_qjs_path(app: &AppHandle) -> Result<PathBuf> {
|
||||
Ok(get_bin_dir(app)?.join(get_qjs_binary_name()))
|
||||
}
|
||||
|
||||
pub fn get_ffmpeg_binary_name() -> &'static str {
|
||||
if cfg!(target_os = "windows") {
|
||||
"ffmpeg.exe"
|
||||
} else {
|
||||
"ffmpeg"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ffmpeg_path(app: &AppHandle) -> Result<PathBuf> {
|
||||
Ok(get_bin_dir(app)?.join(get_ffmpeg_binary_name()))
|
||||
}
|
||||
|
||||
pub fn check_binaries(app: &AppHandle) -> bool {
|
||||
let ytdlp = get_ytdlp_path(app).map(|p| p.exists()).unwrap_or(false);
|
||||
let qjs = get_qjs_path(app).map(|p| p.exists()).unwrap_or(false);
|
||||
ytdlp && qjs
|
||||
get_ytdlp_path(app).map(|p| p.exists()).unwrap_or(false)
|
||||
}
|
||||
|
||||
// --- yt-dlp Logic ---
|
||||
@@ -74,7 +111,7 @@ pub async fn download_ytdlp(app: &AppHandle) -> Result<PathBuf> {
|
||||
|
||||
let response = reqwest::get(&url).await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Failed to download yt-dlp: Status {}", response.status()));
|
||||
return Err(anyhow!("下载 yt-dlp 失败:状态 {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
@@ -93,6 +130,17 @@ pub async fn download_ytdlp(app: &AppHandle) -> Result<PathBuf> {
|
||||
fs::set_permissions(&path, perms)?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Remove quarantine attribute to allow execution on macOS
|
||||
std::process::Command::new("xattr")
|
||||
.arg("-d")
|
||||
.arg("com.apple.quarantine")
|
||||
.arg(&path)
|
||||
.output()
|
||||
.ok();
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -100,17 +148,21 @@ pub async fn update_ytdlp(app: &AppHandle) -> Result<String> {
|
||||
let path = get_ytdlp_path(app)?;
|
||||
if !path.exists() {
|
||||
download_ytdlp(app).await?;
|
||||
return Ok("Downloaded fresh yt-dlp".to_string());
|
||||
return Ok("yt-dlp 已全新下载".to_string());
|
||||
}
|
||||
|
||||
// Use built-in update for yt-dlp
|
||||
let output = std::process::Command::new(&path)
|
||||
.arg("-U")
|
||||
.output()?;
|
||||
let mut cmd = std::process::Command::new(&path);
|
||||
cmd.arg("-U");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||
return Err(anyhow!("yt-dlp update failed: {}", stderr));
|
||||
return Err(anyhow!("yt-dlp 更新失败:{}", stderr));
|
||||
}
|
||||
|
||||
// Update settings timestamp
|
||||
@@ -118,23 +170,27 @@ pub async fn update_ytdlp(app: &AppHandle) -> Result<String> {
|
||||
settings.last_updated = Some(chrono::Utc::now());
|
||||
storage::save_settings(app, &settings)?;
|
||||
|
||||
Ok("yt-dlp updated".to_string())
|
||||
Ok("yt-dlp 已更新".to_string())
|
||||
}
|
||||
|
||||
pub fn get_ytdlp_version(app: &AppHandle) -> Result<String> {
|
||||
let path = get_ytdlp_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok("Not installed".to_string());
|
||||
return Ok("未安装".to_string());
|
||||
}
|
||||
|
||||
let output = std::process::Command::new(&path)
|
||||
.arg("--version")
|
||||
.output()?;
|
||||
let mut cmd = std::process::Command::new(&path);
|
||||
cmd.arg("--version");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
Ok("Unknown".to_string())
|
||||
Ok("未知".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +212,7 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
|
||||
let latest_url = format!("{}/LATEST.json", QJS_REPO_URL);
|
||||
let latest_resp = reqwest::get(&latest_url).await?;
|
||||
if !latest_resp.status().is_success() {
|
||||
return Err(anyhow!("Failed to fetch QuickJS version info"));
|
||||
return Err(anyhow!("获取 QuickJS 版本信息失败"));
|
||||
}
|
||||
let latest_info: LatestInfo = latest_resp.json().await?;
|
||||
let version = latest_info.version;
|
||||
@@ -170,14 +226,14 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
|
||||
// Bellard lists: quickjs-cosmo-YYYY-MM-DD.zip
|
||||
format!("quickjs-cosmo-{}.zip", version)
|
||||
} else {
|
||||
return Err(anyhow!("Unsupported OS for QuickJS auto-download"));
|
||||
return Err(anyhow!("不支持当前操作系统的 QuickJS 自动下载"));
|
||||
};
|
||||
|
||||
let download_url = format!("{}/{}", QJS_REPO_URL, filename);
|
||||
let response = reqwest::get(&download_url).await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Failed to download QuickJS: Status {}", response.status()));
|
||||
return Err(anyhow!("下载 QuickJS 失败:状态 {}", response.status()));
|
||||
}
|
||||
|
||||
let bytes = response.bytes().await?;
|
||||
@@ -194,23 +250,32 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
|
||||
let source_name = get_qjs_source_name_in_zip();
|
||||
let target_name = get_qjs_binary_name(); // quickjs.exe or quickjs
|
||||
|
||||
let mut found = false;
|
||||
let mut found_exe = false;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
// Filenames in zip might be like "quickjs-win-x86_64-2024-01-13/qjs.exe"
|
||||
let name = file.name();
|
||||
let name = file.name().to_string();
|
||||
|
||||
// Skip directories
|
||||
if file.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let filename_only = name.split('/').last().unwrap_or("");
|
||||
|
||||
if filename_only == source_name {
|
||||
let mut out_file = fs::File::create(bin_dir.join(target_name))?;
|
||||
std::io::copy(&mut file, &mut out_file)?;
|
||||
found = true;
|
||||
break;
|
||||
found_exe = true;
|
||||
} else if filename_only.ends_with(".dll") {
|
||||
// Extract DLLs (needed for Windows MinGW builds, e.g. libwinpthread-1.dll)
|
||||
let mut out_file = fs::File::create(bin_dir.join(filename_only))?;
|
||||
std::io::copy(&mut file, &mut out_file)?;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return Err(anyhow!("Could not find {} in downloaded archive", source_name));
|
||||
if !found_exe {
|
||||
return Err(anyhow!("在下载的压缩包中找不到 {}", source_name));
|
||||
}
|
||||
|
||||
let final_path = get_qjs_path(app)?;
|
||||
@@ -222,37 +287,380 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
|
||||
fs::set_permissions(&final_path, perms)?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Remove quarantine attribute to allow execution on macOS
|
||||
std::process::Command::new("xattr")
|
||||
.arg("-d")
|
||||
.arg("com.apple.quarantine")
|
||||
.arg(&final_path)
|
||||
.output()
|
||||
.ok();
|
||||
}
|
||||
|
||||
Ok(final_path)
|
||||
}
|
||||
|
||||
pub async fn update_qjs(app: &AppHandle) -> Result<String> {
|
||||
// QuickJS doesn't have self-update, so we just re-download
|
||||
download_qjs(app).await?;
|
||||
Ok("QuickJS updated/installed".to_string())
|
||||
|
||||
let mut settings = storage::load_settings(app)?;
|
||||
settings.last_updated = Some(chrono::Utc::now());
|
||||
storage::save_settings(app, &settings)?;
|
||||
|
||||
Ok("QuickJS 已更新/安装".to_string())
|
||||
}
|
||||
|
||||
// --- FFmpeg Logic ---
|
||||
|
||||
pub async fn download_ffmpeg(app: &AppHandle) -> Result<PathBuf> {
|
||||
let bin_dir = get_bin_dir(app)?;
|
||||
if cfg!(target_os = "windows") {
|
||||
// Query GitHub releases API to find a suitable win64 zip asset
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client.get(FFMPEG_GITHUB_API)
|
||||
.header(reqwest::header::USER_AGENT, "stream-capture")
|
||||
.send()
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("无法获取 FFmpeg releases 信息"));
|
||||
}
|
||||
let json: serde_json::Value = resp.json().await?;
|
||||
let mut download_url: Option<String> = None;
|
||||
if let Some(assets) = json.get("assets").and_then(|a| a.as_array()) {
|
||||
for asset in assets {
|
||||
if let (Some(name), Some(url)) = (asset.get("name").and_then(|n| n.as_str()), asset.get("browser_download_url").and_then(|u| u.as_str())) {
|
||||
let lname = name.to_lowercase();
|
||||
// Prefer GPL static build, avoid shared to get a single exe
|
||||
if lname.contains("win64") && lname.contains("gpl") && !lname.contains("shared") && lname.ends_with(".zip") {
|
||||
download_url = Some(url.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if download_url.is_none() {
|
||||
// fallback: choose first zip asset that is NOT shared if possible
|
||||
for asset in assets {
|
||||
if let (Some(url), Some(name)) = (asset.get("browser_download_url").and_then(|u| u.as_str()), asset.get("name").and_then(|n| n.as_str())) {
|
||||
let lname = name.to_lowercase();
|
||||
if lname.ends_with(".zip") && !lname.contains("shared") {
|
||||
download_url = Some(url.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let url = download_url.ok_or(anyhow!("未找到适合 Windows 的 FFmpeg 发行包"))?;
|
||||
let resp = client.get(&url).header(reqwest::header::USER_AGENT, "stream-capture").send().await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("下载 FFmpeg 失败"));
|
||||
}
|
||||
let bytes = resp.bytes().await?;
|
||||
let mut archive = ZipArchive::new(Cursor::new(bytes))?;
|
||||
|
||||
let mut found = false;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
if file.is_dir() { continue; }
|
||||
let name = file.name().to_string();
|
||||
let filename_only = name.split('/').last().unwrap_or("");
|
||||
// Only extract the executable, ignore DLLs
|
||||
if filename_only.eq_ignore_ascii_case("ffmpeg.exe") {
|
||||
let mut out_file = fs::File::create(bin_dir.join(filename_only))?;
|
||||
std::io::copy(&mut file, &mut out_file)?;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(anyhow!("在 FFmpeg 压缩包中未找到 ffmpeg 可执行文件"));
|
||||
}
|
||||
|
||||
let final_path = get_ffmpeg_path(app)?;
|
||||
Ok(final_path)
|
||||
} else if cfg!(target_os = "macos") {
|
||||
// Fetch listing page and find latest ffmpeg-*.zip
|
||||
let resp = reqwest::get(FFMPEG_EVERMEET_BASE).await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("无法获取 evermeet.ffmpeg 列表"));
|
||||
}
|
||||
let body = resp.text().await?;
|
||||
let re = regex::Regex::new(r#"href="(ffmpeg-[0-9][^"]*\.zip)"#)?;
|
||||
let mut candidates: Vec<&str> = Vec::new();
|
||||
for cap in re.captures_iter(&body) {
|
||||
if let Some(m) = cap.get(1) {
|
||||
candidates.push(m.as_str());
|
||||
}
|
||||
}
|
||||
let filename = candidates.last().ok_or(anyhow!("未在 evermeet 找到 ffmpeg zip 文件"))?;
|
||||
let url = format!("{}/{}", FFMPEG_EVERMEET_BASE, filename);
|
||||
let resp = reqwest::get(&url).await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(anyhow!("下载 FFmpeg macOS 版本失败"));
|
||||
}
|
||||
let bytes = resp.bytes().await?;
|
||||
let mut archive = ZipArchive::new(Cursor::new(bytes))?;
|
||||
|
||||
let mut found = false;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
if file.is_dir() { continue; }
|
||||
let name = file.name().to_string();
|
||||
let filename_only = name.split('/').last().unwrap_or("");
|
||||
if filename_only == "ffmpeg" {
|
||||
let mut out_file = fs::File::create(bin_dir.join("ffmpeg"))?;
|
||||
std::io::copy(&mut file, &mut out_file)?;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err(anyhow!("在 FFmpeg 压缩包中未找到 ffmpeg 可执行文件"));
|
||||
}
|
||||
|
||||
let final_path = get_ffmpeg_path(app)?;
|
||||
#[cfg(target_family = "unix")]
|
||||
{
|
||||
let mut perms = fs::metadata(&final_path)?.permissions();
|
||||
perms.set_mode(0o755);
|
||||
fs::set_permissions(&final_path, perms)?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("xattr").arg("-d").arg("com.apple.quarantine").arg(&final_path).output().ok();
|
||||
}
|
||||
Ok(final_path)
|
||||
} else {
|
||||
Err(anyhow!("当前操作系统不支持自动下载 FFmpeg"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_ffmpeg(app: &AppHandle) -> Result<String> {
|
||||
let path = get_ffmpeg_path(app)?;
|
||||
if !path.exists() {
|
||||
download_ffmpeg(app).await?;
|
||||
return Ok("FFmpeg 已安装".to_string());
|
||||
}
|
||||
// Re-download to update
|
||||
download_ffmpeg(app).await?;
|
||||
|
||||
// Update settings timestamp
|
||||
let mut settings = storage::load_settings(app)?;
|
||||
settings.last_updated = Some(chrono::Utc::now());
|
||||
storage::save_settings(app, &settings)?;
|
||||
|
||||
Ok("FFmpeg 已更新".to_string())
|
||||
}
|
||||
|
||||
pub fn get_ffmpeg_version(app: &AppHandle) -> Result<String> {
|
||||
if let Some(version) = run_version_command("ffmpeg", "-version") {
|
||||
return Ok(version);
|
||||
}
|
||||
|
||||
let path = get_ffmpeg_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok("未安装".to_string());
|
||||
}
|
||||
let mut cmd = std::process::Command::new(&path);
|
||||
cmd.arg("-version");
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
|
||||
let output = cmd.output()?;
|
||||
if output.status.success() {
|
||||
if let Some(line) = first_non_empty_line(&output) {
|
||||
return Ok(line);
|
||||
}
|
||||
}
|
||||
|
||||
// If we couldn't obtain a usable version string, treat as not installed
|
||||
Ok("未安装".to_string())
|
||||
}
|
||||
|
||||
pub fn get_qjs_version(app: &AppHandle) -> Result<String> {
|
||||
let path = get_qjs_path(app)?;
|
||||
if !path.exists() {
|
||||
return Ok("Not installed".to_string());
|
||||
return Ok("未安装".to_string());
|
||||
}
|
||||
Ok("Installed".to_string())
|
||||
|
||||
let mut version_cmd = std::process::Command::new(&path);
|
||||
version_cmd.arg("--version");
|
||||
#[cfg(target_os = "windows")]
|
||||
version_cmd.creation_flags(0x08000000);
|
||||
|
||||
if let Ok(output) = version_cmd.output() {
|
||||
if output.status.success() {
|
||||
if let Some(line) = first_non_empty_line(&output) {
|
||||
return Ok(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut help_cmd = std::process::Command::new(&path);
|
||||
help_cmd.arg("-h");
|
||||
#[cfg(target_os = "windows")]
|
||||
help_cmd.creation_flags(0x08000000);
|
||||
|
||||
if let Ok(output) = help_cmd.output() {
|
||||
if let Some(line) = first_non_empty_line(&output) {
|
||||
return Ok(line);
|
||||
}
|
||||
}
|
||||
|
||||
Ok("已安装".to_string())
|
||||
}
|
||||
|
||||
pub async fn ensure_binaries(app: &AppHandle) -> Result<()> {
|
||||
let ytdlp = get_ytdlp_path(app)?;
|
||||
if !ytdlp.exists() {
|
||||
download_ytdlp(app).await?;
|
||||
}
|
||||
|
||||
let qjs = get_qjs_path(app)?;
|
||||
if !qjs.exists() {
|
||||
download_qjs(app).await?;
|
||||
} else {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("xattr")
|
||||
.arg("-d")
|
||||
.arg("com.apple.quarantine")
|
||||
.arg(&ytdlp)
|
||||
.output()
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_version_command(command: &str, arg: &str) -> Option<String> {
|
||||
let mut cmd = std::process::Command::new(command);
|
||||
cmd.arg(arg);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
|
||||
cmd.output()
|
||||
.ok()
|
||||
.filter(|output| output.status.success())
|
||||
.and_then(|output| first_non_empty_line(&output))
|
||||
}
|
||||
|
||||
pub fn resolve_ffmpeg(app: &AppHandle, allow_download: bool) -> Result<Option<FfmpegLocation>> {
|
||||
if run_version_command("ffmpeg", "-version").is_some() {
|
||||
return Ok(Some(FfmpegLocation::System));
|
||||
}
|
||||
|
||||
let managed = get_ffmpeg_path(app)?;
|
||||
if managed.exists() {
|
||||
return Ok(Some(FfmpegLocation::Managed(managed)));
|
||||
}
|
||||
|
||||
if allow_download {
|
||||
return Ok(Some(FfmpegLocation::Managed(managed)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn ensure_ffmpeg_available(app: &AppHandle) -> Result<Option<FfmpegLocation>> {
|
||||
if let Some(location) = resolve_ffmpeg(app, false)? {
|
||||
return Ok(Some(location));
|
||||
}
|
||||
|
||||
let path = download_ffmpeg(app).await?;
|
||||
Ok(Some(FfmpegLocation::Managed(path)))
|
||||
}
|
||||
|
||||
pub fn resolve_js_runtime(app: &AppHandle, allow_download: bool) -> Result<Option<JsRuntime>> {
|
||||
if run_version_command("deno", "--version").is_some() {
|
||||
return Ok(Some(JsRuntime::Deno));
|
||||
}
|
||||
|
||||
if run_version_command("node", "--version").is_some() {
|
||||
return Ok(Some(JsRuntime::Node));
|
||||
}
|
||||
|
||||
let managed = get_qjs_path(app)?;
|
||||
if managed.exists() {
|
||||
return Ok(Some(JsRuntime::ManagedQuickJs(managed)));
|
||||
}
|
||||
|
||||
if allow_download {
|
||||
return Ok(Some(JsRuntime::ManagedQuickJs(managed)));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn ensure_js_runtime_available(app: &AppHandle) -> Result<Option<JsRuntime>> {
|
||||
if let Some(runtime) = resolve_js_runtime(app, false)? {
|
||||
return Ok(Some(runtime));
|
||||
}
|
||||
|
||||
let path = download_qjs(app).await?;
|
||||
Ok(Some(JsRuntime::ManagedQuickJs(path)))
|
||||
}
|
||||
|
||||
impl FfmpegLocation {
|
||||
pub fn source_label(&self) -> &'static str {
|
||||
match self {
|
||||
FfmpegLocation::System => "system",
|
||||
FfmpegLocation::Managed(_) => "managed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn version(&self, app: &AppHandle) -> Result<String> {
|
||||
match self {
|
||||
FfmpegLocation::System => Ok(run_version_command("ffmpeg", "-version").unwrap_or_else(|| "未知".to_string())),
|
||||
FfmpegLocation::Managed(_) => get_ffmpeg_version(app),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JsRuntime {
|
||||
pub fn source_label(&self) -> &'static str {
|
||||
match self {
|
||||
JsRuntime::Deno | JsRuntime::Node => "system",
|
||||
JsRuntime::ManagedQuickJs(_) => "managed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(&self, app: &AppHandle) -> Result<String> {
|
||||
match self {
|
||||
JsRuntime::Deno => Ok(run_version_command("deno", "--version").unwrap_or_else(|| "deno".to_string())),
|
||||
JsRuntime::Node => Ok(run_version_command("node", "--version").unwrap_or_else(|| "node".to_string())),
|
||||
JsRuntime::ManagedQuickJs(_) => get_qjs_version(app),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn yt_dlp_argument(&self) -> String {
|
||||
match self {
|
||||
JsRuntime::Deno => "deno".to_string(),
|
||||
JsRuntime::Node => "node".to_string(),
|
||||
JsRuntime::ManagedQuickJs(path) => format!("quickjs:{}", path.to_string_lossy()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_runtime_status(app: &AppHandle) -> Result<RuntimeStatus> {
|
||||
let ffmpeg = resolve_ffmpeg(app, false)?;
|
||||
let js_runtime = resolve_js_runtime(app, false)?;
|
||||
|
||||
let (ffmpeg_source, ffmpeg_version) = match ffmpeg {
|
||||
Some(location) => (location.source_label().to_string(), location.version(app)?),
|
||||
None => ("unavailable".to_string(), "未安装".to_string()),
|
||||
};
|
||||
|
||||
let (js_runtime_name, js_runtime_source) = match js_runtime {
|
||||
Some(runtime) => (runtime.display_name(app)?, runtime.source_label().to_string()),
|
||||
None => ("未安装".to_string(), "unavailable".to_string()),
|
||||
};
|
||||
|
||||
Ok(RuntimeStatus {
|
||||
ffmpeg_source,
|
||||
ffmpeg_version,
|
||||
js_runtime_name,
|
||||
js_runtime_source,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,129 +1,238 @@
|
||||
// filepath: src-tauri/src/commands.rs
|
||||
use tauri::{AppHandle, Manager};
|
||||
use crate::{binary_manager, downloader, storage};
|
||||
use crate::downloader::DownloadOptions;
|
||||
use crate::storage::{Settings, HistoryItem};
|
||||
use uuid::Uuid;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Semaphore;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::binary_manager;
|
||||
use crate::downloader::{self, DownloadOptions};
|
||||
use crate::storage::{self, Settings, TaskLogEntry, TaskRecord};
|
||||
use crate::task_runtime;
|
||||
|
||||
static DOWNLOAD_SEMAPHORE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(3));
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn init_ytdlp(app: AppHandle) -> Result<bool, String> {
|
||||
storage::initialize_storage(&app).map_err(|error| error.to_string())?;
|
||||
storage::recover_incomplete_tasks(&app).map_err(|error| error.to_string())?;
|
||||
|
||||
if binary_manager::check_binaries(&app) {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// If not found, try to download
|
||||
match binary_manager::ensure_binaries(&app).await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(format!("Failed to download binaries: {}", e)),
|
||||
}
|
||||
|
||||
binary_manager::ensure_binaries(&app)
|
||||
.await
|
||||
.map(|_| true)
|
||||
.map_err(|error| format!("Failed to prepare runtime: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_ytdlp(app: AppHandle) -> Result<String, String> {
|
||||
binary_manager::update_ytdlp(&app).await.map_err(|e| e.to_string())
|
||||
binary_manager::update_ytdlp(&app).await.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_quickjs(app: AppHandle) -> Result<String, String> {
|
||||
binary_manager::update_qjs(&app).await.map_err(|e| e.to_string())
|
||||
binary_manager::update_qjs(&app).await.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_ffmpeg(app: AppHandle) -> Result<String, String> {
|
||||
binary_manager::update_ffmpeg(&app).await.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_ytdlp_version(app: AppHandle) -> Result<String, String> {
|
||||
binary_manager::get_ytdlp_version(&app).map_err(|e| e.to_string())
|
||||
binary_manager::get_ytdlp_version(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_quickjs_version(app: AppHandle) -> Result<String, String> {
|
||||
binary_manager::get_qjs_version(&app).map_err(|e| e.to_string())
|
||||
binary_manager::get_qjs_version(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_ffmpeg_version(app: AppHandle) -> Result<String, String> {
|
||||
binary_manager::get_ffmpeg_version(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_runtime_status(app: AppHandle) -> Result<binary_manager::RuntimeStatus, String> {
|
||||
binary_manager::get_runtime_status(&app).await.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_image(url: String) -> Result<String, String> {
|
||||
use base64::{Engine as _, engine::general_purpose};
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let res = client
|
||||
.get(&url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
return Err(format!("image fetch failed with status {}", res.status()));
|
||||
}
|
||||
|
||||
let mime = res
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.split(';').next().unwrap_or("image/jpeg").to_string())
|
||||
.unwrap_or_else(|| {
|
||||
if url.to_lowercase().ends_with(".png") {
|
||||
"image/png".to_string()
|
||||
} else if url.to_lowercase().ends_with(".webp") {
|
||||
"image/webp".to_string()
|
||||
} else {
|
||||
"image/jpeg".to_string()
|
||||
}
|
||||
});
|
||||
let bytes = res.bytes().await.map_err(|error| error.to_string())?;
|
||||
let b64 = general_purpose::STANDARD.encode(&bytes);
|
||||
|
||||
Ok(format!("data:{};base64,{}", mime, b64))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_metadata(app: AppHandle, url: String, parse_mix_playlist: bool) -> Result<downloader::MetadataResult, String> {
|
||||
downloader::fetch_metadata(&app, &url, parse_mix_playlist).await.map_err(|e| e.to_string())
|
||||
downloader::fetch_metadata(&app, &url, parse_mix_playlist)
|
||||
.await
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_download(app: AppHandle, url: String, options: DownloadOptions, metadata: downloader::VideoMetadata) -> Result<String, String> {
|
||||
// Generate a task ID
|
||||
pub async fn start_download(
|
||||
app: AppHandle,
|
||||
url: String,
|
||||
options: DownloadOptions,
|
||||
metadata: downloader::VideoMetadata,
|
||||
) -> Result<String, String> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let normalized_url = metadata.url.clone().unwrap_or_else(|| url.clone());
|
||||
let task = storage::create_task(&app, &id, &url, &normalized_url, &options, &metadata)
|
||||
.map_err(|error| error.to_string())?;
|
||||
app.emit("task-updated", &task).ok();
|
||||
|
||||
let id_clone = id.clone();
|
||||
|
||||
// Spawn the download task
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let res = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await;
|
||||
|
||||
let status = if res.is_ok() { "success" } else { "failed" };
|
||||
|
||||
// Add to history
|
||||
let output_dir = options.output_path.clone(); // Store the directory user selected
|
||||
|
||||
let item = HistoryItem {
|
||||
id: id_clone,
|
||||
title: metadata.title,
|
||||
thumbnail: metadata.thumbnail,
|
||||
url: url,
|
||||
output_path: output_dir,
|
||||
timestamp: chrono::Utc::now(),
|
||||
status: status.to_string(),
|
||||
format: options.quality,
|
||||
};
|
||||
|
||||
let _ = storage::add_history_item(&app, item);
|
||||
let _permit = DOWNLOAD_SEMAPHORE.acquire().await.ok();
|
||||
if let Err(error) = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await {
|
||||
storage::add_log_entry(&app, &id_clone, "error", &error.to_string()).ok();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cancel_task(app: AppHandle, id: String) -> Result<(), String> {
|
||||
let _ = app;
|
||||
task_runtime::cancel_task(&id)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn retry_task(app: AppHandle, id: String) -> Result<String, String> {
|
||||
let payload = storage::get_task_payload(&app, &id).map_err(|error| error.to_string())?;
|
||||
start_download(app, payload.source_url, payload.options, payload.metadata).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_tasks(app: AppHandle) -> Result<Vec<TaskRecord>, String> {
|
||||
storage::list_tasks(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_task_logs(app: AppHandle) -> Result<Vec<TaskLogEntry>, String> {
|
||||
storage::load_logs(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_task_logs(app: AppHandle) -> Result<(), String> {
|
||||
storage::clear_logs(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_settings(app: AppHandle) -> Result<Settings, String> {
|
||||
storage::load_settings(&app).map_err(|e| e.to_string())
|
||||
storage::load_settings(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
|
||||
storage::save_settings(&app, &settings).map_err(|e| e.to_string())
|
||||
storage::save_settings(&app, &settings).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_history(app: AppHandle) -> Result<Vec<HistoryItem>, String> {
|
||||
storage::load_history(&app).map_err(|e| e.to_string())
|
||||
pub fn get_history(app: AppHandle) -> Result<Vec<storage::HistoryItem>, String> {
|
||||
storage::load_history(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clear_history(app: AppHandle) -> Result<(), String> {
|
||||
storage::clear_history(&app).map_err(|e| e.to_string())
|
||||
storage::clear_history(&app).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_history_item(app: AppHandle, id: String) -> Result<(), String> {
|
||||
storage::delete_history_item(&app, &id).map_err(|e| e.to_string())
|
||||
storage::delete_history_item(&app, &id).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn close_splash(app: AppHandle) {
|
||||
if let Some(splash) = app.get_webview_window("splashscreen") {
|
||||
let _ = splash.close();
|
||||
}
|
||||
if let Some(main) = app.get_webview_window("main") {
|
||||
let _ = main.show();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
|
||||
let path_to_open = if Path::new(&path).exists() {
|
||||
let resolved_path = if Path::new(&path).exists() {
|
||||
path
|
||||
} else {
|
||||
app.path().download_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
app.path()
|
||||
.download_dir()
|
||||
.map(|value| value.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| ".".to_string())
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::process::Command::new("explorer")
|
||||
.arg(path_to_open)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resolved = Path::new(&resolved_path);
|
||||
let mut command = std::process::Command::new("explorer");
|
||||
|
||||
if resolved.is_file() {
|
||||
command.arg("/select,").arg(resolved);
|
||||
} else {
|
||||
command.arg(resolved);
|
||||
}
|
||||
|
||||
command.spawn().map_err(|error| error.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(path_to_open)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let resolved = Path::new(&resolved_path);
|
||||
let mut command = std::process::Command::new("open");
|
||||
|
||||
if resolved.is_file() {
|
||||
command.arg("-R").arg(resolved);
|
||||
} else {
|
||||
command.arg(resolved);
|
||||
}
|
||||
|
||||
command.spawn().map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
// filepath: src-tauri/src/downloader.rs
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use std::process::Stdio;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use anyhow::{Result, anyhow};
|
||||
use regex::Regex;
|
||||
use crate::binary_manager;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::binary_manager::{self, FfmpegLocation};
|
||||
use crate::storage::{self, TaskRecord};
|
||||
use crate::task_runtime;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct VideoMetadata {
|
||||
@@ -15,6 +20,9 @@ pub struct VideoMetadata {
|
||||
pub thumbnail: String,
|
||||
pub duration: Option<f64>,
|
||||
pub uploader: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub extractor: Option<String>,
|
||||
pub site_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -31,11 +39,13 @@ pub enum MetadataResult {
|
||||
Playlist(PlaylistMetadata),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||
pub struct DownloadOptions {
|
||||
pub is_audio_only: bool,
|
||||
pub quality: String, // e.g., "1080", "720", "best"
|
||||
pub output_path: String, // Directory
|
||||
pub quality: String,
|
||||
pub output_path: String,
|
||||
pub output_format: String,
|
||||
pub cookies_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
@@ -43,205 +53,382 @@ pub struct ProgressEvent {
|
||||
pub id: String,
|
||||
pub progress: f64,
|
||||
pub speed: String,
|
||||
pub status: String, // "downloading", "processing", "finished", "error"
|
||||
pub eta: Option<String>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone, Debug)]
|
||||
pub struct LogEvent {
|
||||
pub id: String,
|
||||
pub message: String,
|
||||
pub level: String, // "info", "error"
|
||||
pub level: String,
|
||||
}
|
||||
|
||||
const FINAL_PATH_MARKER: &str = "__STREAM_CAPTURE_FINAL_PATH__";
|
||||
|
||||
fn emit_task(app: &AppHandle, task: &TaskRecord) {
|
||||
app.emit("task-updated", task).ok();
|
||||
}
|
||||
|
||||
fn emit_log(app: &AppHandle, task_id: &str, message: impl Into<String>, level: &str) {
|
||||
let message = message.into();
|
||||
storage::add_log_entry(app, task_id, level, &message).ok();
|
||||
app.emit(
|
||||
"download-log",
|
||||
LogEvent {
|
||||
id: task_id.to_string(),
|
||||
message,
|
||||
level: level.to_string(),
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn emit_progress(app: &AppHandle, task_id: &str, progress: f64, speed: String, eta: Option<String>, status: &str) {
|
||||
app.emit(
|
||||
"download-progress",
|
||||
ProgressEvent {
|
||||
id: task_id.to_string(),
|
||||
progress,
|
||||
speed,
|
||||
eta,
|
||||
status: status.to_string(),
|
||||
},
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
pub async fn fetch_metadata(app: &AppHandle, url: &str, parse_mix_playlist: bool) -> Result<MetadataResult> {
|
||||
emit_log(app, "Analysis", format!("正在为 URL: {} 获取元数据", url), "info");
|
||||
|
||||
let ytdlp_path = binary_manager::get_ytdlp_path(app)?;
|
||||
let qjs_path = binary_manager::get_qjs_path(app)?; // Get absolute path to quickjs
|
||||
|
||||
let mut cmd = Command::new(ytdlp_path);
|
||||
|
||||
// Pass the runtime and its absolute path to --js-runtimes
|
||||
cmd.arg("--js-runtimes").arg(format!("quickjs:{}", qjs_path.to_string_lossy()));
|
||||
let js_runtime = binary_manager::ensure_js_runtime_available(app).await?;
|
||||
let settings = storage::load_settings(app)?;
|
||||
|
||||
cmd.arg("--dump-single-json")
|
||||
.arg("--flat-playlist")
|
||||
.arg("--no-warnings");
|
||||
|
||||
if parse_mix_playlist {
|
||||
cmd.arg("--playlist-end").arg("20");
|
||||
let mut args = Vec::new();
|
||||
if let Some(runtime) = js_runtime {
|
||||
args.push("--js-runtimes".to_string());
|
||||
args.push(runtime.yt_dlp_argument());
|
||||
}
|
||||
|
||||
cmd.arg(url);
|
||||
let mut has_cookies = false;
|
||||
if let Some(cookies) = &settings.cookies_path {
|
||||
if !cookies.is_empty() {
|
||||
if std::path::Path::new(cookies).exists() {
|
||||
args.push("--cookies".to_string());
|
||||
args.push(cookies.clone());
|
||||
has_cookies = true;
|
||||
emit_log(app, "Analysis", format!("已加载 Cookies: {}", cookies), "info");
|
||||
} else {
|
||||
emit_log(app, "Analysis", format!("Cookies 文件不存在: {}", cookies), "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
args.push("--dump-single-json".to_string());
|
||||
args.push("--flat-playlist".to_string());
|
||||
args.push("--no-warnings".to_string());
|
||||
|
||||
if has_cookies {
|
||||
args.push("--extractor-args".to_string());
|
||||
args.push("youtube:skip=dash,hls,translated_subs".to_string());
|
||||
} else {
|
||||
args.push("--extractor-args".to_string());
|
||||
args.push("youtube:skip=dash,hls,translated_subs;player_skip=js".to_string());
|
||||
}
|
||||
|
||||
if parse_mix_playlist {
|
||||
args.push("--playlist-end".to_string());
|
||||
args.push("20".to_string());
|
||||
}
|
||||
|
||||
args.push(url.to_string());
|
||||
|
||||
let mut cmd = Command::new(&ytdlp_path);
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.creation_flags(0x08000000);
|
||||
|
||||
cmd.args(&args);
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
|
||||
emit_log(
|
||||
app,
|
||||
"Analysis",
|
||||
format!("正在执行分析命令: {} {}", ytdlp_path.to_string_lossy(), args.join(" ")),
|
||||
"info",
|
||||
);
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
emit_log(app, "Analysis", format!("元数据获取失败: {}", stderr), "error");
|
||||
return Err(anyhow!("yt-dlp error: {}", stderr));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let json: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Check if playlist
|
||||
if let Some(_type) = json.get("_type") {
|
||||
if _type == "playlist" {
|
||||
let entries_json = json["entries"].as_array().ok_or(anyhow!("No entries in playlist"))?;
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for entry in entries_json {
|
||||
entries.push(parse_video_metadata(entry));
|
||||
}
|
||||
|
||||
return Ok(MetadataResult::Playlist(PlaylistMetadata {
|
||||
id: json["id"].as_str().unwrap_or("").to_string(),
|
||||
title: json["title"].as_str().unwrap_or("Unknown Playlist").to_string(),
|
||||
entries,
|
||||
}));
|
||||
}
|
||||
if json.get("_type").and_then(|value| value.as_str()) == Some("playlist") {
|
||||
let entries_json = json["entries"].as_array().ok_or_else(|| anyhow!("No entries in playlist"))?;
|
||||
let entries = entries_json.iter().map(parse_video_metadata).collect();
|
||||
let result = MetadataResult::Playlist(PlaylistMetadata {
|
||||
id: json["id"].as_str().unwrap_or("").to_string(),
|
||||
title: json["title"].as_str().unwrap_or("Unknown Playlist").to_string(),
|
||||
entries,
|
||||
});
|
||||
emit_log(app, "Analysis", "元数据获取成功(播放列表)", "info");
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Single video
|
||||
Ok(MetadataResult::Video(parse_video_metadata(&json)))
|
||||
let result = MetadataResult::Video(parse_video_metadata(&json));
|
||||
emit_log(app, "Analysis", "元数据获取成功(视频)", "info");
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn parse_video_metadata(json: &serde_json::Value) -> VideoMetadata {
|
||||
let id = json["id"].as_str().unwrap_or("").to_string();
|
||||
|
||||
// Thumbnail fallback logic
|
||||
let thumbnail = match json.get("thumbnail").and_then(|t| t.as_str()) {
|
||||
Some(t) if !t.is_empty() => t.to_string(),
|
||||
_ => format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
||||
let extractor = json["extractor_key"].as_str().map(|value| value.to_string());
|
||||
let site_name = json["extractor"].as_str().map(|value| value.to_string());
|
||||
|
||||
let thumbnail = match json.get("thumbnail").and_then(|value| value.as_str()) {
|
||||
Some(value) if !value.is_empty() => value.to_string(),
|
||||
_ if extractor
|
||||
.as_deref()
|
||||
.map(|value| value.to_lowercase().contains("youtube"))
|
||||
.unwrap_or(false) =>
|
||||
{
|
||||
format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
let url = json["webpage_url"]
|
||||
.as_str()
|
||||
.or_else(|| json["url"].as_str())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
VideoMetadata {
|
||||
id,
|
||||
title: json["title"].as_str().unwrap_or("Unknown Title").to_string(),
|
||||
thumbnail,
|
||||
duration: json["duration"].as_f64(),
|
||||
uploader: json["uploader"].as_str().map(|s| s.to_string()),
|
||||
uploader: json["uploader"].as_str().map(|value| value.to_string()),
|
||||
url,
|
||||
extractor,
|
||||
site_name,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_runtime_args(args: &mut Vec<String>, ffmpeg: &Option<FfmpegLocation>, js_runtime: &Option<binary_manager::JsRuntime>) {
|
||||
if let Some(runtime) = js_runtime {
|
||||
args.push("--js-runtimes".to_string());
|
||||
args.push(runtime.yt_dlp_argument());
|
||||
}
|
||||
|
||||
if let Some(FfmpegLocation::Managed(path)) = ffmpeg {
|
||||
args.push("--ffmpeg-location".to_string());
|
||||
args.push(path.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn needs_ffmpeg(options: &DownloadOptions) -> bool {
|
||||
options.is_audio_only || options.output_format != "original" || options.quality != "best"
|
||||
}
|
||||
|
||||
pub async fn download_video(
|
||||
app: AppHandle,
|
||||
id: String, // Unique ID for this download task (provided by frontend)
|
||||
id: String,
|
||||
url: String,
|
||||
options: DownloadOptions,
|
||||
) -> Result<String> {
|
||||
) -> Result<Option<String>> {
|
||||
let ytdlp_path = binary_manager::get_ytdlp_path(&app)?;
|
||||
let qjs_path = binary_manager::get_qjs_path(&app)?; // Get absolute path to quickjs
|
||||
|
||||
let mut args = Vec::new();
|
||||
|
||||
// Pass the runtime and its absolute path to --js-runtimes
|
||||
args.push("--js-runtimes".to_string());
|
||||
args.push(format!("quickjs:{}", qjs_path.to_string_lossy()));
|
||||
let js_runtime = binary_manager::ensure_js_runtime_available(&app).await?;
|
||||
let ffmpeg = if needs_ffmpeg(&options) {
|
||||
binary_manager::ensure_ffmpeg_available(&app).await?
|
||||
} else {
|
||||
binary_manager::resolve_ffmpeg(&app, false)?
|
||||
};
|
||||
|
||||
args.push(url);
|
||||
|
||||
// Output template
|
||||
let output_template = format!("{}/%(title)s.%(ext)s", options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR));
|
||||
let task = storage::update_task_status(&app, &id, "preparing", None, None)?;
|
||||
emit_task(&app, &task);
|
||||
|
||||
let mut args = Vec::new();
|
||||
apply_runtime_args(&mut args, &ffmpeg, &js_runtime);
|
||||
|
||||
if let Some(cookies) = &options.cookies_path {
|
||||
if !cookies.is_empty() {
|
||||
args.push("--cookies".to_string());
|
||||
args.push(cookies.clone());
|
||||
}
|
||||
}
|
||||
|
||||
args.push(url.clone());
|
||||
|
||||
let output_template = format!(
|
||||
"{}/%(title)s.%(ext)s",
|
||||
options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR)
|
||||
);
|
||||
args.push("-o".to_string());
|
||||
args.push(output_template);
|
||||
args.push("--print".to_string());
|
||||
args.push(format!("after_move:{FINAL_PATH_MARKER}%(filepath)s"));
|
||||
|
||||
// Formats
|
||||
if options.is_audio_only {
|
||||
args.push("-x".to_string());
|
||||
args.push("--audio-format".to_string());
|
||||
args.push("mp3".to_string());
|
||||
if options.output_format != "original" {
|
||||
args.push("--audio-format".to_string());
|
||||
args.push(options.output_format.clone());
|
||||
}
|
||||
} else {
|
||||
let format_arg = if options.quality == "best" {
|
||||
"bestvideo+bestaudio/best".to_string()
|
||||
} else {
|
||||
format!("bestvideo[height<={}]+bestaudio/best[height<={}]", options.quality, options.quality)
|
||||
format!(
|
||||
"bestvideo[height<={}]+bestaudio/best[height<={}]/best",
|
||||
options.quality, options.quality
|
||||
)
|
||||
};
|
||||
args.push("-f".to_string());
|
||||
args.push(format_arg);
|
||||
|
||||
if options.output_format != "original" {
|
||||
args.push("--merge-output-format".to_string());
|
||||
args.push(options.output_format.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Progress output
|
||||
args.push("--newline".to_string());
|
||||
|
||||
let mut cmd = Command::new(ytdlp_path);
|
||||
|
||||
let mut child = cmd
|
||||
emit_log(
|
||||
&app,
|
||||
&id,
|
||||
format!("正在执行命令: {} {}", ytdlp_path.to_string_lossy(), args.join(" ")),
|
||||
"info",
|
||||
);
|
||||
|
||||
let mut command = Command::new(ytdlp_path);
|
||||
#[cfg(target_os = "windows")]
|
||||
command.creation_flags(0x08000000);
|
||||
|
||||
let mut child = command
|
||||
.args(&args)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
let stdout = child.stdout.take().ok_or(anyhow!("Failed to open stdout"))?;
|
||||
let stderr = child.stderr.take().ok_or(anyhow!("Failed to open stderr"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| anyhow!("Failed to open stdout"))?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| anyhow!("Failed to open stderr"))?;
|
||||
let shared_child = Arc::new(Mutex::new(child));
|
||||
task_runtime::register_child(&id, shared_child.clone()).await;
|
||||
|
||||
let mut stdout_reader = BufReader::new(stdout);
|
||||
let mut stderr_reader = BufReader::new(stderr);
|
||||
let progress_regex =
|
||||
Regex::new(r"\[download\]\s+(\d+(?:\.\d+)?)%.*?(?:\s+at\s+([^\s]+))?(?:.*?ETA\s+([^\s]+))?").unwrap();
|
||||
|
||||
let re = Regex::new(r"\[download\]\s+(\d+\.?\d*)%").unwrap();
|
||||
let stdout_task = {
|
||||
let app = app.clone();
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stdout).lines();
|
||||
let mut final_path: Option<String> = None;
|
||||
|
||||
// Loop to read both streams
|
||||
loop {
|
||||
let mut out_line = String::new();
|
||||
let mut err_line = String::new();
|
||||
|
||||
tokio::select! {
|
||||
res = stdout_reader.read_line(&mut out_line) => {
|
||||
if res.unwrap_or(0) == 0 {
|
||||
break; // EOF
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse progress
|
||||
if let Some(caps) = re.captures(&out_line) {
|
||||
if let Some(pct_match) = caps.get(1) {
|
||||
if let Ok(pct) = pct_match.as_str().parse::<f64>() {
|
||||
app.emit("download-progress", ProgressEvent {
|
||||
id: id.clone(),
|
||||
progress: pct,
|
||||
speed: "TODO".to_string(),
|
||||
status: "downloading".to_string(),
|
||||
}).ok();
|
||||
|
||||
if let Some(path) = trimmed.strip_prefix(FINAL_PATH_MARKER) {
|
||||
final_path = Some(path.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed.contains("Destination") || trimmed.contains("Merging formats") || trimmed.contains("Post-process") {
|
||||
let task = storage::update_task_status(&app, &id, "postprocessing", None, final_path.as_deref())?;
|
||||
emit_task(&app, &task);
|
||||
}
|
||||
|
||||
if let Some(caps) = progress_regex.captures(trimmed) {
|
||||
if let Some(progress_match) = caps.get(1) {
|
||||
if let Ok(progress) = progress_match.as_str().parse::<f64>() {
|
||||
let speed = caps
|
||||
.get(2)
|
||||
.map(|value| value.as_str().to_string())
|
||||
.unwrap_or_else(|| "待定".to_string());
|
||||
let eta = caps.get(3).map(|value| value.as_str().to_string());
|
||||
let task = storage::update_task_progress(&app, &id, progress, &speed, eta.as_deref(), "downloading")?;
|
||||
emit_task(&app, &task);
|
||||
emit_progress(&app, &id, progress, speed, eta, "downloading");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else { // Only emit download-log if it's NOT a progress line
|
||||
app.emit("download-log", LogEvent {
|
||||
id: id.clone(),
|
||||
message: out_line.trim().to_string(),
|
||||
level: "info".to_string(),
|
||||
}).ok();
|
||||
}
|
||||
}
|
||||
res = stderr_reader.read_line(&mut err_line) => {
|
||||
if res.unwrap_or(0) > 0 {
|
||||
// Log error
|
||||
app.emit("download-log", LogEvent {
|
||||
id: id.clone(),
|
||||
message: err_line.trim().to_string(),
|
||||
level: "error".to_string(),
|
||||
}).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let status = child.wait().await?;
|
||||
emit_log(&app, &id, trimmed.to_string(), "info");
|
||||
}
|
||||
|
||||
if status.success() {
|
||||
app.emit("download-progress", ProgressEvent {
|
||||
id: id.clone(),
|
||||
progress: 100.0,
|
||||
speed: "-".to_string(),
|
||||
status: "finished".to_string(),
|
||||
}).ok();
|
||||
Ok("Download complete".to_string())
|
||||
Ok::<Option<String>, anyhow::Error>(final_path)
|
||||
})
|
||||
};
|
||||
|
||||
let stderr_task = {
|
||||
let app = app.clone();
|
||||
let id = id.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
let mut last_error: Option<String> = None;
|
||||
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
last_error = Some(trimmed.to_string());
|
||||
emit_log(&app, &id, trimmed.to_string(), "error");
|
||||
}
|
||||
|
||||
Ok::<Option<String>, anyhow::Error>(last_error)
|
||||
})
|
||||
};
|
||||
|
||||
let status = {
|
||||
let mut child = shared_child.lock().await;
|
||||
child.wait().await?
|
||||
};
|
||||
|
||||
task_runtime::unregister_child(&id).await;
|
||||
let was_cancelled = task_runtime::take_cancelled(&id).await;
|
||||
let final_path = stdout_task.await.map_err(|error| anyhow!(error.to_string()))??;
|
||||
let last_error = stderr_task.await.map_err(|error| anyhow!(error.to_string()))??;
|
||||
|
||||
if status.success() && !was_cancelled {
|
||||
let task = storage::update_task_status(&app, &id, "completed", None, final_path.as_deref())?;
|
||||
emit_task(&app, &task);
|
||||
emit_progress(&app, &id, 100.0, "-".to_string(), None, "completed");
|
||||
Ok(final_path)
|
||||
} else {
|
||||
app.emit("download-progress", ProgressEvent {
|
||||
id: id.clone(),
|
||||
progress: 0.0,
|
||||
speed: "-".to_string(),
|
||||
status: "error".to_string(),
|
||||
}).ok();
|
||||
Err(anyhow!("Download process failed"))
|
||||
let (status_name, error_message) = if was_cancelled {
|
||||
("cancelled", Some("任务已取消".to_string()))
|
||||
} else {
|
||||
(
|
||||
"failed",
|
||||
Some(
|
||||
last_error
|
||||
.unwrap_or_else(|| "下载进程失败".to_string()),
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let task = storage::update_task_status(
|
||||
&app,
|
||||
&id,
|
||||
status_name,
|
||||
error_message.as_deref(),
|
||||
final_path.as_deref(),
|
||||
)?;
|
||||
emit_task(&app, &task);
|
||||
emit_progress(&app, &id, task.progress, "-".to_string(), None, status_name);
|
||||
Err(anyhow!(error_message.unwrap_or_else(|| "下载失败".to_string())))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ mod binary_manager;
|
||||
mod downloader;
|
||||
mod storage;
|
||||
mod commands;
|
||||
mod process_utils;
|
||||
mod task_runtime;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -13,15 +15,25 @@ pub fn run() {
|
||||
commands::init_ytdlp,
|
||||
commands::update_ytdlp,
|
||||
commands::update_quickjs,
|
||||
commands::update_ffmpeg,
|
||||
commands::get_ytdlp_version,
|
||||
commands::get_quickjs_version,
|
||||
commands::get_ffmpeg_version,
|
||||
commands::get_runtime_status,
|
||||
commands::fetch_image,
|
||||
commands::fetch_metadata,
|
||||
commands::start_download,
|
||||
commands::cancel_task,
|
||||
commands::retry_task,
|
||||
commands::get_tasks,
|
||||
commands::get_task_logs,
|
||||
commands::clear_task_logs,
|
||||
commands::get_settings,
|
||||
commands::save_settings,
|
||||
commands::get_history,
|
||||
commands::clear_history,
|
||||
commands::delete_history_item,
|
||||
commands::close_splash,
|
||||
commands::open_in_explorer
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
10
src-tauri/src/process_utils.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
use std::process::Output;
|
||||
|
||||
pub fn first_non_empty_line(output: &Output) -> Option<String> {
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.chain(String::from_utf8_lossy(&output.stderr).lines())
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
@@ -1,30 +1,67 @@
|
||||
// filepath: src-tauri/src/storage.rs
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::downloader::{DownloadOptions, VideoMetadata};
|
||||
|
||||
const TERMINAL_STATUSES: &[&str] = &["completed", "failed", "cancelled"];
|
||||
const ACTIVE_STATUSES: &[&str] = &["queued", "preparing", "analyzing", "downloading", "postprocessing"];
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct Settings {
|
||||
pub download_path: String,
|
||||
pub theme: String, // 'light', 'dark', 'system'
|
||||
pub cookies_path: Option<String>,
|
||||
pub theme: String,
|
||||
pub last_updated: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
// We'll resolve the actual download path at runtime if empty,
|
||||
// but for default struct we can keep it empty or a placeholder.
|
||||
Self {
|
||||
download_path: "".to_string(),
|
||||
download_path: String::new(),
|
||||
cookies_path: None,
|
||||
theme: "system".to_string(),
|
||||
last_updated: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TaskRecord {
|
||||
pub id: String,
|
||||
pub source_url: String,
|
||||
pub normalized_url: String,
|
||||
pub extractor: Option<String>,
|
||||
pub site_name: Option<String>,
|
||||
pub title: String,
|
||||
pub thumbnail: String,
|
||||
pub output_path: String,
|
||||
pub file_path: Option<String>,
|
||||
pub status: String,
|
||||
pub progress: f64,
|
||||
pub speed: String,
|
||||
pub eta: Option<String>,
|
||||
pub format: String,
|
||||
pub is_audio_only: bool,
|
||||
pub quality: String,
|
||||
pub output_format: String,
|
||||
pub cookies_path: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl TaskRecord {
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
TERMINAL_STATUSES.contains(&self.status.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct HistoryItem {
|
||||
pub id: String,
|
||||
@@ -32,13 +69,47 @@ pub struct HistoryItem {
|
||||
pub thumbnail: String,
|
||||
pub url: String,
|
||||
pub output_path: String,
|
||||
pub file_path: Option<String>,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub status: String, // "success", "failed"
|
||||
pub status: String,
|
||||
pub format: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TaskLogEntry {
|
||||
pub id: i64,
|
||||
pub task_id: String,
|
||||
pub message: String,
|
||||
pub level: String,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskPayload {
|
||||
pub options: DownloadOptions,
|
||||
pub metadata: VideoMetadata,
|
||||
pub source_url: String,
|
||||
}
|
||||
|
||||
fn to_rfc3339(timestamp: DateTime<Utc>) -> String {
|
||||
timestamp.to_rfc3339()
|
||||
}
|
||||
|
||||
fn parse_datetime(value: Option<String>) -> Result<Option<DateTime<Utc>>> {
|
||||
value
|
||||
.map(|item| {
|
||||
DateTime::parse_from_rfc3339(&item)
|
||||
.map(|timestamp| timestamp.with_timezone(&Utc))
|
||||
.map_err(|error| anyhow!(error))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn parse_required_datetime(value: String) -> Result<DateTime<Utc>> {
|
||||
parse_datetime(Some(value))?.ok_or_else(|| anyhow!("missing datetime"))
|
||||
}
|
||||
|
||||
pub fn get_app_data_dir(app: &AppHandle) -> Result<PathBuf> {
|
||||
// In Tauri v2, we use app.path().app_data_dir()
|
||||
let path = app.path().app_data_dir()?;
|
||||
if !path.exists() {
|
||||
fs::create_dir_all(&path)?;
|
||||
@@ -46,71 +117,513 @@ pub fn get_app_data_dir(app: &AppHandle) -> Result<PathBuf> {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn get_settings_path(app: &AppHandle) -> Result<PathBuf> {
|
||||
fn get_db_path(app: &AppHandle) -> Result<PathBuf> {
|
||||
Ok(get_app_data_dir(app)?.join("stream_capture.db"))
|
||||
}
|
||||
|
||||
fn open_database(app: &AppHandle) -> Result<Connection> {
|
||||
let path = get_db_path(app)?;
|
||||
let connection = Connection::open(path)?;
|
||||
connection.execute_batch(
|
||||
"
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
source_url TEXT NOT NULL,
|
||||
normalized_url TEXT NOT NULL,
|
||||
extractor TEXT,
|
||||
site_name TEXT,
|
||||
title TEXT NOT NULL,
|
||||
thumbnail TEXT NOT NULL,
|
||||
output_path TEXT NOT NULL,
|
||||
file_path TEXT,
|
||||
status TEXT NOT NULL,
|
||||
progress REAL NOT NULL DEFAULT 0,
|
||||
speed TEXT NOT NULL DEFAULT '',
|
||||
eta TEXT,
|
||||
format TEXT NOT NULL,
|
||||
is_audio_only INTEGER NOT NULL,
|
||||
quality TEXT NOT NULL,
|
||||
output_format TEXT NOT NULL,
|
||||
cookies_path TEXT,
|
||||
error_message TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
started_at TEXT,
|
||||
finished_at TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
options_json TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL,
|
||||
level TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_created_at ON tasks(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_logs_task_id ON task_logs(task_id, id);
|
||||
"
|
||||
)?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
pub fn initialize_storage(app: &AppHandle) -> Result<()> {
|
||||
let connection = open_database(app)?;
|
||||
migrate_legacy_files(app, &connection)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_legacy_settings_path(app: &AppHandle) -> Result<PathBuf> {
|
||||
Ok(get_app_data_dir(app)?.join("settings.json"))
|
||||
}
|
||||
|
||||
pub fn get_history_path(app: &AppHandle) -> Result<PathBuf> {
|
||||
fn get_legacy_history_path(app: &AppHandle) -> Result<PathBuf> {
|
||||
Ok(get_app_data_dir(app)?.join("history.json"))
|
||||
}
|
||||
|
||||
pub fn load_settings(app: &AppHandle) -> Result<Settings> {
|
||||
let path = get_settings_path(app)?;
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let settings: Settings = serde_json::from_str(&content)?;
|
||||
return Ok(settings);
|
||||
fn migrate_legacy_files(app: &AppHandle, connection: &Connection) -> Result<()> {
|
||||
let task_count: i64 = connection.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))?;
|
||||
|
||||
let settings_path = get_legacy_settings_path(app)?;
|
||||
if settings_path.exists() {
|
||||
let content = fs::read_to_string(&settings_path)?;
|
||||
let legacy_settings: Settings = serde_json::from_str(&content)?;
|
||||
save_settings(app, &legacy_settings)?;
|
||||
}
|
||||
|
||||
// If not exists, return default.
|
||||
// Note: We might want to set a default download path here if possible.
|
||||
|
||||
if task_count == 0 {
|
||||
let history_path = get_legacy_history_path(app)?;
|
||||
if history_path.exists() {
|
||||
let content = fs::read_to_string(&history_path)?;
|
||||
let legacy_history: Vec<HistoryItem> = serde_json::from_str(&content)?;
|
||||
for item in legacy_history {
|
||||
let metadata = VideoMetadata {
|
||||
id: item.id.clone(),
|
||||
title: item.title.clone(),
|
||||
thumbnail: item.thumbnail.clone(),
|
||||
duration: None,
|
||||
uploader: None,
|
||||
url: Some(item.url.clone()),
|
||||
extractor: None,
|
||||
site_name: None,
|
||||
};
|
||||
let options = DownloadOptions {
|
||||
is_audio_only: false,
|
||||
quality: "best".to_string(),
|
||||
output_path: item.output_path.clone(),
|
||||
output_format: item.format.clone(),
|
||||
cookies_path: None,
|
||||
};
|
||||
let metadata_json = serde_json::to_string(&metadata)?;
|
||||
let options_json = serde_json::to_string(&options)?;
|
||||
let status = match item.status.as_str() {
|
||||
"success" => "completed",
|
||||
other => other,
|
||||
};
|
||||
let timestamp = to_rfc3339(item.timestamp);
|
||||
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO tasks (
|
||||
id, source_url, normalized_url, extractor, site_name, title, thumbnail, output_path,
|
||||
file_path, status, progress, speed, eta, format, is_audio_only, quality, output_format,
|
||||
cookies_path, error_message, created_at, started_at, finished_at, metadata_json, options_json
|
||||
) VALUES (
|
||||
?1, ?2, ?3, NULL, NULL, ?4, ?5, ?6,
|
||||
?7, ?8, 100, '-', NULL, ?9, 0, 'best', ?10,
|
||||
'', NULL, ?11, ?11, ?11, ?12, ?13
|
||||
)",
|
||||
params![
|
||||
item.id,
|
||||
item.url,
|
||||
item.url,
|
||||
item.title,
|
||||
item.thumbnail,
|
||||
item.output_path,
|
||||
item.file_path,
|
||||
status,
|
||||
item.format,
|
||||
item.format,
|
||||
timestamp,
|
||||
metadata_json,
|
||||
options_json
|
||||
],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_setting_value(connection: &Connection, key: &str) -> Result<Option<String>> {
|
||||
let value = connection
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
[key],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn save_setting_value(connection: &Connection, key: &str, value: &str) -> Result<()> {
|
||||
connection.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_settings(app: &AppHandle) -> Result<Settings> {
|
||||
let connection = open_database(app)?;
|
||||
let mut settings = Settings::default();
|
||||
|
||||
if let Ok(download_dir) = app.path().download_dir() {
|
||||
settings.download_path = download_dir.to_string_lossy().to_string();
|
||||
}
|
||||
|
||||
if let Some(download_path) = load_setting_value(&connection, "download_path")? {
|
||||
settings.download_path = download_path;
|
||||
}
|
||||
settings.cookies_path = normalize_optional_string(load_setting_value(&connection, "cookies_path")?);
|
||||
if let Some(theme) = load_setting_value(&connection, "theme")? {
|
||||
settings.theme = theme;
|
||||
}
|
||||
settings.last_updated = parse_datetime(load_setting_value(&connection, "last_updated")?)?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub fn save_settings(app: &AppHandle, settings: &Settings) -> Result<()> {
|
||||
let path = get_settings_path(app)?;
|
||||
let content = serde_json::to_string_pretty(settings)?;
|
||||
fs::write(path, content)?;
|
||||
let connection = open_database(app)?;
|
||||
save_setting_value(&connection, "download_path", &settings.download_path)?;
|
||||
save_setting_value(
|
||||
&connection,
|
||||
"cookies_path",
|
||||
settings.cookies_path.as_deref().unwrap_or(""),
|
||||
)?;
|
||||
save_setting_value(&connection, "theme", &settings.theme)?;
|
||||
save_setting_value(
|
||||
&connection,
|
||||
"last_updated",
|
||||
&settings.last_updated.map(to_rfc3339).unwrap_or_default(),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<TaskRecord> {
|
||||
let created_at: String = row.get("created_at")?;
|
||||
let started_at: Option<String> = row.get("started_at")?;
|
||||
let finished_at: Option<String> = row.get("finished_at")?;
|
||||
|
||||
Ok(TaskRecord {
|
||||
id: row.get("id")?,
|
||||
source_url: row.get("source_url")?,
|
||||
normalized_url: row.get("normalized_url")?,
|
||||
extractor: row.get("extractor")?,
|
||||
site_name: row.get("site_name")?,
|
||||
title: row.get("title")?,
|
||||
thumbnail: row.get("thumbnail")?,
|
||||
output_path: row.get("output_path")?,
|
||||
file_path: row.get("file_path")?,
|
||||
status: row.get("status")?,
|
||||
progress: row.get("progress")?,
|
||||
speed: row.get("speed")?,
|
||||
eta: row.get("eta")?,
|
||||
format: row.get("format")?,
|
||||
is_audio_only: row.get::<_, i64>("is_audio_only")? != 0,
|
||||
quality: row.get("quality")?,
|
||||
output_format: row.get("output_format")?,
|
||||
cookies_path: normalize_optional_string(row.get("cookies_path")?),
|
||||
error_message: normalize_optional_string(row.get("error_message")?),
|
||||
created_at: parse_required_datetime(created_at)
|
||||
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(error.into()))?,
|
||||
started_at: parse_datetime(started_at)
|
||||
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(error.into()))?,
|
||||
finished_at: parse_datetime(finished_at)
|
||||
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(error.into()))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn load_task_with_connection(connection: &Connection, id: &str) -> Result<TaskRecord> {
|
||||
connection
|
||||
.query_row("SELECT * FROM tasks WHERE id = ?1", [id], task_from_row)
|
||||
.with_context(|| format!("task not found: {id}"))
|
||||
}
|
||||
|
||||
pub fn list_tasks(app: &AppHandle) -> Result<Vec<TaskRecord>> {
|
||||
let connection = open_database(app)?;
|
||||
let mut stmt = connection.prepare("SELECT * FROM tasks ORDER BY created_at DESC")?;
|
||||
let rows = stmt.query_map([], task_from_row)?;
|
||||
let mut tasks = Vec::new();
|
||||
for row in rows {
|
||||
tasks.push(row?);
|
||||
}
|
||||
Ok(tasks)
|
||||
}
|
||||
|
||||
pub fn create_task(
|
||||
app: &AppHandle,
|
||||
id: &str,
|
||||
source_url: &str,
|
||||
normalized_url: &str,
|
||||
options: &DownloadOptions,
|
||||
metadata: &VideoMetadata,
|
||||
) -> Result<TaskRecord> {
|
||||
let connection = open_database(app)?;
|
||||
let now = Utc::now();
|
||||
let metadata_json = serde_json::to_string(metadata)?;
|
||||
let options_json = serde_json::to_string(options)?;
|
||||
let format = if options.output_format == "original" {
|
||||
if options.is_audio_only {
|
||||
"audio-original".to_string()
|
||||
} else {
|
||||
"video-original".to_string()
|
||||
}
|
||||
} else {
|
||||
options.output_format.clone()
|
||||
};
|
||||
|
||||
connection.execute(
|
||||
"INSERT INTO tasks (
|
||||
id, source_url, normalized_url, extractor, site_name, title, thumbnail, output_path,
|
||||
file_path, status, progress, speed, eta, format, is_audio_only, quality, output_format,
|
||||
cookies_path, error_message, created_at, started_at, finished_at, metadata_json, options_json
|
||||
) VALUES (
|
||||
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8,
|
||||
NULL, 'queued', 0, '', NULL, ?9, ?10, ?11, ?12,
|
||||
?13, NULL, ?14, NULL, NULL, ?15, ?16
|
||||
)",
|
||||
params![
|
||||
id,
|
||||
source_url,
|
||||
normalized_url,
|
||||
metadata.extractor.clone(),
|
||||
metadata.site_name.clone(),
|
||||
metadata.title,
|
||||
metadata.thumbnail,
|
||||
options.output_path,
|
||||
format,
|
||||
if options.is_audio_only { 1 } else { 0 },
|
||||
options.quality,
|
||||
options.output_format,
|
||||
options.cookies_path.clone().unwrap_or_default(),
|
||||
to_rfc3339(now),
|
||||
metadata_json,
|
||||
options_json
|
||||
],
|
||||
)?;
|
||||
|
||||
load_task_with_connection(&connection, id)
|
||||
}
|
||||
|
||||
pub fn update_task_status(
|
||||
app: &AppHandle,
|
||||
id: &str,
|
||||
status: &str,
|
||||
error_message: Option<&str>,
|
||||
file_path: Option<&str>,
|
||||
) -> Result<TaskRecord> {
|
||||
let connection = open_database(app)?;
|
||||
let started_at = if status == "preparing" {
|
||||
Some(to_rfc3339(Utc::now()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let finished_at = if TERMINAL_STATUSES.contains(&status) {
|
||||
Some(to_rfc3339(Utc::now()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
connection.execute(
|
||||
"UPDATE tasks
|
||||
SET status = ?2,
|
||||
error_message = COALESCE(?3, error_message),
|
||||
file_path = COALESCE(?4, file_path),
|
||||
started_at = COALESCE(?5, started_at),
|
||||
finished_at = CASE WHEN ?6 IS NOT NULL THEN ?6 ELSE finished_at END
|
||||
WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
status,
|
||||
error_message,
|
||||
file_path,
|
||||
started_at,
|
||||
finished_at
|
||||
],
|
||||
)?;
|
||||
|
||||
load_task_with_connection(&connection, id)
|
||||
}
|
||||
|
||||
pub fn update_task_progress(
|
||||
app: &AppHandle,
|
||||
id: &str,
|
||||
progress: f64,
|
||||
speed: &str,
|
||||
eta: Option<&str>,
|
||||
status: &str,
|
||||
) -> Result<TaskRecord> {
|
||||
let connection = open_database(app)?;
|
||||
connection.execute(
|
||||
"UPDATE tasks
|
||||
SET progress = ?2,
|
||||
speed = ?3,
|
||||
eta = ?4,
|
||||
status = ?5,
|
||||
started_at = COALESCE(started_at, ?6)
|
||||
WHERE id = ?1",
|
||||
params![id, progress, speed, eta, status, to_rfc3339(Utc::now())],
|
||||
)?;
|
||||
load_task_with_connection(&connection, id)
|
||||
}
|
||||
|
||||
pub fn get_task_payload(app: &AppHandle, id: &str) -> Result<TaskPayload> {
|
||||
let connection = open_database(app)?;
|
||||
let row = connection.query_row(
|
||||
"SELECT source_url, metadata_json, options_json FROM tasks WHERE id = ?1",
|
||||
[id],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(TaskPayload {
|
||||
source_url: row.0,
|
||||
metadata: serde_json::from_str(&row.1)?,
|
||||
options: serde_json::from_str(&row.2)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_history(app: &AppHandle) -> Result<Vec<HistoryItem>> {
|
||||
let path = get_history_path(app)?;
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let history: Vec<HistoryItem> = serde_json::from_str(&content)?;
|
||||
Ok(history)
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
let tasks = list_tasks(app)?;
|
||||
let mut history = Vec::new();
|
||||
|
||||
for task in tasks.into_iter().filter(TaskRecord::is_terminal) {
|
||||
history.push(HistoryItem {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
thumbnail: task.thumbnail,
|
||||
url: task.source_url,
|
||||
output_path: task.output_path,
|
||||
file_path: task.file_path,
|
||||
timestamp: task.finished_at.unwrap_or(task.created_at),
|
||||
status: task.status,
|
||||
format: task.format,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_history(app: &AppHandle, history: &[HistoryItem]) -> Result<()> {
|
||||
let path = get_history_path(app)?;
|
||||
let content = serde_json::to_string_pretty(history)?;
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_history_item(app: &AppHandle, item: HistoryItem) -> Result<()> {
|
||||
let mut history = load_history(app)?;
|
||||
// Prepend
|
||||
history.insert(0, item);
|
||||
save_history(app, &history)?;
|
||||
Ok(())
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
pub fn clear_history(app: &AppHandle) -> Result<()> {
|
||||
save_history(app, &[])
|
||||
let connection = open_database(app)?;
|
||||
let placeholders = TERMINAL_STATUSES
|
||||
.iter()
|
||||
.map(|_| "?")
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let query = format!("DELETE FROM tasks WHERE status IN ({placeholders})");
|
||||
connection.execute(
|
||||
&query,
|
||||
rusqlite::params_from_iter(TERMINAL_STATUSES.iter().copied()),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_history_item(app: &AppHandle, id: &str) -> Result<()> {
|
||||
let mut history = load_history(app)?;
|
||||
history.retain(|item| item.id != id);
|
||||
save_history(app, &history)?;
|
||||
let connection = open_database(app)?;
|
||||
connection.execute("DELETE FROM tasks WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_log_entry(app: &AppHandle, task_id: &str, level: &str, message: &str) -> Result<()> {
|
||||
let connection = open_database(app)?;
|
||||
connection.execute(
|
||||
"INSERT INTO task_logs (task_id, level, message, timestamp) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![task_id, level, message, to_rfc3339(Utc::now())],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_logs(app: &AppHandle) -> Result<Vec<TaskLogEntry>> {
|
||||
let connection = open_database(app)?;
|
||||
let mut stmt = connection.prepare(
|
||||
"SELECT id, task_id, message, level, timestamp FROM task_logs ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let timestamp: String = row.get(4)?;
|
||||
Ok(TaskLogEntry {
|
||||
id: row.get(0)?,
|
||||
task_id: row.get(1)?,
|
||||
message: row.get(2)?,
|
||||
level: row.get(3)?,
|
||||
timestamp: parse_required_datetime(timestamp)
|
||||
.map_err(|error| rusqlite::Error::ToSqlConversionFailure(error.into()))?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut logs = Vec::new();
|
||||
for row in rows {
|
||||
logs.push(row?);
|
||||
}
|
||||
Ok(logs)
|
||||
}
|
||||
|
||||
pub fn clear_logs(app: &AppHandle) -> Result<()> {
|
||||
let connection = open_database(app)?;
|
||||
connection.execute("DELETE FROM task_logs", [])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn recover_incomplete_tasks(app: &AppHandle) -> Result<Vec<TaskRecord>> {
|
||||
let connection = open_database(app)?;
|
||||
let now = to_rfc3339(Utc::now());
|
||||
let placeholders = ACTIVE_STATUSES
|
||||
.iter()
|
||||
.map(|_| "?")
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let update = format!(
|
||||
"UPDATE tasks
|
||||
SET status = 'failed',
|
||||
error_message = COALESCE(error_message, '应用重启导致任务中断'),
|
||||
finished_at = COALESCE(finished_at, ?1)
|
||||
WHERE status IN ({placeholders})"
|
||||
);
|
||||
|
||||
let mut values: Vec<String> = Vec::with_capacity(ACTIVE_STATUSES.len() + 1);
|
||||
values.push(now);
|
||||
values.extend(ACTIVE_STATUSES.iter().map(|item| item.to_string()));
|
||||
connection.execute(&update, rusqlite::params_from_iter(values.iter()))?;
|
||||
|
||||
list_tasks(app)
|
||||
}
|
||||
|
||||
42
src-tauri/src/task_runtime.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::process::Child;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
type SharedChild = Arc<Mutex<Child>>;
|
||||
|
||||
static ACTIVE_TASKS: LazyLock<Mutex<HashMap<String, SharedChild>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static CANCELLED_TASKS: LazyLock<Mutex<HashSet<String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashSet::new()));
|
||||
|
||||
pub async fn register_child(id: &str, child: SharedChild) {
|
||||
ACTIVE_TASKS.lock().await.insert(id.to_string(), child);
|
||||
}
|
||||
|
||||
pub async fn unregister_child(id: &str) {
|
||||
ACTIVE_TASKS.lock().await.remove(id);
|
||||
}
|
||||
|
||||
pub async fn cancel_task(id: &str) -> Result<bool> {
|
||||
CANCELLED_TASKS.lock().await.insert(id.to_string());
|
||||
|
||||
let child = {
|
||||
let tasks = ACTIVE_TASKS.lock().await;
|
||||
tasks.get(id).cloned()
|
||||
};
|
||||
|
||||
if let Some(child) = child {
|
||||
let mut child = child.lock().await;
|
||||
child.start_kill()?;
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn take_cancelled(id: &str) -> bool {
|
||||
CANCELLED_TASKS.lock().await.remove(id)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "stream-capture",
|
||||
"version": "0.1.0",
|
||||
"productName": "StreamCapture",
|
||||
"version": "1.2.1",
|
||||
"identifier": "top.volan.stream-capture",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -12,13 +12,26 @@
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "stream-capture",
|
||||
"label": "main",
|
||||
"title": "流萤 - 视频下载 v1.2.1",
|
||||
"width": 1300,
|
||||
"height": 850
|
||||
"height": 900,
|
||||
"visible": false
|
||||
},
|
||||
{
|
||||
"label": "splashscreen",
|
||||
"title": "StreamCapture Loading",
|
||||
"url": "splashscreen.html",
|
||||
"width": 400,
|
||||
"height": 300,
|
||||
"decorations": false,
|
||||
"center": true,
|
||||
"resizable": false,
|
||||
"alwaysOnTop": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self' asset: http://asset.localhost https://asset.localhost; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; connect-src 'self' ipc: http://ipc.localhost https://ipc.localhost ws://localhost:1420 http://localhost:1420 https:; font-src 'self' asset: http://asset.localhost https://asset.localhost data:;"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
24
src/App.vue
@@ -1,6 +1,5 @@
|
||||
// filepath: src/App.vue
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { RouterView, RouterLink, useRoute } from 'vue-router'
|
||||
import { Home, History, Settings as SettingsIcon, Download, FileText } from 'lucide-vue-next'
|
||||
import { useSettingsStore } from './stores/settings'
|
||||
@@ -14,21 +13,28 @@ const route = useRoute()
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.loadSettings()
|
||||
settingsStore.initThemeListener()
|
||||
await settingsStore.initYtdlp()
|
||||
await queueStore.initListener()
|
||||
await logsStore.initListener()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
settingsStore.disposeThemeListener()
|
||||
queueStore.disposeListener()
|
||||
logsStore.disposeListener()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen bg-gray-50 dark:bg-zinc-950 text-zinc-900 dark:text-gray-100 font-sans overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-20 lg:w-64 bg-white dark:bg-zinc-900 border-r border-gray-200 dark:border-zinc-800 flex flex-col flex-shrink-0">
|
||||
<aside class="w-20 lg:w-48 bg-white dark:bg-zinc-900 border-r border-gray-200 dark:border-zinc-800 flex flex-col flex-shrink-0">
|
||||
<div class="p-6 flex items-center gap-3 justify-center lg:justify-start">
|
||||
<div class="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center text-white shrink-0">
|
||||
<Download class="w-5 h-5" />
|
||||
</div>
|
||||
<span class="font-bold text-lg hidden lg:block">StreamCapture</span>
|
||||
<span class="font-bold text-lg hidden lg:block">流萤</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 px-4 space-y-2 mt-4 flex flex-col pb-6">
|
||||
@@ -38,7 +44,7 @@ onMounted(async () => {
|
||||
:class="route.path === '/' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400' : 'hover:bg-gray-100 dark:hover:bg-zinc-800'"
|
||||
>
|
||||
<Home class="w-5 h-5 shrink-0" />
|
||||
<span class="hidden lg:block font-medium">Downloader</span>
|
||||
<span class="hidden lg:block font-medium">下载</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/history"
|
||||
@@ -46,7 +52,7 @@ onMounted(async () => {
|
||||
:class="route.path === '/history' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400' : 'hover:bg-gray-100 dark:hover:bg-zinc-800'"
|
||||
>
|
||||
<History class="w-5 h-5 shrink-0" />
|
||||
<span class="hidden lg:block font-medium">History</span>
|
||||
<span class="hidden lg:block font-medium">历史</span>
|
||||
</RouterLink>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
@@ -57,7 +63,7 @@ onMounted(async () => {
|
||||
:class="route.path === '/logs' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400' : 'hover:bg-gray-100 dark:hover:bg-zinc-800'"
|
||||
>
|
||||
<FileText class="w-5 h-5 shrink-0" />
|
||||
<span class="hidden lg:block font-medium">Logs</span>
|
||||
<span class="hidden lg:block font-medium">日志</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink to="/settings"
|
||||
@@ -65,7 +71,7 @@ onMounted(async () => {
|
||||
:class="route.path === '/settings' ? 'bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400' : 'hover:bg-gray-100 dark:hover:bg-zinc-800'"
|
||||
>
|
||||
<SettingsIcon class="w-5 h-5 shrink-0" />
|
||||
<span class="hidden lg:block font-medium">Settings</span>
|
||||
<span class="hidden lg:block font-medium">设置</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -75,4 +81,4 @@ onMounted(async () => {
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
44
src/splash/App.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Loader2, DownloadCloud, CheckCircle2, AlertCircle } from 'lucide-vue-next'
|
||||
|
||||
const status = ref('正在初始化...')
|
||||
const isError = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
status.value = '正在检查运行环境...'
|
||||
// This command checks and downloads binaries if missing
|
||||
await invoke('init_ytdlp')
|
||||
|
||||
status.value = '准备就绪'
|
||||
setTimeout(async () => {
|
||||
await invoke('close_splash')
|
||||
}, 800)
|
||||
} catch (e: any) {
|
||||
status.value = '启动错误: ' + (e.toString() || '未知错误')
|
||||
isError.value = true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-screen w-screen bg-white dark:bg-zinc-900 flex flex-col items-center justify-center select-none cursor-default p-8 text-center overflow-hidden" data-tauri-drag-region>
|
||||
<div class="mb-6 relative w-20 h-20 bg-blue-600 rounded-2xl shadow-xl flex items-center justify-center text-white">
|
||||
<DownloadCloud v-if="!isError" class="w-10 h-10" />
|
||||
<AlertCircle v-else class="w-10 h-10" />
|
||||
</div>
|
||||
|
||||
<h1 class="text-xl font-bold text-zinc-900 dark:text-white mb-6">流萤 - 视频下载</h1>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 w-full max-w-xs">
|
||||
<div class="flex items-center gap-2.5 text-sm font-medium transition-colors duration-300"
|
||||
:class="isError ? 'text-red-500' : 'text-gray-600 dark:text-gray-300'">
|
||||
<Loader2 v-if="!isError && status !== '准备就绪'" class="animate-spin w-4 h-4" />
|
||||
<CheckCircle2 v-if="status === '准备就绪'" class="w-4 h-4 text-green-500" />
|
||||
<span>{{ status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
5
src/splash/main.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import '../style.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
@@ -1,23 +1,57 @@
|
||||
// filepath: src/stores/analysis.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { AnalysisMetadata } from '../types/media'
|
||||
import { isPlaylistMetadata } from '../types/media'
|
||||
|
||||
export const useAnalysisStore = defineStore('analysis', () => {
|
||||
const url = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const metadata = ref<any>(null)
|
||||
const metadata = ref<AnalysisMetadata | null>(null)
|
||||
|
||||
// New state for mix detection
|
||||
const isMix = ref(false)
|
||||
const scanMix = ref(false)
|
||||
|
||||
// Input mode state
|
||||
const isBatchMode = ref(false)
|
||||
|
||||
const options = ref({
|
||||
is_audio_only: false,
|
||||
quality: 'best',
|
||||
output_path: ''
|
||||
output_path: '',
|
||||
output_format: 'original',
|
||||
cookies_path: ''
|
||||
})
|
||||
|
||||
function toggleEntry(id: string) {
|
||||
if (isPlaylistMetadata(metadata.value)) {
|
||||
const entry = metadata.value.entries.find(e => e.id === id)
|
||||
if (entry) {
|
||||
entry.selected = !entry.selected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAllEntries(selected: boolean) {
|
||||
if (isPlaylistMetadata(metadata.value)) {
|
||||
metadata.value.entries = metadata.value.entries.map(e => ({
|
||||
...e,
|
||||
selected
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function invertSelection() {
|
||||
if (isPlaylistMetadata(metadata.value)) {
|
||||
metadata.value.entries = metadata.value.entries.map(e => ({
|
||||
...e,
|
||||
selected: !e.selected
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
url.value = ''
|
||||
loading.value = false
|
||||
@@ -27,5 +61,5 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
||||
scanMix.value = false
|
||||
}
|
||||
|
||||
return { url, loading, error, metadata, options, isMix, scanMix, reset }
|
||||
})
|
||||
return { url, loading, error, metadata, options, isMix, scanMix, isBatchMode, toggleEntry, setAllEntries, invertSelection, reset }
|
||||
})
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
// filepath: src/stores/logs.ts
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import type { TaskLogEntry } from '../types/task'
|
||||
|
||||
interface LogEvent {
|
||||
id: string
|
||||
message: string
|
||||
level: 'info' | 'error'
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
id: string
|
||||
@@ -11,15 +18,25 @@ export interface LogEntry {
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
interface LogEvent {
|
||||
id: string
|
||||
message: string
|
||||
level: string
|
||||
function mapTaskLog(entry: TaskLogEntry): LogEntry {
|
||||
return {
|
||||
id: `${entry.id}`,
|
||||
taskId: entry.task_id,
|
||||
message: entry.message,
|
||||
level: entry.level,
|
||||
timestamp: new Date(entry.timestamp).getTime()
|
||||
}
|
||||
}
|
||||
|
||||
export const useLogsStore = defineStore('logs', () => {
|
||||
const logs = ref<LogEntry[]>([])
|
||||
const isListening = ref(false)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
async function loadLogs() {
|
||||
const result = await invoke<TaskLogEntry[]>('get_task_logs')
|
||||
logs.value = result.map(mapTaskLog)
|
||||
}
|
||||
|
||||
function addLog(taskId: string, message: string, level: 'info' | 'error') {
|
||||
logs.value.push({
|
||||
@@ -29,26 +46,37 @@ export const useLogsStore = defineStore('logs', () => {
|
||||
level,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
// Optional: Limit log size to avoid memory issues
|
||||
|
||||
if (logs.value.length > 5000) {
|
||||
logs.value = logs.value.slice(-5000)
|
||||
logs.value = logs.value.slice(-5000)
|
||||
}
|
||||
}
|
||||
|
||||
async function initListener() {
|
||||
if (isListening.value) return
|
||||
isListening.value = true
|
||||
|
||||
await listen<LogEvent>('download-log', (event) => {
|
||||
|
||||
await loadLogs()
|
||||
|
||||
unlisten = await listen<LogEvent>('download-log', (event) => {
|
||||
const { id, message, level } = event.payload
|
||||
addLog(id, message, level as 'info' | 'error')
|
||||
addLog(id, message, level)
|
||||
})
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = []
|
||||
|
||||
async function clearLogs() {
|
||||
await invoke('clear_task_logs')
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
return { logs, addLog, initListener, clearLogs }
|
||||
const autoScroll = ref(true)
|
||||
const scrollTop = ref(0)
|
||||
|
||||
function disposeListener() {
|
||||
unlisten?.()
|
||||
unlisten = null
|
||||
isListening.value = false
|
||||
}
|
||||
|
||||
return { logs, initListener, clearLogs, disposeListener, autoScroll, scrollTop }
|
||||
})
|
||||
|
||||
@@ -1,47 +1,76 @@
|
||||
// filepath: src/stores/queue.ts
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
|
||||
export interface DownloadTask {
|
||||
id: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
progress: number
|
||||
speed: string
|
||||
status: 'pending' | 'downloading' | 'finished' | 'error'
|
||||
}
|
||||
import type { TaskRecord } from '../types/task'
|
||||
|
||||
interface ProgressEvent {
|
||||
id: string
|
||||
progress: number
|
||||
speed: string
|
||||
status: string
|
||||
eta?: string | null
|
||||
status: TaskRecord['status']
|
||||
}
|
||||
|
||||
export const useQueueStore = defineStore('queue', () => {
|
||||
const tasks = ref<DownloadTask[]>([])
|
||||
const tasks = ref<TaskRecord[]>([])
|
||||
const isListening = ref(false)
|
||||
let progressUnlisten: (() => void) | null = null
|
||||
let taskUnlisten: (() => void) | null = null
|
||||
|
||||
function addTask(task: DownloadTask) {
|
||||
tasks.value.push(task)
|
||||
const activeTasks = computed(() => tasks.value.filter(task => !['completed', 'failed', 'cancelled'].includes(task.status)))
|
||||
const recentTasks = computed(() => tasks.value.slice(0, 12))
|
||||
const terminalTasks = computed(() => tasks.value.filter(task => ['completed', 'failed', 'cancelled'].includes(task.status)).slice(0, 8))
|
||||
|
||||
function upsertTask(task: TaskRecord) {
|
||||
const index = tasks.value.findIndex(item => item.id === task.id)
|
||||
if (index === -1) {
|
||||
tasks.value.unshift(task)
|
||||
return
|
||||
}
|
||||
tasks.value[index] = task
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
const result = await invoke<TaskRecord[]>('get_tasks')
|
||||
tasks.value = result
|
||||
}
|
||||
|
||||
async function initListener() {
|
||||
if (isListening.value) return
|
||||
isListening.value = true
|
||||
|
||||
await listen<ProgressEvent>('download-progress', (event) => {
|
||||
const { id, progress, speed, status } = event.payload
|
||||
const task = tasks.value.find(t => t.id === id)
|
||||
if (task) {
|
||||
task.progress = progress
|
||||
task.speed = speed
|
||||
// Map status string to type if needed, or just assign
|
||||
task.status = status as any
|
||||
}
|
||||
|
||||
await loadTasks()
|
||||
|
||||
taskUnlisten = await listen<TaskRecord>('task-updated', (event) => {
|
||||
upsertTask(event.payload)
|
||||
})
|
||||
|
||||
progressUnlisten = await listen<ProgressEvent>('download-progress', (event) => {
|
||||
const task = tasks.value.find(item => item.id === event.payload.id)
|
||||
if (!task) return
|
||||
task.progress = event.payload.progress
|
||||
task.speed = event.payload.speed
|
||||
task.eta = event.payload.eta || null
|
||||
task.status = event.payload.status
|
||||
})
|
||||
}
|
||||
|
||||
return { tasks, addTask, initListener }
|
||||
async function cancelTask(id: string) {
|
||||
await invoke('cancel_task', { id })
|
||||
}
|
||||
|
||||
async function retryTask(id: string) {
|
||||
return invoke<string>('retry_task', { id })
|
||||
}
|
||||
|
||||
function disposeListener() {
|
||||
progressUnlisten?.()
|
||||
taskUnlisten?.()
|
||||
progressUnlisten = null
|
||||
taskUnlisten = null
|
||||
isListening.value = false
|
||||
}
|
||||
|
||||
return { tasks, activeTasks, recentTasks, terminalTasks, initListener, loadTasks, cancelTask, retryTask, disposeListener }
|
||||
})
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ref } from 'vue'
|
||||
import type { RuntimeStatus } from '../types/task'
|
||||
|
||||
export interface Settings {
|
||||
download_path: string
|
||||
cookies_path?: string
|
||||
theme: 'light' | 'dark' | 'system'
|
||||
last_updated: string | null
|
||||
}
|
||||
@@ -12,13 +14,19 @@ export interface Settings {
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const settings = ref<Settings>({
|
||||
download_path: '',
|
||||
cookies_path: '',
|
||||
theme: 'system',
|
||||
last_updated: null
|
||||
})
|
||||
|
||||
const ytdlpVersion = ref('Checking...')
|
||||
const quickjsVersion = ref('Checking...')
|
||||
const ffmpegVersion = ref('Checking...')
|
||||
const runtimeStatus = ref<RuntimeStatus | null>(null)
|
||||
const isInitializing = ref(true)
|
||||
const hasInitialized = ref(false)
|
||||
let mediaQuery: MediaQueryList | null = null
|
||||
let mediaListener: (() => void) | null = null
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
@@ -40,23 +48,35 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
|
||||
async function initYtdlp() {
|
||||
if (hasInitialized.value) return
|
||||
try {
|
||||
isInitializing.value = true
|
||||
// check/download
|
||||
await invoke('init_ytdlp')
|
||||
await refreshVersions()
|
||||
hasInitialized.value = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ytdlpVersion.value = 'Error'
|
||||
quickjsVersion.value = 'Error'
|
||||
ffmpegVersion.value = 'Error'
|
||||
} finally {
|
||||
isInitializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshVersions() {
|
||||
ytdlpVersion.value = await invoke('get_ytdlp_version')
|
||||
quickjsVersion.value = await invoke('get_quickjs_version')
|
||||
const [ytdlp, quickjs, ffmpeg, runtime] = await Promise.allSettled([
|
||||
invoke<string>('get_ytdlp_version'),
|
||||
invoke<string>('get_quickjs_version'),
|
||||
invoke<string>('get_ffmpeg_version'),
|
||||
invoke<RuntimeStatus>('get_runtime_status')
|
||||
])
|
||||
|
||||
ytdlpVersion.value = ytdlp.status === 'fulfilled' ? ytdlp.value : 'Error'
|
||||
quickjsVersion.value = quickjs.status === 'fulfilled' ? quickjs.value : 'Error'
|
||||
ffmpegVersion.value = ffmpeg.status === 'fulfilled' ? ffmpeg.value : 'Error'
|
||||
runtimeStatus.value = runtime.status === 'fulfilled' ? runtime.value : null
|
||||
}
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
@@ -69,12 +89,37 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Watch system preference changes if theme is system
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
function initThemeListener() {
|
||||
if (mediaQuery) return
|
||||
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaListener = () => {
|
||||
if (settings.value.theme === 'system') {
|
||||
applyTheme('system')
|
||||
applyTheme('system')
|
||||
}
|
||||
})
|
||||
}
|
||||
mediaQuery.addEventListener('change', mediaListener)
|
||||
}
|
||||
|
||||
return { settings, loadSettings, save, initYtdlp, refreshVersions, ytdlpVersion, quickjsVersion, isInitializing }
|
||||
})
|
||||
function disposeThemeListener() {
|
||||
if (mediaQuery && mediaListener) {
|
||||
mediaQuery.removeEventListener('change', mediaListener)
|
||||
}
|
||||
mediaQuery = null
|
||||
mediaListener = null
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
loadSettings,
|
||||
save,
|
||||
initYtdlp,
|
||||
refreshVersions,
|
||||
initThemeListener,
|
||||
disposeThemeListener,
|
||||
ytdlpVersion,
|
||||
quickjsVersion,
|
||||
ffmpegVersion,
|
||||
runtimeStatus,
|
||||
isInitializing
|
||||
}
|
||||
})
|
||||
|
||||
24
src/types/media.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface VideoMetadata {
|
||||
id: string
|
||||
title: string
|
||||
thumbnail: string
|
||||
duration?: number | null
|
||||
uploader?: string | null
|
||||
url?: string | null
|
||||
}
|
||||
|
||||
export interface SelectableVideoMetadata extends VideoMetadata {
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
export interface PlaylistMetadata {
|
||||
id: string
|
||||
title: string
|
||||
entries: SelectableVideoMetadata[]
|
||||
}
|
||||
|
||||
export type AnalysisMetadata = VideoMetadata | PlaylistMetadata
|
||||
|
||||
export function isPlaylistMetadata(metadata: AnalysisMetadata | null): metadata is PlaylistMetadata {
|
||||
return !!metadata && Array.isArray((metadata as PlaylistMetadata).entries)
|
||||
}
|
||||
39
src/types/task.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export interface TaskRecord {
|
||||
id: string
|
||||
source_url: string
|
||||
normalized_url: string
|
||||
extractor?: string | null
|
||||
site_name?: string | null
|
||||
title: string
|
||||
thumbnail: string
|
||||
output_path: string
|
||||
file_path?: string | null
|
||||
status: 'queued' | 'preparing' | 'analyzing' | 'downloading' | 'postprocessing' | 'completed' | 'failed' | 'cancelled'
|
||||
progress: number
|
||||
speed: string
|
||||
eta?: string | null
|
||||
format: string
|
||||
is_audio_only: boolean
|
||||
quality: string
|
||||
output_format: string
|
||||
cookies_path?: string | null
|
||||
error_message?: string | null
|
||||
created_at: string
|
||||
started_at?: string | null
|
||||
finished_at?: string | null
|
||||
}
|
||||
|
||||
export interface TaskLogEntry {
|
||||
id: number
|
||||
task_id: string
|
||||
message: string
|
||||
level: 'info' | 'error'
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface RuntimeStatus {
|
||||
ffmpeg_source: 'system' | 'managed' | 'unavailable'
|
||||
ffmpeg_version: string
|
||||
js_runtime_name: string
|
||||
js_runtime_source: 'system' | 'managed' | 'unavailable'
|
||||
}
|
||||
45
src/utils/analysis.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PlaylistMetadata, SelectableVideoMetadata, VideoMetadata } from '../types/media'
|
||||
|
||||
export function detectMixUrl(url: string): boolean {
|
||||
return url.includes('v=') && url.includes('list=')
|
||||
}
|
||||
|
||||
export function stripPlaylistContext(rawUrl: string): string {
|
||||
try {
|
||||
const url = new URL(rawUrl)
|
||||
url.searchParams.delete('list')
|
||||
url.searchParams.delete('index')
|
||||
url.searchParams.delete('start_radio')
|
||||
return url.toString()
|
||||
} catch {
|
||||
return rawUrl
|
||||
.replace(/[?&]list=[^&]+/g, '')
|
||||
.replace(/[?&]index=[^&]+/g, '')
|
||||
.replace(/[?&]start_radio=[^&]+/g, '')
|
||||
.replace('?&', '?')
|
||||
.replace(/&&+/g, '&')
|
||||
.replace(/[?&]$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
export function isLikelyHttpUrl(input: string): boolean {
|
||||
return input.startsWith('http://') || input.startsWith('https://')
|
||||
}
|
||||
|
||||
export function normalizeBatchLinks(input: string): string[] {
|
||||
return input
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.filter(isLikelyHttpUrl)
|
||||
.filter(link => !link.includes('list=') || detectMixUrl(link))
|
||||
.map(link => detectMixUrl(link) ? stripPlaylistContext(link) : link)
|
||||
}
|
||||
|
||||
export function toSelectableEntries(entries: VideoMetadata[]): SelectableVideoMetadata[] {
|
||||
return entries.map(entry => ({ ...entry, selected: true }))
|
||||
}
|
||||
|
||||
export function countSelectedEntries(metadata: PlaylistMetadata | null): number {
|
||||
return metadata?.entries.filter(entry => entry.selected).length ?? 0
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
// filepath: src/views/History.vue
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Trash2, FolderOpen } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Trash2, FolderOpen, RotateCcw, FileText } from 'lucide-vue-next'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
|
||||
interface HistoryItem {
|
||||
id: string
|
||||
@@ -11,12 +12,14 @@ interface HistoryItem {
|
||||
thumbnail: string
|
||||
url: string
|
||||
output_path: string
|
||||
file_path?: string | null
|
||||
timestamp: string
|
||||
status: string
|
||||
format: string
|
||||
}
|
||||
|
||||
const history = ref<HistoryItem[]>([])
|
||||
const router = useRouter()
|
||||
|
||||
async function loadHistory() {
|
||||
try {
|
||||
@@ -46,6 +49,19 @@ async function openFolder(path: string) {
|
||||
await invoke('open_in_explorer', { path })
|
||||
}
|
||||
|
||||
async function retryItem(id: string) {
|
||||
try {
|
||||
await invoke('retry_task', { id })
|
||||
await loadHistory()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function openLogs(id: string) {
|
||||
router.push({ path: '/logs', query: { taskId: id } })
|
||||
}
|
||||
|
||||
onMounted(loadHistory)
|
||||
</script>
|
||||
|
||||
@@ -53,8 +69,8 @@ onMounted(loadHistory)
|
||||
<div class="max-w-5xl mx-auto p-8">
|
||||
<header class="mb-8 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">Download History</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">Manage your past downloads.</p>
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">下载历史</h1>
|
||||
<!-- <p class="text-gray-500 dark:text-gray-400 mt-2">管理您的下载记录。</p> -->
|
||||
</div>
|
||||
<button
|
||||
@click="clearHistory"
|
||||
@@ -62,7 +78,7 @@ onMounted(loadHistory)
|
||||
class="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 px-4 py-2 rounded-lg transition-colors text-sm font-medium flex items-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Clear All
|
||||
清空所有
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -70,8 +86,8 @@ onMounted(loadHistory)
|
||||
<div class="bg-gray-100 dark:bg-zinc-900 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<FolderOpen class="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-zinc-900 dark:text-white">No downloads yet</h3>
|
||||
<p class="text-gray-500">Your download history will appear here.</p>
|
||||
<h3 class="text-lg font-medium text-zinc-900 dark:text-white">暂无下载</h3>
|
||||
<p class="text-gray-500">您的下载记录将显示在这里。</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden">
|
||||
@@ -79,11 +95,11 @@ onMounted(loadHistory)
|
||||
<table class="w-full text-left">
|
||||
<thead class="bg-gray-50 dark:bg-zinc-800/50 text-xs uppercase text-gray-500 font-medium">
|
||||
<tr>
|
||||
<th class="px-6 py-4">Media</th>
|
||||
<th class="px-6 py-4">Date</th>
|
||||
<th class="px-6 py-4">Format</th>
|
||||
<th class="px-6 py-4">Status</th>
|
||||
<th class="px-6 py-4 text-right">Actions</th>
|
||||
<th class="px-6 py-4">媒体</th>
|
||||
<th class="px-6 py-4">日期</th>
|
||||
<th class="px-6 py-4">格式</th>
|
||||
<th class="px-6 py-4">状态</th>
|
||||
<th class="px-6 py-4 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-zinc-800">
|
||||
@@ -98,7 +114,7 @@ onMounted(loadHistory)
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500 whitespace-nowrap">
|
||||
{{ formatDistanceToNow(new Date(item.timestamp), { addSuffix: true }) }}
|
||||
{{ formatDistanceToNow(new Date(item.timestamp), { addSuffix: true, locale: zhCN }) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
<span class="bg-gray-100 dark:bg-zinc-800 px-2 py-1 rounded text-xs font-mono">{{ item.format }}</span>
|
||||
@@ -106,24 +122,46 @@ onMounted(loadHistory)
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
|
||||
:class="item.status === 'success' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
:class="item.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: item.status === 'cancelled'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="item.status === 'success' ? 'bg-green-500' : 'bg-red-500'"></span>
|
||||
{{ item.status === 'success' ? 'Completed' : 'Failed' }}
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="item.status === 'completed' ? 'bg-green-500' : item.status === 'cancelled' ? 'bg-amber-500' : 'bg-red-500'"
|
||||
></span>
|
||||
{{ item.status === 'completed' ? '已完成' : item.status === 'cancelled' ? '已取消' : '失败' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||
<button
|
||||
@click="openFolder(item.output_path)"
|
||||
@click="openFolder(item.file_path || item.output_path)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
title="Open Output Folder"
|
||||
title="打开输出文件夹"
|
||||
>
|
||||
<FolderOpen class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="openLogs(item.id)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors ml-1"
|
||||
title="查看日志"
|
||||
>
|
||||
<FileText class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="item.status !== 'completed'"
|
||||
@click="retryItem(item.id)"
|
||||
class="p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors ml-1"
|
||||
title="重试任务"
|
||||
>
|
||||
<RotateCcw class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
@click="deleteItem(item.id)"
|
||||
class="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors ml-1"
|
||||
title="Delete Record"
|
||||
title="删除记录"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
@@ -1,75 +1,183 @@
|
||||
// filepath: src/views/Home.vue
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Loader2, List, Link, X, RotateCcw, FileText, FolderOpen } from 'lucide-vue-next'
|
||||
import { useQueueStore } from '../stores/queue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { useAnalysisStore } from '../stores/analysis'
|
||||
import AppSelect from '../components/ui/AppSelect.vue'
|
||||
import type { AnalysisMetadata, PlaylistMetadata, VideoMetadata } from '../types/media'
|
||||
import { isPlaylistMetadata } from '../types/media'
|
||||
import {
|
||||
countSelectedEntries,
|
||||
detectMixUrl,
|
||||
normalizeBatchLinks,
|
||||
stripPlaylistContext,
|
||||
toSelectableEntries
|
||||
} from '../utils/analysis'
|
||||
|
||||
const queueStore = useQueueStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const analysisStore = useAnalysisStore()
|
||||
const router = useRouter()
|
||||
|
||||
const qualityOptions = [
|
||||
{ label: 'Best Quality', value: 'best' },
|
||||
{ label: '最佳画质', value: 'best' },
|
||||
{ label: '1080p', value: '1080' },
|
||||
{ label: '720p', value: '720' },
|
||||
{ label: '480p', value: '480' },
|
||||
]
|
||||
|
||||
// Sync default download path if not set
|
||||
const videoFormatOptions = [
|
||||
{ label: '原格式', value: 'original' },
|
||||
{ label: 'MP4', value: 'mp4' },
|
||||
{ label: 'WebM', value: 'webm' },
|
||||
{ label: 'Matroska (MKV)', value: 'mkv' },
|
||||
{ label: 'FLV', value: 'flv' },
|
||||
{ label: 'AVI', value: 'avi' },
|
||||
]
|
||||
|
||||
const audioFormatOptions = [
|
||||
{ label: '原格式', value: 'original' },
|
||||
{ label: 'MP3', value: 'mp3' },
|
||||
{ label: 'M4A', value: 'm4a' },
|
||||
{ label: 'AAC', value: 'aac' },
|
||||
{ label: 'Opus', value: 'opus' },
|
||||
{ label: 'Vorbis', value: 'vorbis' },
|
||||
{ label: 'WAV', value: 'wav' },
|
||||
{ label: 'FLAC', value: 'flac' },
|
||||
]
|
||||
|
||||
const playlistMetadata = computed<PlaylistMetadata | null>(() => {
|
||||
return isPlaylistMetadata(analysisStore.metadata) ? analysisStore.metadata : null
|
||||
})
|
||||
|
||||
const singleMetadata = computed<VideoMetadata | null>(() => {
|
||||
return analysisStore.metadata && !isPlaylistMetadata(analysisStore.metadata)
|
||||
? analysisStore.metadata
|
||||
: null
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => countSelectedEntries(playlistMetadata.value))
|
||||
|
||||
watch(() => settingsStore.settings.download_path, (newPath) => {
|
||||
if (newPath && !analysisStore.options.output_path) {
|
||||
analysisStore.options.output_path = newPath
|
||||
}
|
||||
if (newPath && !analysisStore.options.output_path) {
|
||||
analysisStore.options.output_path = newPath
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Detect Mix URL
|
||||
watch(() => analysisStore.url, (newUrl) => {
|
||||
if (newUrl && newUrl.includes('v=') && newUrl.includes('list=')) {
|
||||
analysisStore.isMix = true
|
||||
} else {
|
||||
analysisStore.isMix = false
|
||||
// Reset scanMix if URL changes to non-mix
|
||||
analysisStore.scanMix = false
|
||||
}
|
||||
analysisStore.isMix = !!newUrl && detectMixUrl(newUrl)
|
||||
if (!analysisStore.isMix) {
|
||||
analysisStore.scanMix = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => analysisStore.options.is_audio_only, () => {
|
||||
analysisStore.options.output_format = 'original'
|
||||
})
|
||||
|
||||
async function processThumbnail(url: string | undefined | null): Promise<string | undefined> {
|
||||
if (!url) return undefined
|
||||
|
||||
if (url.includes('instagram.com') || url.includes('fbcdn.net') || url.includes('cdninstagram.com')) {
|
||||
try {
|
||||
return await invoke<string>('fetch_image', { url })
|
||||
} catch (error) {
|
||||
console.warn('Thumbnail fetch failed, falling back to URL', error)
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
async function processMetadataThumbnails(metadata: AnalysisMetadata) {
|
||||
if (isPlaylistMetadata(metadata)) {
|
||||
await Promise.all(metadata.entries.map(async (entry) => {
|
||||
if (entry.thumbnail) {
|
||||
entry.thumbnail = await processThumbnail(entry.thumbnail) ?? entry.thumbnail
|
||||
}
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (metadata.thumbnail) {
|
||||
metadata.thumbnail = await processThumbnail(metadata.thumbnail) ?? metadata.thumbnail
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeBatchLinks() {
|
||||
const validLinks = normalizeBatchLinks(analysisStore.url)
|
||||
|
||||
if (validLinks.length === 0) {
|
||||
throw new Error('未找到有效的单个视频链接(已忽略纯播放列表链接)。')
|
||||
}
|
||||
|
||||
const results: VideoMetadata[] = []
|
||||
|
||||
for (const link of validLinks) {
|
||||
try {
|
||||
const res = await invoke<AnalysisMetadata>('fetch_metadata', { url: link, parseMixPlaylist: false })
|
||||
if (!isPlaylistMetadata(res)) {
|
||||
results.push(res)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse ${link}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
throw new Error('所有链接解析失败或均为播放列表。')
|
||||
}
|
||||
|
||||
await Promise.all(results.map(result => processMetadataThumbnails(result)))
|
||||
|
||||
analysisStore.metadata = {
|
||||
id: `batch_download_${Date.now()}`,
|
||||
title: `批量解析结果 (${results.length} 个视频)`,
|
||||
entries: toSelectableEntries(results)
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeSingleLink() {
|
||||
let urlToScan = analysisStore.url
|
||||
let parseMix = false
|
||||
|
||||
if (analysisStore.isMix) {
|
||||
if (analysisStore.scanMix) {
|
||||
parseMix = true
|
||||
} else {
|
||||
urlToScan = stripPlaylistContext(urlToScan)
|
||||
}
|
||||
}
|
||||
|
||||
const res = await invoke<AnalysisMetadata>('fetch_metadata', { url: urlToScan, parseMixPlaylist: parseMix })
|
||||
await processMetadataThumbnails(res)
|
||||
|
||||
if (isPlaylistMetadata(res)) {
|
||||
res.entries = toSelectableEntries(res.entries)
|
||||
}
|
||||
|
||||
analysisStore.metadata = res
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
if (!analysisStore.url) return
|
||||
|
||||
analysisStore.loading = true
|
||||
analysisStore.error = ''
|
||||
analysisStore.metadata = null
|
||||
|
||||
|
||||
try {
|
||||
let urlToScan = analysisStore.url;
|
||||
let parseMix = false;
|
||||
|
||||
if (analysisStore.isMix) {
|
||||
if (analysisStore.scanMix) {
|
||||
// Keep URL as is, tell backend to limit scan
|
||||
parseMix = true;
|
||||
} else {
|
||||
// Strip list param
|
||||
try {
|
||||
const u = new URL(urlToScan);
|
||||
u.searchParams.delete('list');
|
||||
u.searchParams.delete('index');
|
||||
u.searchParams.delete('start_radio');
|
||||
urlToScan = u.toString();
|
||||
} catch (e) {
|
||||
// Fallback regex if URL parsing fails
|
||||
urlToScan = urlToScan.replace(/&list=[^&]+/, '');
|
||||
}
|
||||
}
|
||||
if (analysisStore.isBatchMode) {
|
||||
await analyzeBatchLinks()
|
||||
} else {
|
||||
await analyzeSingleLink()
|
||||
}
|
||||
|
||||
const res = await invoke('fetch_metadata', { url: urlToScan, parseMixPlaylist: parseMix })
|
||||
analysisStore.metadata = res
|
||||
} catch (e: any) {
|
||||
analysisStore.error = e.toString()
|
||||
} catch (error: unknown) {
|
||||
analysisStore.error = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
analysisStore.loading = false
|
||||
}
|
||||
@@ -77,56 +185,68 @@ async function analyze() {
|
||||
|
||||
async function startDownload() {
|
||||
if (!analysisStore.metadata) return
|
||||
|
||||
// Ensure path is set
|
||||
|
||||
if (!analysisStore.options.output_path) {
|
||||
analysisStore.options.output_path = settingsStore.settings.download_path
|
||||
analysisStore.options.output_path = settingsStore.settings.download_path
|
||||
}
|
||||
|
||||
analysisStore.options.cookies_path = settingsStore.settings.cookies_path || ''
|
||||
|
||||
try {
|
||||
const metaToSend = analysisStore.metadata.entries ?
|
||||
{ title: analysisStore.metadata.title, thumbnail: "", id: analysisStore.metadata.id } :
|
||||
analysisStore.metadata;
|
||||
if (playlistMetadata.value) {
|
||||
const selectedEntries = playlistMetadata.value.entries.filter(entry => entry.selected)
|
||||
|
||||
// Note: We might want to pass the *cleaned* URL if it was cleaned during analyze
|
||||
// But for now we pass the original URL or whatever was scanned.
|
||||
// Actually, if we scanned as a single video (unchecked), we should probably download as single video.
|
||||
// The user might expect the same result as analysis.
|
||||
// Let's reconstruct the URL logic or just use what `analyze` used?
|
||||
// Since `start_download` just takes a URL string, we should probably use the same logic.
|
||||
|
||||
let urlToDownload = analysisStore.url;
|
||||
if (analysisStore.isMix && !analysisStore.scanMix) {
|
||||
try {
|
||||
const u = new URL(urlToDownload);
|
||||
u.searchParams.delete('list');
|
||||
u.searchParams.delete('index');
|
||||
u.searchParams.delete('start_radio');
|
||||
urlToDownload = u.toString();
|
||||
} catch (e) {
|
||||
urlToDownload = urlToDownload.replace(/&list=[^&]+/, '');
|
||||
}
|
||||
}
|
||||
if (selectedEntries.length === 0) {
|
||||
analysisStore.error = '请至少选择一个要下载的视频。'
|
||||
return
|
||||
}
|
||||
|
||||
const id = await invoke<string>('start_download', {
|
||||
for (const entry of selectedEntries) {
|
||||
const videoUrl = entry.url || `https://www.youtube.com/watch?v=${entry.id}`
|
||||
await invoke<string>('start_download', {
|
||||
url: videoUrl,
|
||||
options: analysisStore.options,
|
||||
metadata: entry
|
||||
})
|
||||
}
|
||||
} else if (singleMetadata.value) {
|
||||
const urlToDownload = analysisStore.isMix && !analysisStore.scanMix
|
||||
? stripPlaylistContext(analysisStore.url)
|
||||
: analysisStore.url
|
||||
|
||||
await invoke<string>('start_download', {
|
||||
url: urlToDownload,
|
||||
options: analysisStore.options,
|
||||
metadata: metaToSend
|
||||
})
|
||||
|
||||
queueStore.addTask({
|
||||
id,
|
||||
title: metaToSend.title,
|
||||
thumbnail: metaToSend.thumbnail,
|
||||
progress: 0,
|
||||
speed: 'Pending...',
|
||||
status: 'pending'
|
||||
metadata: singleMetadata.value
|
||||
})
|
||||
|
||||
// Reset state after successful download start
|
||||
analysisStore.reset()
|
||||
} catch (e: any) {
|
||||
analysisStore.error = "Download failed to start: " + e.toString()
|
||||
}
|
||||
|
||||
analysisStore.reset()
|
||||
} catch (error: unknown) {
|
||||
analysisStore.error = `下载启动失败: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
function openTaskLogs(taskId: string) {
|
||||
router.push({ path: '/logs', query: { taskId } })
|
||||
}
|
||||
|
||||
async function openTaskOutput(path?: string | null) {
|
||||
if (!path) return
|
||||
await invoke('open_in_explorer', { path })
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'queued': return '排队中'
|
||||
case 'preparing': return '准备中'
|
||||
case 'analyzing': return '分析中'
|
||||
case 'downloading': return '下载中'
|
||||
case 'postprocessing': return '处理中'
|
||||
case 'completed': return '已完成'
|
||||
case 'failed': return '失败'
|
||||
case 'cancelled': return '已取消'
|
||||
default: return status
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -134,124 +254,311 @@ async function startDownload() {
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto p-8">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">New Download</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">Paste a URL to start downloading media.</p>
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">新建下载</h1>
|
||||
</header>
|
||||
|
||||
<!-- Input Section -->
|
||||
<div class="bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800 mb-8">
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
v-model="analysisStore.url"
|
||||
type="text"
|
||||
placeholder="https://youtube.com/watch?v=..."
|
||||
class="flex-1 bg-gray-50 dark:bg-zinc-800 border-none rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
@keyup.enter="analyze"
|
||||
/>
|
||||
<button
|
||||
@click="analyze"
|
||||
:disabled="analysisStore.loading"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-medium transition-colors disabled:opacity-50 flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<Loader2 v-if="analysisStore.loading" class="animate-spin w-5 h-5" />
|
||||
<span v-else>Analyze</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mix Toggle -->
|
||||
<div v-if="analysisStore.isMix" class="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
@click="analysisStore.scanMix = !analysisStore.scanMix"
|
||||
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out flex-shrink-0"
|
||||
:class="analysisStore.scanMix ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex justify-end mb-1">
|
||||
<button
|
||||
@click="analysisStore.isBatchMode = !analysisStore.isBatchMode"
|
||||
class="text-xs font-medium flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors"
|
||||
:class="analysisStore.isBatchMode ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-600 dark:bg-zinc-800 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-zinc-700'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
|
||||
:class="analysisStore.scanMix ? 'translate-x-6' : 'translate-x-0'"
|
||||
/>
|
||||
<List v-if="analysisStore.isBatchMode" class="w-3.5 h-3.5" />
|
||||
<Link v-else class="w-3.5 h-3.5" />
|
||||
{{ analysisStore.isBatchMode ? '单链接模式' : '批量输入模式' }}
|
||||
</button>
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-gray-300">Scan Playlist (Max 20)</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-start">
|
||||
<div class="flex-1 relative">
|
||||
<textarea
|
||||
v-if="analysisStore.isBatchMode"
|
||||
v-model="analysisStore.url"
|
||||
placeholder="每行输入一个链接..."
|
||||
rows="5"
|
||||
class="w-full bg-gray-50 dark:bg-zinc-800 border-none rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none resize-none font-mono text-sm leading-relaxed"
|
||||
></textarea>
|
||||
<input
|
||||
v-else
|
||||
v-model="analysisStore.url"
|
||||
type="text"
|
||||
placeholder="https://..."
|
||||
class="w-full bg-gray-50 dark:bg-zinc-800 border-none rounded-xl px-4 py-3 text-zinc-900 dark:text-white focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
@keyup.enter="analyze"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="analyze"
|
||||
:disabled="analysisStore.loading"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-xl font-medium transition-colors disabled:opacity-50 flex items-center gap-2 shrink-0 h-[48px]"
|
||||
>
|
||||
<Loader2 v-if="analysisStore.loading" class="animate-spin w-5 h-5" />
|
||||
<span v-else>解析</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!analysisStore.isBatchMode && analysisStore.isMix" class="mt-4 flex items-center gap-3">
|
||||
<button
|
||||
@click="analysisStore.scanMix = !analysisStore.scanMix"
|
||||
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out flex-shrink-0"
|
||||
:class="analysisStore.scanMix ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
|
||||
:class="analysisStore.scanMix ? 'translate-x-6' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
<span class="text-sm font-medium text-zinc-700 dark:text-gray-300">解析播放列表 (前20项)</span>
|
||||
</div>
|
||||
|
||||
<p v-if="analysisStore.error" class="mt-3 text-red-500 text-sm">{{ analysisStore.error }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Analysis Result -->
|
||||
<div v-if="analysisStore.metadata" class="bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden mb-8">
|
||||
<div class="p-6 flex flex-col md:flex-row gap-6">
|
||||
<!-- Thumbnail -->
|
||||
<img :src="analysisStore.metadata.thumbnail || '/placeholder.png'" class="w-full md:w-64 aspect-video object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-bold text-zinc-900 dark:text-white line-clamp-2">{{ analysisStore.metadata.title }}</h2>
|
||||
<p v-if="analysisStore.metadata.uploader" class="text-gray-500 dark:text-gray-400 mt-1">{{ analysisStore.metadata.uploader }}</p>
|
||||
<p v-if="analysisStore.metadata.entries" class="text-blue-500 mt-1 font-medium">{{ analysisStore.metadata.entries.length }} videos in playlist</p>
|
||||
|
||||
<!-- Options -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
<!-- Audio Only Toggle -->
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
|
||||
<span class="font-medium text-sm">Audio Only</span>
|
||||
<button
|
||||
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
|
||||
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
|
||||
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
|
||||
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="playlistMetadata" class="p-6 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-zinc-900 dark:text-white">{{ playlistMetadata.title }}</h2>
|
||||
<p class="text-blue-500 mt-1 font-medium">{{ playlistMetadata.entries.length }} 个视频</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quality Dropdown -->
|
||||
<div class="relative">
|
||||
<AppSelect
|
||||
<div class="flex flex-col md:flex-row items-center justify-between gap-4 bg-gray-50 dark:bg-zinc-800/50 p-4 rounded-xl">
|
||||
<div class="flex items-center gap-2 w-full md:w-auto">
|
||||
<button @click="analysisStore.setAllEntries(true)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">全选</button>
|
||||
<button @click="analysisStore.setAllEntries(false)" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">取消全选</button>
|
||||
<button @click="analysisStore.invertSelection()" class="text-base font-medium px-3 py-1.5 rounded-lg bg-white dark:bg-zinc-700 hover:bg-gray-100 dark:hover:bg-zinc-600 text-zinc-700 dark:text-gray-200 border border-gray-200 dark:border-zinc-600 transition-colors shadow-sm">反选</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-6 w-full md:w-auto justify-end">
|
||||
<div class="flex items-center gap-x-4 p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
|
||||
<span class="font-medium text-base text-zinc-700 dark:text-gray-300">仅音频</span>
|
||||
<button
|
||||
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
|
||||
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
|
||||
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
|
||||
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-44">
|
||||
<AppSelect
|
||||
v-model="analysisStore.options.quality"
|
||||
:options="qualityOptions"
|
||||
:disabled="analysisStore.options.is_audio_only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-48">
|
||||
<AppSelect
|
||||
v-model="analysisStore.options.output_format"
|
||||
:options="analysisStore.options.is_audio_only ? audioFormatOptions : videoFormatOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="playlistMetadata" class="max-h-[500px] overflow-y-auto p-2 space-y-2 bg-gray-50/50 dark:bg-black/20">
|
||||
<div
|
||||
v-for="entry in playlistMetadata.entries"
|
||||
:key="entry.id"
|
||||
class="flex items-center gap-4 p-3 rounded-xl hover:bg-white dark:hover:bg-zinc-800 transition-colors border border-transparent hover:border-gray-200 dark:hover:border-zinc-700 group"
|
||||
:class="entry.selected ? 'opacity-100' : 'opacity-60 grayscale'"
|
||||
>
|
||||
<button
|
||||
@click="entry.selected = !entry.selected"
|
||||
class="w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-colors shrink-0"
|
||||
:class="entry.selected ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-300 dark:border-zinc-600 hover:border-blue-400'"
|
||||
>
|
||||
<svg v-if="entry.selected" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img :src="entry.thumbnail || '/placeholder.png'" class="w-24 h-14 object-cover rounded-lg bg-gray-200 dark:bg-zinc-700 shrink-0" />
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="font-medium text-zinc-900 dark:text-white truncate text-sm" :title="entry.title">{{ entry.title }}</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
{{ entry.duration ? Math.floor(entry.duration / 60) + ':' + String(Math.floor(entry.duration % 60)).padStart(2, '0') : '' }}
|
||||
<span v-if="entry.uploader" class="mx-1">•</span> {{ entry.uploader }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="singleMetadata" class="p-6 flex flex-col md:flex-row gap-6">
|
||||
<img :src="singleMetadata.thumbnail || '/placeholder.png'" class="w-full md:w-64 aspect-video object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
|
||||
|
||||
<div class="flex-1">
|
||||
<h2 class="text-xl font-bold text-zinc-900 dark:text-white line-clamp-2">{{ singleMetadata.title }}</h2>
|
||||
<p v-if="singleMetadata.uploader" class="text-gray-500 dark:text-gray-400 mt-1">{{ singleMetadata.uploader }}</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-zinc-800 rounded-xl">
|
||||
<span class="font-medium text-base">仅音频</span>
|
||||
<button
|
||||
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
|
||||
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
|
||||
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
|
||||
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<AppSelect
|
||||
v-model="analysisStore.options.quality"
|
||||
:options="qualityOptions"
|
||||
:disabled="analysisStore.options.is_audio_only"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<AppSelect
|
||||
v-model="analysisStore.options.output_format"
|
||||
:options="analysisStore.options.is_audio_only ? audioFormatOptions : videoFormatOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-zinc-800/50 border-t border-gray-200 dark:border-zinc-800 flex justify-end">
|
||||
<button
|
||||
@click="startDownload"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-bold transition-colors shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
Download Now
|
||||
</button>
|
||||
<button
|
||||
@click="startDownload"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-xl font-bold transition-colors shadow-lg shadow-blue-600/20"
|
||||
>
|
||||
立即下载 {{ selectedCount > 0 ? `(${selectedCount})` : '' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Downloads -->
|
||||
<div v-if="queueStore.tasks.length > 0">
|
||||
<h3 class="text-lg font-bold mb-4">Active Downloads</h3>
|
||||
<div class="space-y-3">
|
||||
<!-- Reversed to show newest first -->
|
||||
<div v-for="task in queueStore.tasks.slice().reverse()" :key="task.id" class="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 flex items-center gap-4">
|
||||
<img :src="task.thumbnail || '/placeholder.png'" class="w-16 h-16 object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between mb-1">
|
||||
<h4 class="font-medium truncate pr-4">{{ task.title }}</h4>
|
||||
<span class="text-xs font-mono text-gray-500 whitespace-nowrap">
|
||||
{{ task.status === 'finished' ? 'Completed' : (task.status === 'error' ? 'Failed' : task.speed) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:style="{ width: `${task.progress}%` }"
|
||||
:class="task.status === 'error' ? 'bg-red-500' : (task.status === 'finished' ? 'bg-green-500' : 'bg-blue-600')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="queueStore.activeTasks.length > 0">
|
||||
<h3 class="text-lg font-bold mb-4">进行中的任务</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="task in queueStore.activeTasks" :key="task.id" class="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 flex items-center gap-4">
|
||||
<img :src="task.thumbnail || '/placeholder.png'" class="w-16 h-16 object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex justify-between mb-1">
|
||||
<h4 class="font-medium truncate pr-4">{{ task.title }}</h4>
|
||||
<span class="text-xs font-mono text-gray-500 whitespace-nowrap">
|
||||
{{ task.status === 'postprocessing' ? '处理中' : (task.speed || task.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-300"
|
||||
:style="{ width: `${task.progress}%` }"
|
||||
:class="task.status === 'postprocessing' ? 'bg-amber-500' : 'bg-blue-600'"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2 text-xs text-gray-500">
|
||||
<span>{{ task.site_name || task.extractor || '未知站点' }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@click="queueStore.cancelTask(task.id)"
|
||||
class="inline-flex items-center gap-1 text-red-500 hover:text-red-600"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
@click="openTaskLogs(task.id)"
|
||||
class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<FileText class="w-3.5 h-3.5" />
|
||||
日志
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="queueStore.terminalTasks.length > 0" class="mt-10">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold">最近任务</h3>
|
||||
<button
|
||||
@click="router.push('/history')"
|
||||
class="text-sm text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
查看全部历史
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="task in queueStore.terminalTasks"
|
||||
:key="task.id"
|
||||
class="bg-white dark:bg-zinc-900 p-4 rounded-xl border border-gray-200 dark:border-zinc-800 flex items-center gap-4"
|
||||
>
|
||||
<img :src="task.thumbnail || '/placeholder.png'" class="w-16 h-16 object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h4 class="font-medium truncate">{{ task.title }}</h4>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
{{ statusLabel(task.status) }}
|
||||
<span v-if="task.site_name || task.extractor"> • {{ task.site_name || task.extractor }}</span>
|
||||
<span v-if="task.error_message"> • {{ task.error_message }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium whitespace-nowrap"
|
||||
:class="task.status === 'completed'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: task.status === 'cancelled'
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'"
|
||||
>
|
||||
{{ statusLabel(task.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-3 text-xs">
|
||||
<button
|
||||
@click="openTaskLogs(task.id)"
|
||||
class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<FileText class="w-3.5 h-3.5" />
|
||||
查看日志
|
||||
</button>
|
||||
<button
|
||||
v-if="task.file_path || task.output_path"
|
||||
@click="openTaskOutput(task.file_path || task.output_path)"
|
||||
class="inline-flex items-center gap-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<FolderOpen class="w-3.5 h-3.5" />
|
||||
打开位置
|
||||
</button>
|
||||
<button
|
||||
v-if="task.status === 'failed' || task.status === 'cancelled'"
|
||||
@click="queueStore.retryTask(task.id)"
|
||||
class="inline-flex items-center gap-1 text-blue-500 hover:text-blue-600"
|
||||
>
|
||||
<RotateCcw class="w-3.5 h-3.5" />
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
// filepath: src/views/Logs.vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watch } from 'vue'
|
||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLogsStore } from '../stores/logs'
|
||||
import { Trash2, Search } from 'lucide-vue-next'
|
||||
import { useQueueStore } from '../stores/queue'
|
||||
import { Trash2, Search, X } from 'lucide-vue-next'
|
||||
|
||||
const logsStore = useLogsStore()
|
||||
const queueStore = useQueueStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const logsContainer = ref<HTMLElement | null>(null)
|
||||
const autoScroll = ref(true)
|
||||
const filterLevel = ref<'all' | 'info' | 'error'>('all')
|
||||
const searchQuery = ref('')
|
||||
const selectedTaskId = ref<string>((route.query.taskId as string) || '')
|
||||
|
||||
const selectedTask = computed(() => queueStore.tasks.find(task => task.id === selectedTaskId.value) || null)
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
return logsStore.logs.filter(log => {
|
||||
if (filterLevel.value !== 'all' && log.level !== filterLevel.value) return false
|
||||
if (selectedTaskId.value && log.taskId !== selectedTaskId.value) return false
|
||||
if (searchQuery.value && !log.message.toLowerCase().includes(searchQuery.value.toLowerCase()) && !log.taskId.includes(searchQuery.value)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-scroll
|
||||
const logTaskOptions = computed(() => queueStore.tasks.filter(task =>
|
||||
logsStore.logs.some(log => log.taskId === task.id)
|
||||
).slice(0, 20))
|
||||
|
||||
// Restore scroll position on mount
|
||||
onMounted(() => {
|
||||
if (logsContainer.value) {
|
||||
logsContainer.value.scrollTop = logsStore.scrollTop
|
||||
// If it was auto-scrolling, ensure it's at bottom in case new logs arrived while away
|
||||
if (logsStore.autoScroll) {
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.taskId, (taskId) => {
|
||||
selectedTaskId.value = typeof taskId === 'string' ? taskId : ''
|
||||
})
|
||||
|
||||
// Auto-scroll watcher
|
||||
watch(() => logsStore.logs.length, () => {
|
||||
if (autoScroll.value) {
|
||||
if (logsStore.autoScroll) {
|
||||
nextTick(() => {
|
||||
if (logsContainer.value) {
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight
|
||||
@@ -29,6 +55,15 @@ watch(() => logsStore.logs.length, () => {
|
||||
}
|
||||
})
|
||||
|
||||
function handleScroll(e: Event) {
|
||||
const target = e.target as HTMLElement;
|
||||
logsStore.scrollTop = target.scrollTop;
|
||||
|
||||
// Check if user is near bottom (allow 20px threshold)
|
||||
const isAtBottom = target.scrollTop + target.clientHeight >= target.scrollHeight - 20;
|
||||
logsStore.autoScroll = isAtBottom;
|
||||
}
|
||||
|
||||
function formatTime(ts: number) {
|
||||
const d = new Date(ts)
|
||||
const year = d.getFullYear()
|
||||
@@ -39,6 +74,11 @@ function formatTime(ts: number) {
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function selectTask(taskId: string) {
|
||||
selectedTaskId.value = taskId
|
||||
router.replace({ path: '/logs', query: taskId ? { taskId } : {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -46,18 +86,24 @@ function formatTime(ts: number) {
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">Execution Logs</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">Real-time output from download processes.</p>
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">运行日志</h1>
|
||||
<!-- <p class="text-gray-500 dark:text-gray-400 mt-2">下载任务的实时输出。</p> -->
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="selectedTask" class="hidden lg:flex items-center gap-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-3 py-2 text-xs">
|
||||
<span class="truncate max-w-48">{{ selectedTask.title }}</span>
|
||||
<button @click="selectTask('')" class="text-gray-400 hover:text-red-500">
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
placeholder="搜索日志..."
|
||||
class="pl-9 pr-4 py-2 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl text-sm outline-none focus:ring-2 focus:ring-blue-500 text-zinc-900 dark:text-white w-48"
|
||||
/>
|
||||
</div>
|
||||
@@ -68,23 +114,23 @@ function formatTime(ts: number) {
|
||||
@click="filterLevel = 'all'"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="filterLevel === 'all' ? 'bg-gray-100 dark:bg-zinc-800 text-zinc-900 dark:text-white' : 'text-gray-500 hover:text-zinc-900 dark:hover:text-white'"
|
||||
>All</button>
|
||||
>全部</button>
|
||||
<button
|
||||
@click="filterLevel = 'info'"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="filterLevel === 'info' ? 'bg-gray-100 dark:bg-zinc-800 text-zinc-900 dark:text-white' : 'text-gray-500 hover:text-zinc-900 dark:hover:text-white'"
|
||||
>Info</button>
|
||||
>信息</button>
|
||||
<button
|
||||
@click="filterLevel = 'error'"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
:class="filterLevel === 'error' ? 'bg-gray-100 dark:bg-zinc-800 text-zinc-900 dark:text-white' : 'text-gray-500 hover:text-zinc-900 dark:hover:text-white'"
|
||||
>Error</button>
|
||||
>错误</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="logsStore.clearLogs"
|
||||
class="p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 rounded-xl transition-colors border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900"
|
||||
title="Clear Logs"
|
||||
title="清空日志"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
@@ -92,20 +138,38 @@ function formatTime(ts: number) {
|
||||
</div>
|
||||
|
||||
<!-- Logs Console -->
|
||||
<div class="mb-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="selectTask('')"
|
||||
class="px-3 py-1.5 rounded-lg text-xs border transition-colors"
|
||||
:class="selectedTaskId === '' ? 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800' : 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-500'"
|
||||
>
|
||||
全部任务
|
||||
</button>
|
||||
<button
|
||||
v-for="task in logTaskOptions"
|
||||
:key="task.id"
|
||||
@click="selectTask(task.id)"
|
||||
class="px-3 py-1.5 rounded-lg text-xs border transition-colors max-w-56 truncate"
|
||||
:class="selectedTaskId === task.id ? 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800' : 'bg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-500'"
|
||||
:title="task.title"
|
||||
>
|
||||
{{ task.title }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden relative bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800 font-mono text-xs md:text-sm">
|
||||
<div
|
||||
ref="logsContainer"
|
||||
class="absolute inset-0 overflow-auto p-4 space-y-1"
|
||||
@scroll="(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
autoScroll = target.scrollTop + target.clientHeight >= target.scrollHeight - 10;
|
||||
}"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div v-if="filteredLogs.length === 0" class="h-full flex items-center justify-center text-gray-400 dark:text-gray-600">
|
||||
No logs to display
|
||||
暂无日志
|
||||
</div>
|
||||
<div v-for="log in filteredLogs" :key="log.id" class="flex gap-4 hover:bg-gray-50 dark:hover:bg-zinc-800/50 px-2 py-1 rounded transition-colors">
|
||||
<span class="text-gray-400 dark:text-zinc-600 shrink-0 select-none w-36">{{ formatTime(log.timestamp) }}</span>
|
||||
<span class="text-blue-500 dark:text-blue-400 shrink-0 select-none w-24 truncate">{{ log.taskId }}</span>
|
||||
<span
|
||||
class="break-all whitespace-pre-wrap"
|
||||
:class="log.level === 'error' ? 'text-red-500 dark:text-red-400' : 'text-zinc-700 dark:text-gray-300'"
|
||||
@@ -114,14 +178,14 @@ function formatTime(ts: number) {
|
||||
</div>
|
||||
|
||||
<!-- Auto-scroll indicator -->
|
||||
<div v-if="!autoScroll" class="absolute bottom-4 right-4">
|
||||
<div v-if="!logsStore.autoScroll" class="absolute bottom-4 right-4">
|
||||
<button
|
||||
@click="() => { if(logsContainer) logsContainer.scrollTop = logsContainer.scrollHeight }"
|
||||
@click="() => { if(logsContainer) { logsContainer.scrollTop = logsContainer.scrollHeight; logsStore.autoScroll = true; } }"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white text-xs px-3 py-1.5 rounded-full shadow-lg transition-colors"
|
||||
>
|
||||
Resume Auto-scroll
|
||||
恢复自动滚动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// filepath: src/views/Settings.vue
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { Folder, RefreshCw, Monitor, Sun, Moon, Terminal } from 'lucide-vue-next'
|
||||
import { Folder, RefreshCw, Monitor, Sun, Moon, Terminal, Download, FileText, X } from 'lucide-vue-next'
|
||||
import { format } from 'date-fns' // Import format
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
const updatingYtdlp = ref(false)
|
||||
const updatingQuickjs = ref(false)
|
||||
const updatingFfmpeg = ref(false)
|
||||
const updateStatus = ref('')
|
||||
|
||||
async function browsePath() {
|
||||
@@ -24,15 +25,34 @@ async function browsePath() {
|
||||
}
|
||||
}
|
||||
|
||||
async function browseCookies() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
filters: [{ name: 'Text Files', extensions: ['txt', 'cookies'] }, { name: 'All Files', extensions: ['*'] }],
|
||||
defaultPath: settingsStore.settings.cookies_path || undefined
|
||||
})
|
||||
|
||||
if (selected) {
|
||||
settingsStore.settings.cookies_path = selected as string
|
||||
await settingsStore.save()
|
||||
}
|
||||
}
|
||||
|
||||
async function clearCookies() {
|
||||
settingsStore.settings.cookies_path = ''
|
||||
await settingsStore.save()
|
||||
}
|
||||
|
||||
async function updateYtdlp() {
|
||||
updatingYtdlp.value = true
|
||||
updateStatus.value = 'Updating yt-dlp...'
|
||||
updateStatus.value = '正在更新 yt-dlp...'
|
||||
try {
|
||||
const res = await invoke<string>('update_ytdlp')
|
||||
updateStatus.value = res
|
||||
await settingsStore.refreshVersions()
|
||||
} catch (e: any) {
|
||||
updateStatus.value = 'yt-dlp Error: ' + e.toString()
|
||||
updateStatus.value = 'yt-dlp 错误:' + e.toString()
|
||||
} finally {
|
||||
updatingYtdlp.value = false
|
||||
}
|
||||
@@ -40,18 +60,32 @@ async function updateYtdlp() {
|
||||
|
||||
async function updateQuickjs() {
|
||||
updatingQuickjs.value = true
|
||||
updateStatus.value = 'Updating QuickJS...'
|
||||
updateStatus.value = '正在更新 QuickJS...'
|
||||
try {
|
||||
const res = await invoke<string>('update_quickjs')
|
||||
updateStatus.value = res
|
||||
await settingsStore.refreshVersions()
|
||||
} catch (e: any) {
|
||||
updateStatus.value = 'QuickJS Error: ' + e.toString()
|
||||
updateStatus.value = 'QuickJS 错误:' + e.toString()
|
||||
} finally {
|
||||
updatingQuickjs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateFfmpeg() {
|
||||
updatingFfmpeg.value = true
|
||||
updateStatus.value = '正在更新 FFmpeg...'
|
||||
try {
|
||||
const res = await invoke<string>('update_ffmpeg')
|
||||
updateStatus.value = res
|
||||
await settingsStore.refreshVersions()
|
||||
} catch (e: any) {
|
||||
updateStatus.value = 'FFmpeg 错误:' + e.toString()
|
||||
} finally {
|
||||
updatingFfmpeg.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
settingsStore.settings.theme = theme
|
||||
settingsStore.save()
|
||||
@@ -61,31 +95,59 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
<template>
|
||||
<div class="max-w-3xl mx-auto p-8">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">Settings</h1>
|
||||
<p class="text-gray-500 dark:text-gray-400 mt-2">Configure your download preferences.</p>
|
||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">设置</h1>
|
||||
<!-- <p class="text-gray-500 dark:text-gray-400 mt-2">配置您的下载偏好。</p> -->
|
||||
</header>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Download Path -->
|
||||
<section class="bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800">
|
||||
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">Download Location</h2>
|
||||
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">下载位置</h2>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 bg-gray-50 dark:bg-zinc-800 rounded-xl px-4 py-3 text-sm text-gray-600 dark:text-gray-300 font-mono truncate border border-transparent focus-within:border-blue-500 transition-colors">
|
||||
{{ settingsStore.settings.download_path || 'Not set (using defaults)' }}
|
||||
{{ settingsStore.settings.download_path || '未设置 (使用默认)' }}
|
||||
</div>
|
||||
<button
|
||||
@click="browsePath"
|
||||
class="bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-zinc-900 dark:text-white px-4 py-3 rounded-xl font-medium transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Folder class="w-5 h-5" />
|
||||
Browse
|
||||
浏览
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cookies Path -->
|
||||
<section class="bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800">
|
||||
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">Cookies 文件</h2>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 bg-gray-50 dark:bg-zinc-800 rounded-xl px-4 py-3 text-sm text-gray-600 dark:text-gray-300 font-mono border border-transparent focus-within:border-blue-500 transition-colors flex items-center justify-between group min-w-0">
|
||||
<div class="truncate mr-2">
|
||||
{{ settingsStore.settings.cookies_path || '未设置 (默认空)' }}
|
||||
</div>
|
||||
<button
|
||||
v-if="settingsStore.settings.cookies_path"
|
||||
@click="clearCookies"
|
||||
class="text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all p-1 rounded-md hover:bg-gray-200 dark:hover:bg-zinc-700 shrink-0"
|
||||
title="清除 Cookies 路径"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@click="browseCookies"
|
||||
class="bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-zinc-900 dark:text-white px-4 py-3 rounded-xl font-medium transition-colors flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<FileText class="w-5 h-5" />
|
||||
选择
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-2">选填。请指定包含 cookies 的文本文件(Netscape 格式)。频繁使用 cookies 下载视频可能导致封号,慎用。</p>
|
||||
</section>
|
||||
|
||||
<!-- Theme -->
|
||||
<section class="bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800">
|
||||
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">Appearance</h2>
|
||||
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">外观</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<button
|
||||
@click="setTheme('light')"
|
||||
@@ -93,7 +155,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
:class="settingsStore.settings.theme === 'light' ? 'border-blue-600 bg-blue-50 dark:bg-blue-900/20 text-blue-600' : 'border-transparent bg-gray-50 dark:bg-zinc-800 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-700'"
|
||||
>
|
||||
<Sun class="w-6 h-6" />
|
||||
<span class="font-medium">Light</span>
|
||||
<span class="font-medium">浅色</span>
|
||||
</button>
|
||||
<button
|
||||
@click="setTheme('dark')"
|
||||
@@ -101,7 +163,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
:class="settingsStore.settings.theme === 'dark' ? 'border-blue-600 bg-blue-50 dark:bg-blue-900/20 text-blue-600' : 'border-transparent bg-gray-50 dark:bg-zinc-800 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-700'"
|
||||
>
|
||||
<Moon class="w-6 h-6" />
|
||||
<span class="font-medium">Dark</span>
|
||||
<span class="font-medium">深色</span>
|
||||
</button>
|
||||
<button
|
||||
@click="setTheme('system')"
|
||||
@@ -109,14 +171,14 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
:class="settingsStore.settings.theme === 'system' ? 'border-blue-600 bg-blue-50 dark:bg-blue-900/20 text-blue-600' : 'border-transparent bg-gray-50 dark:bg-zinc-800 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-700'"
|
||||
>
|
||||
<Monitor class="w-6 h-6" />
|
||||
<span class="font-medium">System</span>
|
||||
<span class="font-medium">跟随系统</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Binary Management -->
|
||||
<section class="bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800">
|
||||
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">External Binaries</h2>
|
||||
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">外部二进制文件</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- yt-dlp -->
|
||||
@@ -136,7 +198,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
class="text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 px-4 py-2 rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': updatingYtdlp }" />
|
||||
Update
|
||||
更新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -148,7 +210,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-zinc-900 dark:text-white">QuickJS</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5 font-mono">{{ settingsStore.quickjsVersion }}</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5 font-mono" :title="settingsStore.quickjsVersion">{{ settingsStore.quickjsVersion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -157,7 +219,30 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
class="text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 px-4 py-2 rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': updatingQuickjs }" />
|
||||
Update
|
||||
更新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- FFmpeg -->
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-zinc-800 rounded-xl">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 bg-white dark:bg-zinc-700 rounded-lg flex items-center justify-center shadow-sm">
|
||||
<Terminal class="w-5 h-5 text-purple-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-zinc-900 dark:text-white">FFmpeg</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5 font-mono" :title="settingsStore.ffmpegVersion">
|
||||
{{ ['Checking...', '未安装', 'Error'].includes(settingsStore.ffmpegVersion) ? settingsStore.ffmpegVersion : '已安装' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="updateFfmpeg"
|
||||
:disabled="updatingFfmpeg"
|
||||
class="text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 px-4 py-2 rounded-lg transition-colors text-sm font-medium flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" :class="{ 'animate-spin': updatingFfmpeg }" />
|
||||
更新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,8 +250,16 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||
<div v-if="updateStatus" class="mt-4 p-3 bg-gray-50 dark:bg-zinc-800 rounded-lg text-xs font-mono text-gray-600 dark:text-gray-400 whitespace-pre-wrap border border-gray-200 dark:border-zinc-700">
|
||||
{{ updateStatus }}
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-4 text-right">Last Checked: {{ settingsStore.settings.last_updated ? new Date(settingsStore.settings.last_updated).toLocaleString() : 'Never' }}</p>
|
||||
<div v-if="settingsStore.runtimeStatus" class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800 px-3 py-2">
|
||||
FFmpeg 来源:{{ settingsStore.runtimeStatus.ffmpeg_source }}
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-gray-50 dark:bg-zinc-800 px-3 py-2">
|
||||
JS Runtime:{{ settingsStore.runtimeStatus.js_runtime_name }} ({{ settingsStore.runtimeStatus.js_runtime_source }})
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400 mt-4 text-right">上次检查: {{ settingsStore.settings.last_updated ? format(new Date(settingsStore.settings.last_updated), 'yyyy-MM-dd HH:mm:ss') : '从未' }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { resolve } from "path";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
@@ -29,4 +30,12 @@ export default defineConfig(async () => ({
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
splashscreen: resolve(__dirname, 'splashscreen.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||