Compare commits
23 Commits
56aeafbf41
...
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 |
@@ -3,8 +3,3 @@
|
|||||||
A simple Youtube downloader.
|
A simple Youtube downloader.
|
||||||
|
|
||||||
Generated by Gemini CLI.
|
Generated by Gemini CLI.
|
||||||
|
|
||||||
## Problems
|
|
||||||
|
|
||||||
1. windows 上打包后运行命令会出现黑窗,而且还是会出现找不到 js-runtimes 的问题,但是 quickjs 是正常下载了
|
|
||||||
2. macos 上未测试
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "stream-capture",
|
"name": "stream-capture",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.2.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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
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
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
src-tauri/.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# Generated by Cargo
|
# Generated by Cargo
|
||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
|
/target*/
|
||||||
|
|
||||||
# Generated by Tauri
|
# Generated by Tauri
|
||||||
# will have schema files for capabilities auto-completion
|
# will have schema files for capabilities auto-completion
|
||||||
|
|||||||
71
src-tauri/Cargo.lock
generated
71
src-tauri/Cargo.lock
generated
@@ -19,6 +19,18 @@ dependencies = [
|
|||||||
"cpufeatures",
|
"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]]
|
[[package]]
|
||||||
name = "aho-corasick"
|
name = "aho-corasick"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -1020,6 +1032,18 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1543,12 +1567,30 @@ version = "0.12.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.14.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||||
|
dependencies = [
|
||||||
|
"ahash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
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]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@@ -2110,6 +2152,17 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "libz-rs-sys"
|
name = "libz-rs-sys"
|
||||||
version = "0.5.2"
|
version = "0.5.2"
|
||||||
@@ -3455,6 +3508,20 @@ dependencies = [
|
|||||||
"windows-sys 0.52.0",
|
"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]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -3971,13 +4038,15 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stream-capture"
|
name = "stream-capture"
|
||||||
version = "0.1.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"regex",
|
"regex",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "stream-capture"
|
name = "stream-capture"
|
||||||
version = "0.1.0"
|
version = "1.2.1"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -19,6 +19,7 @@ tauri-plugin-dialog = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
reqwest = { version = "0.12", features = ["json", "rustls-tls", "stream"] }
|
||||||
|
base64 = "0.22"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
@@ -26,3 +27,4 @@ futures-util = "0.3"
|
|||||||
regex = "1.10"
|
regex = "1.10"
|
||||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
zip = "6.0.0"
|
zip = "6.0.0"
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main", "splashscreen"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
|
|||||||
@@ -5,14 +5,41 @@ use tauri::AppHandle;
|
|||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
#[cfg(target_family = "unix")]
|
#[cfg(target_family = "unix")]
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
use zip::ZipArchive;
|
use zip::ZipArchive;
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
use crate::process_utils::first_non_empty_line;
|
||||||
use crate::storage::{self};
|
use crate::storage::{self};
|
||||||
|
|
||||||
const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
|
const YT_DLP_REPO_URL: &str = "https://github.com/yt-dlp/yt-dlp/releases/latest/download";
|
||||||
// Bellard's QuickJS binary releases URL
|
// Bellard's QuickJS binary releases URL
|
||||||
const QJS_REPO_URL: &str = "https://bellard.org/quickjs/binary_releases";
|
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 {
|
pub fn get_ytdlp_binary_name() -> &'static str {
|
||||||
if cfg!(target_os = "windows") {
|
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()))
|
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 {
|
pub fn check_binaries(app: &AppHandle) -> bool {
|
||||||
let ytdlp = get_ytdlp_path(app).map(|p| p.exists()).unwrap_or(false);
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- yt-dlp Logic ---
|
// --- yt-dlp Logic ---
|
||||||
@@ -93,6 +130,17 @@ pub async fn download_ytdlp(app: &AppHandle) -> Result<PathBuf> {
|
|||||||
fs::set_permissions(&path, perms)?;
|
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)
|
Ok(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,9 +152,13 @@ pub async fn update_ytdlp(app: &AppHandle) -> Result<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use built-in update for yt-dlp
|
// Use built-in update for yt-dlp
|
||||||
let output = std::process::Command::new(&path)
|
let mut cmd = std::process::Command::new(&path);
|
||||||
.arg("-U")
|
cmd.arg("-U");
|
||||||
.output()?;
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(0x08000000);
|
||||||
|
|
||||||
|
let output = cmd.output()?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
@@ -127,9 +179,13 @@ pub fn get_ytdlp_version(app: &AppHandle) -> Result<String> {
|
|||||||
return Ok("未安装".to_string());
|
return Ok("未安装".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
let output = std::process::Command::new(&path)
|
let mut cmd = std::process::Command::new(&path);
|
||||||
.arg("--version")
|
cmd.arg("--version");
|
||||||
.output()?;
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
cmd.creation_flags(0x08000000);
|
||||||
|
|
||||||
|
let output = cmd.output()?;
|
||||||
|
|
||||||
if output.status.success() {
|
if output.status.success() {
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
@@ -194,22 +250,31 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
|
|||||||
let source_name = get_qjs_source_name_in_zip();
|
let source_name = get_qjs_source_name_in_zip();
|
||||||
let target_name = get_qjs_binary_name(); // quickjs.exe or quickjs
|
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() {
|
for i in 0..archive.len() {
|
||||||
let mut file = archive.by_index(i)?;
|
let mut file = archive.by_index(i)?;
|
||||||
// Filenames in zip might be like "quickjs-win-x86_64-2024-01-13/qjs.exe"
|
// 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("");
|
let filename_only = name.split('/').last().unwrap_or("");
|
||||||
|
|
||||||
if filename_only == source_name {
|
if filename_only == source_name {
|
||||||
let mut out_file = fs::File::create(bin_dir.join(target_name))?;
|
let mut out_file = fs::File::create(bin_dir.join(target_name))?;
|
||||||
std::io::copy(&mut file, &mut out_file)?;
|
std::io::copy(&mut file, &mut out_file)?;
|
||||||
found = true;
|
found_exe = true;
|
||||||
break;
|
} 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 {
|
if !found_exe {
|
||||||
return Err(anyhow!("在下载的压缩包中找不到 {}", source_name));
|
return Err(anyhow!("在下载的压缩包中找不到 {}", source_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,20 +287,227 @@ pub async fn download_qjs(app: &AppHandle) -> Result<PathBuf> {
|
|||||||
fs::set_permissions(&final_path, perms)?;
|
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)
|
Ok(final_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn update_qjs(app: &AppHandle) -> Result<String> {
|
pub async fn update_qjs(app: &AppHandle) -> Result<String> {
|
||||||
// QuickJS doesn't have self-update, so we just re-download
|
// QuickJS doesn't have self-update, so we just re-download
|
||||||
download_qjs(app).await?;
|
download_qjs(app).await?;
|
||||||
|
|
||||||
|
let mut settings = storage::load_settings(app)?;
|
||||||
|
settings.last_updated = Some(chrono::Utc::now());
|
||||||
|
storage::save_settings(app, &settings)?;
|
||||||
|
|
||||||
Ok("QuickJS 已更新/安装".to_string())
|
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> {
|
pub fn get_qjs_version(app: &AppHandle) -> Result<String> {
|
||||||
let path = get_qjs_path(app)?;
|
let path = get_qjs_path(app)?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return Ok("未安装".to_string());
|
return Ok("未安装".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())
|
Ok("已安装".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,16 +515,152 @@ pub async fn ensure_binaries(app: &AppHandle) -> Result<()> {
|
|||||||
let ytdlp = get_ytdlp_path(app)?;
|
let ytdlp = get_ytdlp_path(app)?;
|
||||||
if !ytdlp.exists() {
|
if !ytdlp.exists() {
|
||||||
download_ytdlp(app).await?;
|
download_ytdlp(app).await?;
|
||||||
}
|
} else {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
let qjs = get_qjs_path(app)?;
|
{
|
||||||
if !qjs.exists() {
|
std::process::Command::new("xattr")
|
||||||
download_qjs(app).await?;
|
.arg("-d")
|
||||||
|
.arg("com.apple.quarantine")
|
||||||
|
.arg(&ytdlp)
|
||||||
|
.output()
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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::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]
|
#[tauri::command]
|
||||||
pub async fn init_ytdlp(app: AppHandle) -> Result<bool, String> {
|
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) {
|
if binary_manager::check_binaries(&app) {
|
||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If not found, try to download
|
binary_manager::ensure_binaries(&app)
|
||||||
match binary_manager::ensure_binaries(&app).await {
|
.await
|
||||||
Ok(_) => Ok(true),
|
.map(|_| true)
|
||||||
Err(e) => Err(format!("Failed to download binaries: {}", e)),
|
.map_err(|error| format!("Failed to prepare runtime: {error}"))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn update_ytdlp(app: AppHandle) -> Result<String, String> {
|
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]
|
#[tauri::command]
|
||||||
pub async fn update_quickjs(app: AppHandle) -> Result<String, String> {
|
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]
|
#[tauri::command]
|
||||||
pub fn get_ytdlp_version(app: AppHandle) -> Result<String, String> {
|
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]
|
#[tauri::command]
|
||||||
pub fn get_quickjs_version(app: AppHandle) -> Result<String, String> {
|
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]
|
#[tauri::command]
|
||||||
pub async fn fetch_metadata(app: AppHandle, url: String, parse_mix_playlist: bool) -> Result<downloader::MetadataResult, String> {
|
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]
|
#[tauri::command]
|
||||||
pub async fn start_download(app: AppHandle, url: String, options: DownloadOptions, metadata: downloader::VideoMetadata) -> Result<String, String> {
|
pub async fn start_download(
|
||||||
// Generate a task ID
|
app: AppHandle,
|
||||||
|
url: String,
|
||||||
|
options: DownloadOptions,
|
||||||
|
metadata: downloader::VideoMetadata,
|
||||||
|
) -> Result<String, String> {
|
||||||
let id = Uuid::new_v4().to_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();
|
let id_clone = id.clone();
|
||||||
|
|
||||||
// Spawn the download task
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let res = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await;
|
let _permit = DOWNLOAD_SEMAPHORE.acquire().await.ok();
|
||||||
|
if let Err(error) = downloader::download_video(app.clone(), id_clone.clone(), url.clone(), options.clone()).await {
|
||||||
let status = if res.is_ok() { "success" } else { "failed" };
|
storage::add_log_entry(&app, &id_clone, "error", &error.to_string()).ok();
|
||||||
|
}
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(id)
|
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]
|
#[tauri::command]
|
||||||
pub fn get_settings(app: AppHandle) -> Result<Settings, String> {
|
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]
|
#[tauri::command]
|
||||||
pub fn save_settings(app: AppHandle, settings: Settings) -> Result<(), String> {
|
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]
|
#[tauri::command]
|
||||||
pub fn get_history(app: AppHandle) -> Result<Vec<HistoryItem>, String> {
|
pub fn get_history(app: AppHandle) -> Result<Vec<storage::HistoryItem>, String> {
|
||||||
storage::load_history(&app).map_err(|e| e.to_string())
|
storage::load_history(&app).map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn clear_history(app: AppHandle) -> Result<(), String> {
|
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]
|
#[tauri::command]
|
||||||
pub fn delete_history_item(app: AppHandle, id: String) -> Result<(), String> {
|
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]
|
#[tauri::command]
|
||||||
pub fn open_in_explorer(app: AppHandle, path: String) -> Result<(), String> {
|
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
|
path
|
||||||
} else {
|
} else {
|
||||||
app.path().download_dir()
|
app.path()
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
.download_dir()
|
||||||
|
.map(|value| value.to_string_lossy().to_string())
|
||||||
.unwrap_or_else(|_| ".".to_string())
|
.unwrap_or_else(|_| ".".to_string())
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
{
|
{
|
||||||
std::process::Command::new("explorer")
|
let resolved = Path::new(&resolved_path);
|
||||||
.arg(path_to_open)
|
let mut command = std::process::Command::new("explorer");
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
std::process::Command::new("open")
|
let resolved = Path::new(&resolved_path);
|
||||||
.arg(path_to_open)
|
let mut command = std::process::Command::new("open");
|
||||||
.spawn()
|
|
||||||
.map_err(|e| e.to_string())?;
|
if resolved.is_file() {
|
||||||
|
command.arg("-R").arg(resolved);
|
||||||
|
} else {
|
||||||
|
command.arg(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
command.spawn().map_err(|error| error.to_string())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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 tauri::{AppHandle, Emitter};
|
||||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use std::process::Stdio;
|
use tokio::sync::Mutex;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use anyhow::{Result, anyhow};
|
use crate::binary_manager::{self, FfmpegLocation};
|
||||||
use regex::Regex;
|
use crate::storage::{self, TaskRecord};
|
||||||
use crate::binary_manager;
|
use crate::task_runtime;
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct VideoMetadata {
|
pub struct VideoMetadata {
|
||||||
@@ -15,6 +20,9 @@ pub struct VideoMetadata {
|
|||||||
pub thumbnail: String,
|
pub thumbnail: String,
|
||||||
pub duration: Option<f64>,
|
pub duration: Option<f64>,
|
||||||
pub uploader: Option<String>,
|
pub uploader: Option<String>,
|
||||||
|
pub url: Option<String>,
|
||||||
|
pub extractor: Option<String>,
|
||||||
|
pub site_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
@@ -31,11 +39,13 @@ pub enum MetadataResult {
|
|||||||
Playlist(PlaylistMetadata),
|
Playlist(PlaylistMetadata),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
pub struct DownloadOptions {
|
pub struct DownloadOptions {
|
||||||
pub is_audio_only: bool,
|
pub is_audio_only: bool,
|
||||||
pub quality: String, // e.g., "1080", "720", "best"
|
pub quality: String,
|
||||||
pub output_path: String, // Directory
|
pub output_path: String,
|
||||||
|
pub output_format: String,
|
||||||
|
pub cookies_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Debug)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
@@ -43,239 +53,382 @@ pub struct ProgressEvent {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub progress: f64,
|
pub progress: f64,
|
||||||
pub speed: String,
|
pub speed: String,
|
||||||
pub status: String, // "downloading", "processing", "finished", "error"
|
pub eta: Option<String>,
|
||||||
|
pub status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone, Debug)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
pub struct LogEvent {
|
pub struct LogEvent {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub message: 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> {
|
pub async fn fetch_metadata(app: &AppHandle, url: &str, parse_mix_playlist: bool) -> Result<MetadataResult> {
|
||||||
app.emit("download-log", LogEvent {
|
emit_log(app, "Analysis", format!("正在为 URL: {} 获取元数据", url), "info");
|
||||||
id: "Analysis".to_string(),
|
|
||||||
message: format!("Starting metadata fetch for URL: {}", url),
|
|
||||||
level: "info".to_string(),
|
|
||||||
}).ok();
|
|
||||||
|
|
||||||
let ytdlp_path = binary_manager::get_ytdlp_path(app)?;
|
let ytdlp_path = binary_manager::get_ytdlp_path(app)?;
|
||||||
let qjs_path = binary_manager::get_qjs_path(app)?; // Get absolute path to quickjs
|
let js_runtime = binary_manager::ensure_js_runtime_available(app).await?;
|
||||||
|
let settings = storage::load_settings(app)?;
|
||||||
|
|
||||||
let mut cmd = Command::new(ytdlp_path);
|
let mut args = Vec::new();
|
||||||
|
if let Some(runtime) = js_runtime {
|
||||||
// Pass the runtime and its absolute path to --js-runtimes
|
args.push("--js-runtimes".to_string());
|
||||||
cmd.arg("--js-runtimes").arg(format!("quickjs:{}", qjs_path.to_string_lossy()));
|
args.push(runtime.yt_dlp_argument());
|
||||||
|
|
||||||
cmd.arg("--dump-single-json")
|
|
||||||
.arg("--flat-playlist")
|
|
||||||
.arg("--no-warnings");
|
|
||||||
|
|
||||||
if parse_mix_playlist {
|
|
||||||
cmd.arg("--playlist-end").arg("20");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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());
|
cmd.stderr(Stdio::piped());
|
||||||
|
|
||||||
|
emit_log(
|
||||||
|
app,
|
||||||
|
"Analysis",
|
||||||
|
format!("正在执行分析命令: {} {}", ytdlp_path.to_string_lossy(), args.join(" ")),
|
||||||
|
"info",
|
||||||
|
);
|
||||||
|
|
||||||
let output = cmd.output().await?;
|
let output = cmd.output().await?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
app.emit("download-log", LogEvent {
|
emit_log(app, "Analysis", format!("元数据获取失败: {}", stderr), "error");
|
||||||
id: "Analysis".to_string(),
|
|
||||||
message: format!("Metadata fetch failed: {}", stderr),
|
|
||||||
level: "error".to_string(),
|
|
||||||
}).ok();
|
|
||||||
return Err(anyhow!("yt-dlp error: {}", stderr));
|
return Err(anyhow!("yt-dlp error: {}", stderr));
|
||||||
}
|
}
|
||||||
|
|
||||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
let json: serde_json::Value = serde_json::from_str(&stdout)?;
|
let json: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||||
|
|
||||||
// Check if playlist
|
if json.get("_type").and_then(|value| value.as_str()) == Some("playlist") {
|
||||||
if let Some(_type) = json.get("_type") {
|
let entries_json = json["entries"].as_array().ok_or_else(|| anyhow!("No entries in playlist"))?;
|
||||||
if _type == "playlist" {
|
let entries = entries_json.iter().map(parse_video_metadata).collect();
|
||||||
let entries_json = json["entries"].as_array().ok_or(anyhow!("No entries in playlist"))?;
|
let result = MetadataResult::Playlist(PlaylistMetadata {
|
||||||
let mut entries = Vec::new();
|
id: json["id"].as_str().unwrap_or("").to_string(),
|
||||||
|
title: json["title"].as_str().unwrap_or("Unknown Playlist").to_string(),
|
||||||
for entry in entries_json {
|
entries,
|
||||||
entries.push(parse_video_metadata(entry));
|
});
|
||||||
}
|
emit_log(app, "Analysis", "元数据获取成功(播放列表)", "info");
|
||||||
|
return Ok(result);
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
app.emit("download-log", LogEvent {
|
|
||||||
id: "Analysis".to_string(),
|
|
||||||
message: "Metadata fetch success (Playlist)".to_string(),
|
|
||||||
level: "info".to_string(),
|
|
||||||
}).ok();
|
|
||||||
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single video
|
|
||||||
let result = MetadataResult::Video(parse_video_metadata(&json));
|
let result = MetadataResult::Video(parse_video_metadata(&json));
|
||||||
app.emit("download-log", LogEvent {
|
emit_log(app, "Analysis", "元数据获取成功(视频)", "info");
|
||||||
id: "Analysis".to_string(),
|
|
||||||
message: "Metadata fetch success (Video)".to_string(),
|
|
||||||
level: "info".to_string(),
|
|
||||||
}).ok();
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_video_metadata(json: &serde_json::Value) -> VideoMetadata {
|
fn parse_video_metadata(json: &serde_json::Value) -> VideoMetadata {
|
||||||
let id = json["id"].as_str().unwrap_or("").to_string();
|
let id = json["id"].as_str().unwrap_or("").to_string();
|
||||||
|
let extractor = json["extractor_key"].as_str().map(|value| value.to_string());
|
||||||
|
let site_name = json["extractor"].as_str().map(|value| value.to_string());
|
||||||
|
|
||||||
// Thumbnail fallback logic
|
let thumbnail = match json.get("thumbnail").and_then(|value| value.as_str()) {
|
||||||
let thumbnail = match json.get("thumbnail").and_then(|t| t.as_str()) {
|
Some(value) if !value.is_empty() => value.to_string(),
|
||||||
Some(t) if !t.is_empty() => t.to_string(),
|
_ if extractor
|
||||||
_ => format!("https://i.ytimg.com/vi/{}/mqdefault.jpg", id),
|
.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 {
|
VideoMetadata {
|
||||||
id,
|
id,
|
||||||
title: json["title"].as_str().unwrap_or("Unknown Title").to_string(),
|
title: json["title"].as_str().unwrap_or("Unknown Title").to_string(),
|
||||||
thumbnail,
|
thumbnail,
|
||||||
duration: json["duration"].as_f64(),
|
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(
|
pub async fn download_video(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
id: String, // Unique ID for this download task (provided by frontend)
|
id: String,
|
||||||
url: String,
|
url: String,
|
||||||
options: DownloadOptions,
|
options: DownloadOptions,
|
||||||
) -> Result<String> {
|
) -> Result<Option<String>> {
|
||||||
let ytdlp_path = binary_manager::get_ytdlp_path(&app)?;
|
let ytdlp_path = binary_manager::get_ytdlp_path(&app)?;
|
||||||
let qjs_path = binary_manager::get_qjs_path(&app)?; // Get absolute path to quickjs
|
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)?
|
||||||
|
};
|
||||||
|
|
||||||
|
let task = storage::update_task_status(&app, &id, "preparing", None, None)?;
|
||||||
|
emit_task(&app, &task);
|
||||||
|
|
||||||
let mut args = Vec::new();
|
let mut args = Vec::new();
|
||||||
|
apply_runtime_args(&mut args, &ffmpeg, &js_runtime);
|
||||||
|
|
||||||
// Pass the runtime and its absolute path to --js-runtimes
|
if let Some(cookies) = &options.cookies_path {
|
||||||
args.push("--js-runtimes".to_string());
|
if !cookies.is_empty() {
|
||||||
args.push(format!("quickjs:{}", qjs_path.to_string_lossy()));
|
args.push("--cookies".to_string());
|
||||||
|
args.push(cookies.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
args.push(url);
|
args.push(url.clone());
|
||||||
|
|
||||||
// Output template
|
let output_template = format!(
|
||||||
let output_template = format!("{}/%(title)s.%(ext)s", options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR));
|
"{}/%(title)s.%(ext)s",
|
||||||
|
options.output_path.trim_end_matches(std::path::MAIN_SEPARATOR)
|
||||||
|
);
|
||||||
args.push("-o".to_string());
|
args.push("-o".to_string());
|
||||||
args.push(output_template);
|
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 {
|
if options.is_audio_only {
|
||||||
args.push("-x".to_string());
|
args.push("-x".to_string());
|
||||||
args.push("--audio-format".to_string());
|
if options.output_format != "original" {
|
||||||
args.push("mp3".to_string());
|
args.push("--audio-format".to_string());
|
||||||
|
args.push(options.output_format.clone());
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let format_arg = if options.quality == "best" {
|
let format_arg = if options.quality == "best" {
|
||||||
"bestvideo+bestaudio/best".to_string()
|
"bestvideo+bestaudio/best".to_string()
|
||||||
} else {
|
} 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("-f".to_string());
|
||||||
args.push(format_arg);
|
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());
|
args.push("--newline".to_string());
|
||||||
|
|
||||||
// Log the full command
|
emit_log(
|
||||||
let full_cmd_str = format!("{} {}", ytdlp_path.to_string_lossy(), args.join(" "));
|
&app,
|
||||||
app.emit("download-log", LogEvent {
|
&id,
|
||||||
id: id.clone(),
|
format!("正在执行命令: {} {}", ytdlp_path.to_string_lossy(), args.join(" ")),
|
||||||
message: format!("Executing command: {}", full_cmd_str),
|
"info",
|
||||||
level: "info".to_string(),
|
);
|
||||||
}).ok();
|
|
||||||
|
|
||||||
let mut cmd = Command::new(ytdlp_path);
|
let mut command = Command::new(ytdlp_path);
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
command.creation_flags(0x08000000);
|
||||||
|
|
||||||
let mut child = cmd
|
let mut child = command
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()?;
|
.spawn()?;
|
||||||
|
|
||||||
let stdout = child.stdout.take().ok_or(anyhow!("Failed to open stdout"))?;
|
let stdout = child.stdout.take().ok_or_else(|| anyhow!("Failed to open stdout"))?;
|
||||||
let stderr = child.stderr.take().ok_or(anyhow!("Failed to open stderr"))?;
|
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 progress_regex =
|
||||||
let mut stderr_reader = BufReader::new(stderr);
|
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
|
while let Some(line) = reader.next_line().await? {
|
||||||
loop {
|
let trimmed = line.trim();
|
||||||
let mut out_line = String::new();
|
if trimmed.is_empty() {
|
||||||
let mut err_line = String::new();
|
continue;
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
res = stdout_reader.read_line(&mut out_line) => {
|
|
||||||
if res.unwrap_or(0) == 0 {
|
|
||||||
break; // EOF
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse progress
|
if let Some(path) = trimmed.strip_prefix(FINAL_PATH_MARKER) {
|
||||||
if let Some(caps) = re.captures(&out_line) {
|
final_path = Some(path.to_string());
|
||||||
if let Some(pct_match) = caps.get(1) {
|
continue;
|
||||||
if let Ok(pct) = pct_match.as_str().parse::<f64>() {
|
}
|
||||||
app.emit("download-progress", ProgressEvent {
|
|
||||||
id: id.clone(),
|
if trimmed.contains("Destination") || trimmed.contains("Merging formats") || trimmed.contains("Post-process") {
|
||||||
progress: pct,
|
let task = storage::update_task_status(&app, &id, "postprocessing", None, final_path.as_deref())?;
|
||||||
speed: "TODO".to_string(),
|
emit_task(&app, &task);
|
||||||
status: "downloading".to_string(),
|
}
|
||||||
}).ok();
|
|
||||||
|
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() {
|
Ok::<Option<String>, anyhow::Error>(final_path)
|
||||||
app.emit("download-progress", ProgressEvent {
|
})
|
||||||
id: id.clone(),
|
};
|
||||||
progress: 100.0,
|
|
||||||
speed: "-".to_string(),
|
let stderr_task = {
|
||||||
status: "finished".to_string(),
|
let app = app.clone();
|
||||||
}).ok();
|
let id = id.clone();
|
||||||
Ok("Download complete".to_string())
|
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 {
|
} else {
|
||||||
app.emit("download-progress", ProgressEvent {
|
let (status_name, error_message) = if was_cancelled {
|
||||||
id: id.clone(),
|
("cancelled", Some("任务已取消".to_string()))
|
||||||
progress: 0.0,
|
} else {
|
||||||
speed: "-".to_string(),
|
(
|
||||||
status: "error".to_string(),
|
"failed",
|
||||||
}).ok();
|
Some(
|
||||||
Err(anyhow!("Download process failed"))
|
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 downloader;
|
||||||
mod storage;
|
mod storage;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod process_utils;
|
||||||
|
mod task_runtime;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
@@ -13,15 +15,25 @@ pub fn run() {
|
|||||||
commands::init_ytdlp,
|
commands::init_ytdlp,
|
||||||
commands::update_ytdlp,
|
commands::update_ytdlp,
|
||||||
commands::update_quickjs,
|
commands::update_quickjs,
|
||||||
|
commands::update_ffmpeg,
|
||||||
commands::get_ytdlp_version,
|
commands::get_ytdlp_version,
|
||||||
commands::get_quickjs_version,
|
commands::get_quickjs_version,
|
||||||
|
commands::get_ffmpeg_version,
|
||||||
|
commands::get_runtime_status,
|
||||||
|
commands::fetch_image,
|
||||||
commands::fetch_metadata,
|
commands::fetch_metadata,
|
||||||
commands::start_download,
|
commands::start_download,
|
||||||
|
commands::cancel_task,
|
||||||
|
commands::retry_task,
|
||||||
|
commands::get_tasks,
|
||||||
|
commands::get_task_logs,
|
||||||
|
commands::clear_task_logs,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::get_history,
|
commands::get_history,
|
||||||
commands::clear_history,
|
commands::clear_history,
|
||||||
commands::delete_history_item,
|
commands::delete_history_item,
|
||||||
|
commands::close_splash,
|
||||||
commands::open_in_explorer
|
commands::open_in_explorer
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
10
src-tauri/src/process_utils.rs
Normal file
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 serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tauri::{AppHandle, Manager};
|
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)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub download_path: String,
|
pub download_path: String,
|
||||||
pub theme: String, // 'light', 'dark', 'system'
|
pub cookies_path: Option<String>,
|
||||||
|
pub theme: String,
|
||||||
pub last_updated: Option<DateTime<Utc>>,
|
pub last_updated: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> Self {
|
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 {
|
Self {
|
||||||
download_path: "".to_string(),
|
download_path: String::new(),
|
||||||
|
cookies_path: None,
|
||||||
theme: "system".to_string(),
|
theme: "system".to_string(),
|
||||||
last_updated: None,
|
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)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct HistoryItem {
|
pub struct HistoryItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -32,13 +69,47 @@ pub struct HistoryItem {
|
|||||||
pub thumbnail: String,
|
pub thumbnail: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub output_path: String,
|
pub output_path: String,
|
||||||
|
pub file_path: Option<String>,
|
||||||
pub timestamp: DateTime<Utc>,
|
pub timestamp: DateTime<Utc>,
|
||||||
pub status: String, // "success", "failed"
|
pub status: String,
|
||||||
pub format: 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> {
|
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()?;
|
let path = app.path().app_data_dir()?;
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
fs::create_dir_all(&path)?;
|
fs::create_dir_all(&path)?;
|
||||||
@@ -46,71 +117,513 @@ pub fn get_app_data_dir(app: &AppHandle) -> Result<PathBuf> {
|
|||||||
Ok(path)
|
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"))
|
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"))
|
Ok(get_app_data_dir(app)?.join("history.json"))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_settings(app: &AppHandle) -> Result<Settings> {
|
fn migrate_legacy_files(app: &AppHandle, connection: &Connection) -> Result<()> {
|
||||||
let path = get_settings_path(app)?;
|
let task_count: i64 = connection.query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get(0))?;
|
||||||
if path.exists() {
|
|
||||||
let content = fs::read_to_string(&path)?;
|
let settings_path = get_legacy_settings_path(app)?;
|
||||||
let settings: Settings = serde_json::from_str(&content)?;
|
if settings_path.exists() {
|
||||||
return Ok(settings);
|
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.
|
if task_count == 0 {
|
||||||
// Note: We might want to set a default download path here if possible.
|
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();
|
let mut settings = Settings::default();
|
||||||
|
|
||||||
if let Ok(download_dir) = app.path().download_dir() {
|
if let Ok(download_dir) = app.path().download_dir() {
|
||||||
settings.download_path = download_dir.to_string_lossy().to_string();
|
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)
|
Ok(settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_settings(app: &AppHandle, settings: &Settings) -> Result<()> {
|
pub fn save_settings(app: &AppHandle, settings: &Settings) -> Result<()> {
|
||||||
let path = get_settings_path(app)?;
|
let connection = open_database(app)?;
|
||||||
let content = serde_json::to_string_pretty(settings)?;
|
save_setting_value(&connection, "download_path", &settings.download_path)?;
|
||||||
fs::write(path, content)?;
|
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(())
|
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>> {
|
pub fn load_history(app: &AppHandle) -> Result<Vec<HistoryItem>> {
|
||||||
let path = get_history_path(app)?;
|
let tasks = list_tasks(app)?;
|
||||||
if path.exists() {
|
let mut history = Vec::new();
|
||||||
let content = fs::read_to_string(&path)?;
|
|
||||||
let history: Vec<HistoryItem> = serde_json::from_str(&content)?;
|
for task in tasks.into_iter().filter(TaskRecord::is_terminal) {
|
||||||
Ok(history)
|
history.push(HistoryItem {
|
||||||
} else {
|
id: task.id,
|
||||||
Ok(Vec::new())
|
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<()> {
|
Ok(history)
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear_history(app: &AppHandle) -> Result<()> {
|
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<()> {
|
pub fn delete_history_item(app: &AppHandle, id: &str) -> Result<()> {
|
||||||
let mut history = load_history(app)?;
|
let connection = open_database(app)?;
|
||||||
history.retain(|item| item.id != id);
|
connection.execute("DELETE FROM tasks WHERE id = ?1", [id])?;
|
||||||
save_history(app, &history)?;
|
|
||||||
Ok(())
|
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
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",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "stream-capture",
|
"productName": "StreamCapture",
|
||||||
"version": "0.1.0",
|
"version": "1.2.1",
|
||||||
"identifier": "top.volan.stream-capture",
|
"identifier": "top.volan.stream-capture",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm dev",
|
"beforeDevCommand": "pnpm dev",
|
||||||
@@ -12,13 +12,26 @@
|
|||||||
"app": {
|
"app": {
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Stream Capture",
|
"label": "main",
|
||||||
|
"title": "流萤 - 视频下载 v1.2.1",
|
||||||
"width": 1300,
|
"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": {
|
"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": {
|
"bundle": {
|
||||||
|
|||||||
12
src/App.vue
12
src/App.vue
@@ -1,6 +1,5 @@
|
|||||||
// filepath: src/App.vue
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
import { RouterView, RouterLink, useRoute } from 'vue-router'
|
import { RouterView, RouterLink, useRoute } from 'vue-router'
|
||||||
import { Home, History, Settings as SettingsIcon, Download, FileText } from 'lucide-vue-next'
|
import { Home, History, Settings as SettingsIcon, Download, FileText } from 'lucide-vue-next'
|
||||||
import { useSettingsStore } from './stores/settings'
|
import { useSettingsStore } from './stores/settings'
|
||||||
@@ -14,16 +13,23 @@ const route = useRoute()
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await settingsStore.loadSettings()
|
await settingsStore.loadSettings()
|
||||||
|
settingsStore.initThemeListener()
|
||||||
await settingsStore.initYtdlp()
|
await settingsStore.initYtdlp()
|
||||||
await queueStore.initListener()
|
await queueStore.initListener()
|
||||||
await logsStore.initListener()
|
await logsStore.initListener()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
settingsStore.disposeThemeListener()
|
||||||
|
queueStore.disposeListener()
|
||||||
|
logsStore.disposeListener()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-screen bg-gray-50 dark:bg-zinc-950 text-zinc-900 dark:text-gray-100 font-sans overflow-hidden">
|
<div class="flex h-screen bg-gray-50 dark:bg-zinc-950 text-zinc-900 dark:text-gray-100 font-sans overflow-hidden">
|
||||||
<!-- Sidebar -->
|
<!-- 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="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">
|
<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" />
|
<Download class="w-5 h-5" />
|
||||||
|
|||||||
44
src/splash/App.vue
Normal file
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
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,26 +1,33 @@
|
|||||||
// filepath: src/stores/analysis.ts
|
// filepath: src/stores/analysis.ts
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import type { AnalysisMetadata } from '../types/media'
|
||||||
|
import { isPlaylistMetadata } from '../types/media'
|
||||||
|
|
||||||
export const useAnalysisStore = defineStore('analysis', () => {
|
export const useAnalysisStore = defineStore('analysis', () => {
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const metadata = ref<any>(null)
|
const metadata = ref<AnalysisMetadata | null>(null)
|
||||||
|
|
||||||
// New state for mix detection
|
// New state for mix detection
|
||||||
const isMix = ref(false)
|
const isMix = ref(false)
|
||||||
const scanMix = ref(false)
|
const scanMix = ref(false)
|
||||||
|
|
||||||
|
// Input mode state
|
||||||
|
const isBatchMode = ref(false)
|
||||||
|
|
||||||
const options = ref({
|
const options = ref({
|
||||||
is_audio_only: false,
|
is_audio_only: false,
|
||||||
quality: 'best',
|
quality: 'best',
|
||||||
output_path: ''
|
output_path: '',
|
||||||
|
output_format: 'original',
|
||||||
|
cookies_path: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleEntry(id: string) {
|
function toggleEntry(id: string) {
|
||||||
if (metadata.value && metadata.value.entries) {
|
if (isPlaylistMetadata(metadata.value)) {
|
||||||
const entry = metadata.value.entries.find((e: any) => e.id === id)
|
const entry = metadata.value.entries.find(e => e.id === id)
|
||||||
if (entry) {
|
if (entry) {
|
||||||
entry.selected = !entry.selected
|
entry.selected = !entry.selected
|
||||||
}
|
}
|
||||||
@@ -28,8 +35,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setAllEntries(selected: boolean) {
|
function setAllEntries(selected: boolean) {
|
||||||
if (metadata.value && metadata.value.entries) {
|
if (isPlaylistMetadata(metadata.value)) {
|
||||||
metadata.value.entries = metadata.value.entries.map((e: any) => ({
|
metadata.value.entries = metadata.value.entries.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
selected
|
selected
|
||||||
}))
|
}))
|
||||||
@@ -37,8 +44,8 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function invertSelection() {
|
function invertSelection() {
|
||||||
if (metadata.value && metadata.value.entries) {
|
if (isPlaylistMetadata(metadata.value)) {
|
||||||
metadata.value.entries = metadata.value.entries.map((e: any) => ({
|
metadata.value.entries = metadata.value.entries.map(e => ({
|
||||||
...e,
|
...e,
|
||||||
selected: !e.selected
|
selected: !e.selected
|
||||||
}))
|
}))
|
||||||
@@ -54,5 +61,5 @@ export const useAnalysisStore = defineStore('analysis', () => {
|
|||||||
scanMix.value = false
|
scanMix.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return { url, loading, error, metadata, options, isMix, scanMix, toggleEntry, setAllEntries, invertSelection, 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 { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
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 {
|
export interface LogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,15 +18,25 @@ export interface LogEntry {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LogEvent {
|
function mapTaskLog(entry: TaskLogEntry): LogEntry {
|
||||||
id: string
|
return {
|
||||||
message: string
|
id: `${entry.id}`,
|
||||||
level: string
|
taskId: entry.task_id,
|
||||||
|
message: entry.message,
|
||||||
|
level: entry.level,
|
||||||
|
timestamp: new Date(entry.timestamp).getTime()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLogsStore = defineStore('logs', () => {
|
export const useLogsStore = defineStore('logs', () => {
|
||||||
const logs = ref<LogEntry[]>([])
|
const logs = ref<LogEntry[]>([])
|
||||||
const isListening = ref(false)
|
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') {
|
function addLog(taskId: string, message: string, level: 'info' | 'error') {
|
||||||
logs.value.push({
|
logs.value.push({
|
||||||
@@ -30,9 +47,8 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Optional: Limit log size to avoid memory issues
|
|
||||||
if (logs.value.length > 5000) {
|
if (logs.value.length > 5000) {
|
||||||
logs.value = logs.value.slice(-5000)
|
logs.value = logs.value.slice(-5000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,19 +56,27 @@ export const useLogsStore = defineStore('logs', () => {
|
|||||||
if (isListening.value) return
|
if (isListening.value) return
|
||||||
isListening.value = true
|
isListening.value = true
|
||||||
|
|
||||||
await listen<LogEvent>('download-log', (event) => {
|
await loadLogs()
|
||||||
|
|
||||||
|
unlisten = await listen<LogEvent>('download-log', (event) => {
|
||||||
const { id, message, level } = event.payload
|
const { id, message, level } = event.payload
|
||||||
addLog(id, message, level as 'info' | 'error')
|
addLog(id, message, level)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearLogs() {
|
async function clearLogs() {
|
||||||
logs.value = []
|
await invoke('clear_task_logs')
|
||||||
|
logs.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI State Persistence
|
|
||||||
const autoScroll = ref(true)
|
const autoScroll = ref(true)
|
||||||
const scrollTop = ref(0)
|
const scrollTop = ref(0)
|
||||||
|
|
||||||
return { logs, addLog, initListener, clearLogs, autoScroll, scrollTop }
|
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 { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
|
import type { TaskRecord } from '../types/task'
|
||||||
export interface DownloadTask {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
thumbnail: string
|
|
||||||
progress: number
|
|
||||||
speed: string
|
|
||||||
status: 'pending' | 'downloading' | 'finished' | 'error'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProgressEvent {
|
interface ProgressEvent {
|
||||||
id: string
|
id: string
|
||||||
progress: number
|
progress: number
|
||||||
speed: string
|
speed: string
|
||||||
status: string
|
eta?: string | null
|
||||||
|
status: TaskRecord['status']
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useQueueStore = defineStore('queue', () => {
|
export const useQueueStore = defineStore('queue', () => {
|
||||||
const tasks = ref<DownloadTask[]>([])
|
const tasks = ref<TaskRecord[]>([])
|
||||||
const isListening = ref(false)
|
const isListening = ref(false)
|
||||||
|
let progressUnlisten: (() => void) | null = null
|
||||||
|
let taskUnlisten: (() => void) | null = null
|
||||||
|
|
||||||
function addTask(task: DownloadTask) {
|
const activeTasks = computed(() => tasks.value.filter(task => !['completed', 'failed', 'cancelled'].includes(task.status)))
|
||||||
tasks.value.push(task)
|
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() {
|
async function initListener() {
|
||||||
if (isListening.value) return
|
if (isListening.value) return
|
||||||
isListening.value = true
|
isListening.value = true
|
||||||
|
|
||||||
await listen<ProgressEvent>('download-progress', (event) => {
|
await loadTasks()
|
||||||
const { id, progress, speed, status } = event.payload
|
|
||||||
const task = tasks.value.find(t => t.id === id)
|
taskUnlisten = await listen<TaskRecord>('task-updated', (event) => {
|
||||||
if (task) {
|
upsertTask(event.payload)
|
||||||
task.progress = progress
|
})
|
||||||
task.speed = speed
|
|
||||||
// Map status string to type if needed, or just assign
|
progressUnlisten = await listen<ProgressEvent>('download-progress', (event) => {
|
||||||
task.status = status as any
|
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 { defineStore } from 'pinia'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import type { RuntimeStatus } from '../types/task'
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
download_path: string
|
download_path: string
|
||||||
|
cookies_path?: string
|
||||||
theme: 'light' | 'dark' | 'system'
|
theme: 'light' | 'dark' | 'system'
|
||||||
last_updated: string | null
|
last_updated: string | null
|
||||||
}
|
}
|
||||||
@@ -12,13 +14,19 @@ export interface Settings {
|
|||||||
export const useSettingsStore = defineStore('settings', () => {
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
const settings = ref<Settings>({
|
const settings = ref<Settings>({
|
||||||
download_path: '',
|
download_path: '',
|
||||||
|
cookies_path: '',
|
||||||
theme: 'system',
|
theme: 'system',
|
||||||
last_updated: null
|
last_updated: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const ytdlpVersion = ref('Checking...')
|
const ytdlpVersion = ref('Checking...')
|
||||||
const quickjsVersion = ref('Checking...')
|
const quickjsVersion = ref('Checking...')
|
||||||
|
const ffmpegVersion = ref('Checking...')
|
||||||
|
const runtimeStatus = ref<RuntimeStatus | null>(null)
|
||||||
const isInitializing = ref(true)
|
const isInitializing = ref(true)
|
||||||
|
const hasInitialized = ref(false)
|
||||||
|
let mediaQuery: MediaQueryList | null = null
|
||||||
|
let mediaListener: (() => void) | null = null
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
try {
|
try {
|
||||||
@@ -40,23 +48,35 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initYtdlp() {
|
async function initYtdlp() {
|
||||||
|
if (hasInitialized.value) return
|
||||||
try {
|
try {
|
||||||
isInitializing.value = true
|
isInitializing.value = true
|
||||||
// check/download
|
// check/download
|
||||||
await invoke('init_ytdlp')
|
await invoke('init_ytdlp')
|
||||||
await refreshVersions()
|
await refreshVersions()
|
||||||
|
hasInitialized.value = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
ytdlpVersion.value = 'Error'
|
ytdlpVersion.value = 'Error'
|
||||||
quickjsVersion.value = 'Error'
|
quickjsVersion.value = 'Error'
|
||||||
|
ffmpegVersion.value = 'Error'
|
||||||
} finally {
|
} finally {
|
||||||
isInitializing.value = false
|
isInitializing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshVersions() {
|
async function refreshVersions() {
|
||||||
ytdlpVersion.value = await invoke('get_ytdlp_version')
|
const [ytdlp, quickjs, ffmpeg, runtime] = await Promise.allSettled([
|
||||||
quickjsVersion.value = await invoke('get_quickjs_version')
|
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) {
|
function applyTheme(theme: string) {
|
||||||
@@ -69,12 +89,37 @@ export const useSettingsStore = defineStore('settings', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Watch system preference changes if theme is system
|
function initThemeListener() {
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
if (mediaQuery) return
|
||||||
|
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
mediaListener = () => {
|
||||||
if (settings.value.theme === 'system') {
|
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
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
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
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,7 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
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 { formatDistanceToNow } from 'date-fns'
|
||||||
import { zhCN } from 'date-fns/locale'
|
import { zhCN } from 'date-fns/locale'
|
||||||
|
|
||||||
@@ -11,12 +12,14 @@ interface HistoryItem {
|
|||||||
thumbnail: string
|
thumbnail: string
|
||||||
url: string
|
url: string
|
||||||
output_path: string
|
output_path: string
|
||||||
|
file_path?: string | null
|
||||||
timestamp: string
|
timestamp: string
|
||||||
status: string
|
status: string
|
||||||
format: string
|
format: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = ref<HistoryItem[]>([])
|
const history = ref<HistoryItem[]>([])
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
async function loadHistory() {
|
async function loadHistory() {
|
||||||
try {
|
try {
|
||||||
@@ -46,6 +49,19 @@ async function openFolder(path: string) {
|
|||||||
await invoke('open_in_explorer', { path })
|
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)
|
onMounted(loadHistory)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -54,7 +70,7 @@ onMounted(loadHistory)
|
|||||||
<header class="mb-8 flex justify-between items-center">
|
<header class="mb-8 flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">下载历史</h1>
|
<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>
|
<!-- <p class="text-gray-500 dark:text-gray-400 mt-2">管理您的下载记录。</p> -->
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="clearHistory"
|
@click="clearHistory"
|
||||||
@@ -106,20 +122,42 @@ onMounted(loadHistory)
|
|||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span
|
<span
|
||||||
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium"
|
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>
|
<span
|
||||||
{{ item.status === 'success' ? '已完成' : '失败' }}
|
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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-right whitespace-nowrap">
|
<td class="px-6 py-4 text-right whitespace-nowrap">
|
||||||
<button
|
<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"
|
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="打开输出文件夹"
|
title="打开输出文件夹"
|
||||||
>
|
>
|
||||||
<FolderOpen class="w-4 h-4" />
|
<FolderOpen class="w-4 h-4" />
|
||||||
</button>
|
</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
|
<button
|
||||||
@click="deleteItem(item.id)"
|
@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"
|
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"
|
||||||
|
|||||||
@@ -1,15 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { watch } from 'vue'
|
import { computed, watch } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
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 { useQueueStore } from '../stores/queue'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { useAnalysisStore } from '../stores/analysis'
|
import { useAnalysisStore } from '../stores/analysis'
|
||||||
import AppSelect from '../components/ui/AppSelect.vue'
|
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 queueStore = useQueueStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const analysisStore = useAnalysisStore()
|
const analysisStore = useAnalysisStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const qualityOptions = [
|
const qualityOptions = [
|
||||||
{ label: '最佳画质', value: 'best' },
|
{ label: '最佳画质', value: 'best' },
|
||||||
@@ -18,63 +29,155 @@ const qualityOptions = [
|
|||||||
{ label: '480p', value: '480' },
|
{ 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) => {
|
watch(() => settingsStore.settings.download_path, (newPath) => {
|
||||||
if (newPath && !analysisStore.options.output_path) {
|
if (newPath && !analysisStore.options.output_path) {
|
||||||
analysisStore.options.output_path = newPath
|
analysisStore.options.output_path = newPath
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Detect Mix URL
|
|
||||||
watch(() => analysisStore.url, (newUrl) => {
|
watch(() => analysisStore.url, (newUrl) => {
|
||||||
if (newUrl && newUrl.includes('v=') && newUrl.includes('list=')) {
|
analysisStore.isMix = !!newUrl && detectMixUrl(newUrl)
|
||||||
analysisStore.isMix = true
|
if (!analysisStore.isMix) {
|
||||||
} else {
|
analysisStore.scanMix = false
|
||||||
analysisStore.isMix = false
|
}
|
||||||
// Reset scanMix if URL changes to non-mix
|
|
||||||
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() {
|
async function analyze() {
|
||||||
if (!analysisStore.url) return
|
if (!analysisStore.url) return
|
||||||
|
|
||||||
analysisStore.loading = true
|
analysisStore.loading = true
|
||||||
analysisStore.error = ''
|
analysisStore.error = ''
|
||||||
analysisStore.metadata = null
|
analysisStore.metadata = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let urlToScan = analysisStore.url;
|
if (analysisStore.isBatchMode) {
|
||||||
let parseMix = false;
|
await analyzeBatchLinks()
|
||||||
|
} else {
|
||||||
if (analysisStore.isMix) {
|
await analyzeSingleLink()
|
||||||
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=[^&]+/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
const res = await invoke<any>('fetch_metadata', { url: urlToScan, parseMixPlaylist: parseMix })
|
analysisStore.error = error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
// Initialize selected state for playlist entries
|
|
||||||
if (res.entries) {
|
|
||||||
res.entries = res.entries.map((e: any) => ({ ...e, selected: true }))
|
|
||||||
}
|
|
||||||
|
|
||||||
analysisStore.metadata = res
|
|
||||||
} catch (e: any) {
|
|
||||||
analysisStore.error = e.toString()
|
|
||||||
} finally {
|
} finally {
|
||||||
analysisStore.loading = false
|
analysisStore.loading = false
|
||||||
}
|
}
|
||||||
@@ -83,74 +186,67 @@ async function analyze() {
|
|||||||
async function startDownload() {
|
async function startDownload() {
|
||||||
if (!analysisStore.metadata) return
|
if (!analysisStore.metadata) return
|
||||||
|
|
||||||
// Ensure path is set
|
|
||||||
if (!analysisStore.options.output_path) {
|
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 {
|
try {
|
||||||
if (analysisStore.metadata.entries) {
|
if (playlistMetadata.value) {
|
||||||
// Playlist Download
|
const selectedEntries = playlistMetadata.value.entries.filter(entry => entry.selected)
|
||||||
const selectedEntries = analysisStore.metadata.entries.filter((e: any) => e.selected)
|
|
||||||
|
|
||||||
if (selectedEntries.length === 0) {
|
if (selectedEntries.length === 0) {
|
||||||
analysisStore.error = "请至少选择一个要下载的视频。"
|
analysisStore.error = '请至少选择一个要下载的视频。'
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of selectedEntries) {
|
|
||||||
const videoUrl = `https://www.youtube.com/watch?v=${entry.id}`
|
|
||||||
const id = await invoke<string>('start_download', {
|
|
||||||
url: videoUrl,
|
|
||||||
options: analysisStore.options,
|
|
||||||
metadata: entry // Pass the individual video metadata
|
|
||||||
})
|
|
||||||
|
|
||||||
queueStore.addTask({
|
|
||||||
id,
|
|
||||||
title: entry.title,
|
|
||||||
thumbnail: entry.thumbnail,
|
|
||||||
progress: 0,
|
|
||||||
speed: '等待中...',
|
|
||||||
status: 'pending'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Single Video Download
|
|
||||||
let urlToDownload = analysisStore.url;
|
|
||||||
// Clean URL if it was a mix but we didn't scan it as one
|
|
||||||
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=[^&]+/, '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = await invoke<string>('start_download', {
|
|
||||||
url: urlToDownload,
|
|
||||||
options: analysisStore.options,
|
|
||||||
metadata: analysisStore.metadata
|
|
||||||
})
|
|
||||||
|
|
||||||
queueStore.addTask({
|
|
||||||
id,
|
|
||||||
title: analysisStore.metadata.title,
|
|
||||||
thumbnail: analysisStore.metadata.thumbnail,
|
|
||||||
progress: 0,
|
|
||||||
speed: '等待中...',
|
|
||||||
status: 'pending'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset state after successful download start
|
for (const entry of selectedEntries) {
|
||||||
analysisStore.reset()
|
const videoUrl = entry.url || `https://www.youtube.com/watch?v=${entry.id}`
|
||||||
} catch (e: any) {
|
await invoke<string>('start_download', {
|
||||||
analysisStore.error = "下载启动失败: " + e.toString()
|
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: singleMetadata.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
@@ -159,209 +255,310 @@ async function startDownload() {
|
|||||||
<div class="max-w-5xl mx-auto p-8">
|
<div class="max-w-5xl mx-auto p-8">
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">新建下载</h1>
|
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">新建下载</h1>
|
||||||
<p class="text-gray-500 dark:text-gray-400 mt-2">粘贴 URL 开始下载媒体。</p>
|
|
||||||
</header>
|
</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="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">
|
<div class="flex flex-col gap-3">
|
||||||
<input
|
<div class="flex justify-end mb-1">
|
||||||
v-model="analysisStore.url"
|
<button
|
||||||
type="text"
|
@click="analysisStore.isBatchMode = !analysisStore.isBatchMode"
|
||||||
placeholder="https://youtube.com/watch?v=..."
|
class="text-xs font-medium flex items-center gap-1.5 px-3 py-1.5 rounded-lg transition-colors"
|
||||||
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"
|
: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'"
|
||||||
@keyup.enter="analyze"
|
>
|
||||||
/>
|
<List v-if="analysisStore.isBatchMode" class="w-3.5 h-3.5" />
|
||||||
<button
|
<Link v-else class="w-3.5 h-3.5" />
|
||||||
@click="analyze"
|
{{ analysisStore.isBatchMode ? '单链接模式' : '批量输入模式' }}
|
||||||
:disabled="analysisStore.loading"
|
</button>
|
||||||
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"
|
</div>
|
||||||
>
|
|
||||||
<Loader2 v-if="analysisStore.loading" class="animate-spin w-5 h-5" />
|
<div class="flex gap-4 items-start">
|
||||||
<span v-else>解析</span>
|
<div class="flex-1 relative">
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
<!-- Mix Toggle -->
|
<div v-if="!analysisStore.isBatchMode && analysisStore.isMix" class="mt-4 flex items-center gap-3">
|
||||||
<div v-if="analysisStore.isMix" class="mt-4 flex items-center gap-3">
|
<button
|
||||||
<button
|
@click="analysisStore.scanMix = !analysisStore.scanMix"
|
||||||
@click="analysisStore.scanMix = !analysisStore.scanMix"
|
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out flex-shrink-0"
|
||||||
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'"
|
||||||
:class="analysisStore.scanMix ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
>
|
||||||
>
|
<span
|
||||||
<span
|
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
|
||||||
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'"
|
||||||
:class="analysisStore.scanMix ? 'translate-x-6' : 'translate-x-0'"
|
/>
|
||||||
/>
|
</button>
|
||||||
</button>
|
<span class="text-sm font-medium text-zinc-700 dark:text-gray-300">解析播放列表 (前20项)</span>
|
||||||
<span class="text-sm font-medium text-zinc-700 dark:text-gray-300">解析播放列表 (前20项)</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="analysisStore.error" class="mt-3 text-red-500 text-sm">{{ analysisStore.error }}</p>
|
<p v-if="analysisStore.error" class="mt-3 text-red-500 text-sm">{{ analysisStore.error }}</p>
|
||||||
</div>
|
</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 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 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>
|
||||||
|
|
||||||
<!-- Playlist Header / Global Controls -->
|
<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 v-if="analysisStore.metadata.entries" class="p-6 border-b border-gray-200 dark:border-zinc-800">
|
<div class="flex items-center gap-2 w-full md:w-auto">
|
||||||
<div class="flex items-start justify-between mb-4">
|
<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>
|
||||||
<div>
|
<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>
|
||||||
<h2 class="text-xl font-bold text-zinc-900 dark:text-white">{{ analysisStore.metadata.title }}</h2>
|
<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>
|
||||||
<p class="text-blue-500 mt-1 font-medium">{{ analysisStore.metadata.entries.length }} 个视频</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Global Options Bar -->
|
<div class="flex items-center gap-6 w-full md:w-auto justify-end">
|
||||||
<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-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>
|
||||||
<!-- Left: Selection Controls -->
|
|
||||||
<div class="flex items-center gap-2 w-full md:w-auto">
|
|
||||||
<button @click="analysisStore.setAllEntries(true)" class="text-xs 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-xs 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-xs 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>
|
|
||||||
|
|
||||||
<!-- Right: Settings -->
|
|
||||||
<div class="flex items-center gap-6 w-full md:w-auto justify-end">
|
|
||||||
<!-- Audio Only Toggle -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="font-medium text-sm text-zinc-700 dark:text-gray-300">仅音频</span>
|
|
||||||
<button
|
|
||||||
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
|
|
||||||
class="w-10 h-5 rounded-full relative transition-colors duration-200 ease-in-out shrink-0"
|
|
||||||
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="absolute top-1 left-1 w-3 h-3 bg-white rounded-full transition-transform duration-200"
|
|
||||||
:class="analysisStore.options.is_audio_only ? 'translate-x-5' : 'translate-x-0'"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quality Dropdown -->
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="font-medium text-sm text-zinc-700 dark:text-gray-300">画质</span>
|
|
||||||
<div class="w-44">
|
|
||||||
<AppSelect
|
|
||||||
v-model="analysisStore.options.quality"
|
|
||||||
:options="qualityOptions"
|
|
||||||
:disabled="analysisStore.options.is_audio_only"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Video List (Playlist Mode) -->
|
|
||||||
<div v-if="analysisStore.metadata.entries" 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 analysisStore.metadata.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'"
|
|
||||||
>
|
|
||||||
<!-- Checkbox -->
|
|
||||||
<button
|
<button
|
||||||
@click="entry.selected = !entry.selected"
|
@click="analysisStore.options.is_audio_only = !analysisStore.options.is_audio_only"
|
||||||
class="w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-colors shrink-0"
|
class="w-12 h-6 rounded-full relative transition-colors duration-200 ease-in-out"
|
||||||
:class="entry.selected ? 'bg-blue-600 border-blue-600 text-white' : 'border-gray-300 dark:border-zinc-600 hover:border-blue-400'"
|
:class="analysisStore.options.is_audio_only ? 'bg-blue-600' : 'bg-gray-300 dark:bg-zinc-600'"
|
||||||
>
|
>
|
||||||
<svg v-if="entry.selected" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
<span
|
||||||
<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" />
|
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200"
|
||||||
</svg>
|
:class="analysisStore.options.is_audio_only ? 'translate-x-6' : 'translate-x-0'"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Thumb -->
|
<div class="flex items-center gap-3">
|
||||||
<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="w-44">
|
||||||
|
|
||||||
<!-- Info -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Single Video Layout -->
|
|
||||||
<div v-else 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 }} 个视频 (播放列表)</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">仅音频</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>
|
|
||||||
|
|
||||||
<!-- Quality Dropdown -->
|
|
||||||
<div class="relative">
|
|
||||||
<AppSelect
|
<AppSelect
|
||||||
v-model="analysisStore.options.quality"
|
v-model="analysisStore.options.quality"
|
||||||
:options="qualityOptions"
|
:options="qualityOptions"
|
||||||
:disabled="analysisStore.options.is_audio_only"
|
: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>
|
||||||
</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">
|
<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
|
<button
|
||||||
@click="startDownload"
|
@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"
|
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"
|
||||||
>
|
>
|
||||||
立即下载 {{ analysisStore.metadata.entries ? `(${analysisStore.metadata.entries.filter((e: any) => e.selected).length})` : '' }}
|
立即下载 {{ selectedCount > 0 ? `(${selectedCount})` : '' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active Downloads -->
|
<div v-if="queueStore.activeTasks.length > 0">
|
||||||
<div v-if="queueStore.tasks.length > 0">
|
<h3 class="text-lg font-bold mb-4">进行中的任务</h3>
|
||||||
<h3 class="text-lg font-bold mb-4">进行中的任务</h3>
|
<div class="space-y-3">
|
||||||
<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">
|
||||||
<!-- Reversed to show newest first -->
|
<img :src="task.thumbnail || '/placeholder.png'" class="w-16 h-16 object-cover rounded-lg bg-gray-100 dark:bg-zinc-800" />
|
||||||
<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">
|
<div class="flex-1 min-w-0">
|
||||||
<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 justify-between mb-1">
|
||||||
<div class="flex-1 min-w-0">
|
<h4 class="font-medium truncate pr-4">{{ task.title }}</h4>
|
||||||
<div class="flex justify-between mb-1">
|
<span class="text-xs font-mono text-gray-500 whitespace-nowrap">
|
||||||
<h4 class="font-medium truncate pr-4">{{ task.title }}</h4>
|
{{ task.status === 'postprocessing' ? '处理中' : (task.speed || task.status) }}
|
||||||
<span class="text-xs font-mono text-gray-500 whitespace-nowrap">
|
</span>
|
||||||
{{ task.status === 'finished' ? '已完成' : (task.status === 'error' ? '失败' : task.speed) }}
|
</div>
|
||||||
</span>
|
<div class="h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
||||||
</div>
|
<div
|
||||||
<div class="h-2 bg-gray-100 dark:bg-zinc-800 rounded-full overflow-hidden">
|
class="h-full transition-all duration-300"
|
||||||
<div
|
:style="{ width: `${task.progress}%` }"
|
||||||
class="h-full transition-all duration-300"
|
:class="task.status === 'postprocessing' ? 'bg-amber-500' : 'bg-blue-600'"
|
||||||
:style="{ width: `${task.progress}%` }"
|
/>
|
||||||
:class="task.status === 'error' ? 'bg-red-500' : (task.status === 'finished' ? 'bg-green-500' : 'bg-blue-600')"
|
</div>
|
||||||
/>
|
<div class="flex items-center justify-between mt-2 text-xs text-gray-500">
|
||||||
</div>
|
<span>{{ task.site_name || task.extractor || '未知站点' }}</span>
|
||||||
</div>
|
<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>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,21 +1,34 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
import { ref, computed, nextTick, watch, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useLogsStore } from '../stores/logs'
|
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 logsStore = useLogsStore()
|
||||||
|
const queueStore = useQueueStore()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const logsContainer = ref<HTMLElement | null>(null)
|
const logsContainer = ref<HTMLElement | null>(null)
|
||||||
const filterLevel = ref<'all' | 'info' | 'error'>('all')
|
const filterLevel = ref<'all' | 'info' | 'error'>('all')
|
||||||
const searchQuery = ref('')
|
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(() => {
|
const filteredLogs = computed(() => {
|
||||||
return logsStore.logs.filter(log => {
|
return logsStore.logs.filter(log => {
|
||||||
if (filterLevel.value !== 'all' && log.level !== filterLevel.value) return false
|
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
|
if (searchQuery.value && !log.message.toLowerCase().includes(searchQuery.value.toLowerCase()) && !log.taskId.includes(searchQuery.value)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const logTaskOptions = computed(() => queueStore.tasks.filter(task =>
|
||||||
|
logsStore.logs.some(log => log.taskId === task.id)
|
||||||
|
).slice(0, 20))
|
||||||
|
|
||||||
// Restore scroll position on mount
|
// Restore scroll position on mount
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (logsContainer.value) {
|
if (logsContainer.value) {
|
||||||
@@ -27,6 +40,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => route.query.taskId, (taskId) => {
|
||||||
|
selectedTaskId.value = typeof taskId === 'string' ? taskId : ''
|
||||||
|
})
|
||||||
|
|
||||||
// Auto-scroll watcher
|
// Auto-scroll watcher
|
||||||
watch(() => logsStore.logs.length, () => {
|
watch(() => logsStore.logs.length, () => {
|
||||||
if (logsStore.autoScroll) {
|
if (logsStore.autoScroll) {
|
||||||
@@ -57,6 +74,11 @@ function formatTime(ts: number) {
|
|||||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectTask(taskId: string) {
|
||||||
|
selectedTaskId.value = taskId
|
||||||
|
router.replace({ path: '/logs', query: taskId ? { taskId } : {} })
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -65,10 +87,16 @@ function formatTime(ts: number) {
|
|||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">运行日志</h1>
|
<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>
|
<!-- <p class="text-gray-500 dark:text-gray-400 mt-2">下载任务的实时输出。</p> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<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 -->
|
<!-- Search -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
@@ -110,6 +138,26 @@ function formatTime(ts: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Console -->
|
<!-- 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 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
|
<div
|
||||||
ref="logsContainer"
|
ref="logsContainer"
|
||||||
@@ -121,6 +169,7 @@ function formatTime(ts: number) {
|
|||||||
</div>
|
</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">
|
<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-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
|
<span
|
||||||
class="break-all whitespace-pre-wrap"
|
class="break-all whitespace-pre-wrap"
|
||||||
:class="log.level === 'error' ? 'text-red-500 dark:text-red-400' : 'text-zinc-700 dark:text-gray-300'"
|
:class="log.level === 'error' ? 'text-red-500 dark:text-red-400' : 'text-zinc-700 dark:text-gray-300'"
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import { ref } from 'vue'
|
|||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
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
|
import { format } from 'date-fns' // Import format
|
||||||
|
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const updatingYtdlp = ref(false)
|
const updatingYtdlp = ref(false)
|
||||||
const updatingQuickjs = ref(false)
|
const updatingQuickjs = ref(false)
|
||||||
|
const updatingFfmpeg = ref(false)
|
||||||
const updateStatus = ref('')
|
const updateStatus = ref('')
|
||||||
|
|
||||||
async function browsePath() {
|
async function browsePath() {
|
||||||
@@ -24,6 +25,25 @@ 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() {
|
async function updateYtdlp() {
|
||||||
updatingYtdlp.value = true
|
updatingYtdlp.value = true
|
||||||
updateStatus.value = '正在更新 yt-dlp...'
|
updateStatus.value = '正在更新 yt-dlp...'
|
||||||
@@ -52,6 +72,20 @@ async function updateQuickjs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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') {
|
function setTheme(theme: 'light' | 'dark' | 'system') {
|
||||||
settingsStore.settings.theme = theme
|
settingsStore.settings.theme = theme
|
||||||
settingsStore.save()
|
settingsStore.save()
|
||||||
@@ -62,7 +96,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
|||||||
<div class="max-w-3xl mx-auto p-8">
|
<div class="max-w-3xl mx-auto p-8">
|
||||||
<header class="mb-8">
|
<header class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-zinc-900 dark:text-white">设置</h1>
|
<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>
|
<!-- <p class="text-gray-500 dark:text-gray-400 mt-2">配置您的下载偏好。</p> -->
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
@@ -83,6 +117,34 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Theme -->
|
||||||
<section class="bg-white dark:bg-zinc-900 p-6 rounded-2xl shadow-sm border border-gray-200 dark:border-zinc-800">
|
<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">外观</h2>
|
<h2 class="text-lg font-bold mb-4 text-zinc-900 dark:text-white">外观</h2>
|
||||||
@@ -148,7 +210,7 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium text-zinc-900 dark:text-white">QuickJS</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>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -160,11 +222,42 @@ function setTheme(theme: 'light' | 'dark' | 'system') {
|
|||||||
更新
|
更新
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<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">
|
<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 }}
|
{{ updateStatus }}
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
// @ts-expect-error process is a nodejs global
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
@@ -29,4 +30,12 @@ export default defineConfig(async () => ({
|
|||||||
ignored: ["**/src-tauri/**"],
|
ignored: ["**/src-tauri/**"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
splashscreen: resolve(__dirname, 'splashscreen.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user