Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f1c6ce59e | ||
|
|
e5d36da904 | ||
|
|
9c3fd2c26f | ||
|
|
894eb9774b | ||
|
|
73af211bef | ||
|
|
4b561cef38 | ||
|
|
761adf2476 | ||
|
|
182a1b9c1e | ||
|
|
59aee07913 | ||
|
|
3d39c9751f | ||
|
|
2bf7af9b25 | ||
|
|
fe55ac1a79 | ||
|
|
c1d1a1b912 | ||
|
|
9d377dd464 |
60
.github/workflows/build.yml
vendored
60
.github/workflows/build.yml
vendored
@@ -33,16 +33,16 @@ jobs:
|
||||
ytdlp_output: yt-dlp.exe
|
||||
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip
|
||||
ffmpeg_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffmpeg.exe
|
||||
ffmpeg_output: ffmpeg.exe
|
||||
ffprobe_inner_path: ffmpeg-master-latest-win64-gpl\bin\ffprobe.exe
|
||||
- platform: macos
|
||||
os: macos-latest
|
||||
build_script: pnpm run build:mac
|
||||
ytdlp_asset: yt-dlp_macos
|
||||
ytdlp_output: yt-dlp_macos
|
||||
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip
|
||||
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip
|
||||
ffmpeg_arm_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip
|
||||
ffmpeg_x86_url: https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip
|
||||
ffmpeg_inner_path: ffmpeg/ffmpeg
|
||||
ffmpeg_output: ffmpeg_macos
|
||||
ffprobe_inner_path: ffmpeg/ffprobe
|
||||
- platform: linux
|
||||
os: ubuntu-latest
|
||||
build_script: pnpm run build:linux
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
ytdlp_output: yt-dlp_linux
|
||||
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz
|
||||
ffmpeg_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
|
||||
ffmpeg_output: ffmpeg_linux
|
||||
ffprobe_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffprobe
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -77,8 +77,11 @@ jobs:
|
||||
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
|
||||
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
|
||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
||||
Copy-Item -Path $source -Destination $destination -Force
|
||||
$ffprobeSource = Join-Path 'ffmpeg' '${{ matrix.ffprobe_inner_path }}'
|
||||
$destinationDir = Join-Path 'resources' 'ffmpeg'
|
||||
New-Item -ItemType Directory -Path $destinationDir -Force | Out-Null
|
||||
Copy-Item -Path $source -Destination (Join-Path $destinationDir 'ffmpeg.exe') -Force
|
||||
Copy-Item -Path $ffprobeSource -Destination (Join-Path $destinationDir 'ffprobe.exe') -Force
|
||||
Remove-Item ffmpeg.zip -Force
|
||||
Remove-Item ffmpeg -Recurse -Force
|
||||
|
||||
@@ -86,7 +89,8 @@ jobs:
|
||||
if: matrix.platform == 'macos'
|
||||
shell: bash
|
||||
env:
|
||||
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
|
||||
FFMPEG_OUTPUT: ffmpeg
|
||||
FFPROBE_OUTPUT: ffprobe
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip
|
||||
@@ -97,6 +101,8 @@ jobs:
|
||||
|
||||
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
|
||||
x86_bin="ffmpeg-x86/${{ matrix.ffmpeg_inner_path }}"
|
||||
arm_probe="ffmpeg-arm/${{ matrix.ffprobe_inner_path }}"
|
||||
x86_probe="ffmpeg-x86/${{ matrix.ffprobe_inner_path }}"
|
||||
|
||||
if [[ ! -f "$arm_bin" ]]; then
|
||||
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
|
||||
@@ -104,6 +110,12 @@ jobs:
|
||||
if [[ ! -f "$x86_bin" ]]; then
|
||||
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
|
||||
fi
|
||||
if [[ ! -f "$arm_probe" ]]; then
|
||||
arm_probe="$(find ffmpeg-arm -type f -name ffprobe -print -quit)"
|
||||
fi
|
||||
if [[ ! -f "$x86_probe" ]]; then
|
||||
x86_probe="$(find ffmpeg-x86 -type f -name ffprobe -print -quit)"
|
||||
fi
|
||||
|
||||
if [[ ! -f "$arm_bin" ]]; then
|
||||
echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
|
||||
@@ -113,9 +125,19 @@ jobs:
|
||||
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$arm_probe" ]]; then
|
||||
echo "::error::Missing arm64 ffprobe binary at $arm_probe"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$x86_probe" ]]; then
|
||||
echo "::error::Missing x86_64 ffprobe binary at $x86_probe"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
lipo -create "$arm_bin" "$x86_bin" -output "resources/$FFMPEG_OUTPUT"
|
||||
chmod +x "resources/$FFMPEG_OUTPUT"
|
||||
mkdir -p resources/ffmpeg
|
||||
lipo -create "$arm_bin" "$x86_bin" -output "resources/ffmpeg/$FFMPEG_OUTPUT"
|
||||
lipo -create "$arm_probe" "$x86_probe" -output "resources/ffmpeg/$FFPROBE_OUTPUT"
|
||||
chmod +x "resources/ffmpeg/$FFMPEG_OUTPUT" "resources/ffmpeg/$FFPROBE_OUTPUT"
|
||||
rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
|
||||
|
||||
- name: Download ffmpeg binary (Linux)
|
||||
@@ -130,8 +152,10 @@ jobs:
|
||||
fi
|
||||
mkdir ffmpeg
|
||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
||||
mkdir -p resources/ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/ffmpeg/ffmpeg"
|
||||
cp "ffmpeg/${{ matrix.ffprobe_inner_path }}" "resources/ffmpeg/ffprobe"
|
||||
chmod +x "resources/ffmpeg/ffmpeg" "resources/ffmpeg/ffprobe"
|
||||
rm -rf ffmpeg.tar.xz ffmpeg
|
||||
|
||||
- name: Download yt-dlp binary
|
||||
@@ -230,5 +254,15 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-${{ matrix.os }}
|
||||
path: dist/
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*.zip
|
||||
dist/*.dmg
|
||||
dist/*.AppImage
|
||||
dist/*.snap
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
dist/*.tar.gz
|
||||
dist/*.yml
|
||||
dist/*.blockmap
|
||||
retention-days: 1
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -7,3 +7,5 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
with:
|
||||
upload_artifacts: true
|
||||
|
||||
@@ -58,7 +58,7 @@ src/
|
||||
- Build production bundles with `pnpm build`.
|
||||
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`.
|
||||
- Use `pnpm build:unpack` to generate unpacked directories under `dist/` for manual inspection.
|
||||
- Bundle platform binaries of `yt-dlp` and `ffmpeg` under `resources/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
|
||||
- Bundle platform binaries of `yt-dlp` and `ffmpeg/ffprobe` under `resources/ffmpeg/` (or set `YTDLP_PATH`/`FFMPEG_PATH`) before packaging so merges and audio extraction work out of the box.
|
||||
|
||||
## Working on Changes
|
||||
- Keep each pull request focused on a single problem or feature.
|
||||
|
||||
@@ -2,7 +2,12 @@ const { execFileSync } = require('node:child_process')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const BINARIES = ['yt-dlp_macos', 'ffmpeg_macos', 'deno']
|
||||
const BINARIES = [
|
||||
'yt-dlp_macos',
|
||||
path.join('ffmpeg', 'ffmpeg'),
|
||||
path.join('ffmpeg', 'ffprobe'),
|
||||
'deno'
|
||||
]
|
||||
|
||||
const findAppBundle = (appOutDir) => {
|
||||
const entries = fs.readdirSync(appOutDir)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.1.11",
|
||||
"version": "1.2.0",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
3
resources/.gitignore
vendored
3
resources/.gitignore
vendored
@@ -6,6 +6,9 @@ ffmpeg.exe
|
||||
ffmpeg_macos
|
||||
ffmpeg_linux
|
||||
ffmpeg
|
||||
ffmpeg/
|
||||
ffprobe
|
||||
ffprobe.exe
|
||||
deno.exe
|
||||
deno
|
||||
|
||||
|
||||
@@ -48,27 +48,27 @@ Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/downloa
|
||||
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp" -OutFile "resources/yt-dlp_linux"
|
||||
```
|
||||
|
||||
## ffmpeg Binaries
|
||||
## ffmpeg/ffprobe Binaries
|
||||
|
||||
ffmpeg is required for merging audio/video streams and audio extraction. Bundle the matching binary for each target platform:
|
||||
ffmpeg is required for merging audio/video streams and audio extraction. ffprobe is required for post-processing metadata. Bundle both binaries under `resources/ffmpeg/`.
|
||||
|
||||
### Required Files
|
||||
|
||||
1. **Windows**: `ffmpeg.exe`
|
||||
2. **macOS**: `ffmpeg_macos`
|
||||
3. **Linux**: `ffmpeg_linux`
|
||||
1. **Windows**: `resources/ffmpeg/ffmpeg.exe` and `resources/ffmpeg/ffprobe.exe`
|
||||
2. **macOS**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
|
||||
3. **Linux**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
|
||||
|
||||
### How to Download
|
||||
|
||||
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and rename the binary to match the filenames above.
|
||||
- **macOS**: Download the `ffmpeg-arm64*.zip` and `ffmpeg-x86_64*.zip` assets from <https://github.com/eko5624/mpv-mac/releases/latest>. Extract them and merge into a universal binary with `lipo -create`, then save the result as `resources/ffmpeg_macos`.
|
||||
- On macOS/Linux ensure the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`).
|
||||
- **Windows / Linux**: Grab static builds from <https://ffmpeg.org/download.html> (or <https://github.com/yt-dlp/FFmpeg-Builds/releases>) and copy `ffmpeg` and `ffprobe` into `resources/ffmpeg/`.
|
||||
- **macOS**: Download the `ffmpeg-*.zip` asset from <https://github.com/eko5624/mpv-mac/releases/latest>, then copy `ffmpeg` and `ffprobe` from the archive into `resources/ffmpeg/`.
|
||||
- On macOS/Linux ensure both binaries are executable: `chmod +x resources/ffmpeg/ffmpeg resources/ffmpeg/ffprobe`.
|
||||
|
||||
### Note
|
||||
|
||||
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
|
||||
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
|
||||
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
|
||||
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/ffprobe from the system PATH.
|
||||
- You can override the lookup path via `FFMPEG_PATH`. It must point to a directory containing both `ffmpeg` and `ffprobe`.
|
||||
- File sizes: ~40-80 MB per ffmpeg build (ffmpeg + ffprobe)
|
||||
|
||||
## JS Runtime (Deno)
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@ if (!supportedPlatforms.includes(platform)) {
|
||||
const binaries = [
|
||||
{
|
||||
label: 'yt-dlp',
|
||||
filenameMap: {
|
||||
win: 'yt-dlp.exe',
|
||||
mac: 'yt-dlp_macos',
|
||||
linux: 'yt-dlp_linux'
|
||||
paths: {
|
||||
win: ['yt-dlp.exe'],
|
||||
mac: ['yt-dlp_macos'],
|
||||
linux: ['yt-dlp_linux']
|
||||
},
|
||||
help: {
|
||||
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
|
||||
@@ -34,10 +34,23 @@ const binaries = [
|
||||
},
|
||||
{
|
||||
label: 'ffmpeg',
|
||||
filenameMap: {
|
||||
win: 'ffmpeg.exe',
|
||||
mac: 'ffmpeg_macos',
|
||||
linux: 'ffmpeg_linux'
|
||||
paths: {
|
||||
win: ['ffmpeg/ffmpeg.exe'],
|
||||
mac: ['ffmpeg/ffmpeg'],
|
||||
linux: ['ffmpeg/ffmpeg']
|
||||
},
|
||||
help: {
|
||||
win: 'https://ffmpeg.org/download.html',
|
||||
linux: 'https://ffmpeg.org/download.html',
|
||||
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'ffprobe',
|
||||
paths: {
|
||||
win: ['ffmpeg/ffprobe.exe'],
|
||||
mac: ['ffmpeg/ffprobe'],
|
||||
linux: ['ffmpeg/ffprobe']
|
||||
},
|
||||
help: {
|
||||
win: 'https://ffmpeg.org/download.html',
|
||||
@@ -47,10 +60,10 @@ const binaries = [
|
||||
},
|
||||
{
|
||||
label: 'deno',
|
||||
filenameMap: {
|
||||
win: 'deno.exe',
|
||||
mac: 'deno',
|
||||
linux: 'deno'
|
||||
paths: {
|
||||
win: ['deno.exe'],
|
||||
mac: ['deno'],
|
||||
linux: ['deno']
|
||||
},
|
||||
help: {
|
||||
default: 'https://github.com/denoland/deno/releases/latest'
|
||||
@@ -61,12 +74,15 @@ const binaries = [
|
||||
let hasMissingBinary = false
|
||||
|
||||
for (const binary of binaries) {
|
||||
const filename = binary.filenameMap[platform]
|
||||
const binaryPath = path.join(__dirname, '..', 'resources', filename)
|
||||
const candidates = binary.paths[platform] || []
|
||||
const found = candidates.find((filename) =>
|
||||
fs.existsSync(path.join(__dirname, '..', 'resources', filename))
|
||||
)
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
console.error(`❌ Error: resources/${filename} not found!`)
|
||||
console.error(`Please download ${filename} to the resources/ directory first.`)
|
||||
if (!found) {
|
||||
const expected = candidates.length ? candidates.join(' or ') : binary.label
|
||||
console.error(`❌ Error: resources/${expected} not found!`)
|
||||
console.error(`Please download ${binary.label} to the resources/ directory first.`)
|
||||
const help =
|
||||
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
|
||||
if (help) {
|
||||
@@ -74,7 +90,7 @@ for (const binary of binaries) {
|
||||
}
|
||||
hasMissingBinary = true
|
||||
} else {
|
||||
console.log(`✅ ${filename} found in resources/ directory`)
|
||||
console.log(`✅ ${binary.label} found: resources/${found}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ const http = require('node:http')
|
||||
|
||||
// Configuration
|
||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
||||
const FFMPEG_DIR = path.join(RESOURCES_DIR, 'ffmpeg')
|
||||
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
||||
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
|
||||
const GITHUB_TOKEN =
|
||||
@@ -29,7 +30,9 @@ const PLATFORM_CONFIG = {
|
||||
ffmpeg: {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
|
||||
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
|
||||
ffprobeInnerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffprobe.exe',
|
||||
output: 'ffmpeg.exe',
|
||||
ffprobeOutput: 'ffprobe.exe',
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
@@ -46,9 +49,11 @@ const PLATFORM_CONFIG = {
|
||||
ffmpeg: {
|
||||
// For development, download only the architecture matching current system
|
||||
arm64: {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-arm64-96e8f3b8cc.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
ffprobeInnerPath: 'ffmpeg/ffprobe',
|
||||
output: 'ffmpeg',
|
||||
ffprobeOutput: 'ffprobe',
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
@@ -56,9 +61,11 @@ const PLATFORM_CONFIG = {
|
||||
}
|
||||
},
|
||||
x64: {
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
|
||||
url: 'https://github.com/eko5624/mpv-mac/releases/download/2026-01-12/ffmpeg-x86_64-96e8f3b8cc.zip',
|
||||
innerPath: 'ffmpeg/ffmpeg',
|
||||
output: 'ffmpeg_macos',
|
||||
ffprobeInnerPath: 'ffmpeg/ffprobe',
|
||||
output: 'ffmpeg',
|
||||
ffprobeOutput: 'ffprobe',
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repo: 'eko5624/mpv-mac',
|
||||
@@ -75,7 +82,9 @@ const PLATFORM_CONFIG = {
|
||||
ffmpeg: {
|
||||
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
|
||||
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
|
||||
output: 'ffmpeg_linux',
|
||||
ffprobeInnerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffprobe',
|
||||
output: 'ffmpeg',
|
||||
ffprobeOutput: 'ffprobe',
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
@@ -329,15 +338,21 @@ function formatBytes(bytes) {
|
||||
return `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
function checkBinary(filePath, args, label) {
|
||||
function checkBinary(filePath, args, label, options = {}) {
|
||||
const timeoutMs =
|
||||
typeof options.timeoutMs === 'number'
|
||||
? options.timeoutMs
|
||||
: os.platform() === 'win32'
|
||||
? 20000
|
||||
: 8000
|
||||
const result = spawnSync(filePath, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 8000,
|
||||
timeout: timeoutMs,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { ok: false, message: result.error.message }
|
||||
return { ok: false, message: result.error.message, code: result.error.code }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
@@ -413,23 +428,43 @@ async function downloadYtDlp(config) {
|
||||
}
|
||||
|
||||
async function downloadFfmpegWindows(config) {
|
||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
const {
|
||||
url: fallbackUrl,
|
||||
innerPath: fallbackInnerPath,
|
||||
ffprobeInnerPath: fallbackFfprobeInnerPath,
|
||||
output,
|
||||
ffprobeOutput,
|
||||
release
|
||||
} = config.ffmpeg
|
||||
const outputPath = path.join(FFMPEG_DIR, output)
|
||||
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const ffmpegExists = fileExists(outputPath)
|
||||
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
|
||||
|
||||
if (ffmpegExists && ffprobeExists) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
const ffprobeValidation = ffprobeOutputPath
|
||||
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
|
||||
: { ok: true }
|
||||
if (!validation.ok || !ffprobeValidation.ok) {
|
||||
log(
|
||||
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
|
||||
'warn'
|
||||
)
|
||||
} else {
|
||||
log('ffmpeg and ffprobe already exist, skipping download', 'info')
|
||||
return
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for Windows...`, 'download')
|
||||
ensureDir(FFMPEG_DIR)
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
let innerPath = fallbackInnerPath
|
||||
let ffprobeInnerPath = fallbackFfprobeInnerPath
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
@@ -440,6 +475,10 @@ async function downloadFfmpegWindows(config) {
|
||||
if (inferred) {
|
||||
innerPath = inferred
|
||||
}
|
||||
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe.exe')
|
||||
if (inferredFfprobe) {
|
||||
ffprobeInnerPath = inferredFfprobe
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
@@ -457,12 +496,24 @@ async function downloadFfmpegWindows(config) {
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
if (ffprobeInnerPath && ffprobeOutputPath) {
|
||||
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath.replace(/\\/g, path.sep))
|
||||
if (!fileExists(ffprobeSourcePath)) {
|
||||
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
|
||||
}
|
||||
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
|
||||
}
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
if (validation.code === 'ETIMEDOUT') {
|
||||
log(`Downloaded ${output} version check timed out; keeping binary`, 'warn')
|
||||
} else {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
} else {
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
fs.unlinkSync(tempZip)
|
||||
@@ -482,19 +533,38 @@ async function downloadFfmpegMac(config) {
|
||||
throw new Error(`Unsupported architecture: ${arch}`)
|
||||
}
|
||||
|
||||
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
const {
|
||||
url: fallbackUrl,
|
||||
innerPath,
|
||||
ffprobeInnerPath,
|
||||
output,
|
||||
ffprobeOutput,
|
||||
release
|
||||
} = ffmpegConfig
|
||||
const outputPath = path.join(FFMPEG_DIR, output)
|
||||
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const ffmpegExists = fileExists(outputPath)
|
||||
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
|
||||
|
||||
if (ffmpegExists && ffprobeExists) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
const ffprobeValidation = ffprobeOutputPath
|
||||
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
|
||||
: { ok: true }
|
||||
if (!validation.ok || !ffprobeValidation.ok) {
|
||||
log(
|
||||
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
|
||||
'warn'
|
||||
)
|
||||
} else {
|
||||
log('ffmpeg and ffprobe already exist, skipping download', 'info')
|
||||
return
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
|
||||
ensureDir(FFMPEG_DIR)
|
||||
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
@@ -522,6 +592,14 @@ async function downloadFfmpegMac(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
if (ffprobeInnerPath && ffprobeOutputPath) {
|
||||
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath)
|
||||
if (!fileExists(ffprobeSourcePath)) {
|
||||
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
|
||||
}
|
||||
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
|
||||
setExecutable(ffprobeOutputPath)
|
||||
}
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
@@ -540,23 +618,43 @@ async function downloadFfmpegMac(config) {
|
||||
}
|
||||
|
||||
async function downloadFfmpegLinux(config) {
|
||||
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
const {
|
||||
url: fallbackUrl,
|
||||
innerPath: fallbackInnerPath,
|
||||
ffprobeInnerPath: fallbackFfprobeInnerPath,
|
||||
output,
|
||||
ffprobeOutput,
|
||||
release
|
||||
} = config.ffmpeg
|
||||
const outputPath = path.join(FFMPEG_DIR, output)
|
||||
const ffprobeOutputPath = ffprobeOutput ? path.join(FFMPEG_DIR, ffprobeOutput) : null
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const ffmpegExists = fileExists(outputPath)
|
||||
const ffprobeExists = ffprobeOutputPath ? fileExists(ffprobeOutputPath) : true
|
||||
|
||||
if (ffmpegExists && ffprobeExists) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
const ffprobeValidation = ffprobeOutputPath
|
||||
? checkBinary(ffprobeOutputPath, ['-version'], 'ffprobe')
|
||||
: { ok: true }
|
||||
if (!validation.ok || !ffprobeValidation.ok) {
|
||||
log(
|
||||
`Existing ffmpeg/ffprobe failed version check: ${validation.message || ffprobeValidation.message}`,
|
||||
'warn'
|
||||
)
|
||||
} else {
|
||||
log('ffmpeg and ffprobe already exist, skipping download', 'info')
|
||||
return
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
|
||||
log(`Downloading ffmpeg for Linux...`, 'download')
|
||||
ensureDir(FFMPEG_DIR)
|
||||
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
|
||||
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
|
||||
let downloadUrl = fallbackUrl
|
||||
let innerPath = fallbackInnerPath
|
||||
let ffprobeInnerPath = fallbackFfprobeInnerPath
|
||||
|
||||
if (release) {
|
||||
try {
|
||||
@@ -567,6 +665,10 @@ async function downloadFfmpegLinux(config) {
|
||||
if (inferred) {
|
||||
innerPath = inferred
|
||||
}
|
||||
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe')
|
||||
if (inferredFfprobe) {
|
||||
ffprobeInnerPath = inferredFfprobe
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
|
||||
@@ -585,6 +687,14 @@ async function downloadFfmpegLinux(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
if (ffprobeInnerPath && ffprobeOutputPath) {
|
||||
const ffprobeSourcePath = path.join(extractDir, ffprobeInnerPath)
|
||||
if (!fileExists(ffprobeSourcePath)) {
|
||||
throw new Error(`ffprobe binary not found at ${ffprobeSourcePath}`)
|
||||
}
|
||||
fs.copyFileSync(ffprobeSourcePath, ffprobeOutputPath)
|
||||
setExecutable(ffprobeOutputPath)
|
||||
}
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
|
||||
@@ -137,9 +137,7 @@ export const buildDownloadArgs = (
|
||||
} else {
|
||||
args.push('--no-embed-subs')
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
|
||||
}
|
||||
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
|
||||
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
|
||||
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
|
||||
|
||||
@@ -152,8 +150,8 @@ export const buildDownloadArgs = (
|
||||
const outputTemplate = path.join(baseDownloadPath, safeTemplate)
|
||||
args.push('-o', outputTemplate)
|
||||
|
||||
// Add options for better filename handling
|
||||
args.push('--no-part')
|
||||
// Allow resume support across restarts
|
||||
args.push('--continue')
|
||||
args.push('--no-playlist-reverse')
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
|
||||
@@ -449,14 +449,20 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
// Initialize yt-dlp
|
||||
let ytdlpReady = false
|
||||
try {
|
||||
log.info('Initializing yt-dlp...')
|
||||
await ytdlpManager.initialize()
|
||||
ytdlpReady = true
|
||||
log.info('yt-dlp initialized successfully')
|
||||
} catch (error) {
|
||||
log.error('Failed to initialize yt-dlp:', error)
|
||||
}
|
||||
|
||||
if (ytdlpReady) {
|
||||
downloadEngine.restoreActiveDownloads()
|
||||
}
|
||||
|
||||
await startExtensionApiServer()
|
||||
|
||||
applyDockVisibility(settingsManager.get('hideDockIcon'))
|
||||
@@ -474,14 +480,27 @@ app.whenReady().then(async () => {
|
||||
handleDeepLinkArgv(process.argv)
|
||||
|
||||
app.on('activate', () => {
|
||||
const existingWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed())
|
||||
if (existingWindow) {
|
||||
if (existingWindow.isMinimized()) {
|
||||
existingWindow.restore()
|
||||
}
|
||||
if (!existingWindow.isVisible()) {
|
||||
existingWindow.show()
|
||||
}
|
||||
existingWindow.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// On macOS it's common to re-create a window in the app when the
|
||||
// dock icon is clicked and there are no other windows open.
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
isQuitting = true
|
||||
downloadEngine.flushDownloadSession()
|
||||
})
|
||||
|
||||
// Quit when all windows are closed, except on macOS. There, it's common
|
||||
|
||||
@@ -5,7 +5,8 @@ import type {
|
||||
PlaylistDownloadOptions,
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoInfo
|
||||
VideoInfo,
|
||||
VideoInfoCommandResult
|
||||
} from '../../../shared/types'
|
||||
import { downloadEngine } from '../../lib/download-engine'
|
||||
|
||||
@@ -17,6 +18,14 @@ class DownloadService extends IpcService {
|
||||
return downloadEngine.getVideoInfo(url)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getVideoInfoWithCommand(
|
||||
_context: IpcContext,
|
||||
url: string
|
||||
): Promise<VideoInfoCommandResult> {
|
||||
return downloadEngine.getVideoInfoWithCommand(url)
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async getPlaylistInfo(_context: IpcContext, url: string): Promise<PlaylistInfo> {
|
||||
return downloadEngine.getPlaylistInfo(url)
|
||||
@@ -37,6 +46,11 @@ class DownloadService extends IpcService {
|
||||
return downloadEngine.getQueueStatus()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
getActiveDownloads(_context: IpcContext): DownloadItem[] {
|
||||
return downloadEngine.getActiveDownloads()
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void {
|
||||
downloadEngine.updateDownloadInfo(id, updates)
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
SubscriptionRule,
|
||||
SubscriptionUpdatePayload
|
||||
} from '../../../shared/types'
|
||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE } from '../../../shared/types'
|
||||
import {
|
||||
DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE,
|
||||
SUBSCRIPTION_DUPLICATE_FEED_ERROR
|
||||
} from '../../../shared/types'
|
||||
import { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
|
||||
import { subscriptionManager } from '../../lib/subscription-manager'
|
||||
import { subscriptionScheduler } from '../../lib/subscription-scheduler'
|
||||
@@ -113,6 +116,10 @@ class SubscriptionService extends IpcService {
|
||||
options: CreateSubscriptionOptions
|
||||
): Promise<SubscriptionRule> {
|
||||
const resolved = resolveFeedFromInput(options.url)
|
||||
const duplicate = subscriptionManager.findDuplicateFeed(resolved.feedUrl)
|
||||
if (duplicate) {
|
||||
throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)
|
||||
}
|
||||
const settings = settingsManager.getAll()
|
||||
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
|
||||
const payload: SubscriptionCreatePayload = {
|
||||
@@ -141,6 +148,12 @@ class SubscriptionService extends IpcService {
|
||||
id: string,
|
||||
updates: SubscriptionUpdatePayload
|
||||
): SubscriptionRule | undefined {
|
||||
if (updates.feedUrl) {
|
||||
const duplicate = subscriptionManager.findDuplicateFeed(updates.feedUrl, id)
|
||||
if (duplicate) {
|
||||
throw new Error(SUBSCRIPTION_DUPLICATE_FEED_ERROR)
|
||||
}
|
||||
}
|
||||
const normalized: SubscriptionUpdatePayload = { ...updates }
|
||||
if (typeof normalized.namingTemplate === 'string') {
|
||||
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)
|
||||
|
||||
@@ -15,6 +15,7 @@ export const downloadHistoryTable = sqliteTable('download_history', {
|
||||
completedAt: integer('completed_at', { mode: 'number' }),
|
||||
sortKey: integer('sort_key', { mode: 'number' }).notNull(),
|
||||
error: text('error'),
|
||||
ytDlpCommand: text('yt_dlp_command'),
|
||||
description: text('description'),
|
||||
channel: text('channel'),
|
||||
uploader: text('uploader'),
|
||||
|
||||
@@ -11,7 +11,8 @@ import type {
|
||||
PlaylistDownloadResult,
|
||||
PlaylistInfo,
|
||||
VideoFormat,
|
||||
VideoInfo
|
||||
VideoInfo,
|
||||
VideoInfoCommandResult
|
||||
} from '../../shared/types'
|
||||
import {
|
||||
buildDownloadArgs,
|
||||
@@ -27,6 +28,11 @@ import { settingsManager } from '../settings'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
import { resolvePathWithHome } from '../utils/path-helpers'
|
||||
import { DownloadQueue } from './download-queue'
|
||||
import {
|
||||
type DownloadSessionItem,
|
||||
loadDownloadSession,
|
||||
saveDownloadSession
|
||||
} from './download-session-store'
|
||||
import { ffmpegManager } from './ffmpeg-manager'
|
||||
import { historyManager } from './history-manager'
|
||||
import { ytdlpManager } from './ytdlp-manager'
|
||||
@@ -49,6 +55,8 @@ const formatYtDlpCommand = (args: string[]): string => {
|
||||
return `yt-dlp ${quoted.join(' ')}`
|
||||
}
|
||||
|
||||
const resolveFfmpegLocation = (ffmpegPath: string): string => path.dirname(ffmpegPath)
|
||||
|
||||
const ensureDirectoryExists = (dir?: string): void => {
|
||||
if (!dir) {
|
||||
return
|
||||
@@ -218,9 +226,49 @@ const appendJsRuntimeArgs = (args: string[]): void => {
|
||||
}
|
||||
}
|
||||
|
||||
const buildVideoInfoArgs = (url: string, settings: ReturnType<typeof settingsManager.getAll>) => {
|
||||
const args = ['-j', '--no-playlist', '--no-warnings']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Note: Some sites (e.g., YouTube) may not provide filesize information
|
||||
// in the initial request. This is normal behavior and filesize may be null/undefined
|
||||
// for many formats. File size information might require additional HTTP HEAD requests
|
||||
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
// Add browser cookies if configured (skip if 'none')
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
class DownloadEngine extends EventEmitter {
|
||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||
private queue: DownloadQueue
|
||||
private sessionPersistTimer: NodeJS.Timeout | null = null
|
||||
private sessionRestored = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
@@ -230,45 +278,17 @@ class DownloadEngine extends EventEmitter {
|
||||
this.queue.on('start-download', async (item) => {
|
||||
await this.executeDownload(item.id, item.options)
|
||||
})
|
||||
|
||||
this.queue.on('queue-updated', () => {
|
||||
this.scheduleSessionPersist()
|
||||
})
|
||||
}
|
||||
|
||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
|
||||
const args = ['-j', '--no-playlist', '--no-warnings']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
|
||||
// Note: Some sites (e.g., YouTube) may not provide filesize information
|
||||
// in the initial request. This is normal behavior and filesize may be null/undefined
|
||||
// for many formats. File size information might require additional HTTP HEAD requests
|
||||
// which would significantly slow down info extraction, so yt-dlp doesn't fetch it by default.
|
||||
|
||||
// Add proxy if configured
|
||||
if (settings.proxy) {
|
||||
args.push('--proxy', settings.proxy)
|
||||
}
|
||||
|
||||
// Add browser cookies if configured (skip if 'none')
|
||||
if (settings.browserForCookies && settings.browserForCookies !== 'none') {
|
||||
args.push('--cookies-from-browser', settings.browserForCookies)
|
||||
}
|
||||
|
||||
const cookiesPath = settings.cookiesPath?.trim()
|
||||
if (cookiesPath) {
|
||||
args.push('--cookies', cookiesPath)
|
||||
}
|
||||
|
||||
// Add config file if configured
|
||||
const configPath = resolvePathWithHome(settings.configPath)
|
||||
if (configPath) {
|
||||
args.push('--config-location', configPath)
|
||||
}
|
||||
|
||||
appendJsRuntimeArgs(args)
|
||||
args.push(url)
|
||||
const args = buildVideoInfoArgs(url, settings)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const process = ytdlp.exec(args)
|
||||
@@ -334,6 +354,90 @@ class DownloadEngine extends EventEmitter {
|
||||
})
|
||||
}
|
||||
|
||||
async getVideoInfoWithCommand(url: string): Promise<VideoInfoCommandResult> {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
const args = buildVideoInfoArgs(url, settings)
|
||||
const ytDlpCommand = formatYtDlpCommand(args)
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const resolveOnce = (payload: VideoInfoCommandResult) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
resolve(payload)
|
||||
}
|
||||
|
||||
const process = ytdlp.exec(args)
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
process.ytDlpProcess?.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
process.ytDlpProcess?.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
process.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const info = JSON.parse(stdout)
|
||||
|
||||
// Calculate estimated file size for formats missing filesize information
|
||||
// Using tbr (total bitrate in kbps) and duration (in seconds)
|
||||
// Formula: (tbr * 1000) / 8 * duration = size in bytes
|
||||
if (info.formats && Array.isArray(info.formats) && info.duration) {
|
||||
const duration = info.duration
|
||||
for (const format of info.formats) {
|
||||
if (
|
||||
!format.filesize &&
|
||||
!format.filesize_approx &&
|
||||
format.tbr &&
|
||||
typeof format.tbr === 'number' &&
|
||||
duration > 0
|
||||
) {
|
||||
const estimatedSize = Math.round(((format.tbr * 1000) / 8) * duration)
|
||||
format.filesize_approx = estimatedSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scopedLoggers.download.info('Successfully retrieved video info for:', url)
|
||||
resolveOnce({ info, ytDlpCommand })
|
||||
} catch (error) {
|
||||
scopedLoggers.download.error('Failed to parse video info for:', url, error)
|
||||
resolveOnce({
|
||||
ytDlpCommand,
|
||||
error: `Failed to parse video info: ${error instanceof Error ? error.message : error}`
|
||||
})
|
||||
}
|
||||
} else {
|
||||
scopedLoggers.download.error(
|
||||
'Failed to fetch video info for:',
|
||||
url,
|
||||
'Exit code:',
|
||||
code,
|
||||
'Error:',
|
||||
stderr
|
||||
)
|
||||
resolveOnce({ ytDlpCommand, error: stderr || 'Failed to fetch video info' })
|
||||
}
|
||||
})
|
||||
|
||||
process.on('error', (error) => {
|
||||
scopedLoggers.download.error('yt-dlp process error for:', url, error)
|
||||
resolveOnce({
|
||||
ytDlpCommand,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch video info'
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getPlaylistInfo(url: string): Promise<PlaylistInfo> {
|
||||
const ytdlp = ytdlpManager.getInstance()
|
||||
const settings = settingsManager.getAll()
|
||||
@@ -806,10 +910,13 @@ class DownloadEngine extends EventEmitter {
|
||||
return
|
||||
}
|
||||
|
||||
args.push('--ffmpeg-location', ffmpegPath)
|
||||
const ffmpegLocation = resolveFfmpegLocation(ffmpegPath)
|
||||
args.push('--ffmpeg-location', ffmpegLocation)
|
||||
args.push(urlArg)
|
||||
|
||||
scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args))
|
||||
const ytDlpCommand = formatYtDlpCommand(args)
|
||||
this.updateDownloadInfo(id, { ytDlpCommand })
|
||||
scopedLoggers.download.info('yt-dlp command:', ytDlpCommand)
|
||||
|
||||
const controller = new AbortController()
|
||||
const ytdlpProcess = ytdlp.exec(args, {
|
||||
@@ -818,6 +925,8 @@ class DownloadEngine extends EventEmitter {
|
||||
|
||||
this.activeDownloads.set(id, { controller, process: ytdlpProcess })
|
||||
|
||||
this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() })
|
||||
this.scheduleSessionPersist()
|
||||
this.emit('download-started', id)
|
||||
|
||||
this.upsertHistoryEntry(id, options, {
|
||||
@@ -871,6 +980,11 @@ class DownloadEngine extends EventEmitter {
|
||||
downloaded: progress.downloaded || '',
|
||||
total: progress.total || ''
|
||||
}
|
||||
this.queue.updateItemInfo(id, {
|
||||
progress: downloadProgress,
|
||||
speed: downloadProgress.currentSpeed || ''
|
||||
})
|
||||
this.scheduleSessionPersist()
|
||||
this.emit('download-progress', id, downloadProgress)
|
||||
}
|
||||
)
|
||||
@@ -1085,6 +1199,72 @@ class DownloadEngine extends EventEmitter {
|
||||
return this.queue.getQueueStatus()
|
||||
}
|
||||
|
||||
getActiveDownloads(): DownloadItem[] {
|
||||
const items = new Map<string, DownloadItem>()
|
||||
for (const item of this.queue.getActiveItems()) {
|
||||
items.set(item.id, item)
|
||||
}
|
||||
for (const item of this.queue.getQueuedItems()) {
|
||||
items.set(item.id, item)
|
||||
}
|
||||
return Array.from(items.values()).sort((a, b) => b.createdAt - a.createdAt)
|
||||
}
|
||||
|
||||
restoreActiveDownloads(): void {
|
||||
if (this.sessionRestored) {
|
||||
return
|
||||
}
|
||||
this.sessionRestored = true
|
||||
|
||||
const sessionItems = loadDownloadSession()
|
||||
if (sessionItems.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const entry of sessionItems) {
|
||||
if (!entry?.id || !entry.options?.url || !entry.options.type) {
|
||||
continue
|
||||
}
|
||||
if (this.queue.getItemDetails(entry.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const historyItem = historyManager.getHistoryById(entry.id)
|
||||
if (historyItem && ['completed', 'error', 'cancelled'].includes(historyItem.status)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const createdAt = entry.item?.createdAt ?? Date.now()
|
||||
const restoredItem: DownloadItem = {
|
||||
...entry.item,
|
||||
id: entry.id,
|
||||
url: entry.options.url,
|
||||
type: entry.options.type,
|
||||
status: 'pending',
|
||||
createdAt,
|
||||
completedAt: undefined
|
||||
}
|
||||
|
||||
this.queue.add(entry.id, entry.options, restoredItem)
|
||||
|
||||
this.upsertHistoryEntry(entry.id, entry.options, {
|
||||
title: restoredItem.title || historyItem?.title || `Download ${entry.id}`,
|
||||
status: 'pending',
|
||||
downloadedAt: historyItem?.downloadedAt ?? createdAt
|
||||
})
|
||||
}
|
||||
|
||||
this.scheduleSessionPersist()
|
||||
}
|
||||
|
||||
flushDownloadSession(): void {
|
||||
if (this.sessionPersistTimer) {
|
||||
clearTimeout(this.sessionPersistTimer)
|
||||
this.sessionPersistTimer = null
|
||||
}
|
||||
this.persistSession()
|
||||
}
|
||||
|
||||
updateDownloadInfo(id: string, updates: Partial<DownloadItem>): void {
|
||||
this.queue.updateItemInfo(id, updates)
|
||||
|
||||
@@ -1146,6 +1326,9 @@ class DownloadEngine extends EventEmitter {
|
||||
if (updates.error !== undefined) {
|
||||
historyUpdates.error = updates.error
|
||||
}
|
||||
if (updates.ytDlpCommand !== undefined) {
|
||||
historyUpdates.ytDlpCommand = updates.ytDlpCommand
|
||||
}
|
||||
if (updates.savedFileName !== undefined) {
|
||||
historyUpdates.savedFileName = updates.savedFileName
|
||||
}
|
||||
@@ -1153,6 +1336,37 @@ class DownloadEngine extends EventEmitter {
|
||||
if (Object.keys(historyUpdates).length > 0) {
|
||||
this.upsertHistoryEntry(id, snapshot.options, historyUpdates)
|
||||
}
|
||||
|
||||
this.scheduleSessionPersist()
|
||||
}
|
||||
|
||||
private scheduleSessionPersist(): void {
|
||||
if (this.sessionPersistTimer) {
|
||||
return
|
||||
}
|
||||
this.sessionPersistTimer = setTimeout(() => {
|
||||
this.sessionPersistTimer = null
|
||||
this.persistSession()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private persistSession(): void {
|
||||
const entries: DownloadSessionItem[] = []
|
||||
const activeEntries = this.queue.getActiveEntries()
|
||||
const queuedEntries = this.queue.getQueuedEntries()
|
||||
|
||||
for (const entry of [...activeEntries, ...queuedEntries]) {
|
||||
if (!entry?.item?.id) {
|
||||
continue
|
||||
}
|
||||
entries.push({
|
||||
id: entry.item.id,
|
||||
options: entry.options,
|
||||
item: entry.item
|
||||
})
|
||||
}
|
||||
|
||||
saveDownloadSession(entries)
|
||||
}
|
||||
|
||||
private addToHistory(
|
||||
@@ -1210,6 +1424,7 @@ class DownloadEngine extends EventEmitter {
|
||||
downloadedAt: updates.downloadedAt ?? Date.now(),
|
||||
completedAt: updates.completedAt,
|
||||
error: updates.error,
|
||||
ytDlpCommand: updates.ytDlpCommand,
|
||||
description: updates.description,
|
||||
channel: updates.channel,
|
||||
uploader: updates.uploader,
|
||||
|
||||
@@ -82,6 +82,28 @@ export class DownloadQueue extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
getActiveItems(): DownloadItem[] {
|
||||
return Array.from(this.activeDownloads.values()).map((item) => ({ ...item.item }))
|
||||
}
|
||||
|
||||
getQueuedItems(): DownloadItem[] {
|
||||
return this.queue.map((item) => ({ ...item.item }))
|
||||
}
|
||||
|
||||
getActiveEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
|
||||
return Array.from(this.activeDownloads.values()).map((entry) => ({
|
||||
options: { ...entry.options },
|
||||
item: { ...entry.item }
|
||||
}))
|
||||
}
|
||||
|
||||
getQueuedEntries(): Array<{ options: DownloadOptions; item: DownloadItem }> {
|
||||
return this.queue.map((entry) => ({
|
||||
options: { ...entry.options },
|
||||
item: { ...entry.item }
|
||||
}))
|
||||
}
|
||||
|
||||
isDownloading(id: string): boolean {
|
||||
return this.activeDownloads.has(id)
|
||||
}
|
||||
|
||||
67
src/main/lib/download-session-store.ts
Normal file
67
src/main/lib/download-session-store.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { app } from 'electron'
|
||||
import type { DownloadItem, DownloadOptions } from '../../shared/types'
|
||||
import { scopedLoggers } from '../utils/logger'
|
||||
|
||||
export interface DownloadSessionItem {
|
||||
id: string
|
||||
options: DownloadOptions
|
||||
item: DownloadItem
|
||||
}
|
||||
|
||||
interface DownloadSessionPayload {
|
||||
version: 1
|
||||
updatedAt: number
|
||||
items: DownloadSessionItem[]
|
||||
}
|
||||
|
||||
const SESSION_FILE_NAME = 'download-session.json'
|
||||
|
||||
const getSessionFilePath = (): string => path.join(app.getPath('userData'), SESSION_FILE_NAME)
|
||||
|
||||
const isValidItem = (item: DownloadSessionItem): boolean =>
|
||||
Boolean(item?.id && item.options && item.item)
|
||||
|
||||
export const loadDownloadSession = (): DownloadSessionItem[] => {
|
||||
const filePath = getSessionFilePath()
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf-8')
|
||||
const payload = JSON.parse(raw) as DownloadSessionPayload
|
||||
if (!payload || payload.version !== 1 || !Array.isArray(payload.items)) {
|
||||
return []
|
||||
}
|
||||
return payload.items.filter(isValidItem)
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to load download session:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export const saveDownloadSession = (items: DownloadSessionItem[]): void => {
|
||||
const filePath = getSessionFilePath()
|
||||
if (items.length === 0) {
|
||||
try {
|
||||
fs.rmSync(filePath, { force: true })
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to clear download session:', error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const payload: DownloadSessionPayload = {
|
||||
version: 1,
|
||||
updatedAt: Date.now(),
|
||||
items
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(filePath, JSON.stringify(payload), 'utf-8')
|
||||
} catch (error) {
|
||||
scopedLoggers.download.warn('Failed to save download session:', error)
|
||||
}
|
||||
}
|
||||
@@ -28,46 +28,58 @@ class FfmpegManager {
|
||||
|
||||
private async findFfmpegBinary(): Promise<string> {
|
||||
const platform = os.platform()
|
||||
const resourceCandidates: string[] = []
|
||||
const ffmpegFileName = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg'
|
||||
const ffprobeFileName = platform === 'win32' ? 'ffprobe.exe' : 'ffprobe'
|
||||
|
||||
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
|
||||
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
|
||||
return process.env.FFMPEG_PATH
|
||||
const resolveBundledFfmpeg = (dirPath: string, label: string): string | null => {
|
||||
const ffmpegPath = path.join(dirPath, ffmpegFileName)
|
||||
const ffprobePath = path.join(dirPath, ffprobeFileName)
|
||||
if (!fs.existsSync(ffmpegPath) || !fs.existsSync(ffprobePath)) {
|
||||
return null
|
||||
}
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(ffmpegPath, 0o755)
|
||||
fs.chmodSync(ffprobePath, 0o755)
|
||||
} catch (error) {
|
||||
scopedLoggers.engine.warn(`Failed to set executable permission on ${label}:`, error)
|
||||
}
|
||||
}
|
||||
scopedLoggers.engine.info(`Using ${label}:`, ffmpegPath)
|
||||
return ffmpegPath
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
resourceCandidates.push('ffmpeg.exe')
|
||||
} else if (platform === 'darwin') {
|
||||
resourceCandidates.push('ffmpeg_macos', 'ffmpeg')
|
||||
} else {
|
||||
resourceCandidates.push('ffmpeg_linux', 'ffmpeg')
|
||||
const envPath = process.env.FFMPEG_PATH
|
||||
if (envPath) {
|
||||
if (!fs.existsSync(envPath)) {
|
||||
throw new Error(
|
||||
'FFMPEG_PATH does not exist. Provide a directory containing ffmpeg and ffprobe.'
|
||||
)
|
||||
}
|
||||
const stats = fs.statSync(envPath)
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error('FFMPEG_PATH must be a directory containing ffmpeg and ffprobe.')
|
||||
}
|
||||
const resolved = resolveBundledFfmpeg(envPath, 'ffmpeg from FFMPEG_PATH directory')
|
||||
if (resolved) {
|
||||
return resolved
|
||||
}
|
||||
throw new Error('FFMPEG_PATH must contain both ffmpeg and ffprobe.')
|
||||
}
|
||||
|
||||
const resourcesPath = this.getResourcesPath()
|
||||
for (const candidate of resourceCandidates) {
|
||||
const fullPath = path.join(resourcesPath, candidate)
|
||||
if (fs.existsSync(fullPath)) {
|
||||
if (platform !== 'win32') {
|
||||
try {
|
||||
fs.chmodSync(fullPath, 0o755)
|
||||
} catch (error) {
|
||||
scopedLoggers.engine.warn(
|
||||
'Failed to set executable permission on ffmpeg binary:',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath)
|
||||
return fullPath
|
||||
}
|
||||
const bundledDir = path.join(resourcesPath, 'ffmpeg')
|
||||
const bundledResolved = resolveBundledFfmpeg(bundledDir, 'bundled ffmpeg')
|
||||
if (bundledResolved) {
|
||||
return bundledResolved
|
||||
}
|
||||
|
||||
if (platform === 'darwin') {
|
||||
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
||||
for (const candidate of commonPaths) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
|
||||
return candidate
|
||||
const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin']
|
||||
for (const candidate of commonDirs) {
|
||||
const resolved = resolveBundledFfmpeg(candidate, 'system ffmpeg')
|
||||
if (resolved) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,8 +88,10 @@ class FfmpegManager {
|
||||
try {
|
||||
const systemPath = execSync('which ffmpeg').toString().trim()
|
||||
if (systemPath && fs.existsSync(systemPath)) {
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
|
||||
return systemPath
|
||||
const resolved = resolveBundledFfmpeg(path.dirname(systemPath), 'system ffmpeg')
|
||||
if (resolved) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error and continue
|
||||
@@ -88,8 +102,10 @@ class FfmpegManager {
|
||||
try {
|
||||
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
||||
if (output && fs.existsSync(output)) {
|
||||
scopedLoggers.engine.info('Using system ffmpeg:', output)
|
||||
return output
|
||||
const resolved = resolveBundledFfmpeg(path.dirname(output), 'system ffmpeg')
|
||||
if (resolved) {
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore error and continue
|
||||
@@ -97,7 +113,7 @@ class FfmpegManager {
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'ffmpeg not found. Bundle it under resources/ (asarUnpack) or set the FFMPEG_PATH environment variable.'
|
||||
'ffmpeg/ffprobe not found. Bundle them under resources/ffmpeg/ (asarUnpack) or set FFMPEG_PATH to a directory containing both.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const createDownloadHistoryTableSql = sql`
|
||||
completed_at INTEGER,
|
||||
sort_key INTEGER NOT NULL,
|
||||
error TEXT,
|
||||
yt_dlp_command TEXT,
|
||||
description TEXT,
|
||||
channel TEXT,
|
||||
uploader TEXT,
|
||||
@@ -218,7 +219,12 @@ class HistoryManager {
|
||||
}
|
||||
|
||||
const deprecatedColumns = ['subscription_title', 'format', 'quality', 'codec']
|
||||
const needsRebuild = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
const requiredColumns = ['yt_dlp_command']
|
||||
const hasDeprecated = columns.some((column) => deprecatedColumns.includes(column.name))
|
||||
const missingRequired = requiredColumns.some(
|
||||
(columnName) => !columns.some((column) => column.name === columnName)
|
||||
)
|
||||
const needsRebuild = hasDeprecated || missingRequired
|
||||
if (needsRebuild) {
|
||||
this.rebuildDownloadHistoryTable()
|
||||
}
|
||||
@@ -387,6 +393,7 @@ class HistoryManager {
|
||||
completedAt: item.completedAt ?? null,
|
||||
sortKey: item.completedAt ?? item.downloadedAt,
|
||||
error: item.error ?? null,
|
||||
ytDlpCommand: item.ytDlpCommand ?? null,
|
||||
description: item.description ?? null,
|
||||
channel: item.channel ?? null,
|
||||
uploader: item.uploader ?? null,
|
||||
@@ -433,6 +440,7 @@ class HistoryManager {
|
||||
downloadedAt: row.downloadedAt,
|
||||
completedAt: row.completedAt ?? undefined,
|
||||
error: row.error ?? undefined,
|
||||
ytDlpCommand: row.ytDlpCommand ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
channel: row.channel ?? undefined,
|
||||
uploader: row.uploader ?? undefined,
|
||||
|
||||
@@ -99,6 +99,22 @@ export class SubscriptionManager extends EventEmitter {
|
||||
return this.attachFeedItems([this.mapRowToRecord(row)])[0]
|
||||
}
|
||||
|
||||
findDuplicateFeed(
|
||||
feedUrl: string,
|
||||
ignoreId?: string
|
||||
): { id: string; feedUrl: string } | undefined {
|
||||
const database = this.getDatabase()
|
||||
const rows = database
|
||||
.select({ id: subscriptionsTable.id, feedUrl: subscriptionsTable.feedUrl })
|
||||
.from(subscriptionsTable)
|
||||
.all()
|
||||
const targetKey = this.buildFeedKey(feedUrl)
|
||||
if (!targetKey) {
|
||||
return undefined
|
||||
}
|
||||
return rows.find((row) => row.id !== ignoreId && this.buildFeedKey(row.feedUrl) === targetKey)
|
||||
}
|
||||
|
||||
add(payload: SubscriptionCreatePayload): SubscriptionRule {
|
||||
const timestamp = Date.now()
|
||||
const keywords = sanitizeList(payload.keywords)
|
||||
@@ -301,6 +317,25 @@ export class SubscriptionManager extends EventEmitter {
|
||||
return this.db
|
||||
}
|
||||
|
||||
private buildFeedKey(feedUrl: string): string {
|
||||
const trimmed = feedUrl.trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
const normalized = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
try {
|
||||
const url = new URL(normalized)
|
||||
let pathname = url.pathname || '/'
|
||||
pathname = pathname.replace(/\/+$/, '')
|
||||
if (!pathname) {
|
||||
pathname = '/'
|
||||
}
|
||||
return `${url.host.toLowerCase()}${pathname}${url.search}`
|
||||
} catch {
|
||||
return trimmed.toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
private ensureItemsSchema(): void {
|
||||
if (!this.sqlite) {
|
||||
return
|
||||
|
||||
@@ -20,9 +20,7 @@ const ensureDirectoryExists = (dir: string) => {
|
||||
}
|
||||
|
||||
const resolveDefaultDownloadPath = () => {
|
||||
const downloadDir = path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
ensureDirectoryExists(downloadDir)
|
||||
return downloadDir
|
||||
return path.join(os.homedir(), 'Downloads', 'VidBee')
|
||||
}
|
||||
|
||||
const DEFAULT_DOWNLOAD_PATH = resolveDefaultDownloadPath()
|
||||
@@ -75,7 +73,6 @@ class SettingsManager {
|
||||
...defaultSettings,
|
||||
downloadPath: DEFAULT_DOWNLOAD_PATH
|
||||
})
|
||||
ensureDirectoryExists(DEFAULT_DOWNLOAD_PATH)
|
||||
}
|
||||
|
||||
private ensureDownloadDirectory(): void {
|
||||
@@ -88,7 +85,6 @@ class SettingsManager {
|
||||
if (normalizedDownloadPath !== currentPath) {
|
||||
this.store.set('downloadPath', normalizedDownloadPath)
|
||||
}
|
||||
ensureDirectoryExists(normalizedDownloadPath)
|
||||
} catch (error) {
|
||||
scopedLoggers.system.error('Failed to verify download directory:', error)
|
||||
}
|
||||
|
||||
2
src/preload/index.d.ts
vendored
2
src/preload/index.d.ts
vendored
@@ -5,7 +5,7 @@ declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: IpcServices & {
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => void
|
||||
on: (channel: string, callback: (...args: unknown[]) => void) => (...args: unknown[]) => void
|
||||
removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
|
||||
send: (channel: string, ...args: unknown[]) => void
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
|
||||
import { toast } from 'sonner'
|
||||
import { ErrorBoundary } from './components/error/ErrorBoundary'
|
||||
import { useDownloadEvents } from './hooks/use-download-events'
|
||||
import { ipcEvents, ipcServices } from './lib/ipc'
|
||||
import { About } from './pages/About'
|
||||
import { Home } from './pages/Home'
|
||||
@@ -62,6 +63,8 @@ function AppContent() {
|
||||
const currentPage = pathToPage(location.pathname)
|
||||
const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
|
||||
|
||||
useDownloadEvents()
|
||||
|
||||
const handlePageChange = useCallback(
|
||||
(page: Page) => {
|
||||
const targetPath = pageToPath[page] ?? '/'
|
||||
|
||||
@@ -3,52 +3,30 @@ import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from '@renderer/components/ui/dialog'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import type { PlaylistInfo, VideoFormat } from '@shared/types'
|
||||
import {
|
||||
buildAudioFormatPreference,
|
||||
buildVideoFormatPreference
|
||||
} from '@shared/utils/format-preferences'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import {
|
||||
AlertCircle,
|
||||
FolderOpen,
|
||||
Github,
|
||||
List,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Plus,
|
||||
Twitter,
|
||||
Video
|
||||
} from 'lucide-react'
|
||||
import { FolderOpen, List, Loader2, Plus, Video } from 'lucide-react'
|
||||
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,
|
||||
addHistoryRecordAtom,
|
||||
removeDownloadAtom,
|
||||
updateDownloadAtom
|
||||
} from '../../store/downloads'
|
||||
import { addDownloadAtom, updateDownloadAtom } from '../../store/downloads'
|
||||
import { loadSettingsAtom, settingsAtom } from '../../store/settings'
|
||||
import {
|
||||
currentVideoInfoAtom,
|
||||
fetchVideoInfoAtom,
|
||||
videoInfoCommandAtom,
|
||||
videoInfoErrorAtom,
|
||||
videoInfoLoadingAtom
|
||||
} from '../../store/video'
|
||||
import { VideoInfoCard, type VideoInfoCardState } from '../video/VideoInfoCard'
|
||||
import { PlaylistDownload } from './PlaylistDownload'
|
||||
import { SingleVideoDownload, type SingleVideoState } from './SingleVideoDownload'
|
||||
|
||||
const isLikelyUrl = (value: string): boolean => {
|
||||
try {
|
||||
@@ -59,35 +37,6 @@ const isLikelyUrl = (value: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeErrorText = (value?: string | null): string =>
|
||||
value ? value.replace(/\s+/g, ' ').trim() : ''
|
||||
|
||||
const clampText = (value: string, maxLength: number): string =>
|
||||
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
|
||||
|
||||
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
|
||||
const FEEDBACK_ISSUE_TITLE = 'Download error report'
|
||||
const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: '
|
||||
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
|
||||
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
|
||||
const FEEDBACK_SOURCE_LABEL = 'Source URL'
|
||||
const FEEDBACK_ERROR_LABEL = 'Error'
|
||||
const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v'
|
||||
|
||||
const buildIssueLogs = (
|
||||
errorText: string,
|
||||
sourceUrl: string | undefined,
|
||||
urlLabel: string,
|
||||
errorLabel: string
|
||||
): string => {
|
||||
const lines: string[] = []
|
||||
if (sourceUrl) {
|
||||
lines.push(`${urlLabel}: ${sourceUrl}`)
|
||||
}
|
||||
lines.push(`${errorLabel}: ${errorText}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const isAudioOnlyFormat = (format: VideoFormat): boolean =>
|
||||
!!format.acodec && format.acodec !== 'none' && (!format.video_ext || format.video_ext === 'none')
|
||||
|
||||
@@ -141,6 +90,7 @@ const pickBestAudioFormatsByLanguage = (formats: VideoFormat[]): string[] => {
|
||||
})
|
||||
.filter((id): id is string => !!id)
|
||||
}
|
||||
|
||||
interface DownloadDialogProps {
|
||||
onOpenSupportedSites?: () => void
|
||||
onOpenSettings?: () => void
|
||||
@@ -151,10 +101,9 @@ export function DownloadDialog({
|
||||
onOpenSettings: _onOpenSettings
|
||||
}: DownloadDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [osVersion, setOsVersion] = useState('')
|
||||
const [open, setOpen] = useState(false)
|
||||
const [videoInfo, _setVideoInfo] = useAtom(currentVideoInfoAtom)
|
||||
const [videoInfoCommand] = useAtom(videoInfoCommandAtom)
|
||||
const [loading] = useAtom(videoInfoLoadingAtom)
|
||||
const [error] = useAtom(videoInfoErrorAtom)
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
@@ -162,19 +111,20 @@ export function DownloadDialog({
|
||||
const loadSettings = useSetAtom(loadSettingsAtom)
|
||||
const updateDownload = useSetAtom(updateDownloadAtom)
|
||||
const addDownload = useSetAtom(addDownloadAtom)
|
||||
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
|
||||
|
||||
// VideoInfoCard state management
|
||||
const [videoInfoCardState, setVideoInfoCardState] = useState<VideoInfoCardState>({
|
||||
// Single video state
|
||||
const [singleVideoState, setSingleVideoState] = useState<SingleVideoState>({
|
||||
title: '',
|
||||
activeTab: 'video',
|
||||
selectedVideoFormat: '',
|
||||
selectedAudioFormat: '',
|
||||
customDownloadPath: ''
|
||||
customDownloadPath: '',
|
||||
selectedContainer: undefined,
|
||||
selectedCodec: undefined,
|
||||
selectedFps: undefined
|
||||
})
|
||||
|
||||
// Playlist states
|
||||
@@ -192,74 +142,6 @@ export function DownloadDialog({
|
||||
const playlistBusy = playlistPreviewLoading || playlistDownloadLoading
|
||||
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
|
||||
const [selectedEntryIds, setSelectedEntryIds] = useState<Set<string>>(new Set())
|
||||
const feedbackLinks = useMemo(() => {
|
||||
const compactError = normalizeErrorText(error)
|
||||
const tweetError = compactError ? clampText(compactError, 160) : ''
|
||||
const tweetText = encodeURIComponent(
|
||||
tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX
|
||||
)
|
||||
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
|
||||
const issueTitle = FEEDBACK_ISSUE_TITLE
|
||||
const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300)
|
||||
const sourceUrl = url.trim() || undefined
|
||||
const issueLogs = clampText(
|
||||
buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL),
|
||||
800
|
||||
)
|
||||
const appVersionValue = appVersion
|
||||
? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}`
|
||||
: FEEDBACK_UNKNOWN_VALUE
|
||||
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
||||
return [
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent(
|
||||
issueTitle
|
||||
)}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent(
|
||||
issueLogs
|
||||
)}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent(
|
||||
osVersionValue
|
||||
)}`
|
||||
},
|
||||
{
|
||||
icon: Twitter,
|
||||
label: t('about.resources.xFeedback'),
|
||||
href: `https://x.com/intent/tweet?text=${tweetText}`
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
]
|
||||
}, [appVersion, error, osVersion, t, url])
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
const loadAppInfo = async () => {
|
||||
try {
|
||||
const [version, osRelease] = await Promise.all([
|
||||
ipcServices.app.getVersion(),
|
||||
ipcServices.app.getOsVersion()
|
||||
])
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
setAppVersion(version)
|
||||
setOsVersion(osRelease)
|
||||
} catch (loadError) {
|
||||
console.error('Failed to load app info for feedback links:', loadError)
|
||||
}
|
||||
}
|
||||
|
||||
void loadAppInfo()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const computePlaylistRange = useCallback(
|
||||
(info: PlaylistInfo) => {
|
||||
@@ -293,21 +175,6 @@ export function DownloadDialog({
|
||||
)
|
||||
}, [playlistInfo, computePlaylistRange, selectedEntryIds])
|
||||
|
||||
const syncHistoryItem = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const historyItem = await ipcServices.history.getHistoryById(id)
|
||||
if (historyItem) {
|
||||
addHistoryRecord(historyItem)
|
||||
removeDownload(id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync history item:', error)
|
||||
}
|
||||
},
|
||||
[addHistoryRecord, removeDownload]
|
||||
)
|
||||
|
||||
// Listen for deep link events
|
||||
useEffect(() => {
|
||||
const handleDeepLink = async (data: unknown) => {
|
||||
@@ -373,6 +240,14 @@ export function DownloadDialog({
|
||||
|
||||
// Wait for dialog to open and settings to load, then fetch video info
|
||||
setTimeout(async () => {
|
||||
setSingleVideoState((prev) => ({
|
||||
...prev,
|
||||
selectedVideoFormat: '',
|
||||
selectedAudioFormat: '',
|
||||
selectedContainer: undefined,
|
||||
selectedCodec: undefined,
|
||||
selectedFps: undefined
|
||||
}))
|
||||
await fetchVideoInfo(url)
|
||||
}, 100)
|
||||
}
|
||||
@@ -386,69 +261,8 @@ export function DownloadDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
// Load settings when dialog opens
|
||||
loadSettings()
|
||||
|
||||
// Listen for download events from main process
|
||||
ipcEvents.on('download:started', (...args: unknown[]) => {
|
||||
const id = args[0] as string
|
||||
console.log('Download started:', id)
|
||||
updateDownload({ id, changes: { status: 'downloading' } })
|
||||
})
|
||||
|
||||
ipcEvents.on('download:progress', (...args: unknown[]) => {
|
||||
const data = args[0] as { id: string; progress: unknown }
|
||||
console.log('Download progress:', data)
|
||||
const progress = data.progress as {
|
||||
percent: number
|
||||
currentSpeed?: string
|
||||
eta?: string
|
||||
downloaded?: string
|
||||
total?: string
|
||||
}
|
||||
updateDownload({
|
||||
id: data.id,
|
||||
changes: {
|
||||
progress: {
|
||||
percent: progress.percent || 0,
|
||||
currentSpeed: progress.currentSpeed || '',
|
||||
eta: progress.eta || '',
|
||||
downloaded: progress.downloaded || '',
|
||||
total: progress.total || ''
|
||||
},
|
||||
speed: progress.currentSpeed || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
ipcEvents.on('download:completed', (...args: unknown[]) => {
|
||||
const id = args[0] as string
|
||||
console.log('Download completed:', id)
|
||||
updateDownload({ id, changes: { status: 'completed' } })
|
||||
toast.success(t('notifications.downloadCompleted'))
|
||||
void syncHistoryItem(id)
|
||||
})
|
||||
|
||||
ipcEvents.on('download:error', (...args: unknown[]) => {
|
||||
const data = args[0] as { id: string; error: string }
|
||||
console.error('Download error:', data)
|
||||
updateDownload({ id: data.id, changes: { status: 'error', error: data.error } })
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
void syncHistoryItem(data.id)
|
||||
})
|
||||
|
||||
ipcEvents.on('download:cancelled', (...args: unknown[]) => {
|
||||
const id = args[0] as string
|
||||
console.log('Download cancelled:', id)
|
||||
updateDownload({ id, changes: { status: 'cancelled' } })
|
||||
void syncHistoryItem(id)
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Event listeners are automatically cleaned up when the component unmounts
|
||||
}
|
||||
}, [open, loadSettings, syncHistoryItem, t, updateDownload])
|
||||
}, [open, loadSettings])
|
||||
|
||||
const startOneClickDownload = useCallback(
|
||||
async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => {
|
||||
@@ -489,7 +303,11 @@ export function DownloadDialog({
|
||||
})
|
||||
|
||||
try {
|
||||
const videoInfo = await ipcServices.download.getVideoInfo(trimmedUrl)
|
||||
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,
|
||||
@@ -552,6 +370,14 @@ export function DownloadDialog({
|
||||
toast.error(t('errors.emptyUrl'))
|
||||
return
|
||||
}
|
||||
setSingleVideoState((prev) => ({
|
||||
...prev,
|
||||
selectedVideoFormat: '',
|
||||
selectedAudioFormat: '',
|
||||
selectedContainer: undefined,
|
||||
selectedCodec: undefined,
|
||||
selectedFps: undefined
|
||||
}))
|
||||
await fetchVideoInfo(url.trim())
|
||||
}, [url, fetchVideoInfo, t])
|
||||
|
||||
@@ -859,77 +685,76 @@ export function DownloadDialog({
|
||||
selectedEntryIds
|
||||
])
|
||||
|
||||
// Update videoInfoCardState when videoInfo changes
|
||||
// Update single video title when videoInfo changes
|
||||
useEffect(() => {
|
||||
if (videoInfo) {
|
||||
setVideoInfoCardState((prev) => ({
|
||||
setSingleVideoState((prev) => ({
|
||||
...prev,
|
||||
title: videoInfo.title || prev.title
|
||||
}))
|
||||
}
|
||||
}, [videoInfo])
|
||||
|
||||
// Handle video download from VideoInfoCard
|
||||
const handleVideoDownload = useCallback(
|
||||
async (type: 'video' | 'audio') => {
|
||||
if (!videoInfo) return
|
||||
const handleSingleVideoDownload = useCallback(async () => {
|
||||
if (!videoInfo) return
|
||||
|
||||
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
const type = singleVideoState.activeTab
|
||||
const selectedFormat =
|
||||
type === 'video' ? singleVideoState.selectedVideoFormat : singleVideoState.selectedAudioFormat
|
||||
if (!selectedFormat) {
|
||||
return
|
||||
}
|
||||
const id = `download_${Date.now()}_${Math.random().toString(36).substring(7)}`
|
||||
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: videoInfo.webpage_url || '',
|
||||
title: videoInfoCardState.title,
|
||||
const downloadItem = {
|
||||
id,
|
||||
url: videoInfo.webpage_url || '',
|
||||
title: singleVideoState.title || videoInfo.title || t('download.fetchingVideoInfo'),
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
type,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
|
||||
const audioFormatIds =
|
||||
type === 'video' ? pickBestAudioFormatsByLanguage(videoInfo.formats || []) : undefined
|
||||
|
||||
const options = {
|
||||
url: videoInfo.webpage_url || '',
|
||||
type,
|
||||
format: selectedFormat || undefined,
|
||||
audioFormat: type === 'video' ? 'best' : undefined,
|
||||
audioFormatIds: audioFormatIds && audioFormatIds.length > 0 ? audioFormatIds : undefined,
|
||||
customDownloadPath: singleVideoState.customDownloadPath.trim() || undefined
|
||||
}
|
||||
|
||||
addDownload(downloadItem)
|
||||
|
||||
try {
|
||||
await ipcServices.download.startDownload(id, options)
|
||||
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title: singleVideoState.title || videoInfo.title || t('download.fetchingVideoInfo'),
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
type,
|
||||
status: 'pending' as const,
|
||||
progress: { percent: 0 },
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
const audioFormatIds =
|
||||
type === 'video' ? pickBestAudioFormatsByLanguage(videoInfo.formats || []) : undefined
|
||||
|
||||
const options = {
|
||||
url: videoInfo.webpage_url || '',
|
||||
type,
|
||||
format:
|
||||
type === 'video'
|
||||
? videoInfoCardState.selectedVideoFormat || undefined
|
||||
: videoInfoCardState.selectedAudioFormat || undefined,
|
||||
audioFormat: type === 'video' ? 'best' : undefined,
|
||||
audioFormatIds: audioFormatIds && audioFormatIds.length > 0 ? audioFormatIds : undefined,
|
||||
customDownloadPath: videoInfoCardState.customDownloadPath.trim() || undefined
|
||||
}
|
||||
|
||||
addDownload(downloadItem)
|
||||
|
||||
try {
|
||||
await ipcServices.download.startDownload(id, options)
|
||||
|
||||
await ipcServices.download.updateDownloadInfo(id, {
|
||||
title: videoInfoCardState.title,
|
||||
thumbnail: videoInfo.thumbnail,
|
||||
duration: videoInfo.duration,
|
||||
description: videoInfo.description,
|
||||
channel: videoInfo.extractor_key,
|
||||
uploader: videoInfo.extractor_key,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
|
||||
toast.success(t('notifications.downloadStarted'))
|
||||
setOpen(false) // Close dialog after download starts
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error)
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
}
|
||||
},
|
||||
[videoInfo, videoInfoCardState, addDownload, t]
|
||||
)
|
||||
toast.success(t('notifications.downloadStarted'))
|
||||
setOpen(false) // Close dialog after download starts
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error)
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
}
|
||||
}, [videoInfo, singleVideoState, addDownload, t])
|
||||
|
||||
// Reset form when dialog closes
|
||||
useEffect(() => {
|
||||
@@ -937,12 +762,15 @@ export function DownloadDialog({
|
||||
// Reset single video states
|
||||
setUrl('')
|
||||
setActiveTab('single')
|
||||
setVideoInfoCardState({
|
||||
setSingleVideoState({
|
||||
title: '',
|
||||
activeTab: 'video',
|
||||
selectedVideoFormat: '',
|
||||
selectedAudioFormat: '',
|
||||
customDownloadPath: ''
|
||||
customDownloadPath: '',
|
||||
selectedContainer: undefined,
|
||||
selectedCodec: undefined,
|
||||
selectedFps: undefined
|
||||
})
|
||||
|
||||
// Reset playlist states
|
||||
@@ -956,6 +784,14 @@ export function DownloadDialog({
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleSingleVideoStateChange = useCallback((updates: Partial<SingleVideoState>) => {
|
||||
setSingleVideoState((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
const selectedSingleFormat =
|
||||
singleVideoState.activeTab === 'video'
|
||||
? singleVideoState.selectedVideoFormat
|
||||
: singleVideoState.selectedAudioFormat
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
@@ -967,320 +803,107 @@ export function DownloadDialog({
|
||||
<Plus className="h-4 w-4" />
|
||||
{t('download.pasteUrlButton')}
|
||||
</Button>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-xl max-h-[90vh] flex flex-col p-5 gap-0">
|
||||
<Tabs
|
||||
defaultValue="single"
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'single' | 'playlist')}
|
||||
className="w-full flex flex-col flex-1 min-h-0"
|
||||
className="w-full flex flex-col flex-1 min-h-0 gap-0"
|
||||
>
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogHeader>
|
||||
<TabsList>
|
||||
<TabsTrigger value="single" onClick={() => setActiveTab('single')}>
|
||||
<Video className="h-4 w-4 mr-2" />
|
||||
<Video className="h-3.5 w-3.5" />
|
||||
{t('download.singleVideo')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="playlist" onClick={() => setActiveTab('playlist')}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
<List className="h-3.5 w-3.5" />
|
||||
{t('download.metadata.playlist')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</DialogHeader>
|
||||
{/* Single Video Download Tab */}
|
||||
<TabsContent value="single" className="flex flex-col flex-1 min-h-0 mt-3">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="shrink-0 mb-3 rounded-lg border border-destructive/20 bg-destructive/5 p-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-0.5 min-w-0">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
{t('errors.fetchInfoFailed')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground wrap-break-word">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('download.feedback.title')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{feedbackLinks.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-[10px]"
|
||||
asChild
|
||||
>
|
||||
<a href={resource.href} target="_blank" rel="noreferrer">
|
||||
<Icon className="h-3 w-3" />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Video Info and Download Options */}
|
||||
{(loading || videoInfo) && (
|
||||
<VideoInfoCard
|
||||
videoInfo={videoInfo}
|
||||
loading={loading}
|
||||
state={videoInfoCardState}
|
||||
onStateChange={(updates) =>
|
||||
setVideoInfoCardState((prev) => ({ ...prev, ...updates }))
|
||||
}
|
||||
onTabChange={(tab) => {
|
||||
setVideoInfoCardState((prev) => ({ ...prev, activeTab: tab }))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<TabsContent value="single" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<SingleVideoDownload
|
||||
loading={loading}
|
||||
error={error}
|
||||
videoInfo={videoInfo}
|
||||
state={singleVideoState}
|
||||
feedbackSourceUrl={url}
|
||||
ytDlpCommand={videoInfoCommand ?? undefined}
|
||||
onStateChange={handleSingleVideoStateChange}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{/* Playlist Download Tab */}
|
||||
<TabsContent value="playlist" className="px-6 space-y-6 mt-3">
|
||||
<ScrollArea className="flex-1 -mx-6 overflow-y-auto min-h-0">
|
||||
<div className="space-y-6">
|
||||
{/* Preview State */}
|
||||
{playlistInfo && !playlistPreviewLoading && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-1 shrink-0">
|
||||
<h3 className="font-semibold leading-none">{playlistInfo.title}</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<List className="h-3.5 w-3.5" />
|
||||
<span>{t('playlist.foundVideos', { count: playlistInfo.entryCount })}</span>
|
||||
{selectedPlaylistEntries.length !== playlistInfo.entryCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-primary font-medium">
|
||||
{t('playlist.selectedVideos', {
|
||||
count: selectedPlaylistEntries.length
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[320px] w-full rounded-lg border">
|
||||
<div className="p-1">
|
||||
{playlistInfo.entries.map((entry) => {
|
||||
const isSelected = selectedEntryIds.has(entry.id)
|
||||
const isInRange =
|
||||
selectedEntryIds.size === 0 &&
|
||||
selectedPlaylistEntries.some((e) => e.id === entry.id)
|
||||
|
||||
const handleToggle = () => {
|
||||
setSelectedEntryIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(entry.id)) {
|
||||
next.delete(entry.id)
|
||||
} else {
|
||||
next.add(entry.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
// Clear range inputs when manual selection is used
|
||||
if (selectedEntryIds.size === 0) {
|
||||
setStartIndex('1')
|
||||
setEndIndex('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-lg transition-colors cursor-pointer w-full text-left',
|
||||
isSelected || isInRange
|
||||
? 'bg-primary/10 hover:bg-primary/20'
|
||||
: 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}
|
||||
}}
|
||||
aria-label={t('playlist.selectEntry', { index: entry.index })}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isInRange}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedEntryIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(entry.id)
|
||||
} else {
|
||||
next.delete(entry.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (selectedEntryIds.size === 0) {
|
||||
setStartIndex('1')
|
||||
setEndIndex('')
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="shrink-0 w-8 text-[10px] font-medium text-muted-foreground tabular-nums">
|
||||
#{entry.index}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium line-clamp-1 leading-tight">
|
||||
{entry.title || t('download.fetchingVideoInfo')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-semibold text-destructive">
|
||||
{t('playlist.previewFailed')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{playlistPreviewError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Options Content - Playlist */}
|
||||
<div
|
||||
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||
advancedOptionsOpen
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
aria-hidden={!advancedOptionsOpen}
|
||||
>
|
||||
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||
<div className="w-full pt-4 mt-4 border-t">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('playlist.range')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="1"
|
||||
value={startIndex}
|
||||
onChange={(e) => {
|
||||
setStartIndex(e.target.value)
|
||||
// Clear manual selection when using range
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
<Input
|
||||
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
||||
value={endIndex}
|
||||
onChange={(e) => {
|
||||
setEndIndex(e.target.value)
|
||||
// Clear manual selection when using range
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<TabsContent value="playlist" className="flex flex-col flex-1 min-h-0 mt-0">
|
||||
<PlaylistDownload
|
||||
playlistPreviewLoading={playlistPreviewLoading}
|
||||
playlistPreviewError={playlistPreviewError}
|
||||
playlistInfo={playlistInfo}
|
||||
playlistBusy={playlistBusy}
|
||||
selectedPlaylistEntries={selectedPlaylistEntries}
|
||||
selectedEntryIds={selectedEntryIds}
|
||||
downloadType={downloadType}
|
||||
downloadTypeId={downloadTypeId}
|
||||
startIndex={startIndex}
|
||||
endIndex={endIndex}
|
||||
advancedOptionsOpen={advancedOptionsOpen}
|
||||
setSelectedEntryIds={setSelectedEntryIds}
|
||||
setStartIndex={setStartIndex}
|
||||
setEndIndex={setEndIndex}
|
||||
setDownloadType={setDownloadType}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<DialogFooter className="shrink-0">
|
||||
<div className="flex items-center justify-between w-full gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<DialogFooter className="shrink-0 pt-3 border-t">
|
||||
<div className="flex items-center justify-between w-full gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Download Location - Single Video */}
|
||||
{activeTab === 'single' && videoInfo && !loading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-[280px]">
|
||||
<div className="relative w-[240px]">
|
||||
<Input
|
||||
value={videoInfoCardState.customDownloadPath || settings.downloadPath}
|
||||
value={singleVideoState.customDownloadPath || settings.downloadPath}
|
||||
readOnly
|
||||
className="pr-8 text-xs"
|
||||
className="pr-7"
|
||||
placeholder={t('download.autoFolderPlaceholder')}
|
||||
/>
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
setSingleVideoState((prev) => ({
|
||||
...prev,
|
||||
customDownloadPath: path
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const path = await ipcServices.fs.selectDirectory()
|
||||
if (path) {
|
||||
setVideoInfoCardState((prev) => ({
|
||||
...prev,
|
||||
customDownloadPath: path
|
||||
}))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select directory:', error)
|
||||
toast.error(t('settings.directorySelectError'))
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
{videoInfoCardState.customDownloadPath && (
|
||||
|
||||
{singleVideoState.customDownloadPath && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
setVideoInfoCardState((prev) => ({
|
||||
setSingleVideoState((prev) => ({
|
||||
...prev,
|
||||
customDownloadPath: ''
|
||||
}))
|
||||
}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t('download.useAutoFolder')}
|
||||
</Button>
|
||||
@@ -1295,11 +918,11 @@ export function DownloadDialog({
|
||||
<Input
|
||||
value={playlistCustomDownloadPath || settings.downloadPath}
|
||||
readOnly
|
||||
className="pr-8 text-xs"
|
||||
className="pr-7 text-xs h-8 bg-muted/30"
|
||||
placeholder={t('download.autoFolderPlaceholder')}
|
||||
/>
|
||||
<div className="absolute right-2.5 top-1/2 -translate-y-1/2">
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<FolderOpen className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -1307,6 +930,7 @@ export function DownloadDialog({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={playlistBusy}
|
||||
className="h-8"
|
||||
>
|
||||
{t('settings.selectPath')}
|
||||
</Button>
|
||||
@@ -1316,6 +940,7 @@ export function DownloadDialog({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={playlistBusy}
|
||||
className="h-8 text-xs"
|
||||
>
|
||||
{t('download.useAutoFolder')}
|
||||
</Button>
|
||||
@@ -1324,7 +949,7 @@ export function DownloadDialog({
|
||||
)}
|
||||
|
||||
{/* Advanced Options - Playlist (when no playlist info) */}
|
||||
{activeTab === 'playlist' && !playlistInfo && (
|
||||
{activeTab === 'playlist' && !playlistInfo && !playlistPreviewLoading && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={advancedOptionsId}
|
||||
@@ -1333,7 +958,7 @@ export function DownloadDialog({
|
||||
setAdvancedOptionsOpen(checked === true)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={advancedOptionsId} className="cursor-pointer">
|
||||
<Label htmlFor={advancedOptionsId} className="cursor-pointer text-xs">
|
||||
{t('advancedOptions.title')}
|
||||
</Label>
|
||||
</div>
|
||||
@@ -1341,7 +966,7 @@ export function DownloadDialog({
|
||||
</div>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{activeTab === 'single' ? (
|
||||
!videoInfo ? (
|
||||
!videoInfo && !loading ? (
|
||||
<Button
|
||||
onClick={settings.oneClickDownload ? handleOneClickDownload : handleFetchVideo}
|
||||
disabled={loading || !url.trim()}
|
||||
@@ -1350,28 +975,20 @@ export function DownloadDialog({
|
||||
? t('download.oneClickDownloadNow')
|
||||
: t('download.startDownload')}
|
||||
</Button>
|
||||
) : videoInfoCardState.activeTab === 'video' ? (
|
||||
) : !loading && videoInfo ? (
|
||||
<Button
|
||||
onClick={() => handleVideoDownload('video')}
|
||||
disabled={loading || !videoInfoCardState.selectedVideoFormat}
|
||||
size="lg"
|
||||
onClick={handleSingleVideoDownload}
|
||||
disabled={loading || !selectedSingleFormat}
|
||||
>
|
||||
{t('download.downloadVideo')}
|
||||
{singleVideoState.activeTab === 'video'
|
||||
? t('download.downloadVideo')
|
||||
: t('download.downloadAudio')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleVideoDownload('audio')}
|
||||
disabled={loading || !videoInfoCardState.selectedAudioFormat}
|
||||
size="lg"
|
||||
>
|
||||
{t('download.downloadAudio')}
|
||||
</Button>
|
||||
)
|
||||
) : null
|
||||
) : playlistInfo && !playlistPreviewLoading ? (
|
||||
<Button
|
||||
onClick={handleDownloadPlaylist}
|
||||
disabled={playlistDownloadLoading || selectedPlaylistEntries.length === 0}
|
||||
size="lg"
|
||||
>
|
||||
{playlistDownloadLoading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
@@ -1379,19 +996,18 @@ export function DownloadDialog({
|
||||
t('playlist.downloadCurrentRange')
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
) : !playlistPreviewLoading ? (
|
||||
<Button
|
||||
onClick={handlePreviewPlaylist}
|
||||
disabled={playlistBusy || !playlistUrl.trim()}
|
||||
size="lg"
|
||||
>
|
||||
{playlistPreviewLoading ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
) : (
|
||||
t('download.startDownload')
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
DOWNLOAD_FEEDBACK_ISSUE_TITLE,
|
||||
FeedbackLinkButtons
|
||||
} from '@renderer/components/feedback/FeedbackLinks'
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
@@ -17,16 +21,13 @@ import {
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Github,
|
||||
Info,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
Play,
|
||||
Trash2,
|
||||
Twitter,
|
||||
X
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react'
|
||||
import { type ReactNode, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcServices } from '../../lib/ipc'
|
||||
@@ -203,72 +204,8 @@ const formatDateShort = (timestamp?: number) => {
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeErrorText = (value?: string | null): string =>
|
||||
value ? value.replace(/\s+/g, ' ').trim() : ''
|
||||
|
||||
const clampText = (value: string, maxLength: number): string =>
|
||||
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
|
||||
|
||||
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
|
||||
const FEEDBACK_ISSUE_TITLE = 'Download error report'
|
||||
const FEEDBACK_ISSUE_OBSERVED_PREFIX = 'Download failed with error: '
|
||||
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
|
||||
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
|
||||
const FEEDBACK_SOURCE_LABEL = 'Source URL'
|
||||
const FEEDBACK_ERROR_LABEL = 'Error'
|
||||
const FEEDBACK_APP_VERSION_PREFIX = 'VidBee v'
|
||||
|
||||
const buildIssueLogs = (
|
||||
errorText: string,
|
||||
sourceUrl: string | undefined,
|
||||
urlLabel: string,
|
||||
errorLabel: string
|
||||
): string => {
|
||||
const lines: string[] = []
|
||||
if (sourceUrl) {
|
||||
lines.push(`${urlLabel}: ${sourceUrl}`)
|
||||
}
|
||||
lines.push(`${errorLabel}: ${errorText}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
type AppInfo = {
|
||||
appVersion: string
|
||||
osVersion: string
|
||||
}
|
||||
|
||||
let cachedAppInfo: AppInfo | null = null
|
||||
let appInfoPromise: Promise<AppInfo> | null = null
|
||||
|
||||
const loadAppInfo = async (): Promise<AppInfo> => {
|
||||
if (cachedAppInfo) {
|
||||
return cachedAppInfo
|
||||
}
|
||||
if (appInfoPromise) {
|
||||
return appInfoPromise
|
||||
}
|
||||
|
||||
appInfoPromise = (async () => {
|
||||
try {
|
||||
const [version, osRelease] = await Promise.all([
|
||||
ipcServices.app.getVersion(),
|
||||
ipcServices.app.getOsVersion()
|
||||
])
|
||||
cachedAppInfo = { appVersion: version, osVersion: osRelease }
|
||||
} catch (error) {
|
||||
console.error('Failed to load app info for feedback links:', error)
|
||||
cachedAppInfo = { appVersion: '', osVersion: '' }
|
||||
}
|
||||
return cachedAppInfo
|
||||
})()
|
||||
|
||||
return appInfoPromise
|
||||
}
|
||||
|
||||
export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const [appVersion, setAppVersion] = useState('')
|
||||
const [osVersion, setOsVersion] = useState('')
|
||||
const settings = useAtomValue(settingsAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const removeHistory = useSetAtom(removeHistoryRecordAtom)
|
||||
@@ -281,66 +218,6 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
const resolvedExtension = resolveDownloadExtension(download)
|
||||
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
|
||||
const selectionEnabled = isHistory && Boolean(onToggleSelect)
|
||||
const feedbackLinks = useMemo(() => {
|
||||
const compactError = normalizeErrorText(download.error)
|
||||
const tweetError = compactError ? clampText(compactError, 160) : ''
|
||||
const tweetText = encodeURIComponent(
|
||||
tweetError ? `${FEEDBACK_TWEET_PREFIX} - ${tweetError}` : FEEDBACK_TWEET_PREFIX
|
||||
)
|
||||
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR
|
||||
const issueTitle = FEEDBACK_ISSUE_TITLE
|
||||
const issueObserved = clampText(`${FEEDBACK_ISSUE_OBSERVED_PREFIX}${issueError}`, 300)
|
||||
const sourceUrl = download.url?.trim() || undefined
|
||||
const issueLogs = clampText(
|
||||
buildIssueLogs(issueError, sourceUrl, FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL),
|
||||
800
|
||||
)
|
||||
const appVersionValue = appVersion
|
||||
? `${FEEDBACK_APP_VERSION_PREFIX}${appVersion}`
|
||||
: FEEDBACK_UNKNOWN_VALUE
|
||||
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
||||
return [
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
href: `https://github.com/nexmoe/VidBee/issues/new?template=bug_report.yml&title=${encodeURIComponent(
|
||||
issueTitle
|
||||
)}&actual=${encodeURIComponent(issueObserved)}&logs=${encodeURIComponent(
|
||||
issueLogs
|
||||
)}&app_version=${encodeURIComponent(appVersionValue)}&os_version=${encodeURIComponent(
|
||||
osVersionValue
|
||||
)}`
|
||||
},
|
||||
{
|
||||
icon: Twitter,
|
||||
label: t('about.resources.xFeedback'),
|
||||
href: `https://x.com/intent/tweet?text=${tweetText}`
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
]
|
||||
}, [appVersion, download.error, download.url, osVersion, t])
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
const fetchInfo = async () => {
|
||||
const info = await loadAppInfo()
|
||||
if (!isActive) {
|
||||
return
|
||||
}
|
||||
setAppVersion(info.appVersion)
|
||||
setOsVersion(info.osVersion)
|
||||
}
|
||||
|
||||
void fetchInfo()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Track if the file exists
|
||||
const [fileExists, setFileExists] = useState(false)
|
||||
@@ -1065,24 +942,18 @@ export function DownloadItem({ download, isSelected = false, onToggleSelect }: D
|
||||
{t('download.feedback.title')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{feedbackLinks.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-[10px]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
asChild
|
||||
>
|
||||
<a href={resource.href} target="_blank" rel="noreferrer">
|
||||
<Icon className="h-3 w-3" />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<FeedbackLinkButtons
|
||||
error={download.error}
|
||||
sourceUrl={download.url}
|
||||
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
|
||||
includeAppInfo
|
||||
ytDlpCommand={download.ytDlpCommand}
|
||||
buttonVariant="outline"
|
||||
buttonSize="sm"
|
||||
buttonClassName="h-6 gap-1 px-1.5 text-[10px]"
|
||||
iconClassName="h-3 w-3"
|
||||
onLinkClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
248
src/renderer/src/components/download/PlaylistDownload.tsx
Normal file
248
src/renderer/src/components/download/PlaylistDownload.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||
import { Input } from '@renderer/components/ui/input'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import type { PlaylistInfo } from '@shared/types'
|
||||
import { AlertCircle, List, Loader2 } from 'lucide-react'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface PlaylistDownloadProps {
|
||||
playlistPreviewLoading: boolean
|
||||
playlistPreviewError: string | null
|
||||
playlistInfo: PlaylistInfo | null
|
||||
playlistBusy: boolean
|
||||
selectedPlaylistEntries: PlaylistInfo['entries']
|
||||
selectedEntryIds: Set<string>
|
||||
downloadType: 'video' | 'audio'
|
||||
downloadTypeId: string
|
||||
startIndex: string
|
||||
endIndex: string
|
||||
advancedOptionsOpen: boolean
|
||||
setSelectedEntryIds: Dispatch<SetStateAction<Set<string>>>
|
||||
setStartIndex: Dispatch<SetStateAction<string>>
|
||||
setEndIndex: Dispatch<SetStateAction<string>>
|
||||
setDownloadType: Dispatch<SetStateAction<'video' | 'audio'>>
|
||||
}
|
||||
|
||||
export function PlaylistDownload({
|
||||
playlistPreviewLoading,
|
||||
playlistPreviewError,
|
||||
playlistInfo,
|
||||
playlistBusy,
|
||||
selectedPlaylistEntries,
|
||||
selectedEntryIds,
|
||||
downloadType,
|
||||
downloadTypeId,
|
||||
startIndex,
|
||||
endIndex,
|
||||
advancedOptionsOpen,
|
||||
setSelectedEntryIds,
|
||||
setStartIndex,
|
||||
setEndIndex,
|
||||
setDownloadType
|
||||
}: PlaylistDownloadProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
{playlistPreviewLoading && !playlistPreviewError && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 min-h-[200px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{t('playlist.fetchingInfo')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlistPreviewError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 mb-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium text-destructive">{t('playlist.previewFailed')}</p>
|
||||
<p className="text-xs text-muted-foreground/80">{playlistPreviewError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{playlistInfo && !playlistPreviewLoading && (
|
||||
<div className="flex-1 flex flex-col min-h-0 gap-3">
|
||||
<div className="space-y-0.5 shrink-0">
|
||||
<h3 className="font-bold text-sm leading-tight line-clamp-1">{playlistInfo.title}</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<List className="h-3 w-3" />
|
||||
<span>{t('playlist.foundVideos', { count: playlistInfo.entryCount })}</span>
|
||||
{selectedPlaylistEntries.length !== playlistInfo.entryCount && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-primary font-medium">
|
||||
{t('playlist.selectedVideos', { count: selectedPlaylistEntries.length })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 w-full rounded-md border max-h-[45vh]">
|
||||
<div className="p-1">
|
||||
{playlistInfo.entries.map((entry) => {
|
||||
const isSelected = selectedEntryIds.has(entry.id)
|
||||
const isInRange =
|
||||
selectedEntryIds.size === 0 &&
|
||||
selectedPlaylistEntries.some((playlistEntry) => playlistEntry.id === entry.id)
|
||||
|
||||
const handleToggle = () => {
|
||||
setSelectedEntryIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(entry.id)) {
|
||||
next.delete(entry.id)
|
||||
} else {
|
||||
next.add(entry.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (selectedEntryIds.size === 0) {
|
||||
setStartIndex('1')
|
||||
setEndIndex('')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-2.5 py-1.5 rounded transition-colors cursor-pointer w-full text-left',
|
||||
isSelected || isInRange ? 'bg-primary/10' : 'hover:bg-muted/50'
|
||||
)}
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
handleToggle()
|
||||
}
|
||||
}}
|
||||
aria-label={t('playlist.selectEntry', { index: entry.index })}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isInRange}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedEntryIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(entry.id)
|
||||
} else {
|
||||
next.delete(entry.id)
|
||||
}
|
||||
return next
|
||||
})
|
||||
if (selectedEntryIds.size === 0) {
|
||||
setStartIndex('1')
|
||||
setEndIndex('')
|
||||
}
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="shrink-0"
|
||||
/>
|
||||
<div className="shrink-0 w-8 text-xs font-medium text-muted-foreground/70 tabular-nums">
|
||||
#{entry.index}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium line-clamp-1 leading-tight">
|
||||
{entry.title || t('download.fetchingVideoInfo')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div
|
||||
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||
className={cn(
|
||||
'grid overflow-hidden transition-all duration-300 ease-out shrink-0',
|
||||
advancedOptionsOpen ? 'grid-rows-[1fr] py-3 opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||
)}
|
||||
aria-hidden={!advancedOptionsOpen}
|
||||
>
|
||||
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||
<div className="w-full pt-3 border-t">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor={downloadTypeId}
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{t('playlist.downloadType')}
|
||||
</Label>
|
||||
<Select
|
||||
value={downloadType}
|
||||
onValueChange={(value) => setDownloadType(value as 'video' | 'audio')}
|
||||
disabled={playlistBusy}
|
||||
>
|
||||
<SelectTrigger id={downloadTypeId} className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video" className="text-xs">
|
||||
{t('download.video')}
|
||||
</SelectItem>
|
||||
<SelectItem value="audio" className="text-xs">
|
||||
{t('download.audio')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
{t('playlist.range')}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="1"
|
||||
value={startIndex}
|
||||
onChange={(event) => {
|
||||
setStartIndex(event.target.value)
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center h-8 text-xs"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
<Input
|
||||
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
||||
value={endIndex}
|
||||
onChange={(event) => {
|
||||
setEndIndex(event.target.value)
|
||||
if (selectedEntryIds.size > 0) {
|
||||
setSelectedEntryIds(new Set())
|
||||
}
|
||||
}}
|
||||
className="text-center h-8 text-xs"
|
||||
disabled={playlistBusy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -78,11 +78,11 @@ export function PlaylistDownloadGroup({
|
||||
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2">
|
||||
<div className="rounded-md bg-muted/30 mx-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60 -ml-1.5 -mr-1.5"
|
||||
className="px-3 flex min-w-0 flex-1 items-center gap-2 rounded-md p-1.5 transition-colors hover:bg-muted/40 active:bg-muted/60"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
aria-expanded={isExpanded}
|
||||
aria-label={toggleLabel}
|
||||
@@ -118,7 +118,7 @@ export function PlaylistDownloadGroup({
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<div className="flex shrink-0 items-center gap-1 pr-3">
|
||||
{canDeletePlaylist && (
|
||||
<Button
|
||||
type="button"
|
||||
@@ -153,17 +153,15 @@ export function PlaylistDownloadGroup({
|
||||
}}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<div className="space-y-3 pt-1">
|
||||
{records.map((record) => (
|
||||
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
||||
<DownloadItem
|
||||
download={record}
|
||||
isSelected={selectedIds?.has(record.id) ?? false}
|
||||
onToggleSelect={onToggleSelect}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{records.map((record) => (
|
||||
<div key={`${groupId}:${record.entryType}:${record.id}`}>
|
||||
<DownloadItem
|
||||
download={record}
|
||||
isSelected={selectedIds?.has(record.id) ?? false}
|
||||
onToggleSelect={onToggleSelect}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
753
src/renderer/src/components/download/SingleVideoDownload.tsx
Normal file
753
src/renderer/src/components/download/SingleVideoDownload.tsx
Normal file
@@ -0,0 +1,753 @@
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { Separator } from '@renderer/components/ui/separator'
|
||||
import { cn } from '@renderer/lib/utils'
|
||||
import type { OneClickQualityPreset, VideoFormat, VideoInfo } from '@shared/types'
|
||||
import { useAtom } from 'jotai'
|
||||
import { AlertCircle, ExternalLink, Loader2, Settings2 } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
import { DOWNLOAD_FEEDBACK_ISSUE_TITLE, FeedbackLinkButtons } from '../feedback/FeedbackLinks'
|
||||
|
||||
export interface SingleVideoState {
|
||||
title: string
|
||||
activeTab: 'video' | 'audio'
|
||||
selectedVideoFormat: string
|
||||
selectedAudioFormat: string
|
||||
customDownloadPath: string
|
||||
selectedContainer?: string
|
||||
selectedCodec?: string
|
||||
selectedFps?: string
|
||||
}
|
||||
|
||||
interface SingleVideoDownloadProps {
|
||||
loading: boolean
|
||||
error: string | null
|
||||
videoInfo: VideoInfo | null
|
||||
state: SingleVideoState
|
||||
feedbackSourceUrl?: string | null
|
||||
ytDlpCommand?: string
|
||||
onStateChange: (state: Partial<SingleVideoState>) => void
|
||||
}
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
bad: 480,
|
||||
worst: 360
|
||||
}
|
||||
|
||||
const formatDuration = (seconds?: number): string => {
|
||||
if (!seconds) return '00:00'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds
|
||||
.toString()
|
||||
.padStart(2, '0')}`
|
||||
}
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getCodecShortName = (codec?: string): string => {
|
||||
if (!codec || codec === 'none') return 'Unknown'
|
||||
return codec.split('.')[0].toUpperCase()
|
||||
}
|
||||
|
||||
const filterFormatsByType = (
|
||||
formats: VideoInfo['formats'],
|
||||
activeTab: 'video' | 'audio'
|
||||
): VideoInfo['formats'] => {
|
||||
if (!formats) return []
|
||||
|
||||
return formats.filter((format) => {
|
||||
if (activeTab === 'video') {
|
||||
return format.vcodec && format.vcodec !== 'none'
|
||||
}
|
||||
|
||||
return (
|
||||
format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(format.video_ext === 'none' ||
|
||||
!format.video_ext ||
|
||||
!format.vcodec ||
|
||||
format.vcodec === 'none')
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
interface FormatListProps {
|
||||
formats: VideoFormat[]
|
||||
type: 'video' | 'audio'
|
||||
codec?: string
|
||||
selectedFormat: string
|
||||
onFormatChange: (formatId: string) => void
|
||||
}
|
||||
|
||||
const FormatList = ({ formats, type, codec, selectedFormat, onFormatChange }: FormatListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])
|
||||
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
|
||||
|
||||
const getFileSize = useCallback((format: VideoFormat): number => {
|
||||
return format.filesize ?? format.filesize_approx ?? 0
|
||||
}, [])
|
||||
|
||||
const sortVideoFormatsByQuality = useCallback(
|
||||
(a: VideoFormat, b: VideoFormat) => {
|
||||
const aHeight = a.height ?? 0
|
||||
const bHeight = b.height ?? 0
|
||||
if (aHeight !== bHeight) {
|
||||
return bHeight - aHeight
|
||||
}
|
||||
const aFps = a.fps ?? 0
|
||||
const bFps = b.fps ?? 0
|
||||
if (aFps !== bFps) {
|
||||
return bFps - aFps
|
||||
}
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return getFileSize(b) - getFileSize(a)
|
||||
},
|
||||
[getFileSize]
|
||||
)
|
||||
|
||||
const sortAudioFormatsByQuality = useCallback(
|
||||
(a: VideoFormat, b: VideoFormat) => {
|
||||
const aQuality = a.tbr ?? a.quality ?? 0
|
||||
const bQuality = b.tbr ?? b.quality ?? 0
|
||||
if (aQuality !== bQuality) {
|
||||
return bQuality - aQuality
|
||||
}
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return getFileSize(b) - getFileSize(a)
|
||||
},
|
||||
[getFileSize]
|
||||
)
|
||||
|
||||
const pickVideoFormatForPreset = useCallback(
|
||||
(presetFormats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {
|
||||
if (presetFormats.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const heightLimit = qualityPresetToVideoHeight[preset]
|
||||
const sorted = [...presetFormats].sort(sortVideoFormatsByQuality)
|
||||
|
||||
if (preset === 'worst') {
|
||||
return sorted[sorted.length - 1] ?? sorted[0]
|
||||
}
|
||||
|
||||
if (!heightLimit) {
|
||||
return sorted[0]
|
||||
}
|
||||
|
||||
const matchingLimit = sorted.find((format) => {
|
||||
if (!format.height) return false
|
||||
return format.height <= heightLimit
|
||||
})
|
||||
|
||||
return matchingLimit ?? sorted[0]
|
||||
},
|
||||
[sortVideoFormatsByQuality]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const isVideoFormat = (format: VideoFormat) =>
|
||||
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
|
||||
const isAudioFormat = (format: VideoFormat) =>
|
||||
format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(format.video_ext === 'none' ||
|
||||
!format.video_ext ||
|
||||
!format.vcodec ||
|
||||
format.vcodec === 'none')
|
||||
|
||||
const videos = formats.filter(isVideoFormat)
|
||||
const audios = formats.filter(isAudioFormat)
|
||||
|
||||
const groupedByHeight = new Map<number, VideoFormat[]>()
|
||||
videos.forEach((format) => {
|
||||
const height = format.height ?? 0
|
||||
const existing = groupedByHeight.get(height) || []
|
||||
existing.push(format)
|
||||
groupedByHeight.set(height, existing)
|
||||
})
|
||||
|
||||
const finalVideos = Array.from(groupedByHeight.values()).map((group) => {
|
||||
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
|
||||
})
|
||||
|
||||
let finalAudios = audios
|
||||
|
||||
if (codec === 'auto' && type === 'audio') {
|
||||
const groupedByQuality = new Map<string, VideoFormat[]>()
|
||||
audios.forEach((format) => {
|
||||
const qualityKey = format.tbr
|
||||
? `tbr_${format.tbr}`
|
||||
: format.quality
|
||||
? `quality_${format.quality}`
|
||||
: 'unknown'
|
||||
const existing = groupedByQuality.get(qualityKey) || []
|
||||
existing.push(format)
|
||||
groupedByQuality.set(qualityKey, existing)
|
||||
})
|
||||
|
||||
finalAudios = Array.from(groupedByQuality.values()).map((group) => {
|
||||
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
|
||||
})
|
||||
}
|
||||
|
||||
finalVideos.sort(sortVideoFormatsByQuality)
|
||||
finalAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(finalVideos)
|
||||
setAudioFormats(finalAudios)
|
||||
|
||||
if (type === 'video') {
|
||||
const videosWithAudio = finalVideos.filter(
|
||||
(format) => format.acodec && format.acodec !== 'none'
|
||||
)
|
||||
const autoVideos =
|
||||
finalAudios.length > 0
|
||||
? finalVideos
|
||||
: videosWithAudio.length > 0
|
||||
? videosWithAudio
|
||||
: finalVideos
|
||||
|
||||
const hasSelectedVideo = finalVideos.some((format) => format.format_id === selectedFormat)
|
||||
if (autoVideos.length > 0 && (!selectedFormat || !hasSelectedVideo)) {
|
||||
const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)
|
||||
if (preferred) {
|
||||
onFormatChange(preferred.format_id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const hasSelectedAudio = finalAudios.some((format) => format.format_id === selectedFormat)
|
||||
if (finalAudios.length > 0 && (!selectedFormat || !hasSelectedAudio)) {
|
||||
const best = finalAudios[0]
|
||||
onFormatChange(best.format_id)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
formats,
|
||||
settings.oneClickQuality,
|
||||
type,
|
||||
selectedFormat,
|
||||
onFormatChange,
|
||||
pickVideoFormatForPreset,
|
||||
codec,
|
||||
getFileSize,
|
||||
sortVideoFormatsByQuality,
|
||||
sortAudioFormatsByQuality
|
||||
])
|
||||
|
||||
const formatSize = (bytes?: number) => {
|
||||
if (!bytes) return t('download.unknownSize')
|
||||
const mb = bytes / 1000000
|
||||
return `${mb.toFixed(2)} MB`
|
||||
}
|
||||
|
||||
const formatVideoQuality = (format: VideoFormat) => {
|
||||
if (format.height) {
|
||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return t('download.unknownQuality')
|
||||
}
|
||||
|
||||
const formatAudioQuality = (format: VideoFormat) => {
|
||||
if (format.tbr) {
|
||||
return `${Math.round(format.tbr)} kbps`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return t('download.unknownQuality')
|
||||
}
|
||||
|
||||
const formatVideoDetail = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
parts.push(format.ext.toUpperCase())
|
||||
if (format.vcodec) {
|
||||
parts.push(format.vcodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
if (format.acodec && format.acodec !== 'none') {
|
||||
parts.push(format.acodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatAudioDetail = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
const ext = format.ext === 'webm' ? 'opus' : format.ext
|
||||
parts.push(ext.toUpperCase())
|
||||
if (format.acodec) {
|
||||
parts.push(format.acodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const list = type === 'video' ? videoFormats : audioFormats
|
||||
|
||||
if (list.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroup value={selectedFormat} onValueChange={onFormatChange} className="w-full gap-1">
|
||||
{list.map((format) => {
|
||||
const qualityLabel =
|
||||
type === 'video' ? formatVideoQuality(format) : formatAudioQuality(format)
|
||||
const detailLabel = type === 'video' ? formatVideoDetail(format) : formatAudioDetail(format)
|
||||
const thirdColumnLabel =
|
||||
type === 'video'
|
||||
? format.fps
|
||||
? `${format.fps}fps`
|
||||
: ''
|
||||
: format.acodec
|
||||
? format.acodec.split('.')[0].toUpperCase()
|
||||
: ''
|
||||
const sizeLabel = formatSize(format.filesize || format.filesize_approx)
|
||||
const isSelected = selectedFormat === format.format_id
|
||||
|
||||
return (
|
||||
<label
|
||||
key={format.format_id}
|
||||
htmlFor={`${type}-${format.format_id}`}
|
||||
className={cn(
|
||||
'relative flex items-center gap-3 px-3 py-2 cursor-pointer transition-colors rounded-md',
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={format.format_id}
|
||||
id={`${type}-${format.format_id}`}
|
||||
className="shrink-0 hidden"
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-w-0 flex items-center gap-4">
|
||||
<span
|
||||
className={cn('text-sm font-medium w-16 shrink-0', isSelected && 'text-primary')}
|
||||
>
|
||||
{qualityLabel}
|
||||
</span>
|
||||
|
||||
<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>
|
||||
|
||||
<span className="text-xs text-muted-foreground tabular-nums shrink-0 w-20 text-right">
|
||||
{sizeLabel}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
export function SingleVideoDownload({
|
||||
loading,
|
||||
error,
|
||||
videoInfo,
|
||||
state,
|
||||
feedbackSourceUrl,
|
||||
ytDlpCommand,
|
||||
onStateChange
|
||||
}: SingleVideoDownloadProps) {
|
||||
const { t } = useTranslation()
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
|
||||
const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state
|
||||
const displayTitle = title || videoInfo?.title || t('download.fetchingVideoInfo')
|
||||
|
||||
const relevantFormats = useMemo(() => {
|
||||
if (!videoInfo?.formats) return []
|
||||
return filterFormatsByType(videoInfo.formats, activeTab)
|
||||
}, [videoInfo?.formats, activeTab])
|
||||
|
||||
const containers = useMemo(() => {
|
||||
if (relevantFormats.length === 0) return []
|
||||
const exts = new Set(relevantFormats.map((format) => format.ext))
|
||||
return Array.from(exts).sort()
|
||||
}, [relevantFormats])
|
||||
|
||||
useEffect(() => {
|
||||
if (containers.length === 0) return undefined
|
||||
|
||||
if (selectedContainer && !containers.includes(selectedContainer)) {
|
||||
let defaultContainer: string
|
||||
if (activeTab === 'video') {
|
||||
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
|
||||
} else {
|
||||
defaultContainer = containers.includes('m4a')
|
||||
? 'm4a'
|
||||
: containers.includes('mp3')
|
||||
? 'mp3'
|
||||
: containers[0]
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedContainer: defaultContainer, selectedCodec: 'auto' })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
if (!selectedContainer) {
|
||||
let defaultContainer: string
|
||||
if (activeTab === 'video') {
|
||||
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
|
||||
} else {
|
||||
defaultContainer = containers.includes('m4a')
|
||||
? 'm4a'
|
||||
: containers.includes('mp3')
|
||||
? 'mp3'
|
||||
: containers[0]
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedContainer: defaultContainer })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [containers, selectedContainer, activeTab, onStateChange])
|
||||
|
||||
const formatsByContainer = useMemo(() => {
|
||||
if (relevantFormats.length === 0) return []
|
||||
|
||||
if (!selectedContainer) {
|
||||
return relevantFormats
|
||||
}
|
||||
|
||||
return relevantFormats.filter((format) => format.ext === selectedContainer)
|
||||
}, [relevantFormats, selectedContainer])
|
||||
|
||||
const codecs = useMemo(() => {
|
||||
if (formatsByContainer.length === 0) return []
|
||||
|
||||
const SetVals = new Set<string>()
|
||||
formatsByContainer.forEach((format) => {
|
||||
if (activeTab === 'video') {
|
||||
const c = format.vcodec
|
||||
if (c && c !== 'none') {
|
||||
SetVals.add(getCodecShortName(c))
|
||||
}
|
||||
} else {
|
||||
const c = format.acodec
|
||||
if (c && c !== 'none') {
|
||||
SetVals.add(getCodecShortName(c))
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(SetVals).sort()
|
||||
}, [formatsByContainer, activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
if (codecs.length === 0) return undefined
|
||||
if (selectedCodec && selectedCodec !== 'auto' && !codecs.includes(selectedCodec)) {
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedCodec: 'auto' })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
return undefined
|
||||
}, [codecs, selectedCodec, onStateChange])
|
||||
|
||||
const formatsByCodec = useMemo(() => {
|
||||
if (!selectedCodec || selectedCodec === 'auto') return formatsByContainer
|
||||
return formatsByContainer.filter((format) => {
|
||||
if (activeTab === 'video') {
|
||||
const c = format.vcodec
|
||||
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
|
||||
}
|
||||
const c = format.acodec
|
||||
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
|
||||
})
|
||||
}, [formatsByContainer, selectedCodec, activeTab])
|
||||
|
||||
const framerates = useMemo(() => {
|
||||
if (activeTab !== 'video') return []
|
||||
const SetVals = new Set<number>()
|
||||
formatsByCodec.forEach((format) => {
|
||||
if (format.fps) SetVals.add(format.fps)
|
||||
})
|
||||
return Array.from(SetVals).sort((a, b) => b - a)
|
||||
}, [formatsByCodec, activeTab])
|
||||
|
||||
const filteredFormats = useMemo(() => {
|
||||
let res = formatsByCodec
|
||||
if (activeTab === 'video' && selectedFps && selectedFps !== 'highest') {
|
||||
res = res.filter((format) => format.fps === Number(selectedFps))
|
||||
}
|
||||
return res
|
||||
}, [formatsByCodec, selectedFps, activeTab])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{loading && !error && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3 min-h-[200px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">{t('download.fetchingVideoInfo')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="shrink-0 mb-3 rounded-md border border-destructive/30 bg-destructive/5 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
|
||||
<div className="flex-1 space-y-1 min-w-0">
|
||||
<p className="text-sm font-medium text-destructive">{t('errors.fetchInfoFailed')}</p>
|
||||
<p className="text-xs text-muted-foreground/80 break-words">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium text-muted-foreground/70">
|
||||
{t('download.feedback.title')}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<FeedbackLinkButtons
|
||||
error={error}
|
||||
sourceUrl={feedbackSourceUrl}
|
||||
issueTitle={DOWNLOAD_FEEDBACK_ISSUE_TITLE}
|
||||
includeAppInfo
|
||||
ytDlpCommand={ytDlpCommand}
|
||||
buttonVariant="outline"
|
||||
buttonSize="sm"
|
||||
buttonClassName="h-5 gap-1 px-1.5 text-[10px]"
|
||||
iconClassName="h-2.5 w-2.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && videoInfo && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex gap-4 py-4 shrink-0">
|
||||
<div className="shrink-0 w-32 relative rounded-md overflow-hidden bg-muted">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={displayTitle}
|
||||
className="w-full h-full object-cover aspect-video"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/80 text-white text-[10px] px-1 rounded">
|
||||
{formatDuration(videoInfo.duration)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between py-0.5">
|
||||
<div className="space-y-0.5">
|
||||
<h3 className="font-bold text-[13px] leading-tight line-clamp-2">{displayTitle}</h3>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{videoInfo.uploader && (
|
||||
<span className="truncate max-w-[140px] uppercase tracking-wider font-semibold opacity-70">
|
||||
{videoInfo.uploader}
|
||||
</span>
|
||||
)}
|
||||
{videoInfo.webpage_url && (
|
||||
<a
|
||||
href={videoInfo.webpage_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:text-primary transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex p-0.5 bg-muted rounded-md gap-0.5">
|
||||
<Button
|
||||
variant={activeTab === 'video' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onStateChange({ activeTab: 'video' })}
|
||||
className={cn(
|
||||
'h-5 px-2 text-[11px] rounded-sm',
|
||||
activeTab === 'video'
|
||||
? 'bg-background text-foreground'
|
||||
: 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{t('download.video')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'audio' ? 'secondary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => onStateChange({ activeTab: 'audio' })}
|
||||
className={cn(
|
||||
'h-5 px-2 text-[11px] rounded-sm',
|
||||
activeTab === 'audio'
|
||||
? 'bg-background text-foreground'
|
||||
: 'text-muted-foreground/60'
|
||||
)}
|
||||
>
|
||||
{t('download.audio')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className={cn(
|
||||
'h-6 w-6 p-0 rounded-full hover:bg-muted font-normal text-muted-foreground transition-colors',
|
||||
showAdvanced && 'bg-muted text-foreground'
|
||||
)}
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'grid transition-all duration-300 ease-in-out',
|
||||
showAdvanced ? 'grid-rows-[1fr] py-3 border-b' : 'grid-rows-[0fr]'
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden min-h-0">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="space-y-1.5 flex-1 min-w-[120px]">
|
||||
<Label className="text-xs text-muted-foreground font-medium px-0.5">
|
||||
{t('download.container') || 'Format'}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedContainer || ''}
|
||||
onValueChange={(value) => onStateChange({ selectedContainer: value })}
|
||||
disabled={containers.length <= 1}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((ext) => (
|
||||
<SelectItem key={ext} value={ext} className="text-xs">
|
||||
{ext.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 flex-1 min-w-[120px]">
|
||||
<Label className="text-xs text-muted-foreground font-medium px-0.5">
|
||||
Codec
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedCodec || 'auto'}
|
||||
onValueChange={(value) => onStateChange({ selectedCodec: value })}
|
||||
disabled={codecs.length <= 1}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Auto" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto" className="text-xs">
|
||||
Auto
|
||||
</SelectItem>
|
||||
{codecs.map((codecName) => (
|
||||
<SelectItem key={codecName} value={codecName} className="text-xs">
|
||||
{codecName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeTab === 'video' && (
|
||||
<div className="space-y-1.5 flex-1 min-w-[120px]">
|
||||
<Label className="text-xs text-muted-foreground font-medium px-0.5">
|
||||
Frame Rate
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedFps || 'highest'}
|
||||
onValueChange={(value) => onStateChange({ selectedFps: value })}
|
||||
disabled={framerates.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Highest" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="highest" className="text-xs">
|
||||
Highest
|
||||
</SelectItem>
|
||||
{framerates.map((fps) => (
|
||||
<SelectItem key={fps} value={String(fps)} className="text-xs">
|
||||
{fps} fps
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 overflow-y-auto my-3 max-h-72">
|
||||
<FormatList
|
||||
formats={filteredFormats}
|
||||
type={activeTab}
|
||||
codec={selectedCodec}
|
||||
selectedFormat={
|
||||
activeTab === 'video' ? state.selectedVideoFormat : state.selectedAudioFormat
|
||||
}
|
||||
onFormatChange={(formatId) =>
|
||||
onStateChange(
|
||||
activeTab === 'video'
|
||||
? { selectedVideoFormat: formatId }
|
||||
: { selectedAudioFormat: formatId }
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -113,6 +113,17 @@ const resolveDownloadExtension = (download: DownloadRecord): string => {
|
||||
return download.type === 'audio' ? 'mp3' : 'mp4'
|
||||
}
|
||||
|
||||
const isEditableTarget = (target: EventTarget | null): boolean => {
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
if (target.isContentEditable) {
|
||||
return true
|
||||
}
|
||||
const tagName = target.tagName
|
||||
return tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT'
|
||||
}
|
||||
|
||||
interface UnifiedDownloadHistoryProps {
|
||||
onOpenSupportedSites?: () => void
|
||||
onOpenSettings?: () => void
|
||||
@@ -163,6 +174,12 @@ export function UnifiedDownloadHistory({
|
||||
})
|
||||
}, [allRecords, statusFilter])
|
||||
|
||||
const visibleHistoryIds = useMemo(
|
||||
() =>
|
||||
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
||||
[filteredRecords]
|
||||
)
|
||||
|
||||
const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
|
||||
{ key: 'all', label: t('download.all'), count: downloadStats.total },
|
||||
{ key: 'active', label: t('download.active'), count: downloadStats.active },
|
||||
@@ -170,16 +187,34 @@ export function UnifiedDownloadHistory({
|
||||
{ key: 'error', label: t('download.error'), count: downloadStats.error }
|
||||
]
|
||||
|
||||
const selectableIds = useMemo(
|
||||
() =>
|
||||
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id),
|
||||
[filteredRecords]
|
||||
)
|
||||
const selectableIds = useMemo(() => {
|
||||
if (visibleHistoryIds.length === 0) {
|
||||
return []
|
||||
}
|
||||
const ids = new Set(visibleHistoryIds)
|
||||
const playlistIds = new Set(
|
||||
filteredRecords
|
||||
.filter((record) => record.entryType === 'history' && record.playlistId)
|
||||
.map((record) => record.playlistId as string)
|
||||
)
|
||||
if (playlistIds.size === 0) {
|
||||
return Array.from(ids)
|
||||
}
|
||||
for (const record of historyRecords) {
|
||||
if (record.playlistId && playlistIds.has(record.playlistId)) {
|
||||
ids.add(record.id)
|
||||
}
|
||||
}
|
||||
return Array.from(ids)
|
||||
}, [filteredRecords, historyRecords, visibleHistoryIds])
|
||||
const selectableCount = selectableIds.length
|
||||
const visibleSelectableCount = visibleHistoryIds.length
|
||||
const selectionSummary =
|
||||
selectableCount === 0
|
||||
? t('history.selectedCount', { count: selectedCount })
|
||||
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
||||
: selectableCount > visibleSelectableCount
|
||||
? t('history.selectedCount', { count: selectedCount })
|
||||
: t('history.selectionSummary', { selected: selectedCount, total: selectableCount })
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedIds.size === 0) {
|
||||
@@ -216,6 +251,13 @@ export function UnifiedDownloadHistory({
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectableIds.length === 0) {
|
||||
return
|
||||
}
|
||||
setSelectedIds(new Set(selectableIds))
|
||||
}
|
||||
|
||||
const handleRequestDeleteSelected = () => {
|
||||
if (selectedIds.size === 0) {
|
||||
return
|
||||
@@ -398,6 +440,41 @@ export function UnifiedDownloadHistory({
|
||||
return { order, groups }
|
||||
}, [filteredRecords])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
if (isEditableTarget(event.target)) {
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
if (confirmAction) {
|
||||
return
|
||||
}
|
||||
if (selectedIds.size === 0) {
|
||||
return
|
||||
}
|
||||
setSelectedIds(new Set())
|
||||
return
|
||||
}
|
||||
if (!(event.metaKey || event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
if (event.key.toLowerCase() !== 'a') {
|
||||
return
|
||||
}
|
||||
if (selectableIds.length === 0) {
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
setSelectedIds(new Set(selectableIds))
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [confirmAction, selectableIds, selectedIds])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full')}>
|
||||
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur">
|
||||
@@ -431,6 +508,15 @@ export function UnifiedDownloadHistory({
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3"
|
||||
onClick={handleSelectAll}
|
||||
disabled={selectableIds.length === 0}
|
||||
>
|
||||
{t('history.selectAll')}
|
||||
</Button>
|
||||
<DownloadDialog
|
||||
onOpenSupportedSites={onOpenSupportedSites}
|
||||
onOpenSettings={onOpenSettings}
|
||||
|
||||
244
src/renderer/src/components/feedback/FeedbackLinks.tsx
Normal file
244
src/renderer/src/components/feedback/FeedbackLinks.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Button, type ButtonProps } from '@renderer/components/ui/button'
|
||||
import { ipcServices } from '@renderer/lib/ipc'
|
||||
import { Github, MessageCircle, Twitter } from 'lucide-react'
|
||||
import { type MouseEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
type AppInfo = {
|
||||
appVersion: string
|
||||
osVersion: string
|
||||
}
|
||||
|
||||
const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' }
|
||||
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
|
||||
export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[Bug]: Download error report'
|
||||
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
|
||||
const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
|
||||
const FEEDBACK_SOURCE_LABEL = 'Source URL'
|
||||
const FEEDBACK_ERROR_LABEL = 'Error'
|
||||
const FEEDBACK_COMMAND_LABEL = 'yt-dlp command'
|
||||
const FEEDBACK_MAX_GITHUB_URL_LENGTH = 7000
|
||||
|
||||
let cachedAppInfo: AppInfo | null = null
|
||||
let appInfoPromise: Promise<AppInfo> | null = null
|
||||
|
||||
const normalizeErrorText = (value?: string | null): string =>
|
||||
value ? value.replace(/\s+/g, ' ').trim() : ''
|
||||
|
||||
const clampText = (value: string, maxLength: number): string =>
|
||||
value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value
|
||||
|
||||
const buildIssueLogs = (
|
||||
errorText: string,
|
||||
sourceUrl: string | undefined,
|
||||
ytDlpCommand: string | undefined,
|
||||
urlLabel: string,
|
||||
errorLabel: string,
|
||||
commandLabel: string
|
||||
): string => {
|
||||
const lines: string[] = []
|
||||
if (sourceUrl) {
|
||||
lines.push(`**${urlLabel}:**\n${sourceUrl}\n`)
|
||||
}
|
||||
if (ytDlpCommand) {
|
||||
lines.push(`**${commandLabel}:**\n\`\`\`bash\n${ytDlpCommand}\n\`\`\`\n`)
|
||||
}
|
||||
lines.push(`**${errorLabel}:**\n${errorText}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const loadAppInfo = async (): Promise<AppInfo> => {
|
||||
if (cachedAppInfo) {
|
||||
return cachedAppInfo
|
||||
}
|
||||
if (appInfoPromise) {
|
||||
return appInfoPromise
|
||||
}
|
||||
|
||||
appInfoPromise = (async () => {
|
||||
try {
|
||||
const [version, osRelease] = await Promise.all([
|
||||
ipcServices.app.getVersion(),
|
||||
ipcServices.app.getOsVersion()
|
||||
])
|
||||
cachedAppInfo = { appVersion: version, osVersion: osRelease }
|
||||
} catch (error) {
|
||||
console.error('Failed to load app info for feedback links:', error)
|
||||
cachedAppInfo = DEFAULT_APP_INFO
|
||||
}
|
||||
return cachedAppInfo
|
||||
})()
|
||||
|
||||
return appInfoPromise
|
||||
}
|
||||
|
||||
export const useAppInfo = (): AppInfo => {
|
||||
const [appInfo, setAppInfo] = useState<AppInfo>(DEFAULT_APP_INFO)
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
const loadInfo = async () => {
|
||||
const info = await loadAppInfo()
|
||||
if (isActive) {
|
||||
setAppInfo(info)
|
||||
}
|
||||
}
|
||||
|
||||
void loadInfo()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return appInfo
|
||||
}
|
||||
|
||||
type FeedbackLinkButtonsProps = {
|
||||
error?: string | null
|
||||
sourceUrl?: string | null
|
||||
issueTitle?: string
|
||||
includeAppInfo?: boolean
|
||||
appInfo?: AppInfo
|
||||
buttonVariant?: ButtonProps['variant']
|
||||
buttonSize?: ButtonProps['size']
|
||||
buttonClassName?: string
|
||||
iconClassName?: string
|
||||
onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void
|
||||
ytDlpCommand?: string
|
||||
useSimpleGithubUrl?: boolean
|
||||
}
|
||||
|
||||
export const FeedbackLinkButtons = ({
|
||||
error,
|
||||
sourceUrl,
|
||||
issueTitle = '[Bug]: ',
|
||||
includeAppInfo = false,
|
||||
appInfo,
|
||||
buttonVariant = 'outline',
|
||||
buttonSize = 'sm',
|
||||
buttonClassName,
|
||||
iconClassName,
|
||||
onLinkClick,
|
||||
ytDlpCommand,
|
||||
useSimpleGithubUrl = false
|
||||
}: FeedbackLinkButtonsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const fallbackAppInfo = useAppInfo()
|
||||
const { appVersion, osVersion } = appInfo ?? fallbackAppInfo
|
||||
|
||||
const links = useMemo(() => {
|
||||
const compactError = normalizeErrorText(error)
|
||||
const tweetError = compactError ? clampText(compactError, 160) : ''
|
||||
const versionLabels = [
|
||||
appVersion ? `v${appVersion}` : null,
|
||||
osVersion ? osVersion : null
|
||||
].filter(Boolean)
|
||||
const tweetPrefix = versionLabels.length
|
||||
? `${FEEDBACK_TWEET_PREFIX} ${versionLabels.join(' ')}`
|
||||
: FEEDBACK_TWEET_PREFIX
|
||||
const tweetText = encodeURIComponent(
|
||||
tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix
|
||||
)
|
||||
const issueError = compactError || FEEDBACK_UNKNOWN_ERROR
|
||||
const resolvedSourceUrl = sourceUrl?.trim() || undefined
|
||||
const normalizedCommand = ytDlpCommand?.trim() || undefined
|
||||
const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)
|
||||
const issueLogs = shouldIncludeLogs
|
||||
? buildIssueLogs(
|
||||
issueError,
|
||||
resolvedSourceUrl,
|
||||
normalizedCommand,
|
||||
FEEDBACK_SOURCE_LABEL,
|
||||
FEEDBACK_ERROR_LABEL,
|
||||
FEEDBACK_COMMAND_LABEL
|
||||
)
|
||||
: null
|
||||
const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE
|
||||
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
|
||||
|
||||
let githubUrl: string
|
||||
if (useSimpleGithubUrl) {
|
||||
githubUrl = 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||
} else {
|
||||
const issueParams = new URLSearchParams({
|
||||
template: 'bug_report.yml',
|
||||
title: issueTitle
|
||||
})
|
||||
|
||||
if (issueLogs) {
|
||||
issueParams.set('logs', issueLogs)
|
||||
}
|
||||
if (includeAppInfo) {
|
||||
issueParams.set('app_version', appVersionValue)
|
||||
issueParams.set('os_version', osVersionValue)
|
||||
}
|
||||
|
||||
githubUrl = `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}`
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
href: githubUrl
|
||||
},
|
||||
{
|
||||
icon: Twitter,
|
||||
label: t('about.resources.xFeedback'),
|
||||
href: `https://x.com/intent/tweet?text=${tweetText}`
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
]
|
||||
}, [
|
||||
appVersion,
|
||||
error,
|
||||
includeAppInfo,
|
||||
issueTitle,
|
||||
osVersion,
|
||||
sourceUrl,
|
||||
t,
|
||||
ytDlpCommand,
|
||||
useSimpleGithubUrl
|
||||
])
|
||||
|
||||
const handleLinkClick = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
if (href.startsWith('https://github.com') && href.length >= FEEDBACK_MAX_GITHUB_URL_LENGTH) {
|
||||
toast.info(t('download.feedback.githubUrlTooLong'))
|
||||
}
|
||||
onLinkClick?.(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{links.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant={buttonVariant}
|
||||
size={buttonSize}
|
||||
className={buttonClassName}
|
||||
asChild
|
||||
>
|
||||
<a
|
||||
href={resource.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onClick={(event) => handleLinkClick(event, resource.href)}
|
||||
>
|
||||
<Icon className={iconClassName} />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
import { RadioGroup, RadioGroupItem } from '@renderer/components/ui/radio-group'
|
||||
import { Table, TableBody, TableCell, TableRow } from '@renderer/components/ui/table'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { OneClickQualityPreset, VideoFormat } from '../../../../shared/types'
|
||||
|
||||
const qualityPresetToVideoHeight: Record<OneClickQualityPreset, number | null> = {
|
||||
best: null,
|
||||
good: 1080,
|
||||
normal: 720,
|
||||
bad: 480,
|
||||
worst: 360
|
||||
}
|
||||
|
||||
import { settingsAtom } from '../../store/settings'
|
||||
|
||||
interface FormatSelectorProps {
|
||||
formats: VideoFormat[]
|
||||
type: 'video' | 'audio'
|
||||
onVideoFormatChange?: (format: string) => void
|
||||
onAudioFormatChange?: (format: string) => void
|
||||
codec?: string // 'auto' or specific codec name
|
||||
}
|
||||
|
||||
export function FormatSelector({
|
||||
formats,
|
||||
type,
|
||||
onVideoFormatChange,
|
||||
onAudioFormatChange,
|
||||
codec
|
||||
}: FormatSelectorProps) {
|
||||
const { t } = useTranslation()
|
||||
const [settings] = useAtom(settingsAtom)
|
||||
const [videoFormats, setVideoFormats] = useState<VideoFormat[]>([])
|
||||
const [audioFormats, setAudioFormats] = useState<VideoFormat[]>([])
|
||||
const [selectedVideo, setSelectedVideo] = useState('')
|
||||
const [selectedAudio, setSelectedAudio] = useState('')
|
||||
|
||||
const pickVideoFormatForPreset = useCallback(
|
||||
(formats: VideoFormat[], preset: OneClickQualityPreset): VideoFormat | null => {
|
||||
if (formats.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const heightLimit = qualityPresetToVideoHeight[preset]
|
||||
const byHeightDescending = (a: VideoFormat, b: VideoFormat) =>
|
||||
(b.height ?? 0) - (a.height ?? 0)
|
||||
const sorted = [...formats].sort(byHeightDescending)
|
||||
|
||||
if (preset === 'worst') {
|
||||
return sorted[sorted.length - 1] ?? sorted[0]
|
||||
}
|
||||
|
||||
if (!heightLimit) {
|
||||
return sorted[0]
|
||||
}
|
||||
|
||||
const matchingLimit = sorted.find((format) => {
|
||||
if (!format.height) return false
|
||||
return format.height <= heightLimit
|
||||
})
|
||||
|
||||
return matchingLimit ?? sorted[0]
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Formats are already filtered by VideoInfoCard based on Container, Codec, etc.
|
||||
// We just need to separate video and audio formats, exclude HLS, and sort them.
|
||||
const isHlsFormat = (format: VideoFormat) =>
|
||||
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
|
||||
|
||||
// Filter out HLS formats and separate by type
|
||||
const filteredFormats = formats.filter((f) => !isHlsFormat(f))
|
||||
|
||||
const isVideoFormat = (format: VideoFormat) =>
|
||||
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
|
||||
const isAudioFormat = (format: VideoFormat) =>
|
||||
format.acodec &&
|
||||
format.acodec !== 'none' &&
|
||||
(format.video_ext === 'none' ||
|
||||
!format.video_ext ||
|
||||
!format.vcodec ||
|
||||
format.vcodec === 'none')
|
||||
|
||||
const videos = filteredFormats.filter(isVideoFormat)
|
||||
const audios = filteredFormats.filter(isAudioFormat)
|
||||
|
||||
// Get file size for comparison (prefer filesize over filesize_approx)
|
||||
const getFileSize = (format: VideoFormat): number => {
|
||||
return format.filesize ?? format.filesize_approx ?? 0
|
||||
}
|
||||
|
||||
// When codec is 'auto', filter to show only the largest file size per resolution
|
||||
let finalVideos = videos
|
||||
let finalAudios = audios
|
||||
|
||||
if (codec === 'auto') {
|
||||
if (type === 'video') {
|
||||
// Group by height (resolution) and keep only the one with largest file size
|
||||
const groupedByHeight = new Map<number, VideoFormat[]>()
|
||||
videos.forEach((format) => {
|
||||
const height = format.height ?? 0
|
||||
const existing = groupedByHeight.get(height) || []
|
||||
existing.push(format)
|
||||
groupedByHeight.set(height, existing)
|
||||
})
|
||||
|
||||
finalVideos = Array.from(groupedByHeight.values()).map((group) => {
|
||||
// Sort by file size descending and take the first (largest)
|
||||
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
|
||||
})
|
||||
} else {
|
||||
// For audio, group by quality/tbr and keep only the one with largest file size
|
||||
const groupedByQuality = new Map<string, VideoFormat[]>()
|
||||
audios.forEach((format) => {
|
||||
// Use tbr or quality as grouping key
|
||||
const qualityKey = format.tbr
|
||||
? `tbr_${format.tbr}`
|
||||
: format.quality
|
||||
? `quality_${format.quality}`
|
||||
: 'unknown'
|
||||
const existing = groupedByQuality.get(qualityKey) || []
|
||||
existing.push(format)
|
||||
groupedByQuality.set(qualityKey, existing)
|
||||
})
|
||||
|
||||
finalAudios = Array.from(groupedByQuality.values()).map((group) => {
|
||||
// Sort by file size descending and take the first (largest)
|
||||
return group.sort((a, b) => getFileSize(b) - getFileSize(a))[0]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Sort formats by quality (best first)
|
||||
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by height (higher is better)
|
||||
const aHeight = a.height ?? 0
|
||||
const bHeight = b.height ?? 0
|
||||
if (aHeight !== bHeight) {
|
||||
return bHeight - aHeight
|
||||
}
|
||||
// If same height, sort by fps (higher is better)
|
||||
const aFps = a.fps ?? 0
|
||||
const bFps = b.fps ?? 0
|
||||
if (aFps !== bFps) {
|
||||
return bFps - aFps
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const sortAudioFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
|
||||
// Sort by bitrate/quality if available
|
||||
const aQuality = a.tbr ?? a.quality ?? 0
|
||||
const bQuality = b.tbr ?? b.quality ?? 0
|
||||
if (aQuality !== bQuality) {
|
||||
return bQuality - aQuality
|
||||
}
|
||||
// If same quality, prefer formats with file size information
|
||||
const aHasSize = !!(a.filesize || a.filesize_approx)
|
||||
const bHasSize = !!(b.filesize || b.filesize_approx)
|
||||
if (aHasSize !== bHasSize) {
|
||||
return bHasSize ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
finalVideos.sort(sortVideoFormatsByQuality)
|
||||
finalAudios.sort(sortAudioFormatsByQuality)
|
||||
|
||||
setVideoFormats(finalVideos)
|
||||
setAudioFormats(finalAudios)
|
||||
|
||||
// Auto-select best format based on preferences
|
||||
if (type === 'video') {
|
||||
const videosWithAudio = finalVideos.filter(
|
||||
(format) => format.acodec && format.acodec !== 'none'
|
||||
)
|
||||
const autoVideos =
|
||||
finalAudios.length > 0
|
||||
? finalVideos
|
||||
: videosWithAudio.length > 0
|
||||
? videosWithAudio
|
||||
: finalVideos
|
||||
|
||||
const hasSelectedVideo = finalVideos.some((format) => format.format_id === selectedVideo)
|
||||
if (autoVideos.length > 0 && (!selectedVideo || !hasSelectedVideo)) {
|
||||
const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)
|
||||
if (preferred) {
|
||||
setSelectedVideo(preferred.format_id)
|
||||
onVideoFormatChange?.(preferred.format_id)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const hasSelectedAudio = finalAudios.some((format) => format.format_id === selectedAudio)
|
||||
if (finalAudios.length > 0 && (!selectedAudio || !hasSelectedAudio)) {
|
||||
const best = finalAudios[0]
|
||||
setSelectedAudio(best.format_id)
|
||||
onAudioFormatChange?.(best.format_id)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
formats,
|
||||
settings.oneClickQuality,
|
||||
type,
|
||||
selectedVideo,
|
||||
selectedAudio,
|
||||
onAudioFormatChange,
|
||||
onVideoFormatChange,
|
||||
pickVideoFormatForPreset,
|
||||
codec
|
||||
])
|
||||
|
||||
const formatSize = (bytes?: number) => {
|
||||
if (!bytes) return t('download.unknownSize')
|
||||
const mb = bytes / 1000000
|
||||
return `${mb.toFixed(2)} MB`
|
||||
}
|
||||
|
||||
const formatVideoQuality = (format: VideoFormat) => {
|
||||
if (format.height) {
|
||||
return `${format.height}p${format.fps === 60 ? '60' : ''}`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return t('download.unknownQuality')
|
||||
}
|
||||
|
||||
const formatAudioQuality = (format: VideoFormat) => {
|
||||
if (format.tbr) {
|
||||
return `${Math.round(format.tbr)} kbps`
|
||||
}
|
||||
if (format.format_note) {
|
||||
return format.format_note
|
||||
}
|
||||
if (typeof format.quality === 'number') {
|
||||
return format.quality.toString()
|
||||
}
|
||||
return t('download.unknownQuality')
|
||||
}
|
||||
|
||||
const formatVideoDetail = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Format extension
|
||||
parts.push(format.ext.toUpperCase())
|
||||
// Codec information
|
||||
if (format.vcodec) {
|
||||
parts.push(format.vcodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
if (format.acodec && format.acodec !== 'none') {
|
||||
parts.push(format.acodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
const formatAudioDetail = (format: VideoFormat) => {
|
||||
const parts: string[] = []
|
||||
// Format extension
|
||||
const ext = format.ext === 'webm' ? 'opus' : format.ext
|
||||
parts.push(ext.toUpperCase())
|
||||
if (format.acodec) {
|
||||
parts.push(format.acodec.split('.')[0].toUpperCase())
|
||||
}
|
||||
return parts.join(' • ')
|
||||
}
|
||||
|
||||
// Unified table rendering for both video and audio
|
||||
const renderFormatTable = () => {
|
||||
const formats = type === 'video' ? videoFormats : audioFormats
|
||||
const selected = type === 'video' ? selectedVideo : selectedAudio
|
||||
const onFormatChange =
|
||||
type === 'video'
|
||||
? (value: string) => {
|
||||
setSelectedVideo(value)
|
||||
onVideoFormatChange?.(value)
|
||||
}
|
||||
: (value: string) => {
|
||||
setSelectedAudio(value)
|
||||
onAudioFormatChange?.(value)
|
||||
}
|
||||
|
||||
if (formats.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroup value={selected} onValueChange={onFormatChange} className="w-full">
|
||||
<Table>
|
||||
<TableBody>
|
||||
{formats.map((format) => {
|
||||
const qualityLabel =
|
||||
type === 'video' ? formatVideoQuality(format) : formatAudioQuality(format)
|
||||
const detailLabel =
|
||||
type === 'video' ? formatVideoDetail(format) : formatAudioDetail(format)
|
||||
const thirdColumnLabel =
|
||||
type === 'video'
|
||||
? format.fps
|
||||
? `${format.fps}fps`
|
||||
: '-'
|
||||
: format.acodec
|
||||
? format.acodec.split('.')[0].toUpperCase()
|
||||
: '-'
|
||||
const sizeLabel = formatSize(format.filesize || format.filesize_approx)
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={format.format_id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onFormatChange(format.format_id)}
|
||||
>
|
||||
<TableCell className="w-[24px] pl-0">
|
||||
<RadioGroupItem
|
||||
className="bg-background mt-1 border-border"
|
||||
value={format.format_id}
|
||||
id={`${type}-${format.format_id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="w-[90px]">
|
||||
<div className="text-sm font-medium">{qualityLabel}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-xs text-muted-foreground truncate">{detailLabel}</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[70px]">
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{thirdColumnLabel}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-[90px] text-right">
|
||||
<div className="text-xs text-muted-foreground tabular-nums">{sizeLabel}</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
return renderFormatTable()
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
import { ImageWithPlaceholder } from '@renderer/components/ui/image-with-placeholder'
|
||||
import { Label } from '@renderer/components/ui/label'
|
||||
import { ScrollArea } from '@renderer/components/ui/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@renderer/components/ui/select'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { VideoInfo } from '../../../../shared/types'
|
||||
import { useCachedThumbnail } from '../../hooks/use-cached-thumbnail'
|
||||
import { FormatSelector } from './FormatSelector'
|
||||
|
||||
const VideoInfoSkeleton = () => (
|
||||
<div className="flex flex-col w-full flex-1 h-full min-h-0">
|
||||
{/* Header Info Skeleton */}
|
||||
<div className="flex gap-4 shrink-0 -mx-6 px-6 pb-4 shadow-sm animate-pulse">
|
||||
<div className="shrink-0 w-[96px] aspect-video rounded-md bg-muted" />
|
||||
<div className="flex-1 min-w-0 py-1 flex flex-col justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-muted" />
|
||||
<div className="h-4 w-1/2 rounded bg-muted/70" />
|
||||
</div>
|
||||
<div className="h-3 w-1/3 rounded bg-muted/70" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export interface VideoInfoCardState {
|
||||
title: string
|
||||
activeTab: 'video' | 'audio'
|
||||
selectedVideoFormat: string
|
||||
selectedAudioFormat: string
|
||||
customDownloadPath: string
|
||||
selectedContainer?: string
|
||||
selectedCodec?: string
|
||||
selectedFps?: string
|
||||
}
|
||||
|
||||
interface VideoInfoCardProps {
|
||||
videoInfo: VideoInfo | null
|
||||
loading?: boolean
|
||||
state: VideoInfoCardState
|
||||
onStateChange: (state: Partial<VideoInfoCardState>) => void
|
||||
onTabChange: (tab: 'video' | 'audio') => void
|
||||
}
|
||||
|
||||
function formatDuration(seconds?: number): string {
|
||||
if (!seconds) return '00:00'
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
||||
return `${m}:${s.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function getCodecShortName(codec?: string): string {
|
||||
if (!codec || codec === 'none') return 'Unknown'
|
||||
return codec.split('.')[0].toUpperCase()
|
||||
}
|
||||
|
||||
export function VideoInfoCard({
|
||||
videoInfo,
|
||||
loading = false,
|
||||
state,
|
||||
onStateChange,
|
||||
onTabChange
|
||||
}: VideoInfoCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const cachedThumbnail = useCachedThumbnail(videoInfo?.thumbnail)
|
||||
|
||||
const { title, activeTab, selectedContainer, selectedCodec, selectedFps } = state
|
||||
|
||||
// Get unique containers based on activeTab
|
||||
const containers = useMemo(() => {
|
||||
if (!videoInfo?.formats) return []
|
||||
const relevantFormats = videoInfo.formats.filter((f) => {
|
||||
if (activeTab === 'video') {
|
||||
// In video mode, we want formats with video codec
|
||||
return f.vcodec && f.vcodec !== 'none'
|
||||
} else {
|
||||
// In audio mode, we only want audio-only formats (no video)
|
||||
return (
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext || !f.vcodec || f.vcodec === 'none')
|
||||
)
|
||||
}
|
||||
})
|
||||
const exts = new Set(relevantFormats.map((f) => f.ext))
|
||||
return Array.from(exts).sort()
|
||||
}, [videoInfo?.formats, activeTab])
|
||||
|
||||
// Set default container if not set or if current container is not in the list
|
||||
useEffect(() => {
|
||||
if (containers.length === 0) return undefined
|
||||
|
||||
// Reset container if it's not in the current containers list (e.g., after tab switch)
|
||||
if (selectedContainer && !containers.includes(selectedContainer)) {
|
||||
let defaultContainer: string
|
||||
if (activeTab === 'video') {
|
||||
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
|
||||
} else {
|
||||
defaultContainer = containers.includes('m4a')
|
||||
? 'm4a'
|
||||
: containers.includes('mp3')
|
||||
? 'mp3'
|
||||
: containers[0]
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedContainer: defaultContainer, selectedCodec: 'auto' })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
// Set default container if not set
|
||||
if (!selectedContainer) {
|
||||
let defaultContainer: string
|
||||
if (activeTab === 'video') {
|
||||
defaultContainer = containers.includes('mp4') ? 'mp4' : containers[0]
|
||||
} else {
|
||||
defaultContainer = containers.includes('m4a')
|
||||
? 'm4a'
|
||||
: containers.includes('mp3')
|
||||
? 'mp3'
|
||||
: containers[0]
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedContainer: defaultContainer })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}, [containers, selectedContainer, activeTab, onStateChange])
|
||||
|
||||
// Step 1: Filter formats based on activeTab and selected container
|
||||
const formatsByContainer = useMemo(() => {
|
||||
if (!videoInfo?.formats) return []
|
||||
|
||||
// First filter by activeTab type
|
||||
let filteredByType = videoInfo.formats.filter((f) => {
|
||||
if (activeTab === 'video') {
|
||||
// For video, only formats with video codec
|
||||
return f.vcodec && f.vcodec !== 'none'
|
||||
} else {
|
||||
// For audio, only audio-only formats (no video)
|
||||
return (
|
||||
f.acodec &&
|
||||
f.acodec !== 'none' &&
|
||||
(f.video_ext === 'none' || !f.video_ext || !f.vcodec || f.vcodec === 'none')
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Then filter by selected container if one is selected
|
||||
if (selectedContainer) {
|
||||
filteredByType = filteredByType.filter((f) => f.ext === selectedContainer)
|
||||
}
|
||||
|
||||
return filteredByType
|
||||
}, [videoInfo?.formats, selectedContainer, activeTab])
|
||||
|
||||
// Get unique Codecs from formatsByContainer based on activeTab
|
||||
// This should only show codecs that exist in the filtered format list
|
||||
const codecs = useMemo(() => {
|
||||
if (formatsByContainer.length === 0) return []
|
||||
|
||||
const SetVals = new Set<string>()
|
||||
formatsByContainer.forEach((f) => {
|
||||
if (activeTab === 'video') {
|
||||
// For video, only get video codecs from formats that have video
|
||||
const c = f.vcodec
|
||||
if (c && c !== 'none') {
|
||||
SetVals.add(getCodecShortName(c))
|
||||
}
|
||||
} else {
|
||||
// For audio, only get audio codecs from formats that have audio
|
||||
const c = f.acodec
|
||||
if (c && c !== 'none') {
|
||||
SetVals.add(getCodecShortName(c))
|
||||
}
|
||||
}
|
||||
})
|
||||
return Array.from(SetVals).sort()
|
||||
}, [formatsByContainer, activeTab])
|
||||
|
||||
// Reset codec if it's not in the current codecs list (e.g., after tab or container switch)
|
||||
useEffect(() => {
|
||||
if (codecs.length === 0) return undefined
|
||||
if (selectedCodec && selectedCodec !== 'auto' && !codecs.includes(selectedCodec)) {
|
||||
const timer = setTimeout(() => {
|
||||
onStateChange({ selectedCodec: 'auto' })
|
||||
}, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
return undefined
|
||||
}, [codecs, selectedCodec, onStateChange])
|
||||
|
||||
// Step 2: Filter formats by selected Codec based on activeTab
|
||||
const formatsByCodec = useMemo(() => {
|
||||
if (!selectedCodec || selectedCodec === 'auto') return formatsByContainer
|
||||
return formatsByContainer.filter((f) => {
|
||||
if (activeTab === 'video') {
|
||||
// For video, filter by video codec
|
||||
const c = f.vcodec
|
||||
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
|
||||
} else {
|
||||
// For audio, filter by audio codec
|
||||
const c = f.acodec
|
||||
return c && c !== 'none' && getCodecShortName(c) === selectedCodec
|
||||
}
|
||||
})
|
||||
}, [formatsByContainer, selectedCodec, activeTab])
|
||||
|
||||
// Get unique Framerates from formatsByCodec (only for video)
|
||||
const framerates = useMemo(() => {
|
||||
if (activeTab !== 'video') return []
|
||||
const SetVals = new Set<number>()
|
||||
formatsByCodec.forEach((f) => {
|
||||
if (f.fps) SetVals.add(f.fps)
|
||||
})
|
||||
return Array.from(SetVals).sort((a, b) => b - a)
|
||||
}, [formatsByCodec, activeTab])
|
||||
|
||||
// Step 3: Filter formats by selected FPS
|
||||
const filteredFormats = useMemo(() => {
|
||||
let res = formatsByCodec
|
||||
if (activeTab === 'video' && selectedFps && selectedFps !== 'highest') {
|
||||
res = res.filter((f) => f.fps === Number(selectedFps))
|
||||
}
|
||||
return res
|
||||
}, [formatsByCodec, selectedFps, activeTab])
|
||||
|
||||
if (loading || !videoInfo) {
|
||||
return <VideoInfoSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full flex-1 h-full min-h-0">
|
||||
{/* Header Info */}
|
||||
<div className="flex gap-4 shrink-0 -mx-6 px-6 pb-4 shadow-sm">
|
||||
<div className="shrink-0 w-[96px] aspect-video rounded-md overflow-hidden bg-muted relative">
|
||||
<ImageWithPlaceholder
|
||||
src={cachedThumbnail}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute bottom-1 right-1 bg-black/70 text-white text-[10px] px-1 rounded">
|
||||
{formatDuration(videoInfo.duration)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 py-1 flex flex-col justify-between">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-medium text-sm leading-snug line-clamp-2">{title}</h3>
|
||||
<a
|
||||
href={videoInfo.webpage_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-muted-foreground hover:text-primary relative top-0.5"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{videoInfo.uploader}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls Area */}
|
||||
<ScrollArea className="bg-muted/30 overflow-y-auto max-h-68 -mx-6 flex-1 min-h-0">
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-medium">
|
||||
{t('download.download') || 'Download'}
|
||||
</Label>
|
||||
<Select
|
||||
value={activeTab}
|
||||
onValueChange={(v) => {
|
||||
onTabChange(v as 'video' | 'audio')
|
||||
onStateChange({ activeTab: v as 'video' | 'audio' })
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs text-muted-foreground font-medium">
|
||||
{t('download.container') || 'Container'}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedContainer || ''}
|
||||
onValueChange={(v) => {
|
||||
onStateChange({ selectedContainer: v })
|
||||
}}
|
||||
disabled={containers.length === 0}
|
||||
>
|
||||
<SelectTrigger className="bg-background">
|
||||
<SelectValue placeholder="Select container" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((ext) => (
|
||||
<SelectItem key={ext} value={ext}>
|
||||
{ext.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Filters */}
|
||||
<div className="flex flex-wrap items-center gap-4 pt-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Codec</span>
|
||||
<Select
|
||||
value={selectedCodec || 'auto'}
|
||||
onValueChange={(v) => onStateChange({ selectedCodec: v })}
|
||||
disabled={codecs.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto min-w-[70px] text-xs bg-transparent border-none shadow-none focus:ring-0 px-0 gap-1">
|
||||
<SelectValue placeholder="Auto" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
{codecs.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{c}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeTab === 'video' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Frame Rate</span>
|
||||
<Select
|
||||
value={selectedFps || 'highest'}
|
||||
onValueChange={(v) => onStateChange({ selectedFps: v })}
|
||||
disabled={framerates.length === 0}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-auto min-w-[80px] text-xs bg-transparent border-none shadow-none focus:ring-0 px-0 gap-1">
|
||||
<SelectValue placeholder="Highest" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="highest">Highest</SelectItem>
|
||||
{framerates.map((fps) => (
|
||||
<SelectItem key={fps} value={String(fps)}>
|
||||
{fps}fps
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="mt-4 min-h-[200px]">
|
||||
<FormatSelector
|
||||
formats={filteredFormats}
|
||||
type={activeTab}
|
||||
codec={selectedCodec}
|
||||
onVideoFormatChange={(format) => onStateChange({ selectedVideoFormat: format })}
|
||||
onAudioFormatChange={(format) => onStateChange({ selectedAudioFormat: format })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
144
src/renderer/src/hooks/use-download-events.ts
Normal file
144
src/renderer/src/hooks/use-download-events.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import { ipcEvents, ipcServices } from '../lib/ipc'
|
||||
import {
|
||||
addDownloadAtom,
|
||||
addHistoryRecordAtom,
|
||||
removeDownloadAtom,
|
||||
updateDownloadAtom
|
||||
} from '../store/downloads'
|
||||
|
||||
const isFinalStatus = (status?: string): boolean =>
|
||||
status === 'completed' || status === 'error' || status === 'cancelled'
|
||||
|
||||
export function useDownloadEvents() {
|
||||
const updateDownload = useSetAtom(updateDownloadAtom)
|
||||
const addDownload = useSetAtom(addDownloadAtom)
|
||||
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
|
||||
const removeDownload = useSetAtom(removeDownloadAtom)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const syncHistoryItem = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
const historyItem = await ipcServices.history.getHistoryById(id)
|
||||
if (!historyItem) {
|
||||
return
|
||||
}
|
||||
addHistoryRecord(historyItem)
|
||||
if (isFinalStatus(historyItem.status)) {
|
||||
removeDownload(id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to sync history item:', error)
|
||||
}
|
||||
},
|
||||
[addHistoryRecord, removeDownload]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const syncActiveDownloads = async () => {
|
||||
try {
|
||||
const activeDownloads = await ipcServices.download.getActiveDownloads()
|
||||
activeDownloads.forEach((item) => {
|
||||
addDownload(item)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load active downloads:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void syncActiveDownloads()
|
||||
}, [addDownload])
|
||||
|
||||
useEffect(() => {
|
||||
const handleStarted = (rawId: unknown) => {
|
||||
const id = typeof rawId === 'string' ? rawId : ''
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
updateDownload({
|
||||
id,
|
||||
changes: {
|
||||
status: 'downloading',
|
||||
startedAt: Date.now()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleProgress = (rawData: unknown) => {
|
||||
const data = rawData as { id?: string; progress?: unknown }
|
||||
const id = typeof data?.id === 'string' ? data.id : ''
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const progress = (data.progress ?? {}) as {
|
||||
percent?: number
|
||||
currentSpeed?: string
|
||||
eta?: string
|
||||
downloaded?: string
|
||||
total?: string
|
||||
}
|
||||
updateDownload({
|
||||
id,
|
||||
changes: {
|
||||
progress: {
|
||||
percent: typeof progress.percent === 'number' ? progress.percent : 0,
|
||||
currentSpeed: progress.currentSpeed || '',
|
||||
eta: progress.eta || '',
|
||||
downloaded: progress.downloaded || '',
|
||||
total: progress.total || ''
|
||||
},
|
||||
speed: progress.currentSpeed || ''
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleCompleted = (rawId: unknown) => {
|
||||
const id = typeof rawId === 'string' ? rawId : ''
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
updateDownload({ id, changes: { status: 'completed', completedAt: Date.now() } })
|
||||
toast.success(t('notifications.downloadCompleted'))
|
||||
void syncHistoryItem(id)
|
||||
}
|
||||
|
||||
const handleError = (rawData: unknown) => {
|
||||
const data = rawData as { id?: string; error?: string }
|
||||
const id = typeof data?.id === 'string' ? data.id : ''
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
const errorMessage = typeof data?.error === 'string' ? data.error : ''
|
||||
updateDownload({ id, changes: { status: 'error', error: errorMessage } })
|
||||
toast.error(t('notifications.downloadFailed'))
|
||||
void syncHistoryItem(id)
|
||||
}
|
||||
|
||||
const handleCancelled = (rawId: unknown) => {
|
||||
const id = typeof rawId === 'string' ? rawId : ''
|
||||
if (!id) {
|
||||
return
|
||||
}
|
||||
updateDownload({ id, changes: { status: 'cancelled', completedAt: Date.now() } })
|
||||
void syncHistoryItem(id)
|
||||
}
|
||||
|
||||
const startedSubscription = ipcEvents.on('download:started', handleStarted)
|
||||
const progressSubscription = ipcEvents.on('download:progress', handleProgress)
|
||||
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:started', startedSubscription)
|
||||
ipcEvents.removeListener('download:progress', progressSubscription)
|
||||
ipcEvents.removeListener('download:completed', completedSubscription)
|
||||
ipcEvents.removeListener('download:error', errorSubscription)
|
||||
ipcEvents.removeListener('download:cancelled', cancelledSubscription)
|
||||
}
|
||||
}, [syncHistoryItem, t, updateDownload])
|
||||
}
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "الاشتراك"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "رابط GitHub هذا طويل جدًا. إذا لم يفتح، فالرجاء فتح صفحة المشكلة ولصق السجلات يدويًا."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "تفضيلات الصوت",
|
||||
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
|
||||
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
|
||||
"browserForCookiesWindowsNote": "في Windows، يتم دعم ملفات تعريف الارتباط من Firefox فقط. للمتصفحات الأخرى، يرجى إعداد ملف ملفات تعريف الارتباط يدويًا.",
|
||||
"browserForCookiesProfile": "اسم الملف الشخصي أو المسار",
|
||||
"browserForCookiesProfileDescription": "مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.",
|
||||
"browserForCookiesProfilePlaceholder": "اسم الملف الشخصي أو المسار الكامل (اختياري)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
|
||||
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
|
||||
"openLinkError": "فشل في فتح رابط الفيديو.",
|
||||
"resolveError": "فشل في حل رابط تغذية RSS."
|
||||
"resolveError": "فشل في حل رابط تغذية RSS.",
|
||||
"duplicateUrl": "تم الاشتراك في موجز RSS هذا بالفعل."
|
||||
},
|
||||
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
|
||||
"detecting": "جاري اكتشاف التغذية...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "Abonnement"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "Dieser GitHub-Link ist sehr lang. Wenn er sich nicht öffnet, öffnen Sie bitte die Issue-Seite und fügen Sie die Logs manuell ein."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Audio-Einstellungen",
|
||||
"browserForCookies": "Browser für Cookies auswählen",
|
||||
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung",
|
||||
"browserForCookiesWindowsNote": "Unter Windows werden nur Firefox-Cookies unterstützt. Für andere Browser richten Sie bitte manuell eine Cookie-Datei ein.",
|
||||
"browserForCookiesProfile": "Profilname oder Pfad",
|
||||
"browserForCookiesProfileDescription": "Profilpfad für den oben ausgewählten Browser. Wird wenn möglich automatisch ausgefüllt.",
|
||||
"browserForCookiesProfilePlaceholder": "Profilname oder vollständiger Pfad (optional)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange",
|
||||
"queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.",
|
||||
"openLinkError": "Video-Link konnte nicht geöffnet werden.",
|
||||
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden."
|
||||
"resolveError": "RSS-Feed-URL konnte nicht aufgelöst werden.",
|
||||
"duplicateUrl": "Dieser RSS-Feed ist bereits abonniert."
|
||||
},
|
||||
"detectedFeed": "{{platform}} Feed erkannt -> {{feed}}",
|
||||
"detecting": "Feed wird erkannt...",
|
||||
|
||||
@@ -131,7 +131,8 @@
|
||||
"fetch": "Fetch",
|
||||
"fetchingVideoInfo": "Fetching video info...",
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "This GitHub link is very long. If it fails to open, please open the issue page and paste the logs manually."
|
||||
},
|
||||
"history": "History",
|
||||
"imageLoadError": "Image failed to load",
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Audio Preferences",
|
||||
"browserForCookies": "Select browser to use cookies from",
|
||||
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.",
|
||||
"browserForCookiesWindowsNote": "Windows only supports Firefox cookies. For other browsers, please configure a cookies file manually.",
|
||||
"browserForCookiesProfile": "Profile name or path",
|
||||
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
|
||||
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
|
||||
@@ -538,6 +540,7 @@
|
||||
"missingUrl": "Please paste a channel link first.",
|
||||
"created": "Subscription added",
|
||||
"createError": "Failed to add subscription.",
|
||||
"duplicateUrl": "This RSS feed is already subscribed.",
|
||||
"refreshStarted": "Refresh started",
|
||||
"removed": "Subscription removed",
|
||||
"updated": "Subscription updated",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "Suscripción"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "Este enlace de GitHub es muy largo. Si no se abre, abre la página de la incidencia y pega los registros manualmente."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Preferencias de Audio",
|
||||
"browserForCookies": "Seleccionar navegador para usar cookies",
|
||||
"browserForCookiesDescription": "Navegador del que extraer cookies para autenticación",
|
||||
"browserForCookiesWindowsNote": "Windows solo admite cookies de Firefox. Para otros navegadores, configura manualmente un archivo de cookies.",
|
||||
"browserForCookiesProfile": "Nombre de perfil o ruta",
|
||||
"browserForCookiesProfileDescription": "Ruta del perfil para el navegador seleccionado arriba. Se completa automáticamente cuando es posible.",
|
||||
"browserForCookiesProfilePlaceholder": "Nombre de perfil o ruta completa (opcional)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "Este video ya está en cola",
|
||||
"queueError": "Error al agregar a la cola de descarga.",
|
||||
"openLinkError": "Error al abrir el enlace del video.",
|
||||
"resolveError": "Error al resolver la URL del feed RSS."
|
||||
"resolveError": "Error al resolver la URL del feed RSS.",
|
||||
"duplicateUrl": "Este feed RSS ya está suscrito."
|
||||
},
|
||||
"detectedFeed": "Feed {{platform}} detectado -> {{feed}}",
|
||||
"detecting": "Detectando feed...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "Abonnement"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "Ce lien GitHub est très long. S'il ne s'ouvre pas, ouvrez la page de l'issue et collez les logs manuellement."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Préférences Audio",
|
||||
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
|
||||
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification",
|
||||
"browserForCookiesWindowsNote": "Sous Windows, seuls les cookies de Firefox sont pris en charge. Pour les autres navigateurs, configurez un fichier de cookies manuellement.",
|
||||
"browserForCookiesProfile": "Nom du profil ou chemin",
|
||||
"browserForCookiesProfileDescription": "Chemin du profil pour le navigateur sélectionné ci-dessus. Rempli automatiquement si possible.",
|
||||
"browserForCookiesProfilePlaceholder": "Nom du profil ou chemin complet (facultatif)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
|
||||
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
|
||||
"openLinkError": "Échec de l'ouverture du lien vidéo.",
|
||||
"resolveError": "Échec de la résolution de l'URL du flux RSS."
|
||||
"resolveError": "Échec de la résolution de l'URL du flux RSS.",
|
||||
"duplicateUrl": "Ce flux RSS est déjà abonné."
|
||||
},
|
||||
"detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
|
||||
"detecting": "Détection du flux...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "Berlangganan"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "Tautan GitHub ini sangat panjang. Jika tidak terbuka, buka halaman issue dan tempel log secara manual."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Preferensi Audio",
|
||||
"browserForCookies": "Pilih browser untuk menggunakan cookie",
|
||||
"browserForCookiesDescription": "Browser untuk mengekstrak cookie untuk autentikasi",
|
||||
"browserForCookiesWindowsNote": "Di Windows, hanya cookie Firefox yang didukung. Untuk browser lain, silakan konfigurasi file cookie secara manual.",
|
||||
"browserForCookiesProfile": "Nama profil atau path",
|
||||
"browserForCookiesProfileDescription": "Path profil untuk browser yang dipilih di atas. Diisi otomatis bila memungkinkan.",
|
||||
"browserForCookiesProfilePlaceholder": "Nama profil atau path lengkap (opsional)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "Video ini sudah diantre",
|
||||
"queueError": "Gagal menambahkan ke antrian unduhan.",
|
||||
"openLinkError": "Gagal membuka tautan video.",
|
||||
"resolveError": "Gagal menyelesaikan URL feed RSS."
|
||||
"resolveError": "Gagal menyelesaikan URL feed RSS.",
|
||||
"duplicateUrl": "Feed RSS ini sudah berlangganan."
|
||||
},
|
||||
"detectedFeed": "Feed {{platform}} terdeteksi -> {{feed}}",
|
||||
"detecting": "Mendeteksi feed...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "Sottoscrizione"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "Questo link GitHub è molto lungo. Se non si apre, apri la pagina dell'issue e incolla i log manualmente."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Preferenze Audio",
|
||||
"browserForCookies": "Seleziona browser per usare i cookie",
|
||||
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione",
|
||||
"browserForCookiesWindowsNote": "Su Windows sono supportati solo i cookie di Firefox. Per altri browser, configura manualmente un file di cookie.",
|
||||
"browserForCookiesProfile": "Nome profilo o percorso",
|
||||
"browserForCookiesProfileDescription": "Percorso del profilo per il browser selezionato sopra. Compilato automaticamente quando possibile.",
|
||||
"browserForCookiesProfilePlaceholder": "Nome profilo o percorso completo (opzionale)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "Questo video è già in coda",
|
||||
"queueError": "Impossibile aggiungere alla coda di download.",
|
||||
"openLinkError": "Impossibile aprire il collegamento video.",
|
||||
"resolveError": "Impossibile risolvere l'URL del feed RSS."
|
||||
"resolveError": "Impossibile risolvere l'URL del feed RSS.",
|
||||
"duplicateUrl": "Questo feed RSS è già sottoscritto."
|
||||
},
|
||||
"detectedFeed": "Feed {{platform}} rilevato -> {{feed}}",
|
||||
"detecting": "Rilevamento alimentazione...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "サブスクリプション"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "このGitHubリンクは非常に長いです。開けない場合は、issueページを開いてログを手動で貼り付けてください。"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "オーディオ設定",
|
||||
"browserForCookies": "Cookieに使用するブラウザを選択",
|
||||
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
|
||||
"browserForCookiesWindowsNote": "Windows では Firefox の Cookie のみ対応しています。他のブラウザは Cookie ファイルを手動で設定してください。",
|
||||
"browserForCookiesProfile": "プロファイル名またはパス",
|
||||
"browserForCookiesProfileDescription": "上で選択したブラウザのプロファイルパス。可能な場合は自動入力されます。",
|
||||
"browserForCookiesProfilePlaceholder": "プロファイル名または完全なパス(任意)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "このビデオはすでにキューに登録されています",
|
||||
"queueError": "ダウンロードキューへの追加に失敗しました。",
|
||||
"openLinkError": "ビデオリンクを開けませんでした。",
|
||||
"resolveError": "RSS フィード URL を解決できませんでした。"
|
||||
"resolveError": "RSS フィード URL を解決できませんでした。",
|
||||
"duplicateUrl": "このRSSフィードはすでに登録されています。"
|
||||
},
|
||||
"detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}",
|
||||
"detecting": "フィードを検出中...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "신청"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "이 GitHub 링크가 매우 깁니다. 열리지 않으면 이슈 페이지를 열고 로그를 수동으로 붙여 넣어 주세요."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "오디오 환경설정",
|
||||
"browserForCookies": "쿠키를 사용할 브라우저 선택",
|
||||
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
|
||||
"browserForCookiesWindowsNote": "Windows에서는 Firefox 쿠키만 지원됩니다. 다른 브라우저는 쿠키 파일을 수동으로 설정하세요.",
|
||||
"browserForCookiesProfile": "프로필 이름 또는 경로",
|
||||
"browserForCookiesProfileDescription": "위에서 선택한 브라우저의 프로필 경로입니다. 가능하면 자동으로 채워집니다.",
|
||||
"browserForCookiesProfilePlaceholder": "프로필 이름 또는 전체 경로(선택 사항)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.",
|
||||
"queueError": "다운로드 대기열에 추가하지 못했습니다.",
|
||||
"openLinkError": "동영상 링크를 열지 못했습니다.",
|
||||
"resolveError": "RSS 피드 URL을 확인하지 못했습니다."
|
||||
"resolveError": "RSS 피드 URL을 확인하지 못했습니다.",
|
||||
"duplicateUrl": "이 RSS 피드는 이미 구독되어 있습니다."
|
||||
},
|
||||
"detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}",
|
||||
"detecting": "피드 감지 중...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "Subscrição"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "Este link do GitHub é muito longo. Se não abrir, abra a página da issue e cole os logs manualmente."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Preferências de Áudio",
|
||||
"browserForCookies": "Selecionar navegador para usar cookies",
|
||||
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação",
|
||||
"browserForCookiesWindowsNote": "No Windows, apenas cookies do Firefox são suportados. Para outros navegadores, configure um arquivo de cookies manualmente.",
|
||||
"browserForCookiesProfile": "Nome do perfil ou caminho",
|
||||
"browserForCookiesProfileDescription": "Caminho do perfil para o navegador selecionado acima. Preenchido automaticamente quando possível.",
|
||||
"browserForCookiesProfilePlaceholder": "Nome do perfil ou caminho completo (opcional)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "Este vídeo já está na fila",
|
||||
"queueError": "Falha ao adicionar à fila de download.",
|
||||
"openLinkError": "Falha ao abrir o link do vídeo.",
|
||||
"resolveError": "Falha ao resolver o URL do feed RSS."
|
||||
"resolveError": "Falha ao resolver o URL do feed RSS.",
|
||||
"duplicateUrl": "Este feed RSS já está inscrito."
|
||||
},
|
||||
"detectedFeed": "Feed de {{platform}} detectado -> {{feed}}",
|
||||
"detecting": "Detectando feed...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "Подписка"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "Эта ссылка GitHub очень длинная. Если она не открывается, откройте страницу issue и вставьте логи вручную."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "Настройки аудио",
|
||||
"browserForCookies": "Выбрать браузер для использования cookie",
|
||||
"browserForCookiesDescription": "Браузер для извлечения cookie для аутентификации",
|
||||
"browserForCookiesWindowsNote": "В Windows поддерживаются только cookie Firefox. Для других браузеров вручную укажите файл cookie.",
|
||||
"browserForCookiesProfile": "Имя профиля или путь",
|
||||
"browserForCookiesProfileDescription": "Путь профиля для выбранного выше браузера. Заполняется автоматически, если возможно.",
|
||||
"browserForCookiesProfilePlaceholder": "Имя профиля или полный путь (необязательно)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "Это видео уже в очереди",
|
||||
"queueError": "Не удалось добавить в очередь загрузки.",
|
||||
"openLinkError": "Не удалось открыть ссылку на видео.",
|
||||
"resolveError": "Не удалось разрешить URL RSS-канала."
|
||||
"resolveError": "Не удалось разрешить URL RSS-канала.",
|
||||
"duplicateUrl": "Этот RSS-канал уже добавлен."
|
||||
},
|
||||
"detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}",
|
||||
"detecting": "Определение канала...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "訂閱"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "這個 GitHub 連結很長。如果無法開啟,請開啟 issue 頁面並手動貼上日誌。"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "音訊偏好",
|
||||
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
|
||||
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
|
||||
"browserForCookiesWindowsNote": "Windows 僅支援 Firefox 的 cookie,其他瀏覽器請手動設定 cookie 檔案。",
|
||||
"browserForCookiesProfile": "設定檔名稱或路徑",
|
||||
"browserForCookiesProfileDescription": "上方選取之瀏覽器的設定檔路徑。如可用將自動填入。",
|
||||
"browserForCookiesProfilePlaceholder": "設定檔名稱或完整路徑(選填)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "該視頻已排隊",
|
||||
"queueError": "無法添加到下載隊列。",
|
||||
"openLinkError": "無法打開視頻鏈接。",
|
||||
"resolveError": "無法解析 RSS 源 URL。"
|
||||
"resolveError": "無法解析 RSS 源 URL。",
|
||||
"duplicateUrl": "此 RSS 訂閱已存在。"
|
||||
},
|
||||
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
|
||||
"detecting": "檢測飼料...",
|
||||
|
||||
@@ -197,7 +197,8 @@
|
||||
"subscription": "订阅"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Report this error:"
|
||||
"title": "Report this error:",
|
||||
"githubUrlTooLong": "这个 GitHub 链接很长。如果无法打开,请打开 issue 页面并手动粘贴日志。"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -370,6 +371,7 @@
|
||||
"audio": "音频偏好",
|
||||
"browserForCookies": "选择用于读取 Cookie 的浏览器",
|
||||
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
|
||||
"browserForCookiesWindowsNote": "Windows 仅支持 Firefox 的 cookie,其他浏览器请手动配置 cookie 文件。",
|
||||
"browserForCookiesProfile": "配置文件名称或路径",
|
||||
"browserForCookiesProfileDescription": "上方所选浏览器的配置文件路径。如可用会自动填写。",
|
||||
"browserForCookiesProfilePlaceholder": "配置文件名称或完整路径(可选)",
|
||||
@@ -545,7 +547,8 @@
|
||||
"itemAlreadyQueued": "该视频已排队",
|
||||
"queueError": "无法添加到下载队列。",
|
||||
"openLinkError": "无法打开视频链接。",
|
||||
"resolveError": "无法解析 RSS 源 URL。"
|
||||
"resolveError": "无法解析 RSS 源 URL。",
|
||||
"duplicateUrl": "该 RSS 订阅已存在。"
|
||||
},
|
||||
"detectedFeed": "检测到 {{platform}} feed -> {{feed}}",
|
||||
"detecting": "检测饲料...",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FeedbackLinkButtons, useAppInfo } from '@renderer/components/feedback/FeedbackLinks'
|
||||
import { Badge } from '@renderer/components/ui/badge'
|
||||
import { Button } from '@renderer/components/ui/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
FileText,
|
||||
Github,
|
||||
Link as LinkIcon,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Twitter
|
||||
@@ -44,33 +44,13 @@ export function About() {
|
||||
const [updateReady] = useAtom(updateReadyAtom)
|
||||
const [updateAvailableState] = useAtom(updateAvailableAtom)
|
||||
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
|
||||
const [appVersion, setAppVersion] = useState<string>('—')
|
||||
const { appVersion, osVersion } = useAppInfo()
|
||||
const appVersionLabel = appVersion || '—'
|
||||
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
|
||||
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
|
||||
const saveSetting = useSetAtom(saveSettingAtom)
|
||||
const shareTargetUrl = 'https://vidbee.org'
|
||||
|
||||
useEffect(() => {
|
||||
let isActive = true
|
||||
|
||||
const fetchAppVersion = async () => {
|
||||
try {
|
||||
const version = await ipcServices.app.getVersion()
|
||||
if (isActive) {
|
||||
setAppVersion(version)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get app version:', error)
|
||||
}
|
||||
}
|
||||
|
||||
void fetchAppVersion()
|
||||
|
||||
return () => {
|
||||
isActive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!updateAvailableState.available) {
|
||||
return
|
||||
@@ -163,7 +143,7 @@ export function About() {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
version: result.version ?? appVersionLabel
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: false,
|
||||
@@ -210,7 +190,7 @@ export function About() {
|
||||
toast.success(t('about.notifications.noUpdatesAvailable'))
|
||||
setLatestVersionState({
|
||||
status: 'uptodate',
|
||||
version: result.version ?? appVersion
|
||||
version: result.version ?? appVersionLabel
|
||||
})
|
||||
setUpdateAvailable({
|
||||
available: false,
|
||||
@@ -279,39 +259,6 @@ export function About() {
|
||||
const shouldShowCheckUpdates =
|
||||
!updateAvailableState.available && latestVersionState?.status !== 'available'
|
||||
|
||||
const handleXFeedback = useCallback(() => {
|
||||
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
|
||||
const tweetText = encodeURIComponent(`@nexmoex${versionText}`)
|
||||
openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`)
|
||||
}, [appVersion, openShareUrl])
|
||||
|
||||
const feedbackResources = useMemo<AboutResource[]>(
|
||||
() => [
|
||||
{
|
||||
icon: Github,
|
||||
label: t('about.resources.githubIssues'),
|
||||
description: t('about.resources.githubIssuesDescription'),
|
||||
actionLabel: t('about.actions.feedback'),
|
||||
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||
},
|
||||
{
|
||||
icon: Twitter,
|
||||
label: t('about.resources.xFeedback'),
|
||||
description: t('about.resources.xFeedbackDescription'),
|
||||
actionLabel: t('about.actions.feedback'),
|
||||
onClick: handleXFeedback
|
||||
},
|
||||
{
|
||||
icon: MessageCircle,
|
||||
label: t('about.resources.discord'),
|
||||
description: t('about.resources.discordDescription'),
|
||||
actionLabel: t('about.actions.visit'),
|
||||
href: 'https://discord.gg/uBqXV6QPdm'
|
||||
}
|
||||
],
|
||||
[t, handleXFeedback]
|
||||
)
|
||||
|
||||
const aboutResources = useMemo<AboutResource[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -345,7 +292,7 @@ export function About() {
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
|
||||
<Badge variant="secondary">
|
||||
{t('about.versionLabel', { version: appVersion })}
|
||||
{t('about.versionLabel', { version: appVersionLabel })}
|
||||
</Badge>
|
||||
{latestVersionState ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -496,28 +443,12 @@ export function About() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{feedbackResources.map((resource) => {
|
||||
const Icon = resource.icon
|
||||
return resource.href ? (
|
||||
<Button key={resource.label} variant="outline" size="sm" asChild>
|
||||
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{resource.label}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
key={resource.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={resource.onClick}
|
||||
className="gap-2"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{resource.label}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
<FeedbackLinkButtons
|
||||
appInfo={{ appVersion, osVersion }}
|
||||
useSimpleGithubUrl={true}
|
||||
buttonClassName="gap-2"
|
||||
iconClassName="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Other resources */}
|
||||
|
||||
@@ -520,30 +520,26 @@ export function Settings() {
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
{platform !== 'darwin' && (
|
||||
<>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedThumbnail ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedThumbnail', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedThumbnail ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedThumbnail', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
</>
|
||||
)}
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
@@ -676,6 +672,9 @@ export function Settings() {
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
|
||||
{platform === 'win32' && (
|
||||
<ItemDescription>{t('settings.browserForCookiesWindowsNote')}</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{(() => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
updateSubscriptionAtom
|
||||
} from '@renderer/store/subscriptions'
|
||||
import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'
|
||||
import { SUBSCRIPTION_DUPLICATE_FEED_ERROR } from '@shared/types'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react'
|
||||
@@ -87,6 +88,41 @@ const subscriptionItemStatusLabels: Record<SubscriptionItemStatus, string> = {
|
||||
cancelled: 'subscriptions.items.status.cancelled'
|
||||
}
|
||||
|
||||
const getErrorMessage = (error: unknown): string | undefined => {
|
||||
if (!error) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
if (typeof error === 'object') {
|
||||
if ('message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
if ('code' in error && typeof error.code === 'string') {
|
||||
return error.code
|
||||
}
|
||||
if ('error' in error) {
|
||||
const nested = (error as { error?: unknown }).error
|
||||
if (typeof nested === 'string') {
|
||||
return nested
|
||||
}
|
||||
if (nested && typeof nested === 'object' && 'message' in nested) {
|
||||
const nestedMessage = (nested as { message?: unknown }).message
|
||||
if (typeof nestedMessage === 'string') {
|
||||
return nestedMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isDuplicateFeedError = (error: unknown) => {
|
||||
const message = getErrorMessage(error)
|
||||
return Boolean(message?.includes(SUBSCRIPTION_DUPLICATE_FEED_ERROR))
|
||||
}
|
||||
|
||||
function SubscriptionTab({
|
||||
subscription,
|
||||
onRefresh,
|
||||
@@ -252,8 +288,17 @@ export function Subscriptions() {
|
||||
}
|
||||
}
|
||||
|
||||
await updateSubscription({ id, data: updatePayload })
|
||||
await refreshSubscription(id)
|
||||
try {
|
||||
await updateSubscription({ id, data: updatePayload })
|
||||
await refreshSubscription(id)
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription:', error)
|
||||
toast.error(
|
||||
isDuplicateFeedError(error)
|
||||
? t('subscriptions.notifications.duplicateUrl')
|
||||
: t('subscriptions.notifications.createError')
|
||||
)
|
||||
}
|
||||
},
|
||||
[refreshSubscription, updateSubscription, resolveFeed, t]
|
||||
)
|
||||
@@ -281,7 +326,11 @@ export function Subscriptions() {
|
||||
setAddDialogOpen(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to create subscription:', error)
|
||||
toast.error(t('subscriptions.notifications.createError'))
|
||||
toast.error(
|
||||
isDuplicateFeedError(error)
|
||||
? t('subscriptions.notifications.duplicateUrl')
|
||||
: t('subscriptions.notifications.createError')
|
||||
)
|
||||
}
|
||||
},
|
||||
[createSubscription, t]
|
||||
|
||||
@@ -8,6 +8,9 @@ export type DownloadRecord = DownloadItem & {
|
||||
savedFileName?: string
|
||||
}
|
||||
|
||||
const isFinalStatus = (status: DownloadHistoryItem['status']): boolean =>
|
||||
status === 'completed' || status === 'error' || status === 'cancelled'
|
||||
|
||||
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
|
||||
|
||||
const toActiveRecord = (item: DownloadItem): DownloadRecord => ({
|
||||
@@ -24,6 +27,7 @@ const toHistoryRecord = (item: DownloadHistoryItem): DownloadRecord => ({
|
||||
status: item.status,
|
||||
progress: undefined,
|
||||
error: item.error,
|
||||
ytDlpCommand: item.ytDlpCommand,
|
||||
downloadPath: item.downloadPath,
|
||||
speed: undefined,
|
||||
duration: item.duration,
|
||||
@@ -50,6 +54,7 @@ export const downloadRecordsAtom = atom<Map<string, DownloadRecord>>(new Map())
|
||||
|
||||
export const addDownloadAtom = atom(null, (get, set, item: DownloadItem) => {
|
||||
const downloads = new Map(get(downloadRecordsAtom))
|
||||
downloads.delete(recordKey('history', item.id))
|
||||
downloads.set(recordKey('active', item.id), toActiveRecord(item))
|
||||
set(downloadRecordsAtom, downloads)
|
||||
})
|
||||
@@ -86,6 +91,14 @@ export const clearCompletedAtom = atom(null, (get, set) => {
|
||||
|
||||
export const addHistoryRecordAtom = atom(null, (get, set, item: DownloadHistoryItem) => {
|
||||
const downloads = new Map(get(downloadRecordsAtom))
|
||||
const activeKey = recordKey('active', item.id)
|
||||
if (downloads.has(activeKey)) {
|
||||
if (!isFinalStatus(item.status)) {
|
||||
set(downloadRecordsAtom, downloads)
|
||||
return
|
||||
}
|
||||
downloads.delete(activeKey)
|
||||
}
|
||||
downloads.set(recordKey('history', item.id), toHistoryRecord(item))
|
||||
set(downloadRecordsAtom, downloads)
|
||||
})
|
||||
|
||||
@@ -11,15 +11,24 @@ export const videoInfoLoadingAtom = atom<boolean>(false)
|
||||
// Error state for video info
|
||||
export const videoInfoErrorAtom = atom<string | null>(null)
|
||||
|
||||
// Last yt-dlp command used for video info
|
||||
export const videoInfoCommandAtom = atom<string | null>(null)
|
||||
|
||||
// Fetch video info
|
||||
export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
|
||||
set(videoInfoLoadingAtom, true)
|
||||
set(videoInfoErrorAtom, null)
|
||||
set(videoInfoCommandAtom, null)
|
||||
set(currentVideoInfoAtom, null)
|
||||
|
||||
try {
|
||||
const info = await ipcServices.download.getVideoInfo(url)
|
||||
set(currentVideoInfoAtom, info)
|
||||
const result = await ipcServices.download.getVideoInfoWithCommand(url)
|
||||
set(videoInfoCommandAtom, result.ytDlpCommand)
|
||||
if (result.info) {
|
||||
set(currentVideoInfoAtom, result.info)
|
||||
return
|
||||
}
|
||||
set(videoInfoErrorAtom, result.error || 'Failed to fetch video info')
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch video info'
|
||||
set(videoInfoErrorAtom, errorMessage)
|
||||
@@ -32,4 +41,5 @@ export const fetchVideoInfoAtom = atom(null, async (_get, set, url: string) => {
|
||||
export const clearVideoInfoAtom = atom(null, (_get, set) => {
|
||||
set(currentVideoInfoAtom, null)
|
||||
set(videoInfoErrorAtom, null)
|
||||
set(videoInfoCommandAtom, null)
|
||||
})
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface VideoInfo {
|
||||
uploader?: string
|
||||
}
|
||||
|
||||
export interface VideoInfoCommandResult {
|
||||
info?: VideoInfo
|
||||
ytDlpCommand: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
percent: number
|
||||
currentSpeed?: string
|
||||
@@ -60,6 +66,7 @@ export interface DownloadItem {
|
||||
progress?: DownloadProgress
|
||||
error?: string
|
||||
speed?: string
|
||||
ytDlpCommand?: string
|
||||
// Enhanced video information
|
||||
duration?: number
|
||||
fileSize?: number
|
||||
@@ -109,6 +116,7 @@ export interface DownloadHistoryItem {
|
||||
downloadedAt: number
|
||||
completedAt?: number
|
||||
error?: string
|
||||
ytDlpCommand?: string
|
||||
// Additional metadata
|
||||
description?: string
|
||||
channel?: string
|
||||
@@ -192,6 +200,8 @@ export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom'
|
||||
|
||||
export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed'
|
||||
|
||||
export const SUBSCRIPTION_DUPLICATE_FEED_ERROR = 'SUBSCRIPTION_DUPLICATE_FEED_URL'
|
||||
|
||||
export interface SubscriptionRule {
|
||||
id: string
|
||||
title: string
|
||||
@@ -291,7 +301,7 @@ export const defaultSettings: AppSettings = {
|
||||
oneClickDownload: false,
|
||||
oneClickDownloadType: 'video',
|
||||
oneClickQuality: 'best',
|
||||
closeToTray: false,
|
||||
closeToTray: true,
|
||||
hideDockIcon: false,
|
||||
launchAtLogin: false,
|
||||
autoUpdate: true,
|
||||
|
||||
Reference in New Issue
Block a user