Compare commits
1 Commits
main
...
nexmoe/bag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e9a0e39f7 |
4
.github/workflows/translator.yaml
vendored
@@ -8,6 +8,10 @@ on:
|
||||
types: [created, edited]
|
||||
discussion_comment:
|
||||
types: [created, edited]
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
pull_request_review_comment:
|
||||
types: [created, edited]
|
||||
|
||||
jobs:
|
||||
translate:
|
||||
|
||||
@@ -17,13 +17,11 @@ Cookies reuse your browser's signed-in session so VidBee can download content th
|
||||
|
||||
In **Settings**, select your browser. VidBee will try to detect the browser profile path automatically. You can also enter the profile path manually.
|
||||
|
||||

|
||||
|
||||
**Supported browsers (platform dependent):**
|
||||
|
||||
- **Windows: Firefox only. Other browsers cannot be used for cookie reading.**
|
||||
- macOS: All browsers supported.
|
||||
- Linux: All browsers supported.
|
||||
- macOS: Safari, Chrome, Edge, Brave, Firefox, and more.
|
||||
- Linux: Chrome, Chromium, Brave, Firefox, and more.
|
||||
|
||||
**Steps:**
|
||||
|
||||
@@ -38,8 +36,6 @@ On Windows, if you are not using Firefox, switch to the cookies file method.
|
||||
|
||||
You can import a **Netscape-formatted** cookies file. This is useful when reading the browser profile is not possible.
|
||||
|
||||

|
||||
|
||||
**Steps:**
|
||||
|
||||
1. Export a Netscape cookies file using a browser extension.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: Introduction
|
||||
title: VidBee Docs
|
||||
description: VidBee desktop downloader documentation and FAQ
|
||||
---
|
||||
|
||||
@@ -9,7 +9,6 @@ These docs focus on real-world usage and settings, especially signed-in download
|
||||
|
||||
## Start here
|
||||
|
||||
- [vidbee:// Protocol](./protocol.mdx): Quick download using URL protocol.
|
||||
- [Cookies](./cookies.mdx): Configure signed-in sessions and restricted content.
|
||||
- [FAQ](./faq.mdx): Common questions and troubleshooting.
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "VidBee Docs",
|
||||
"pages": ["index", "protocol", "cookies", "faq"]
|
||||
"pages": ["index", "cookies", "faq"]
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
title: vidbee:// Protocol
|
||||
description: Quick download using vidbee:// URL protocol
|
||||
---
|
||||
|
||||
VidBee registers a custom URL protocol (`vidbee://`) that allows you to trigger downloads directly from web browsers, browser extensions, or userscripts.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
The `vidbee://` protocol can be used to open VidBee and automatically start downloading videos.
|
||||
|
||||
### Protocol Format
|
||||
|
||||
```
|
||||
vidbee://download?url=<encoded-video-url>
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `url` (required): The video URL to download, must be URL-encoded
|
||||
|
||||
### Example
|
||||
|
||||
To download a YouTube video:
|
||||
|
||||
```html
|
||||
<a href="vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ">
|
||||
Download with VidBee
|
||||
</a>
|
||||
```
|
||||
|
||||
Or in JavaScript:
|
||||
|
||||
```javascript
|
||||
const videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||
window.location.href = vidbeeUrl
|
||||
```
|
||||
|
||||
## Opening VidBee
|
||||
|
||||
To simply open the VidBee app without starting a download:
|
||||
|
||||
```
|
||||
vidbee://
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Browser Extension
|
||||
|
||||
The VidBee browser extension uses this protocol to send the current tab's URL to the desktop app:
|
||||
|
||||
```javascript
|
||||
const currentUrl = window.location.href
|
||||
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
|
||||
window.location.href = deepLink
|
||||
```
|
||||
|
||||
### Userscript Integration
|
||||
|
||||
The VidBee userscript adds quick download buttons to supported video sites:
|
||||
|
||||
```javascript
|
||||
// Single click triggers download via protocol
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||
window.location.href = vidbeeUrl
|
||||
```
|
||||
|
||||
### Web Pages
|
||||
|
||||
You can add direct download links to your web pages:
|
||||
|
||||
```html
|
||||
<!-- Simple link -->
|
||||
<a href="vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo">
|
||||
Download with VidBee
|
||||
</a>
|
||||
|
||||
<!-- Button with JavaScript -->
|
||||
<button onclick="openInVidBee('https://example.com/video')">
|
||||
Quick Download
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function openInVidBee(url) {
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`
|
||||
window.location.href = vidbeeUrl
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Playlist Support
|
||||
|
||||
To download an entire playlist:
|
||||
|
||||
```
|
||||
vidbee://download?url=<encoded-playlist-url>&type=playlist
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `url` (required): The playlist URL, must be URL-encoded
|
||||
- `type`: Set to `playlist` to download all videos in the playlist
|
||||
|
||||
### Example
|
||||
|
||||
```javascript
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`
|
||||
window.location.href = vidbeeUrl
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Protocol Registration**: VidBee registers as the handler for the `vidbee://` protocol during installation
|
||||
2. **URL Parsing**: When a `vidbee://download?url=...` link is clicked, the OS launches VidBee
|
||||
3. **Queue Processing**: VidBee extracts the video URL and adds it to the download queue
|
||||
4. **Auto-start**: The download begins automatically if the app is configured for auto-download
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
The `vidbee://` protocol works across all major browsers:
|
||||
- Chrome/Edge/Brave
|
||||
- Firefox
|
||||
- Safari
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Only URLs starting with `vidbee://` will trigger the app
|
||||
- The app validates the URL format before processing
|
||||
- Malformed URLs are ignored with a warning in the logs
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Protocol Not Working
|
||||
|
||||
If clicking `vidbee://` links doesn't open VidBee:
|
||||
|
||||
1. **Check installation**: Ensure VidBee is properly installed
|
||||
2. **Reinstall**: Try reinstalling VidBee to re-register the protocol
|
||||
3. **OS permissions**: On macOS, check System Settings > Privacy & Security for any blocks
|
||||
4. **Browser settings**: Some browsers may require you to allow the protocol on first use
|
||||
|
||||
### App Opens But Doesn't Download
|
||||
|
||||
If VidBee opens but the download doesn't start:
|
||||
|
||||
1. **Check URL encoding**: Ensure the video URL is properly encoded with `encodeURIComponent()`
|
||||
2. **Check logs**: Open the app and check the developer console for errors
|
||||
3. **Supported sites**: Verify the URL is from a [supported site](https://vidbee.org/supported-sites/)
|
||||
@@ -17,13 +17,11 @@ Cookie 用于复用浏览器的登录态,帮助 VidBee 下载需要登录或
|
||||
|
||||
在 **设置** 中选择你的浏览器,VidBee 会尝试自动识别浏览器配置文件路径。你也可以手动填写配置文件路径。
|
||||
|
||||

|
||||
|
||||
**支持的浏览器(按平台差异显示):**
|
||||
|
||||
- **Windows:仅支持 Firefox。其他浏览器无法读取 Cookie。**
|
||||
- macOS:全部支持。
|
||||
- Linux:全部支持。
|
||||
- macOS:Safari、Chrome、Edge、Brave、Firefox 等。
|
||||
- Linux:Chrome、Chromium、Brave、Firefox 等。
|
||||
|
||||
**使用步骤:**
|
||||
|
||||
@@ -38,8 +36,6 @@ Cookie 用于复用浏览器的登录态,帮助 VidBee 下载需要登录或
|
||||
|
||||
你也可以导入 **Netscape 格式** 的 cookies 文件。这个方式适合在不方便读取浏览器配置文件时使用。
|
||||
|
||||

|
||||
|
||||
**使用步骤:**
|
||||
|
||||
1. 使用浏览器扩展导出 Netscape cookies 文件。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
title: 简介
|
||||
title: VidBee 文档
|
||||
description: VidBee 桌面下载器使用说明与常见问题
|
||||
---
|
||||
|
||||
@@ -9,7 +9,6 @@ VidBee 是一款基于 Electron 的桌面下载器,内置 yt-dlp 引擎,提
|
||||
|
||||
## 从这里开始
|
||||
|
||||
- [vidbee:// 协议](./protocol.mdx):使用 URL 协议快速下载。
|
||||
- [Cookie 使用](./cookies.mdx):登录态与限制内容的下载配置。
|
||||
- [常见问题](./faq.mdx):常见问题与排查思路。
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"title": "VidBee 文档",
|
||||
"pages": ["index", "protocol", "cookies", "faq"]
|
||||
"pages": ["index", "cookies", "faq"]
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
---
|
||||
title: vidbee:// 协议
|
||||
description: 使用 vidbee:// URL 协议快速下载
|
||||
---
|
||||
|
||||
VidBee 注册了自定义 URL 协议(`vidbee://`),允许您直接从网页浏览器、浏览器扩展或用户脚本触发下载。
|
||||
|
||||
## 基本用法
|
||||
|
||||
`vidbee://` 协议可用于打开 VidBee 并自动开始下载视频。
|
||||
|
||||
### 协议格式
|
||||
|
||||
```
|
||||
vidbee://download?url=<编码后的视频URL>
|
||||
```
|
||||
|
||||
**参数:**
|
||||
- `url`(必需):要下载的视频 URL,必须经过 URL 编码
|
||||
|
||||
### 示例
|
||||
|
||||
下载 YouTube 视频:
|
||||
|
||||
```html
|
||||
<a href="vidbee://download?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ">
|
||||
使用 VidBee 下载
|
||||
</a>
|
||||
```
|
||||
|
||||
或使用 JavaScript:
|
||||
|
||||
```javascript
|
||||
const videoUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||
window.location.href = vidbeeUrl
|
||||
```
|
||||
|
||||
## 打开 VidBee
|
||||
|
||||
仅打开 VidBee 应用而不开始下载:
|
||||
|
||||
```
|
||||
vidbee://
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 浏览器扩展
|
||||
|
||||
VidBee 浏览器扩展使用此协议将当前标签页的 URL 发送到桌面应用:
|
||||
|
||||
```javascript
|
||||
const currentUrl = window.location.href
|
||||
const deepLink = `vidbee://download?url=${encodeURIComponent(currentUrl)}`
|
||||
window.location.href = deepLink
|
||||
```
|
||||
|
||||
### 用户脚本集成
|
||||
|
||||
VidBee 用户脚本在支持的视频网站上添加快速下载按钮:
|
||||
|
||||
```javascript
|
||||
// 单击通过协议触发下载
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(videoUrl)}`
|
||||
window.location.href = vidbeeUrl
|
||||
```
|
||||
|
||||
### 网页集成
|
||||
|
||||
您可以在网页中添加直接下载链接:
|
||||
|
||||
```html
|
||||
<!-- 简单链接 -->
|
||||
<a href="vidbee://download?url=https%3A%2F%2Fexample.com%2Fvideo">
|
||||
使用 VidBee 下载
|
||||
</a>
|
||||
|
||||
<!-- 带 JavaScript 的按钮 -->
|
||||
<button onclick="openInVidBee('https://example.com/video')">
|
||||
快速下载
|
||||
</button>
|
||||
|
||||
<script>
|
||||
function openInVidBee(url) {
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(url)}`
|
||||
window.location.href = vidbeeUrl
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 播放列表支持
|
||||
|
||||
下载整个播放列表:
|
||||
|
||||
```
|
||||
vidbee://download?url=<编码后的播放列表URL>&type=playlist
|
||||
```
|
||||
|
||||
**参数:**
|
||||
- `url`(必需):播放列表 URL,必须经过 URL 编码
|
||||
- `type`:设置为 `playlist` 以下载播放列表中的所有视频
|
||||
|
||||
### 示例
|
||||
|
||||
```javascript
|
||||
const playlistUrl = 'https://www.youtube.com/playlist?list=PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf'
|
||||
const vidbeeUrl = `vidbee://download?url=${encodeURIComponent(playlistUrl)}&type=playlist`
|
||||
window.location.href = vidbeeUrl
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **协议注册**:VidBee 在安装过程中注册为 `vidbee://` 协议的处理程序
|
||||
2. **URL 解析**:当点击 `vidbee://download?url=...` 链接时,操作系统会启动 VidBee
|
||||
3. **队列处理**:VidBee 提取视频 URL 并将其添加到下载队列
|
||||
4. **自动开始**:如果应用配置为自动下载,下载会自动开始
|
||||
|
||||
## 浏览器兼容性
|
||||
|
||||
`vidbee://` 协议适用于所有主流浏览器:
|
||||
- Chrome/Edge/Brave
|
||||
- Firefox
|
||||
- Safari
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 仅以 `vidbee://` 开头的 URL 会触发应用
|
||||
- 应用在处理前会验证 URL 格式
|
||||
- 格式错误的 URL 将被忽略,并在日志中显示警告
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 协议不工作
|
||||
|
||||
如果点击 `vidbee://` 链接无法打开 VidBee:
|
||||
|
||||
1. **检查安装**:确保 VidBee 已正确安装
|
||||
2. **重新安装**:尝试重新安装 VidBee 以重新注册协议
|
||||
3. **操作系统权限**:在 macOS 上,检查系统设置 > 隐私与安全性 是否有任何阻止
|
||||
4. **浏览器设置**:某些浏览器可能需要您在首次使用时允许该协议
|
||||
|
||||
### 应用打开但未下载
|
||||
|
||||
如果 VidBee 打开但下载未开始:
|
||||
|
||||
1. **检查 URL 编码**:确保视频 URL 使用 `encodeURIComponent()` 正确编码
|
||||
2. **检查日志**:打开应用并检查开发者控制台是否有错误
|
||||
3. **支持的网站**:验证 URL 是否来自[支持的网站](https://vidbee.org/supported-sites/)
|
||||
@@ -4,8 +4,7 @@ const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
// Only use static export for production builds, not dev mode
|
||||
output: process.env.NODE_ENV === 'production' ? 'export' : undefined,
|
||||
output: 'export',
|
||||
reactStrictMode: true,
|
||||
// Use trailing slashes to avoid conflicts with route handlers that have file extensions
|
||||
trailingSlash: true,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# VidBee Documentation Icons
|
||||
|
||||
This directory contains the VidBee logo in various sizes for use in the documentation site.
|
||||
|
||||
## Source
|
||||
|
||||
All icons are generated from the original VidBee application icon located at:
|
||||
`/Users/air15/Documents/GitHub/VidBee/build/icon.png`
|
||||
|
||||
## Available Sizes
|
||||
|
||||
| Filename | Size | Use Case |
|
||||
|----------|------|----------|
|
||||
| `icon.png` | 512×512 | Default/Original icon |
|
||||
| `icon-16.png` | 16×16 | Browser favicon (small) |
|
||||
| `icon-32.png` | 32×32 | Browser favicon (standard) |
|
||||
| `icon-48.png` | 48×48 | Browser favicon (large) |
|
||||
| `icon-64.png` | 64×64 | Small UI elements |
|
||||
| `icon-128.png` | 128×128 | Medium UI elements |
|
||||
| `icon-192.png` | 192×192 | PWA icon (Android) |
|
||||
| `icon-256.png` | 256×256 | Large UI elements |
|
||||
| `icon-512.png` | 512×512 | PWA splash screen, high-res displays |
|
||||
| `apple-touch-icon.png` | 180×180 | iOS/macOS home screen icon |
|
||||
| `favicon.png` | 32×32 | Standard favicon |
|
||||
|
||||
## Usage in Next.js
|
||||
|
||||
### In `app/layout.tsx` or `app/favicon.ico`:
|
||||
|
||||
```tsx
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'VidBee Documentation',
|
||||
description: 'Official VidBee documentation',
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/icon-16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/icon-32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/icon-48.png', sizes: '48x48', type: 'image/png' },
|
||||
],
|
||||
apple: [
|
||||
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' },
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### For PWA (Progressive Web App):
|
||||
|
||||
Add to your `manifest.json` or `site.webmanifest`:
|
||||
|
||||
```json
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Regeneration
|
||||
|
||||
If you need to regenerate these icons from the source:
|
||||
|
||||
```bash
|
||||
cd docs/public
|
||||
SOURCE="/Users/air15/Documents/GitHub/VidBee/build/icon.png"
|
||||
|
||||
# Copy original
|
||||
cp $SOURCE icon-original.png
|
||||
|
||||
# Generate sizes
|
||||
sips -z 16 16 icon-original.png --out icon-16.png
|
||||
sips -z 32 32 icon-original.png --out icon-32.png
|
||||
sips -z 48 48 icon-original.png --out icon-48.png
|
||||
sips -z 64 64 icon-original.png --out icon-64.png
|
||||
sips -z 128 128 icon-original.png --out icon-128.png
|
||||
sips -z 192 192 icon-original.png --out icon-192.png
|
||||
sips -z 256 256 icon-original.png --out icon-256.png
|
||||
sips -z 180 180 icon-original.png --out apple-touch-icon.png
|
||||
|
||||
# Create standard copies
|
||||
cp icon-original.png icon-512.png
|
||||
cp icon-original.png icon.png
|
||||
cp icon-32.png favicon.png
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All icons maintain the VidBee bee/honeycomb theme
|
||||
- Icons use PNG format with transparency (RGBA)
|
||||
- Generated using macOS `sips` tool for quality consistency
|
||||
|
Before Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.9 MiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 924 B |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -57,17 +57,11 @@ export async function generateMetadata(
|
||||
const page = source.getPage(params.slug, params.lang);
|
||||
if (!page) notFound();
|
||||
|
||||
const baseUrl = 'https://docs.vidbee.org';
|
||||
const canonicalUrl = `${baseUrl}${page.url}`;
|
||||
|
||||
return {
|
||||
title: `${page.data.title} | VidBee Docs`,
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
openGraph: {
|
||||
images: getPageImage(page).url,
|
||||
},
|
||||
alternates: {
|
||||
canonical: canonicalUrl,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import './global.css';
|
||||
import { Inter } from 'next/font/google';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import { i18n, isLocale } from '@/lib/i18n';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: '/favicon.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/icon-16.png', sizes: '16x16', type: 'image/png' },
|
||||
{ url: '/icon-32.png', sizes: '32x32', type: 'image/png' },
|
||||
{ url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
],
|
||||
apple: '/apple-touch-icon.png',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { i18n } from '@/lib/i18n';
|
||||
import { source } from '@/lib/source';
|
||||
|
||||
export const dynamic = 'force-static';
|
||||
|
||||
const baseUrl = 'https://docs.vidbee.org';
|
||||
|
||||
function buildPath(segments: string[]): string {
|
||||
if (segments.length === 0) {
|
||||
return '/';
|
||||
}
|
||||
return `/${segments.join('/')}/`;
|
||||
}
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const params = source.generateParams();
|
||||
|
||||
return params.map(({ lang, slug }) => {
|
||||
const segments: string[] = [];
|
||||
|
||||
if (lang && lang !== i18n.defaultLanguage) {
|
||||
segments.push(lang);
|
||||
}
|
||||
|
||||
if (slug && slug.length > 0) {
|
||||
segments.push(...slug);
|
||||
}
|
||||
|
||||
return {
|
||||
url: `${baseUrl}${buildPath(segments)}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.0",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
@@ -35,7 +35,7 @@ const PLATFORM_CONFIG = {
|
||||
ffprobeOutput: 'ffprobe.exe',
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
|
||||
binaryName: 'ffmpeg.exe'
|
||||
}
|
||||
@@ -87,7 +87,7 @@ const PLATFORM_CONFIG = {
|
||||
ffprobeOutput: 'ffprobe',
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'yt-dlp/FFmpeg-Builds'],
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
|
||||
@@ -71,7 +71,14 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
||||
const isBilibiliUrl = (url: string): boolean => {
|
||||
try {
|
||||
const host = new URL(url).hostname.toLowerCase()
|
||||
return host.includes('bilibili.com') || host.includes('b23.tv') || host.includes('bili.tv')
|
||||
return (
|
||||
host === 'bilibili.com' ||
|
||||
host.endsWith('.bilibili.com') ||
|
||||
host === 'b23.tv' ||
|
||||
host.endsWith('.b23.tv') ||
|
||||
host === 'bili.tv' ||
|
||||
host.endsWith('.bili.tv')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -91,9 +98,7 @@ export const buildDownloadArgs = (
|
||||
// Format selection
|
||||
if (options.type === 'video') {
|
||||
const formatSelector = resolveVideoFormatSelector(options)
|
||||
if (formatSelector) {
|
||||
args.push('-f', formatSelector)
|
||||
}
|
||||
args.push('-f', formatSelector)
|
||||
if (options.audioFormatIds && options.audioFormatIds.length > 0) {
|
||||
args.push('--audio-multistreams')
|
||||
} else if (formatSelector.includes('mergeall')) {
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
import log from 'electron-log/main'
|
||||
import { autoUpdater } from 'electron-updater'
|
||||
import appIcon from '../../build/icon.png?asset'
|
||||
import {
|
||||
buildAudioFormatPreference,
|
||||
buildVideoFormatPreference
|
||||
} from '../shared/utils/format-preferences'
|
||||
import { configureLogger } from './config/logger-config'
|
||||
import { services } from './ipc'
|
||||
import { downloadEngine } from './lib/download-engine'
|
||||
@@ -47,11 +51,13 @@ protocol.registerSchemesAsPrivileged([
|
||||
|
||||
let mainWindow: BrowserWindow | null = null
|
||||
let isQuitting = false
|
||||
let isYtdlpReady = false
|
||||
interface DeepLinkData {
|
||||
url: string
|
||||
type: 'single' | 'playlist'
|
||||
}
|
||||
const pendingDeepLinkUrls: DeepLinkData[] = []
|
||||
const pendingOneClickDownloads: DeepLinkData[] = []
|
||||
let isRendererReady = false
|
||||
|
||||
const parseDownloadDeepLink = (rawUrl: string): DeepLinkData | null => {
|
||||
@@ -119,6 +125,10 @@ const handleDeepLinkUrl = (rawUrl: string): void => {
|
||||
log.warn('Ignored unsupported deep link:', rawUrl)
|
||||
return
|
||||
}
|
||||
if (settingsManager.get('oneClickDownload')) {
|
||||
queueOneClickDownload(data)
|
||||
return
|
||||
}
|
||||
deliverDeepLink(data)
|
||||
}
|
||||
|
||||
@@ -245,6 +255,14 @@ function setupRendererErrorHandling(): void {
|
||||
}
|
||||
|
||||
function setupDownloadEvents(): void {
|
||||
downloadEngine.on('download-queued', (item: unknown) => {
|
||||
mainWindow?.webContents.send('download:queued', item)
|
||||
})
|
||||
|
||||
downloadEngine.on('download-updated', (id: string, updates: unknown) => {
|
||||
mainWindow?.webContents.send('download:updated', { id, updates })
|
||||
})
|
||||
|
||||
downloadEngine.on('download-started', (id: string) => {
|
||||
mainWindow?.webContents.send('download:started', id)
|
||||
})
|
||||
@@ -253,10 +271,6 @@ function setupDownloadEvents(): void {
|
||||
mainWindow?.webContents.send('download:progress', { id, progress })
|
||||
})
|
||||
|
||||
downloadEngine.on('download-log', (id: string, logText: string) => {
|
||||
mainWindow?.webContents.send('download:log', { id, log: logText })
|
||||
})
|
||||
|
||||
downloadEngine.on('download-completed', (id: string) => {
|
||||
mainWindow?.webContents.send('download:completed', id)
|
||||
})
|
||||
@@ -270,6 +284,61 @@ function setupDownloadEvents(): void {
|
||||
})
|
||||
}
|
||||
|
||||
const createDownloadId = (): string =>
|
||||
`download_${Date.now()}_${Math.random().toString(36).substring(2, 10)}`
|
||||
|
||||
const queueOneClickDownload = (data: DeepLinkData): void => {
|
||||
if (!isYtdlpReady) {
|
||||
pendingOneClickDownloads.push(data)
|
||||
return
|
||||
}
|
||||
void startOneClickDownload(data)
|
||||
}
|
||||
|
||||
const flushPendingOneClickDownloads = (): void => {
|
||||
if (!isYtdlpReady || pendingOneClickDownloads.length === 0) {
|
||||
return
|
||||
}
|
||||
const pending = pendingOneClickDownloads.splice(0, pendingOneClickDownloads.length)
|
||||
for (const data of pending) {
|
||||
void startOneClickDownload(data)
|
||||
}
|
||||
}
|
||||
|
||||
const startOneClickDownload = async (data: DeepLinkData): Promise<void> => {
|
||||
try {
|
||||
const settings = settingsManager.getAll()
|
||||
const downloadType = settings.oneClickDownloadType ?? 'video'
|
||||
const format =
|
||||
downloadType === 'video'
|
||||
? buildVideoFormatPreference(settings)
|
||||
: buildAudioFormatPreference(settings)
|
||||
|
||||
if (data.type === 'playlist') {
|
||||
const result = await downloadEngine.startPlaylistDownload({
|
||||
url: data.url,
|
||||
type: downloadType,
|
||||
format
|
||||
})
|
||||
log.info('One-click playlist download queued:', {
|
||||
url: data.url,
|
||||
count: result.totalCount
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const downloadId = createDownloadId()
|
||||
downloadEngine.startDownload(downloadId, {
|
||||
url: data.url,
|
||||
type: downloadType,
|
||||
format
|
||||
})
|
||||
log.info('One-click download queued:', { id: downloadId, url: data.url })
|
||||
} catch (error) {
|
||||
log.error('Failed to start one-click download:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeRequestPath(requestUrl: URL): string {
|
||||
const rawPath = `${requestUrl.hostname}${decodeURIComponent(requestUrl.pathname)}`
|
||||
const trimmedLeading = rawPath.replace(/^\/+/, '')
|
||||
@@ -453,18 +522,18 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
// Initialize yt-dlp
|
||||
let ytdlpReady = false
|
||||
try {
|
||||
log.info('Initializing yt-dlp...')
|
||||
await ytdlpManager.initialize()
|
||||
ytdlpReady = true
|
||||
isYtdlpReady = true
|
||||
log.info('yt-dlp initialized successfully')
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
if (ytdlpReady) {
|
||||
if (isYtdlpReady) {
|
||||
downloadEngine.restoreActiveDownloads()
|
||||
flushPendingOneClickDownloads()
|
||||
}
|
||||
|
||||
await startExtensionApiServer()
|
||||
|
||||
@@ -106,35 +106,6 @@ class FileSystemService extends IpcService {
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async openFile(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
if (!filePath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sanitizedPath = this.sanitizePath(filePath)
|
||||
const normalizedPath = path.normalize(sanitizedPath)
|
||||
const stats = await fs.stat(normalizedPath).catch(() => null)
|
||||
|
||||
if (!stats || (!stats.isFile() && !stats.isDirectory())) {
|
||||
scopedLoggers.system.error('File does not exist:', normalizedPath)
|
||||
return false
|
||||
}
|
||||
|
||||
const result = await shell.openPath(normalizedPath)
|
||||
if (result) {
|
||||
scopedLoggers.system.error('Failed to open file:', result)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
scopedLoggers.system.error('Failed to open file:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async copyFileToClipboard(_context: IpcContext, filePath: string): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@@ -16,7 +16,6 @@ export const downloadHistoryTable = sqliteTable('download_history', {
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
error: text('error'),
|
||||
ytDlpCommand: text('yt_dlp_command'),
|
||||
ytDlpLog: text('yt_dlp_log'),
|
||||
description: text('description'),
|
||||
channel: text('channel'),
|
||||
uploader: text('uploader'),
|
||||
|
||||
@@ -269,6 +269,8 @@ class DownloadEngine extends EventEmitter {
|
||||
private queue: DownloadQueue
|
||||
private sessionPersistTimer: NodeJS.Timeout | null = null
|
||||
private sessionRestored = false
|
||||
private prefetchTasks: Map<string, Promise<VideoInfo | null>> = new Map()
|
||||
private prefetchedInfo: Map<string, VideoInfo> = new Map()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -628,34 +630,16 @@ class DownloadEngine extends EventEmitter {
|
||||
`Starting playlist download: ${selectionSize} items from "${playlistInfo.title}"`
|
||||
)
|
||||
|
||||
const normalizedTitles = new Set<string>()
|
||||
let hasDuplicateTitles = false
|
||||
for (const entry of selectedEntries) {
|
||||
const key = sanitizeTemplateValue(entry.title || '').toLowerCase()
|
||||
if (normalizedTitles.has(key)) {
|
||||
hasDuplicateTitles = true
|
||||
break
|
||||
}
|
||||
normalizedTitles.add(key)
|
||||
}
|
||||
const indexWidth = hasDuplicateTitles
|
||||
? String(Math.max(...selectedEntries.map((entry) => entry.index))).length
|
||||
: 0
|
||||
|
||||
// Create download items for each video in the playlist
|
||||
for (const entry of selectedEntries) {
|
||||
const downloadId = `${groupId}_${Math.random().toString(36).substring(2, 10)}`
|
||||
const customFilenameTemplate = hasDuplicateTitles
|
||||
? `${String(entry.index).padStart(indexWidth, '0')} - %(title)s via VidBee.%(ext)s`
|
||||
: undefined
|
||||
|
||||
const downloadOptions: DownloadOptions = {
|
||||
url: entry.url,
|
||||
type: options.type,
|
||||
format: options.format,
|
||||
audioFormat: options.type === 'audio' ? options.format : undefined,
|
||||
customDownloadPath: resolvedDownloadPath,
|
||||
customFilenameTemplate
|
||||
customDownloadPath: resolvedDownloadPath
|
||||
}
|
||||
|
||||
const createdAt = Date.now()
|
||||
@@ -668,7 +652,7 @@ class DownloadEngine extends EventEmitter {
|
||||
})
|
||||
|
||||
// Add to queue
|
||||
this.queue.add(downloadId, downloadOptions, {
|
||||
const queueItem: DownloadItem = {
|
||||
id: downloadId,
|
||||
url: entry.url,
|
||||
title: entry.title,
|
||||
@@ -680,7 +664,9 @@ class DownloadEngine extends EventEmitter {
|
||||
playlistTitle: playlistInfo.title,
|
||||
playlistIndex: entry.index,
|
||||
playlistSize: selectionSize
|
||||
})
|
||||
}
|
||||
this.queue.add(downloadId, downloadOptions, queueItem)
|
||||
this.emit('download-queued', { ...queueItem })
|
||||
|
||||
this.upsertHistoryEntry(downloadId, downloadOptions, {
|
||||
title: entry.title,
|
||||
@@ -729,6 +715,7 @@ class DownloadEngine extends EventEmitter {
|
||||
title: 'Downloading...',
|
||||
type: options.type,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
createdAt,
|
||||
tags: options.tags,
|
||||
origin,
|
||||
@@ -746,6 +733,45 @@ class DownloadEngine extends EventEmitter {
|
||||
origin,
|
||||
subscriptionId: options.subscriptionId
|
||||
})
|
||||
|
||||
this.emit('download-queued', { ...item })
|
||||
void this.prefetchVideoInfo(id, options)
|
||||
}
|
||||
|
||||
private async prefetchVideoInfo(id: string, options: DownloadOptions): Promise<void> {
|
||||
const url = options.url?.trim()
|
||||
if (!url) {
|
||||
return
|
||||
}
|
||||
if (this.prefetchTasks.has(id) || this.prefetchedInfo.has(id)) {
|
||||
return
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const info = await this.getVideoInfo(url)
|
||||
this.prefetchedInfo.set(id, info)
|
||||
this.updateDownloadInfo(id, {
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
duration: info.duration,
|
||||
description: info.description,
|
||||
uploader: info.uploader,
|
||||
viewCount: info.view_count
|
||||
})
|
||||
return info
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to prefetch video info for ID:', id, error)
|
||||
return null
|
||||
}
|
||||
})()
|
||||
|
||||
this.prefetchTasks.set(id, task)
|
||||
try {
|
||||
await task
|
||||
} finally {
|
||||
this.prefetchTasks.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async executeDownload(id: string, options: DownloadOptions): Promise<void> {
|
||||
@@ -769,52 +795,8 @@ class DownloadEngine extends EventEmitter {
|
||||
let totalParts = estimateProgressParts(options)
|
||||
let completedParts = 0
|
||||
let lastPercent = 0
|
||||
let ytDlpLog = ''
|
||||
let logFlushTimer: NodeJS.Timeout | null = null
|
||||
let lastFlushedLog = ''
|
||||
|
||||
const normalizeLogChunk = (chunk: string): string =>
|
||||
chunk.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
||||
|
||||
const flushLogUpdate = (): void => {
|
||||
if (logFlushTimer) {
|
||||
clearTimeout(logFlushTimer)
|
||||
logFlushTimer = null
|
||||
}
|
||||
if (ytDlpLog === lastFlushedLog) {
|
||||
return
|
||||
}
|
||||
lastFlushedLog = ytDlpLog
|
||||
this.updateDownloadInfo(id, { ytDlpLog })
|
||||
this.emit('download-log', id, ytDlpLog)
|
||||
}
|
||||
|
||||
const scheduleLogUpdate = (): void => {
|
||||
if (logFlushTimer) {
|
||||
return
|
||||
}
|
||||
logFlushTimer = setTimeout(() => {
|
||||
flushLogUpdate()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const appendLogChunk = (chunk: string | Buffer): void => {
|
||||
if (!chunk) {
|
||||
return
|
||||
}
|
||||
const text = typeof chunk === 'string' ? chunk : chunk.toString()
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
ytDlpLog += normalizeLogChunk(text)
|
||||
scheduleLogUpdate()
|
||||
}
|
||||
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
const info = await this.getVideoInfo(options.url)
|
||||
videoInfo = info
|
||||
|
||||
const applyVideoInfo = (info: VideoInfo) => {
|
||||
availableFormats = Array.isArray(info.formats) ? info.formats : []
|
||||
selectedFormat = resolveSelectedFormat(availableFormats, options, settings)
|
||||
|
||||
@@ -851,8 +833,33 @@ class DownloadEngine extends EventEmitter {
|
||||
// Store only essential download info
|
||||
selectedFormat
|
||||
})
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||
}
|
||||
|
||||
videoInfo = this.prefetchedInfo.get(id)
|
||||
if (!videoInfo) {
|
||||
const prefetchTask = this.prefetchTasks.get(id)
|
||||
if (prefetchTask) {
|
||||
try {
|
||||
await prefetchTask
|
||||
} catch {
|
||||
// Ignore prefetch failures, download will attempt again below.
|
||||
}
|
||||
videoInfo = this.prefetchedInfo.get(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (videoInfo) {
|
||||
this.prefetchedInfo.delete(id)
|
||||
applyVideoInfo(videoInfo)
|
||||
} else {
|
||||
// First, get detailed video info to capture basic metadata and formats
|
||||
try {
|
||||
const info = await this.getVideoInfo(options.url)
|
||||
videoInfo = info
|
||||
applyVideoInfo(info)
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to get detailed video info for ID:', id, error)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.customDownloadPath?.trim()) {
|
||||
@@ -983,14 +990,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
this.activeDownloads.set(id, { controller, process: ytdlpProcess })
|
||||
|
||||
ytdlpProcess.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
|
||||
appendLogChunk(data)
|
||||
})
|
||||
|
||||
ytdlpProcess.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
|
||||
appendLogChunk(data)
|
||||
})
|
||||
|
||||
this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() })
|
||||
this.scheduleSessionPersist()
|
||||
this.emit('download-started', id)
|
||||
@@ -1057,6 +1056,17 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
// Handle yt-dlp events to capture format info
|
||||
ytdlpProcess.on('ytDlpEvent', (eventType: string, eventData: string) => {
|
||||
if (
|
||||
eventType === 'postprocess' ||
|
||||
eventData.toLowerCase().includes('merging formats') ||
|
||||
eventData.toLowerCase().includes('post-process')
|
||||
) {
|
||||
const snapshot = this.queue.getItemDetails(id)
|
||||
if (snapshot?.item.status !== 'processing') {
|
||||
this.updateDownloadInfo(id, { status: 'processing' })
|
||||
}
|
||||
}
|
||||
|
||||
// Look for format selection messages
|
||||
if (eventType === 'info' && eventData.includes('format')) {
|
||||
// Extract format info from yt-dlp output
|
||||
@@ -1090,7 +1100,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
// Handle completion
|
||||
ytdlpProcess.on('close', async (code: number | null) => {
|
||||
flushLogUpdate()
|
||||
this.activeDownloads.delete(id)
|
||||
this.queue.downloadCompleted(id)
|
||||
|
||||
@@ -1221,7 +1230,6 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
// Handle errors
|
||||
ytdlpProcess.on('error', (error: Error) => {
|
||||
flushLogUpdate()
|
||||
scopedLoggers.download.error('Download process error for ID:', id, error)
|
||||
this.activeDownloads.delete(id)
|
||||
this.queue.downloadCompleted(id)
|
||||
@@ -1397,9 +1405,6 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.ytDlpCommand !== undefined) {
|
||||
historyUpdates.ytDlpCommand = updates.ytDlpCommand
|
||||
}
|
||||
if (updates.ytDlpLog !== undefined) {
|
||||
historyUpdates.ytDlpLog = updates.ytDlpLog
|
||||
}
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
@@ -1408,6 +1413,10 @@ class DownloadEngine extends EventEmitter {
|
||||
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
this.emit('download-updated', id, { ...updates })
|
||||
}
|
||||
|
||||
this.scheduleSessionPersist()
|
||||
}
|
||||
|
||||
@@ -1448,7 +1457,7 @@ class DownloadEngine extends EventEmitter {
|
||||
): void {
|
||||
// Get the download item from the queue to get additional info
|
||||
const completedDownload = this.queue.getCompletedDownload(id)
|
||||
// scopedLoggers.download.info('Completed download:', completedDownload)
|
||||
scopedLoggers.download.info('Completed download:', completedDownload)
|
||||
const completedAt = Date.now()
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
@@ -1496,7 +1505,6 @@ class DownloadEngine extends EventEmitter {
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
ytDlpCommand: updates.ytDlpCommand,
|
||||
ytDlpLog: updates.ytDlpLog,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
|
||||
@@ -37,7 +37,6 @@ const createDownloadHistoryTableSql = sql`
|
||||
sort_key INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
yt_dlp_command TEXT,
|
||||
yt_dlp_log TEXT,
|
||||
description TEXT,
|
||||
channel TEXT,
|
||||
uploader TEXT,
|
||||
@@ -220,53 +219,20 @@ class HistoryManager {
|
||||
}
|
||||
|
||||
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
||||
const requiredColumns = ['yt_dlp_command', 'yt_dlp_log']
|
||||
const requiredColumns = ['yt_dlp_command']
|
||||
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
const missingRequired = requiredColumns.filter(
|
||||
const missingRequired = requiredColumns.some(
|
||||
(columnName) => !columns.some((column) => column.name === columnName)
|
||||
)
|
||||
if (hasDeprecated) {
|
||||
const needsRebuild = hasDeprecated || missingRequired
|
||||
if (needsRebuild) {
|
||||
this.rebuildDownloadHistoryTable()
|
||||
return
|
||||
}
|
||||
if (missingRequired.length > 0) {
|
||||
this.addMissingColumns(missingRequired)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to inspect schema', error)
|
||||
}
|
||||
}
|
||||
|
||||
private addMissingColumns(columns: string[]): void {
|
||||
if (columns.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const database = this.getDatabase()
|
||||
const definitions: Record<string, string> = {
|
||||
yt_dlp_command: 'TEXT',
|
||||
yt_dlp_log: 'TEXT'
|
||||
}
|
||||
|
||||
try {
|
||||
database.transaction(
|
||||
(tx) => {
|
||||
for (const column of columns) {
|
||||
const definition = definitions[column]
|
||||
if (!definition) {
|
||||
continue
|
||||
}
|
||||
tx.run(sql.raw(`ALTER TABLE download_history ADD COLUMN ${column} ${definition}`))
|
||||
}
|
||||
},
|
||||
{ behavior: 'immediate' }
|
||||
)
|
||||
logger.info(`history-db added missing columns: ${columns.join(', ')}`)
|
||||
} catch (error) {
|
||||
logger.error('history-db failed to add missing columns', error)
|
||||
}
|
||||
}
|
||||
|
||||
private migrateLegacyPayloadTable(): void {
|
||||
const database = this.getDatabase()
|
||||
logger.info('history-db migrating legacy payload schema to structured columns')
|
||||
@@ -428,7 +394,6 @@ class HistoryManager {
|
||||
sortKey: item.completedAt ?? item.downloadedAt,
|
||||
error: item.error ?? null,
|
||||
ytDlpCommand: item.ytDlpCommand ?? null,
|
||||
ytDlpLog: item.ytDlpLog ?? null,
|
||||
description: item.description ?? null,
|
||||
channel: item.channel ?? null,
|
||||
uploader: item.uploader ?? null,
|
||||
@@ -476,7 +441,6 @@ class HistoryManager {
|
||||
completedAt: row.completedAt ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
ytDlpCommand: row.ytDlpCommand ?? undefined,
|
||||
ytDlpLog: row.ytDlpLog ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
channel: row.channel ?? undefined,
|
||||
uploader: row.uploader ?? undefined,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useCallback, useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcEvents, ipcServices } from '../../lib/ipc'
|
||||
import { addDownloadAtom, updateDownloadAtom } from '../../store/downloads'
|
||||
import { addDownloadAtom } from '../../store/downloads'
|
||||
import { loadSettingsAtom, settingsAtom } from '../../store/settings'
|
||||
import {
|
||||
currentVideoInfoAtom,
|
||||
@@ -109,7 +109,6 @@ export function DownloadDialog({
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const fetchVideoInfo = useSetAtom(fetchVideoInfoAtom)
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const updateDownload = useSetAtom(updateDownloadAtom)
|
||||
const addDownload = useSetAtom(addDownloadAtom)
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
@@ -302,57 +301,6 @@ export function DownloadDialog({
|
||||
format
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await ipcServices.download.getVideoInfoWithCommand(trimmedUrl)
|
||||
if (!result.info) {
|
||||
throw new Error(result.error || 'Failed to fetch video info')
|
||||
}
|
||||
const videoInfo = result.info
|
||||
|
||||
updateDownload({
|
||||
id,
|
||||
changes: {
|
||||
title: videoInfo.title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title: videoInfo.title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
})
|
||||
|
||||
toast.success(t('download.videoInfoUpdated'))
|
||||
} catch (infoError) {
|
||||
console.warn('Failed to fetch video info for one-click download:', infoError)
|
||||
updateDownload({
|
||||
id,
|
||||
changes: {
|
||||
title: t('download.infoUnavailable'),
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title: t('download.infoUnavailable'),
|
||||
createdAt: Date.now(),
|
||||
startedAt: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
toast.success(t('download.oneClickDownloadStarted'))
|
||||
if (options?.clearInput) {
|
||||
setUrl('')
|
||||
@@ -362,7 +310,7 @@ export function DownloadDialog({
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
}
|
||||
},
|
||||
[settings, addDownload, updateDownload, t]
|
||||
[settings, addDownload, t]
|
||||
)
|
||||
|
||||
const handleFetchVideo = useCallback(async () => {
|
||||
@@ -737,16 +685,6 @@ export function DownloadDialog({
|
||||
try {
|
||||
await ipcServices.download.startDownload(id, options)
|
||||
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title: singleVideoState.title || videoInfo.title || t('download.fetchingVideoInfo'),
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
|
||||
setOpen(false) // Close dialog after download starts
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error)
|
||||
|
||||
@@ -68,12 +68,6 @@ const getCodecShortName = (codec?: string): string => {
|
||||
return codec.split('.')[0].toUpperCase()
|
||||
}
|
||||
|
||||
const isHlsFormat = (format: VideoFormat): boolean =>
|
||||
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||
|
||||
const isHttpProtocol = (format: VideoFormat): boolean =>
|
||||
!!format.protocol && format.protocol.startsWith('http')
|
||||
|
||||
const filterFormatsByType = (
|
||||
formats: VideoInfo['formats'],
|
||||
activeTab: 'video' | 'audio'
|
||||
@@ -276,30 +270,6 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo
|
||||
return `${mb.toFixed(2)} MB`
|
||||
}
|
||||
|
||||
const formatMetaLabel = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
const pushPart = (label: string, value?: string) => {
|
||||
if (!value) return
|
||||
parts.push(`${label}:${value}`)
|
||||
}
|
||||
pushPart('proto', format.protocol)
|
||||
pushPart('lang', format.language?.trim())
|
||||
if (format.tbr) {
|
||||
pushPart('tbr', `${Math.round(format.tbr)}k`)
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
pushPart('q', String(format.quality))
|
||||
}
|
||||
if (format.vcodec && format.vcodec !== 'none') {
|
||||
pushPart('vcodec', format.vcodec)
|
||||
}
|
||||
if (format.acodec && format.acodec !== 'none') {
|
||||
pushPart('acodec', format.acodec)
|
||||
}
|
||||
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatVideoQuality = (format: VideoFormat) => {
|
||||
if (format.height) {
|
||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||
@@ -369,7 +339,6 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo
|
||||
? format.acodec.split('.')[0].toUpperCase()
|
||||
: ''
|
||||
const sizeLabel = formatSize(format.filesize || format.filesize_approx)
|
||||
const metaLabel = formatMetaLabel(format)
|
||||
const isSelected = selectedFormat === format.format_id
|
||||
|
||||
return (
|
||||
@@ -394,19 +363,12 @@ const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: Fo
|
||||
{qualityLabel}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
|
||||
{thirdColumnLabel && thirdColumnLabel !== '-' && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{thirdColumnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{metaLabel && (
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground/70 leading-snug break-words">
|
||||
{metaLabel}
|
||||
</div>
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
<span className="text-xs text-muted-foreground truncate">{detailLabel}</span>
|
||||
{thirdColumnLabel && thirdColumnLabel !== '-' && (
|
||||
<span className="shrink-0 px-1.5 py-0.5 rounded bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{thirdColumnLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -439,16 +401,7 @@ export function SingleVideoDownload({
|
||||
|
||||
const relevantFormats = useMemo(() => {
|
||||
if (!videoInfo?.formats) return []
|
||||
const baseFormats = filterFormatsByType(videoInfo.formats, activeTab)
|
||||
if (baseFormats.length === 0) return []
|
||||
|
||||
const hasHttpFormats = baseFormats.some(isHttpProtocol)
|
||||
if (!hasHttpFormats) {
|
||||
return baseFormats
|
||||
}
|
||||
|
||||
const nonHlsFormats = baseFormats.filter((format) => !isHlsFormat(format))
|
||||
return nonHlsFormats.length > 0 ? nonHlsFormats : baseFormats
|
||||
return filterFormatsByType(videoInfo.formats, activeTab)
|
||||
}, [videoInfo?.formats, activeTab])
|
||||
|
||||
const containers = useMemo(() => {
|
||||
|
||||
@@ -178,7 +178,7 @@ export function SubscriptionFormDialog({
|
||||
|
||||
const handleOpenRSSHubDocs = async () => {
|
||||
try {
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/')
|
||||
await ipcServices.fs.openExternal('https://docs.rsshub.app/routes/social-media#youtube')
|
||||
} catch (error) {
|
||||
console.error('Failed to open RSSHub documentation:', error)
|
||||
toast.error(t('subscriptions.notifications.openLinkError'))
|
||||
@@ -242,7 +242,7 @@ export function SubscriptionFormDialog({
|
||||
<Input
|
||||
id={urlInputId}
|
||||
value={url}
|
||||
placeholder="https://docs.rsshub.app/routes/youtube/user/@FKJ"
|
||||
placeholder={t('subscriptions.placeholders.url')}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
/>
|
||||
{detectingFeed && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { DownloadItem } from '@shared/types'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -96,16 +97,6 @@ export function useDownloadEvents() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleLog = (rawData: unknown) => {
|
||||
const data = rawData as { id?: string; log?: string }
|
||||
const id = typeof data?.id === 'string' ? data.id : ''
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const logText = typeof data?.log === 'string' ? data.log : ''
|
||||
updateDownload({ id, changes: { ytDlpLog: logText } })
|
||||
}
|
||||
|
||||
const handleCompleted = (rawId: unknown) => {
|
||||
const id = typeof rawId === 'string' ? rawId : ''
|
||||
if (!id) {
|
||||
@@ -137,19 +128,39 @@ export function useDownloadEvents() {
|
||||
void syncHistoryItem(id)
|
||||
}
|
||||
|
||||
const handleQueued = (rawItem: unknown) => {
|
||||
const item = rawItem as DownloadItem
|
||||
if (!item || typeof item.id !== 'string') {
|
||||
return
|
||||
}
|
||||
addDownload(item)
|
||||
}
|
||||
|
||||
const handleUpdated = (rawData: unknown) => {
|
||||
const data = rawData as { id?: string; updates?: Partial<DownloadItem> }
|
||||
const id = typeof data?.id === 'string' ? data.id : ''
|
||||
if (!id || !data?.updates) {
|
||||
return
|
||||
}
|
||||
updateDownload({ id, changes: data.updates })
|
||||
}
|
||||
|
||||
const queuedSubscription = ipcEvents.on('download:queued', handleQueued)
|
||||
const updatedSubscription = ipcEvents.on('download:updated', handleUpdated)
|
||||
const startedSubscription = ipcEvents.on('download:started', handleStarted)
|
||||
const progressSubscription = ipcEvents.on('download:progress', handleProgress)
|
||||
const logSubscription = ipcEvents.on('download:log', handleLog)
|
||||
const completedSubscription = ipcEvents.on('download:completed', handleCompleted)
|
||||
const errorSubscription = ipcEvents.on('download:error', handleError)
|
||||
const cancelledSubscription = ipcEvents.on('download:cancelled', handleCancelled)
|
||||
|
||||
return () => {
|
||||
ipcEvents.removeListener('download:queued', queuedSubscription)
|
||||
ipcEvents.removeListener('download:updated', updatedSubscription)
|
||||
ipcEvents.removeListener('download:started', startedSubscription)
|
||||
ipcEvents.removeListener('download:progress', progressSubscription)
|
||||
ipcEvents.removeListener('download:log', logSubscription)
|
||||
ipcEvents.removeListener('download:completed', completedSubscription)
|
||||
ipcEvents.removeListener('download:error', errorSubscription)
|
||||
ipcEvents.removeListener('download:cancelled', cancelledSubscription)
|
||||
}
|
||||
}, [syncHistoryItem, t, updateDownload])
|
||||
}, [addDownload, syncHistoryItem, t, updateDownload])
|
||||
}
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "قيد الانتظار",
|
||||
"downloadQueue": "قائمة انتظار التحميل",
|
||||
"customDownloadFolder": "مجلد تنزيل مخصص",
|
||||
"retry": "إعادة المحاولة",
|
||||
"autoFolderPlaceholder": "مجلد تلقائي (استنادًا إلى البيانات الوصفية)",
|
||||
"autoFolderHint": "يتم إنشاء المجلدات التلقائية من البيانات الوصفية.",
|
||||
"useAutoFolder": "استخدام المجلد التلقائي",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "التقدم",
|
||||
"showDetails": "إظهار التفاصيل",
|
||||
"hideDetails": "إخفاء التفاصيل",
|
||||
"viewLogs": "عرض السجلات",
|
||||
"detailsTab": "التفاصيل",
|
||||
"logsTab": "السجلات",
|
||||
"logs": {
|
||||
"live": "سجلات مباشرة",
|
||||
"history": "سجلات محفوظة",
|
||||
"command": "أمر yt-dlp",
|
||||
"empty": "لا توجد سجلات بعد.",
|
||||
"scrollPaused": "تم إيقاف التمرير"
|
||||
},
|
||||
"selectAudioFormat": "اختر تنسيق الصوت",
|
||||
"selectDownloadType": "اختر نوع التنزيل",
|
||||
"selectFormat": "اختر التنسيق",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "انقر لفتح في المتصفح",
|
||||
"removeAction": "إزالة",
|
||||
"removeItem": "إزالة العنصر",
|
||||
"deleteFile": "حذف الملف",
|
||||
"deleteRecord": "إزالة من القائمة",
|
||||
"select": "تحديد",
|
||||
"selectAll": "تحديد الكل",
|
||||
"selectVisible": "تحديد المرئي",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "معطل",
|
||||
"onlyLatestShort": "الأحدث فقط"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "إضافة",
|
||||
"refresh": "تحديث",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "Ausstehend",
|
||||
"downloadQueue": "Download-Warteschlange",
|
||||
"customDownloadFolder": "Benutzerdefinierter Download-Ordner",
|
||||
"retry": "Download erneut versuchen",
|
||||
"autoFolderPlaceholder": "Automatischer Ordner (basierend auf Metadaten)",
|
||||
"autoFolderHint": "Automatische Ordner werden aus Metadaten erstellt.",
|
||||
"useAutoFolder": "Automatischen Ordner verwenden",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Fortschritt",
|
||||
"showDetails": "Details anzeigen",
|
||||
"hideDetails": "Details ausblenden",
|
||||
"viewLogs": "Logs anzeigen",
|
||||
"detailsTab": "Details",
|
||||
"logsTab": "Logs",
|
||||
"logs": {
|
||||
"live": "Live-Logs",
|
||||
"history": "Gespeicherte Logs",
|
||||
"command": "yt-dlp-Befehl",
|
||||
"empty": "Noch keine Logs.",
|
||||
"scrollPaused": "Scrollen pausiert"
|
||||
},
|
||||
"selectAudioFormat": "Audio-Format auswählen",
|
||||
"selectDownloadType": "Download-Typ auswählen",
|
||||
"selectFormat": "Format auswählen",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Klicken, um im Browser zu öffnen",
|
||||
"removeAction": "Entfernen",
|
||||
"removeItem": "Element entfernen",
|
||||
"deleteFile": "Datei löschen",
|
||||
"deleteRecord": "Aus Liste entfernen",
|
||||
"select": "Auswählen",
|
||||
"selectAll": "Alle auswählen",
|
||||
"selectVisible": "Sichtbare auswählen",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Deaktiviert",
|
||||
"onlyLatestShort": "Nur neueste"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Hinzufügen",
|
||||
"refresh": "Aktualisieren",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "Pending",
|
||||
"downloadQueue": "Download Queue",
|
||||
"customDownloadFolder": "Custom download folder",
|
||||
"retry": "Retry Download",
|
||||
"autoFolderPlaceholder": "Automatic folder (based on metadata)",
|
||||
"autoFolderHint": "Automatic folders are created from metadata.",
|
||||
"useAutoFolder": "Use automatic folder",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Progress",
|
||||
"showDetails": "Show details",
|
||||
"hideDetails": "Hide details",
|
||||
"viewLogs": "View logs",
|
||||
"detailsTab": "Details",
|
||||
"logsTab": "Logs",
|
||||
"logs": {
|
||||
"live": "Live logs",
|
||||
"history": "Saved logs",
|
||||
"command": "yt-dlp command",
|
||||
"empty": "No logs yet.",
|
||||
"scrollPaused": "Scroll paused"
|
||||
},
|
||||
"selectAudioFormat": "Select Audio Format",
|
||||
"selectDownloadType": "Select download type",
|
||||
"selectFormat": "Select Format",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Click to open in browser",
|
||||
"removeAction": "Remove",
|
||||
"removeItem": "Remove Item",
|
||||
"deleteFile": "Delete File",
|
||||
"deleteRecord": "Remove from List",
|
||||
"select": "Select",
|
||||
"selectAll": "Select All",
|
||||
"selectVisible": "Select visible",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Disabled",
|
||||
"onlyLatestShort": "Only latest"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Add",
|
||||
"refresh": "Refresh",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "Pendiente",
|
||||
"downloadQueue": "Cola de Descarga",
|
||||
"customDownloadFolder": "Carpeta de descarga personalizada",
|
||||
"retry": "Reintentar descarga",
|
||||
"autoFolderPlaceholder": "Carpeta automática (basada en metadatos)",
|
||||
"autoFolderHint": "Las carpetas automáticas se crean a partir de metadatos.",
|
||||
"useAutoFolder": "Usar carpeta automática",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Progreso",
|
||||
"showDetails": "Mostrar detalles",
|
||||
"hideDetails": "Ocultar detalles",
|
||||
"viewLogs": "Ver registros",
|
||||
"detailsTab": "Detalles",
|
||||
"logsTab": "Registros",
|
||||
"logs": {
|
||||
"live": "Registros en vivo",
|
||||
"history": "Registros guardados",
|
||||
"command": "Comando de yt-dlp",
|
||||
"empty": "Aún no hay registros.",
|
||||
"scrollPaused": "Desplazamiento en pausa"
|
||||
},
|
||||
"selectAudioFormat": "Seleccionar Formato de Audio",
|
||||
"selectDownloadType": "Seleccionar tipo de descarga",
|
||||
"selectFormat": "Seleccionar Formato",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Haz clic para abrir en el navegador",
|
||||
"removeAction": "Eliminar",
|
||||
"removeItem": "Eliminar Elemento",
|
||||
"deleteFile": "Eliminar Archivo",
|
||||
"deleteRecord": "Eliminar de la Lista",
|
||||
"select": "Seleccionar",
|
||||
"selectAll": "Seleccionar todo",
|
||||
"selectVisible": "Seleccionar visibles",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Deshabilitado",
|
||||
"onlyLatestShort": "Solo último"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Agregar",
|
||||
"refresh": "Actualizar",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "En attente",
|
||||
"downloadQueue": "File de Téléchargement",
|
||||
"customDownloadFolder": "Dossier de téléchargement personnalisé",
|
||||
"retry": "Relancer le téléchargement",
|
||||
"autoFolderPlaceholder": "Dossier automatique (basé sur les métadonnées)",
|
||||
"autoFolderHint": "Les dossiers automatiques sont créés à partir des métadonnées.",
|
||||
"useAutoFolder": "Utiliser le dossier automatique",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Progrès",
|
||||
"showDetails": "Afficher les détails",
|
||||
"hideDetails": "Masquer les détails",
|
||||
"viewLogs": "Voir les journaux",
|
||||
"detailsTab": "Détails",
|
||||
"logsTab": "Journaux",
|
||||
"logs": {
|
||||
"live": "Journaux en direct",
|
||||
"history": "Journaux enregistrés",
|
||||
"command": "Commande yt-dlp",
|
||||
"empty": "Aucun journal pour le moment.",
|
||||
"scrollPaused": "Défilement en pause"
|
||||
},
|
||||
"selectAudioFormat": "Sélectionner le Format Audio",
|
||||
"selectDownloadType": "Sélectionner le type de téléchargement",
|
||||
"selectFormat": "Sélectionner le Format",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Cliquez pour ouvrir dans le navigateur",
|
||||
"removeAction": "Supprimer",
|
||||
"removeItem": "Supprimer l'Élément",
|
||||
"deleteFile": "Supprimer le Fichier",
|
||||
"deleteRecord": "Supprimer de la Liste",
|
||||
"select": "Sélectionner",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"selectVisible": "Sélectionner les visibles",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Désactivé",
|
||||
"onlyLatestShort": "Seulement le dernier"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Ajouter",
|
||||
"refresh": "Rafraîchir",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "Menunggu",
|
||||
"downloadQueue": "Antrian Unduhan",
|
||||
"customDownloadFolder": "Folder unduhan khusus",
|
||||
"retry": "Coba ulang unduhan",
|
||||
"autoFolderPlaceholder": "Folder otomatis (berdasarkan metadata)",
|
||||
"autoFolderHint": "Folder otomatis dibuat dari metadata.",
|
||||
"useAutoFolder": "Gunakan folder otomatis",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Kemajuan",
|
||||
"showDetails": "Tampilkan detail",
|
||||
"hideDetails": "Sembunyikan detail",
|
||||
"viewLogs": "Lihat log",
|
||||
"detailsTab": "Detail",
|
||||
"logsTab": "Log",
|
||||
"logs": {
|
||||
"live": "Log langsung",
|
||||
"history": "Log tersimpan",
|
||||
"command": "Perintah yt-dlp",
|
||||
"empty": "Belum ada log.",
|
||||
"scrollPaused": "Pengguliran dijeda"
|
||||
},
|
||||
"selectAudioFormat": "Pilih Format Audio",
|
||||
"selectDownloadType": "Pilih jenis unduhan",
|
||||
"selectFormat": "Pilih Format",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Klik untuk membuka di browser",
|
||||
"removeAction": "Hapus",
|
||||
"removeItem": "Hapus Item",
|
||||
"deleteFile": "Hapus File",
|
||||
"deleteRecord": "Hapus dari Daftar",
|
||||
"select": "Pilih",
|
||||
"selectAll": "Pilih semua",
|
||||
"selectVisible": "Pilih yang terlihat",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Dinonaktifkan",
|
||||
"onlyLatestShort": "Hanya terbaru"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Tambah",
|
||||
"refresh": "Segarkan",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "In attesa",
|
||||
"downloadQueue": "Coda Download",
|
||||
"customDownloadFolder": "Cartella di download personalizzata",
|
||||
"retry": "Riprova download",
|
||||
"autoFolderPlaceholder": "Cartella automatica (in base ai metadati)",
|
||||
"autoFolderHint": "Le cartelle automatiche vengono create dai metadati.",
|
||||
"useAutoFolder": "Usa cartella automatica",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Progresso",
|
||||
"showDetails": "Mostra dettagli",
|
||||
"hideDetails": "Nascondi dettagli",
|
||||
"viewLogs": "Visualizza log",
|
||||
"detailsTab": "Dettagli",
|
||||
"logsTab": "Log",
|
||||
"logs": {
|
||||
"live": "Log in tempo reale",
|
||||
"history": "Log salvati",
|
||||
"command": "Comando yt-dlp",
|
||||
"empty": "Nessun log ancora.",
|
||||
"scrollPaused": "Scorrimento in pausa"
|
||||
},
|
||||
"selectAudioFormat": "Seleziona Formato Audio",
|
||||
"selectDownloadType": "Seleziona tipo di download",
|
||||
"selectFormat": "Seleziona Formato",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Clicca per aprire nel browser",
|
||||
"removeAction": "Rimuovi",
|
||||
"removeItem": "Rimuovi Elemento",
|
||||
"deleteFile": "Elimina File",
|
||||
"deleteRecord": "Rimuovi dall'Elenco",
|
||||
"select": "Seleziona",
|
||||
"selectAll": "Seleziona tutto",
|
||||
"selectVisible": "Seleziona visibili",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Disabilitato",
|
||||
"onlyLatestShort": "Solo più recente"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Aggiungere",
|
||||
"refresh": "Aggiorna",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "保留中",
|
||||
"downloadQueue": "ダウンロードキュー",
|
||||
"customDownloadFolder": "カスタムダウンロードフォルダー",
|
||||
"retry": "ダウンロードを再試行",
|
||||
"autoFolderPlaceholder": "自動フォルダー(メタデータに基づく)",
|
||||
"autoFolderHint": "自動フォルダーはメタデータから作成されます。",
|
||||
"useAutoFolder": "自動フォルダーを使用",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "進行状況",
|
||||
"showDetails": "詳細を表示",
|
||||
"hideDetails": "詳細を隠す",
|
||||
"viewLogs": "ログを表示",
|
||||
"detailsTab": "詳細",
|
||||
"logsTab": "ログ",
|
||||
"logs": {
|
||||
"live": "ライブログ",
|
||||
"history": "保存済みログ",
|
||||
"command": "yt-dlp コマンド",
|
||||
"empty": "ログはまだありません。",
|
||||
"scrollPaused": "スクロールを一時停止"
|
||||
},
|
||||
"selectAudioFormat": "オーディオフォーマットを選択",
|
||||
"selectDownloadType": "ダウンロードの種類を選択",
|
||||
"selectFormat": "フォーマットを選択",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "ブラウザで開くにはクリック",
|
||||
"removeAction": "削除",
|
||||
"removeItem": "アイテムを削除",
|
||||
"deleteFile": "ファイルを削除",
|
||||
"deleteRecord": "リストから削除",
|
||||
"select": "選択",
|
||||
"selectAll": "すべて選択",
|
||||
"selectVisible": "表示中を選択",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "無効",
|
||||
"onlyLatestShort": "最新のもののみ"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "追加",
|
||||
"refresh": "リフレッシュ",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "대기 중",
|
||||
"downloadQueue": "다운로드 큐",
|
||||
"customDownloadFolder": "사용자 지정 다운로드 폴더",
|
||||
"retry": "다운로드 재시도",
|
||||
"autoFolderPlaceholder": "자동 폴더(메타데이터 기반)",
|
||||
"autoFolderHint": "자동 폴더는 메타데이터에서 생성됩니다.",
|
||||
"useAutoFolder": "자동 폴더 사용",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "진행률",
|
||||
"showDetails": "세부정보 표시",
|
||||
"hideDetails": "세부정보 숨기기",
|
||||
"viewLogs": "로그 보기",
|
||||
"detailsTab": "세부정보",
|
||||
"logsTab": "로그",
|
||||
"logs": {
|
||||
"live": "실시간 로그",
|
||||
"history": "저장된 로그",
|
||||
"command": "yt-dlp 명령어",
|
||||
"empty": "아직 로그가 없습니다.",
|
||||
"scrollPaused": "스크롤 일시 중지"
|
||||
},
|
||||
"selectAudioFormat": "오디오 형식 선택",
|
||||
"selectDownloadType": "다운로드 유형 선택",
|
||||
"selectFormat": "형식 선택",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "브라우저에서 열려면 클릭",
|
||||
"removeAction": "제거",
|
||||
"removeItem": "항목 제거",
|
||||
"deleteFile": "파일 삭제",
|
||||
"deleteRecord": "목록에서 제거",
|
||||
"select": "선택",
|
||||
"selectAll": "모두 선택",
|
||||
"selectVisible": "표시된 항목 선택",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "장애가 있는",
|
||||
"onlyLatestShort": "최신만"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "추가하다",
|
||||
"refresh": "새로 고치다",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "Pendente",
|
||||
"downloadQueue": "Fila de Download",
|
||||
"customDownloadFolder": "Pasta de download personalizada",
|
||||
"retry": "Tentar baixar novamente",
|
||||
"autoFolderPlaceholder": "Pasta automática (com base nos metadados)",
|
||||
"autoFolderHint": "Pastas automáticas são criadas a partir de metadados.",
|
||||
"useAutoFolder": "Usar pasta automática",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Progresso",
|
||||
"showDetails": "Mostrar detalhes",
|
||||
"hideDetails": "Ocultar detalhes",
|
||||
"viewLogs": "Ver logs",
|
||||
"detailsTab": "Detalhes",
|
||||
"logsTab": "Logs",
|
||||
"logs": {
|
||||
"live": "Logs ao vivo",
|
||||
"history": "Logs salvos",
|
||||
"command": "Comando do yt-dlp",
|
||||
"empty": "Ainda não há logs.",
|
||||
"scrollPaused": "Rolagem pausada"
|
||||
},
|
||||
"selectAudioFormat": "Selecionar Formato de Áudio",
|
||||
"selectDownloadType": "Selecionar tipo de download",
|
||||
"selectFormat": "Selecionar Formato",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Clique para abrir no navegador",
|
||||
"removeAction": "Remover",
|
||||
"removeItem": "Remover Item",
|
||||
"deleteFile": "Remover Arquivo",
|
||||
"deleteRecord": "Remover da Lista",
|
||||
"select": "Selecionar",
|
||||
"selectAll": "Selecionar tudo",
|
||||
"selectVisible": "Selecionar visíveis",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Desabilitado",
|
||||
"onlyLatestShort": "Apenas o mais recente"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Adicionar",
|
||||
"refresh": "Atualizar",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "Ожидание",
|
||||
"downloadQueue": "Очередь загрузки",
|
||||
"customDownloadFolder": "Пользовательская папка загрузки",
|
||||
"retry": "Повторить загрузку",
|
||||
"autoFolderPlaceholder": "Автоматическая папка (на основе метаданных)",
|
||||
"autoFolderHint": "Автоматические папки создаются из метаданных.",
|
||||
"useAutoFolder": "Использовать автоматическую папку",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "Прогресс",
|
||||
"showDetails": "Показать детали",
|
||||
"hideDetails": "Скрыть детали",
|
||||
"viewLogs": "Посмотреть логи",
|
||||
"detailsTab": "Детали",
|
||||
"logsTab": "Логи",
|
||||
"logs": {
|
||||
"live": "Логи в реальном времени",
|
||||
"history": "Сохранённые логи",
|
||||
"command": "Команда yt-dlp",
|
||||
"empty": "Пока нет логов.",
|
||||
"scrollPaused": "Прокрутка приостановлена"
|
||||
},
|
||||
"selectAudioFormat": "Выбрать формат аудио",
|
||||
"selectDownloadType": "Выберите тип загрузки",
|
||||
"selectFormat": "Выбрать формат",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "Нажмите, чтобы открыть в браузере",
|
||||
"removeAction": "Удалить",
|
||||
"removeItem": "Удалить элемент",
|
||||
"deleteFile": "Удалить файл",
|
||||
"deleteRecord": "Удалить из списка",
|
||||
"select": "Выбрать",
|
||||
"selectAll": "Выбрать все",
|
||||
"selectVisible": "Выбрать видимые",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "Отключено",
|
||||
"onlyLatestShort": "Только последнее"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "Добавить",
|
||||
"refresh": "Обновить",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "待處理",
|
||||
"downloadQueue": "下載佇列",
|
||||
"customDownloadFolder": "自訂下載資料夾",
|
||||
"retry": "重試下載",
|
||||
"autoFolderPlaceholder": "自動資料夾(依中繼資料)",
|
||||
"autoFolderHint": "自動資料夾會由中繼資料建立。",
|
||||
"useAutoFolder": "使用自動資料夾",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "進度",
|
||||
"showDetails": "顯示詳情",
|
||||
"hideDetails": "隱藏詳細信息",
|
||||
"viewLogs": "查看日誌",
|
||||
"detailsTab": "詳細資料",
|
||||
"logsTab": "日誌",
|
||||
"logs": {
|
||||
"live": "即時日誌",
|
||||
"history": "已儲存日誌",
|
||||
"command": "yt-dlp 指令",
|
||||
"empty": "尚無日誌。",
|
||||
"scrollPaused": "捲動已暫停"
|
||||
},
|
||||
"selectAudioFormat": "選擇音訊格式",
|
||||
"selectDownloadType": "選擇下載類型",
|
||||
"selectFormat": "選擇格式",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "點擊在瀏覽器中開啟",
|
||||
"removeAction": "移除",
|
||||
"removeItem": "移除項目",
|
||||
"deleteFile": "刪除檔案",
|
||||
"deleteRecord": "從列表中移除",
|
||||
"select": "選取",
|
||||
"selectAll": "全選",
|
||||
"selectVisible": "選取可見項目",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "殘疾人",
|
||||
"onlyLatestShort": "僅最新"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加",
|
||||
"refresh": "重新整理",
|
||||
|
||||
@@ -119,7 +119,6 @@
|
||||
"downloadPending": "待处理",
|
||||
"downloadQueue": "下载队列",
|
||||
"customDownloadFolder": "自定义下载文件夹",
|
||||
"retry": "重试下载",
|
||||
"autoFolderPlaceholder": "自动文件夹(基于元数据)",
|
||||
"autoFolderHint": "自动文件夹由元数据创建。",
|
||||
"useAutoFolder": "使用自动文件夹",
|
||||
@@ -159,16 +158,6 @@
|
||||
"progress": "进度",
|
||||
"showDetails": "显示详情",
|
||||
"hideDetails": "隐藏详细信息",
|
||||
"viewLogs": "查看日志",
|
||||
"detailsTab": "详情",
|
||||
"logsTab": "日志",
|
||||
"logs": {
|
||||
"live": "实时日志",
|
||||
"history": "已保存日志",
|
||||
"command": "yt-dlp 命令",
|
||||
"empty": "暂无日志。",
|
||||
"scrollPaused": "滚动已暂停"
|
||||
},
|
||||
"selectAudioFormat": "选择音频格式",
|
||||
"selectDownloadType": "选择下载类型",
|
||||
"selectFormat": "选择格式",
|
||||
@@ -280,8 +269,6 @@
|
||||
"openInBrowser": "点击在浏览器中打开",
|
||||
"removeAction": "移除",
|
||||
"removeItem": "移除项目",
|
||||
"deleteFile": "删除文件",
|
||||
"deleteRecord": "从列表中移除",
|
||||
"select": "选择",
|
||||
"selectAll": "全选",
|
||||
"selectVisible": "选择可见项",
|
||||
@@ -502,6 +489,9 @@
|
||||
"disabled": "残疾人",
|
||||
"onlyLatestShort": "仅最新"
|
||||
},
|
||||
"placeholders": {
|
||||
"url": "https://rsshub.app/youtube/user/@FKJ"
|
||||
},
|
||||
"actions": {
|
||||
"add": "添加",
|
||||
"refresh": "刷新",
|
||||
|
||||
@@ -28,7 +28,6 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
progress: undefined,
|
||||
error: item.error,
|
||||
ytDlpCommand: item.ytDlpCommand,
|
||||
ytDlpLog: item.ytDlpLog,
|
||||
downloadPath: item.downloadPath,
|
||||
speed: undefined,
|
||||
duration: item.duration,
|
||||
@@ -191,9 +190,8 @@ export const downloadStatsAtom = atom((get) => {
|
||||
(acc, item) => {
|
||||
acc.total += 1
|
||||
if (
|
||||
(item.entryType === 'active' && item.status === 'downloading') ||
|
||||
item.status === 'processing' ||
|
||||
item.status === 'pending'
|
||||
item.entryType === 'active' &&
|
||||
(item.status === 'downloading' || item.status === 'processing' || item.status === 'pending')
|
||||
) {
|
||||
acc.active += 1
|
||||
}
|
||||
@@ -211,8 +209,8 @@ export const activeDownloadsCountAtom = atom((get) => {
|
||||
let count = 0
|
||||
for (const item of downloads.values()) {
|
||||
if (
|
||||
(item.entryType === 'active' && item.status === 'downloading') ||
|
||||
item.status === 'processing'
|
||||
item.entryType === 'active' &&
|
||||
(item.status === 'downloading' || item.status === 'processing')
|
||||
) {
|
||||
count++
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ export interface DownloadItem {
|
||||
error?: string
|
||||
speed?: string
|
||||
ytDlpCommand?: string
|
||||
ytDlpLog?: string
|
||||
// Enhanced video information
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
@@ -118,7 +117,6 @@ export interface DownloadHistoryItem {
|
||||
completedAt?: number
|
||||
error?: string
|
||||
ytDlpCommand?: string
|
||||
ytDlpLog?: string
|
||||
// Additional metadata
|
||||
description?: string
|
||||
channel?: string
|
||||
|
||||