Compare commits

..

1 Commits

Author SHA1 Message Date
Nexmoe
4e9a0e39f7 feat(download): handle one-click in main 2026-01-18 13:36:17 +08:00
56 changed files with 605 additions and 1590 deletions

View File

@@ -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:

View File

@@ -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.
![Browser cookies settings](/browser-cookies.png)
**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.
![Cookies file settings](/cookies-file.png)
**Steps:**
1. Export a Netscape cookies file using a browser extension.

View File

@@ -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.

View File

@@ -1,4 +1,4 @@
{
"title": "VidBee Docs",
"pages": ["index", "protocol", "cookies", "faq"]
"pages": ["index", "cookies", "faq"]
}

View File

@@ -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/)

View File

@@ -17,13 +17,11 @@ Cookie 用于复用浏览器的登录态,帮助 VidBee 下载需要登录或
在 **设置** 中选择你的浏览器VidBee 会尝试自动识别浏览器配置文件路径。你也可以手动填写配置文件路径。
![浏览器 Cookie 设置](/browser-cookies.png)
**支持的浏览器(按平台差异显示):**
- **Windows仅支持 Firefox。其他浏览器无法读取 Cookie。**
- macOS全部支持
- Linux全部支持
- macOSSafari、Chrome、Edge、Brave、Firefox 等
- LinuxChrome、Chromium、Brave、Firefox 等
**使用步骤:**
@@ -38,8 +36,6 @@ Cookie 用于复用浏览器的登录态,帮助 VidBee 下载需要登录或
你也可以导入 **Netscape 格式** 的 cookies 文件。这个方式适合在不方便读取浏览器配置文件时使用。
![Cookies 文件设置](/cookies-file.png)
**使用步骤:**
1. 使用浏览器扩展导出 Netscape cookies 文件。

View File

@@ -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):常见问题与排查思路。

View File

@@ -1,4 +1,4 @@
{
"title": "VidBee 文档",
"pages": ["index", "protocol", "cookies", "faq"]
"pages": ["index", "cookies", "faq"]
}

View File

@@ -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/)

View File

@@ -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,

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 924 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -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,
},
};
}

View File

@@ -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,

View File

@@ -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)}`,
};
});
}

View File

@@ -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",

View File

@@ -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'
}

View File

@@ -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')) {

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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'),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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(() => {

View File

@@ -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 && (

View File

@@ -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])
}

View File

@@ -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": "تحديث",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "リフレッシュ",

View File

@@ -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": "새로 고치다",

View File

@@ -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",

View File

@@ -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": "Обновить",

View File

@@ -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": "重新整理",

View File

@@ -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": "刷新",

View File

@@ -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++
}

View File

@@ -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