Compare commits

..

10 Commits

Author SHA1 Message Date
Nexmoe
6f1c6ce59e chore: release v1.2.0 2026-01-17 13:58:17 +08:00
Nexmoe
e5d36da904 fix(ffmpeg): require ffmpeg/ffprobe bundle directory, closes #94 #106 #105 #69 #133 (#139)
* fix(ffmpeg): require bundled ffprobe directory

* ci(build): upload artifacts on PR

* fix(download): allow embed thumbnail on macos
2026-01-17 13:35:29 +08:00
Nexmoe
9c3fd2c26f fix(settings): show windows-only cookies note (#141) 2026-01-17 13:23:45 +08:00
Nexmoe
894eb9774b fix(download): persist resume sessions (#137)
* fix(download): persist resume sessions

* fix(ipc): cleanup download listeners
2026-01-17 13:00:36 +08:00
Nexmoe
73af211bef feat(feedback): warn on long github issue urls (#140) 2026-01-17 12:52:30 +08:00
Nexmoe
4b561cef38 feat(download): add select all and clear history (#136)
* feat(download): add select all and clear history

* fix(download): remove clear all history action

* feat(download): clear selection on escape

* feat(download): select full playlist groups

* style(download): adjust playlist group spacing
2026-01-17 12:47:54 +08:00
Nexmoe
761adf2476 feat(tray): minimize to tray by default (#138) 2026-01-17 12:17:34 +08:00
Nexmoe
182a1b9c1e fix(subscriptions): detect duplicate RSS feeds (#134) 2026-01-17 11:50:08 +08:00
Nexmoe
59aee07913 fix(download): constrain playlist list height (#130)
* fix(download): constrain playlist list height

* fix(ci): handle ffmpeg timeout on windows
2026-01-16 21:58:33 +08:00
Nexmoe
3d39c9751f fix(feedback): simplify github issue link (#131) 2026-01-16 21:27:29 +08:00
44 changed files with 1054 additions and 311 deletions

View File

@@ -33,16 +33,16 @@ jobs:
ytdlp_output: yt-dlp.exe ytdlp_output: yt-dlp.exe
ffmpeg_url: https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip 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_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 - platform: macos
os: macos-latest os: macos-latest
build_script: pnpm run build:mac build_script: pnpm run build:mac
ytdlp_asset: yt-dlp_macos ytdlp_asset: yt-dlp_macos
ytdlp_output: 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_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/2025-10-25/ffmpeg-x86_64-defd5f3f64.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_inner_path: ffmpeg/ffmpeg
ffmpeg_output: ffmpeg_macos ffprobe_inner_path: ffmpeg/ffprobe
- platform: linux - platform: linux
os: ubuntu-latest os: ubuntu-latest
build_script: pnpm run build:linux build_script: pnpm run build:linux
@@ -50,7 +50,7 @@ jobs:
ytdlp_output: yt-dlp_linux 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_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_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffmpeg
ffmpeg_output: ffmpeg_linux ffprobe_inner_path: ffmpeg-master-latest-linux64-gpl/bin/ffprobe
steps: steps:
- name: Check out Git repository - name: Check out Git repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -77,8 +77,11 @@ jobs:
Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip Invoke-WebRequest -Uri $ffmpegUrl -OutFile ffmpeg.zip
Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force Expand-Archive ffmpeg.zip -DestinationPath ffmpeg -Force
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}' $source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}' $ffprobeSource = Join-Path 'ffmpeg' '${{ matrix.ffprobe_inner_path }}'
Copy-Item -Path $source -Destination $destination -Force $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.zip -Force
Remove-Item ffmpeg -Recurse -Force Remove-Item ffmpeg -Recurse -Force
@@ -86,7 +89,8 @@ jobs:
if: matrix.platform == 'macos' if: matrix.platform == 'macos'
shell: bash shell: bash
env: env:
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }} FFMPEG_OUTPUT: ffmpeg
FFPROBE_OUTPUT: ffprobe
run: | run: |
set -euo pipefail set -euo pipefail
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_arm_url }}" -o ffmpeg-arm.zip 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 }}" arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
x86_bin="ffmpeg-x86/${{ 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 if [[ ! -f "$arm_bin" ]]; then
arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)" arm_bin="$(find ffmpeg-arm -type f -name ffmpeg -print -quit)"
@@ -104,6 +110,12 @@ jobs:
if [[ ! -f "$x86_bin" ]]; then if [[ ! -f "$x86_bin" ]]; then
x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)" x86_bin="$(find ffmpeg-x86 -type f -name ffmpeg -print -quit)"
fi 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 if [[ ! -f "$arm_bin" ]]; then
echo "::error::Missing arm64 ffmpeg binary at $arm_bin" echo "::error::Missing arm64 ffmpeg binary at $arm_bin"
@@ -113,9 +125,19 @@ jobs:
echo "::error::Missing x86_64 ffmpeg binary at $x86_bin" echo "::error::Missing x86_64 ffmpeg binary at $x86_bin"
exit 1 exit 1
fi 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" mkdir -p resources/ffmpeg
chmod +x "resources/$FFMPEG_OUTPUT" 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 rm -rf ffmpeg-arm ffmpeg-x86 ffmpeg-arm.zip ffmpeg-x86.zip
- name: Download ffmpeg binary (Linux) - name: Download ffmpeg binary (Linux)
@@ -130,8 +152,10 @@ jobs:
fi fi
mkdir ffmpeg mkdir ffmpeg
tar -xf ffmpeg.tar.xz -C ffmpeg tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}" mkdir -p resources/ffmpeg
chmod +x "resources/${{ matrix.ffmpeg_output }}" 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 rm -rf ffmpeg.tar.xz ffmpeg
- name: Download yt-dlp binary - name: Download yt-dlp binary
@@ -230,5 +254,15 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: dist-${{ matrix.os }} 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 retention-days: 1

View File

@@ -7,3 +7,5 @@ on:
jobs: jobs:
build: build:
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
with:
upload_artifacts: true

View File

@@ -58,7 +58,7 @@ src/
- Build production bundles with `pnpm build`. - Build production bundles with `pnpm build`.
- Create platform-specific artifacts with `pnpm build:win`, `pnpm build:mac`, or `pnpm build:linux`. - 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. - 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 ## Working on Changes
- Keep each pull request focused on a single problem or feature. - Keep each pull request focused on a single problem or feature.

View File

@@ -2,7 +2,12 @@ const { execFileSync } = require('node:child_process')
const fs = require('node:fs') const fs = require('node:fs')
const path = require('node:path') 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 findAppBundle = (appOutDir) => {
const entries = fs.readdirSync(appOutDir) const entries = fs.readdirSync(appOutDir)

View File

@@ -1,6 +1,6 @@
{ {
"name": "vidbee", "name": "vidbee",
"version": "1.1.12", "version": "1.2.0",
"description": "A modern Electron application for downloading videos and audios", "description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "VidBee", "author": "VidBee",

View File

@@ -6,6 +6,9 @@ ffmpeg.exe
ffmpeg_macos ffmpeg_macos
ffmpeg_linux ffmpeg_linux
ffmpeg ffmpeg
ffmpeg/
ffprobe
ffprobe.exe
deno.exe deno.exe
deno deno

View File

@@ -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" 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 ### Required Files
1. **Windows**: `ffmpeg.exe` 1. **Windows**: `resources/ffmpeg/ffmpeg.exe` and `resources/ffmpeg/ffprobe.exe`
2. **macOS**: `ffmpeg_macos` 2. **macOS**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
3. **Linux**: `ffmpeg_linux` 3. **Linux**: `resources/ffmpeg/ffmpeg` and `resources/ffmpeg/ffprobe`
### How to Download ### 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. - **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-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`. - **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 the final binary is executable: `chmod +x resources/ffmpeg_macos` (or `ffmpeg_linux`). - On macOS/Linux ensure both binaries are executable: `chmod +x resources/ffmpeg/ffmpeg resources/ffmpeg/ffprobe`.
### Note ### Note
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH. - 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 paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations. - You can override the lookup path via `FFMPEG_PATH`. It must point to a directory containing both `ffmpeg` and `ffprobe`.
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary - File sizes: ~40-80 MB per ffmpeg build (ffmpeg + ffprobe)
## JS Runtime (Deno) ## JS Runtime (Deno)

View File

@@ -23,10 +23,10 @@ if (!supportedPlatforms.includes(platform)) {
const binaries = [ const binaries = [
{ {
label: 'yt-dlp', label: 'yt-dlp',
filenameMap: { paths: {
win: 'yt-dlp.exe', win: ['yt-dlp.exe'],
mac: 'yt-dlp_macos', mac: ['yt-dlp_macos'],
linux: 'yt-dlp_linux' linux: ['yt-dlp_linux']
}, },
help: { help: {
default: 'https://github.com/yt-dlp/yt-dlp/releases/latest' default: 'https://github.com/yt-dlp/yt-dlp/releases/latest'
@@ -34,10 +34,23 @@ const binaries = [
}, },
{ {
label: 'ffmpeg', label: 'ffmpeg',
filenameMap: { paths: {
win: 'ffmpeg.exe', win: ['ffmpeg/ffmpeg.exe'],
mac: 'ffmpeg_macos', mac: ['ffmpeg/ffmpeg'],
linux: 'ffmpeg_linux' 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: { help: {
win: 'https://ffmpeg.org/download.html', win: 'https://ffmpeg.org/download.html',
@@ -47,10 +60,10 @@ const binaries = [
}, },
{ {
label: 'deno', label: 'deno',
filenameMap: { paths: {
win: 'deno.exe', win: ['deno.exe'],
mac: 'deno', mac: ['deno'],
linux: 'deno' linux: ['deno']
}, },
help: { help: {
default: 'https://github.com/denoland/deno/releases/latest' default: 'https://github.com/denoland/deno/releases/latest'
@@ -61,12 +74,15 @@ const binaries = [
let hasMissingBinary = false let hasMissingBinary = false
for (const binary of binaries) { for (const binary of binaries) {
const filename = binary.filenameMap[platform] const candidates = binary.paths[platform] || []
const binaryPath = path.join(__dirname, '..', 'resources', filename) const found = candidates.find((filename) =>
fs.existsSync(path.join(__dirname, '..', 'resources', filename))
)
if (!fs.existsSync(binaryPath)) { if (!found) {
console.error(`❌ Error: resources/${filename} not found!`) const expected = candidates.length ? candidates.join(' or ') : binary.label
console.error(`Please download ${filename} to the resources/ directory first.`) console.error(`❌ Error: resources/${expected} not found!`)
console.error(`Please download ${binary.label} to the resources/ directory first.`)
const help = const help =
typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default typeof binary.help === 'string' ? binary.help : binary.help[platform] || binary.help.default
if (help) { if (help) {
@@ -74,7 +90,7 @@ for (const binary of binaries) {
} }
hasMissingBinary = true hasMissingBinary = true
} else { } else {
console.log(`${filename} found in resources/ directory`) console.log(`${binary.label} found: resources/${found}`)
} }
} }

View File

@@ -14,6 +14,7 @@ const http = require('node:http')
// Configuration // Configuration
const RESOURCES_DIR = path.join(__dirname, '..', 'resources') 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 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 DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
const GITHUB_TOKEN = const GITHUB_TOKEN =
@@ -29,7 +30,9 @@ const PLATFORM_CONFIG = {
ffmpeg: { ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip', 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', innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
ffprobeInnerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffprobe.exe',
output: 'ffmpeg.exe', output: 'ffmpeg.exe',
ffprobeOutput: 'ffprobe.exe',
extract: 'unzip', extract: 'unzip',
release: { release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
@@ -46,9 +49,11 @@ const PLATFORM_CONFIG = {
ffmpeg: { ffmpeg: {
// For development, download only the architecture matching current system // For development, download only the architecture matching current system
arm64: { 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', innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos', ffprobeInnerPath: 'ffmpeg/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'unzip', extract: 'unzip',
release: { release: {
repo: 'eko5624/mpv-mac', repo: 'eko5624/mpv-mac',
@@ -56,9 +61,11 @@ const PLATFORM_CONFIG = {
} }
}, },
x64: { 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', innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos', ffprobeInnerPath: 'ffmpeg/ffprobe',
output: 'ffmpeg',
ffprobeOutput: 'ffprobe',
extract: 'unzip', extract: 'unzip',
release: { release: {
repo: 'eko5624/mpv-mac', repo: 'eko5624/mpv-mac',
@@ -75,7 +82,9 @@ const PLATFORM_CONFIG = {
ffmpeg: { ffmpeg: {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz', 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', 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', extract: 'tar',
release: { release: {
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'], repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
@@ -329,15 +338,21 @@ function formatBytes(bytes) {
return `${Math.round(bytes / 1024)} KB` 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, { const result = spawnSync(filePath, args, {
encoding: 'utf8', encoding: 'utf8',
timeout: 8000, timeout: timeoutMs,
windowsHide: true windowsHide: true
}) })
if (result.error) { 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) { if (result.status !== 0) {
@@ -413,23 +428,43 @@ async function downloadYtDlp(config) {
} }
async function downloadFfmpegWindows(config) { async function downloadFfmpegWindows(config) {
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg const {
const outputPath = path.join(RESOURCES_DIR, output) 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') const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) { const ffprobeValidation = ffprobeOutputPath
log(`Existing ${output} failed version check: ${validation.message}`, 'warn') ? 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') log(`Downloading ffmpeg for Windows...`, 'download')
ensureDir(FFMPEG_DIR)
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath let innerPath = fallbackInnerPath
let ffprobeInnerPath = fallbackFfprobeInnerPath
if (release) { if (release) {
try { try {
@@ -440,6 +475,10 @@ async function downloadFfmpegWindows(config) {
if (inferred) { if (inferred) {
innerPath = inferred innerPath = inferred
} }
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe.exe')
if (inferredFfprobe) {
ffprobeInnerPath = inferredFfprobe
}
} }
} catch (error) { } catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn') log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
@@ -457,12 +496,24 @@ async function downloadFfmpegWindows(config) {
} }
fs.copyFileSync(sourcePath, outputPath) 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') const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) { if (!validation.ok) {
safeUnlink(outputPath) if (validation.code === 'ETIMEDOUT') {
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`) 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 // Cleanup
fs.unlinkSync(tempZip) fs.unlinkSync(tempZip)
@@ -482,19 +533,38 @@ async function downloadFfmpegMac(config) {
throw new Error(`Unsupported architecture: ${arch}`) throw new Error(`Unsupported architecture: ${arch}`)
} }
const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig const {
const outputPath = path.join(RESOURCES_DIR, output) 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') const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) { const ffprobeValidation = ffprobeOutputPath
log(`Existing ${output} failed version check: ${validation.message}`, 'warn') ? 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') log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
ensureDir(FFMPEG_DIR)
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip') const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl let downloadUrl = fallbackUrl
@@ -522,6 +592,14 @@ async function downloadFfmpegMac(config) {
fs.copyFileSync(sourcePath, outputPath) fs.copyFileSync(sourcePath, outputPath)
setExecutable(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') const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) { if (!validation.ok) {
safeUnlink(outputPath) safeUnlink(outputPath)
@@ -540,23 +618,43 @@ async function downloadFfmpegMac(config) {
} }
async function downloadFfmpegLinux(config) { async function downloadFfmpegLinux(config) {
const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg const {
const outputPath = path.join(RESOURCES_DIR, output) 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') const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) { const ffprobeValidation = ffprobeOutputPath
log(`Existing ${output} failed version check: ${validation.message}`, 'warn') ? 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') log(`Downloading ffmpeg for Linux...`, 'download')
ensureDir(FFMPEG_DIR)
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz') const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp') const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
let downloadUrl = fallbackUrl let downloadUrl = fallbackUrl
let innerPath = fallbackInnerPath let innerPath = fallbackInnerPath
let ffprobeInnerPath = fallbackFfprobeInnerPath
if (release) { if (release) {
try { try {
@@ -567,6 +665,10 @@ async function downloadFfmpegLinux(config) {
if (inferred) { if (inferred) {
innerPath = inferred innerPath = inferred
} }
const inferredFfprobe = inferFfmpegInnerPath(resolved.name, 'ffprobe')
if (inferredFfprobe) {
ffprobeInnerPath = inferredFfprobe
}
} }
} catch (error) { } catch (error) {
log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn') log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
@@ -585,6 +687,14 @@ async function downloadFfmpegLinux(config) {
fs.copyFileSync(sourcePath, outputPath) fs.copyFileSync(sourcePath, outputPath)
setExecutable(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') const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
if (!validation.ok) { if (!validation.ok) {
safeUnlink(outputPath) safeUnlink(outputPath)

View File

@@ -137,9 +137,7 @@ export const buildDownloadArgs = (
} else { } else {
args.push('--no-embed-subs') 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(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters') args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
@@ -152,8 +150,8 @@ export const buildDownloadArgs = (
const outputTemplate = path.join(baseDownloadPath, safeTemplate) const outputTemplate = path.join(baseDownloadPath, safeTemplate)
args.push('-o', outputTemplate) args.push('-o', outputTemplate)
// Add options for better filename handling // Allow resume support across restarts
args.push('--no-part') args.push('--continue')
args.push('--no-playlist-reverse') args.push('--no-playlist-reverse')
if (process.platform === 'win32') { if (process.platform === 'win32') {

View File

@@ -449,14 +449,20 @@ app.whenReady().then(async () => {
} }
// Initialize yt-dlp // Initialize yt-dlp
let ytdlpReady = false
try { try {
log.info('Initializing yt-dlp...') log.info('Initializing yt-dlp...')
await ytdlpManager.initialize() await ytdlpManager.initialize()
ytdlpReady = true
log.info('yt-dlp initialized successfully') log.info('yt-dlp initialized successfully')
} catch (error) { } catch (error) {
log.error('Failed to initialize yt-dlp:', error) log.error('Failed to initialize yt-dlp:', error)
} }
if (ytdlpReady) {
downloadEngine.restoreActiveDownloads()
}
await startExtensionApiServer() await startExtensionApiServer()
applyDockVisibility(settingsManager.get('hideDockIcon')) applyDockVisibility(settingsManager.get('hideDockIcon'))
@@ -474,14 +480,27 @@ app.whenReady().then(async () => {
handleDeepLinkArgv(process.argv) handleDeepLinkArgv(process.argv)
app.on('activate', () => { 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 // 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. // dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) createWindow() createWindow()
}) })
}) })
app.on('before-quit', () => { app.on('before-quit', () => {
isQuitting = true isQuitting = true
downloadEngine.flushDownloadSession()
}) })
// Quit when all windows are closed, except on macOS. There, it's common // Quit when all windows are closed, except on macOS. There, it's common

View File

@@ -46,6 +46,11 @@ class DownloadService extends IpcService {
return downloadEngine.getQueueStatus() return downloadEngine.getQueueStatus()
} }
@IpcMethod()
getActiveDownloads(_context: IpcContext): DownloadItem[] {
return downloadEngine.getActiveDownloads()
}
@IpcMethod() @IpcMethod()
updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void { updateDownloadInfo(_context: IpcContext, id: string, updates: Partial<DownloadItem>): void {
downloadEngine.updateDownloadInfo(id, updates) downloadEngine.updateDownloadInfo(id, updates)

View File

@@ -6,7 +6,10 @@ import type {
SubscriptionRule, SubscriptionRule,
SubscriptionUpdatePayload SubscriptionUpdatePayload
} from '../../../shared/types' } 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 { sanitizeFilenameTemplate } from '../../download-engine/args-builder'
import { subscriptionManager } from '../../lib/subscription-manager' import { subscriptionManager } from '../../lib/subscription-manager'
import { subscriptionScheduler } from '../../lib/subscription-scheduler' import { subscriptionScheduler } from '../../lib/subscription-scheduler'
@@ -113,6 +116,10 @@ class SubscriptionService extends IpcService {
options: CreateSubscriptionOptions options: CreateSubscriptionOptions
): Promise<SubscriptionRule> { ): Promise<SubscriptionRule> {
const resolved = resolveFeedFromInput(options.url) 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 settings = settingsManager.getAll()
const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions') const defaultDownloadDirectory = path.join(settings.downloadPath, 'Subscriptions')
const payload: SubscriptionCreatePayload = { const payload: SubscriptionCreatePayload = {
@@ -141,6 +148,12 @@ class SubscriptionService extends IpcService {
id: string, id: string,
updates: SubscriptionUpdatePayload updates: SubscriptionUpdatePayload
): SubscriptionRule | undefined { ): 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 } const normalized: SubscriptionUpdatePayload = { ...updates }
if (typeof normalized.namingTemplate === 'string') { if (typeof normalized.namingTemplate === 'string') {
normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate) normalized.namingTemplate = sanitizeFilenameTemplate(normalized.namingTemplate)

View File

@@ -28,6 +28,11 @@ import { settingsManager } from '../settings'
import { scopedLoggers } from '../utils/logger' import { scopedLoggers } from '../utils/logger'
import { resolvePathWithHome } from '../utils/path-helpers' import { resolvePathWithHome } from '../utils/path-helpers'
import { DownloadQueue } from './download-queue' import { DownloadQueue } from './download-queue'
import {
type DownloadSessionItem,
loadDownloadSession,
saveDownloadSession
} from './download-session-store'
import { ffmpegManager } from './ffmpeg-manager' import { ffmpegManager } from './ffmpeg-manager'
import { historyManager } from './history-manager' import { historyManager } from './history-manager'
import { ytdlpManager } from './ytdlp-manager' import { ytdlpManager } from './ytdlp-manager'
@@ -50,6 +55,8 @@ const formatYtDlpCommand = (args: string[]): string => {
return `yt-dlp ${quoted.join(' ')}` return `yt-dlp ${quoted.join(' ')}`
} }
const resolveFfmpegLocation = (ffmpegPath: string): string => path.dirname(ffmpegPath)
const ensureDirectoryExists = (dir?: string): void => { const ensureDirectoryExists = (dir?: string): void => {
if (!dir) { if (!dir) {
return return
@@ -260,6 +267,8 @@ const buildVideoInfoArgs = (url: string, settings: ReturnType<typeof settingsMan
class DownloadEngine extends EventEmitter { class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = new Map() private activeDownloads: Map<string, DownloadProcess> = new Map()
private queue: DownloadQueue private queue: DownloadQueue
private sessionPersistTimer: NodeJS.Timeout | null = null
private sessionRestored = false
constructor() { constructor() {
super() super()
@@ -269,6 +278,10 @@ class DownloadEngine extends EventEmitter {
this.queue.on('start-download', async (item) => { this.queue.on('start-download', async (item) => {
await this.executeDownload(item.id, item.options) await this.executeDownload(item.id, item.options)
}) })
this.queue.on('queue-updated', () => {
this.scheduleSessionPersist()
})
} }
async getVideoInfo(url: string): Promise<VideoInfo> { async getVideoInfo(url: string): Promise<VideoInfo> {
@@ -897,7 +910,8 @@ class DownloadEngine extends EventEmitter {
return return
} }
args.push('--ffmpeg-location', ffmpegPath) const ffmpegLocation = resolveFfmpegLocation(ffmpegPath)
args.push('--ffmpeg-location', ffmpegLocation)
args.push(urlArg) args.push(urlArg)
const ytDlpCommand = formatYtDlpCommand(args) const ytDlpCommand = formatYtDlpCommand(args)
@@ -911,6 +925,8 @@ class DownloadEngine extends EventEmitter {
this.activeDownloads.set(id, { controller, process: ytdlpProcess }) this.activeDownloads.set(id, { controller, process: ytdlpProcess })
this.queue.updateItemInfo(id, { status: 'downloading', startedAt: Date.now() })
this.scheduleSessionPersist()
this.emit('download-started', id) this.emit('download-started', id)
this.upsertHistoryEntry(id, options, { this.upsertHistoryEntry(id, options, {
@@ -964,6 +980,11 @@ class DownloadEngine extends EventEmitter {
downloaded: progress.downloaded || '', downloaded: progress.downloaded || '',
total: progress.total || '' total: progress.total || ''
} }
this.queue.updateItemInfo(id, {
progress: downloadProgress,
speed: downloadProgress.currentSpeed || ''
})
this.scheduleSessionPersist()
this.emit('download-progress', id, downloadProgress) this.emit('download-progress', id, downloadProgress)
} }
) )
@@ -1178,6 +1199,72 @@ class DownloadEngine extends EventEmitter {
return this.queue.getQueueStatus() 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 { updateDownloadInfo(id: string, updates: Partial<DownloadItem>): void {
this.queue.updateItemInfo(id, updates) this.queue.updateItemInfo(id, updates)
@@ -1249,6 +1336,37 @@ class DownloadEngine extends EventEmitter {
if (Object.keys(historyUpdates).length > 0) { if (Object.keys(historyUpdates).length > 0) {
this.upsertHistoryEntry(id, snapshot.options, historyUpdates) 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( private addToHistory(

View File

@@ -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 { isDownloading(id: string): boolean {
return this.activeDownloads.has(id) return this.activeDownloads.has(id)
} }

View 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)
}
}

View File

@@ -28,46 +28,58 @@ class FfmpegManager {
private async findFfmpegBinary(): Promise<string> { private async findFfmpegBinary(): Promise<string> {
const platform = os.platform() 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)) { const resolveBundledFfmpeg = (dirPath: string, label: string): string | null => {
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH) const ffmpegPath = path.join(dirPath, ffmpegFileName)
return process.env.FFMPEG_PATH 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') { const envPath = process.env.FFMPEG_PATH
resourceCandidates.push('ffmpeg.exe') if (envPath) {
} else if (platform === 'darwin') { if (!fs.existsSync(envPath)) {
resourceCandidates.push('ffmpeg_macos', 'ffmpeg') throw new Error(
} else { 'FFMPEG_PATH does not exist. Provide a directory containing ffmpeg and ffprobe.'
resourceCandidates.push('ffmpeg_linux', 'ffmpeg') )
}
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() const resourcesPath = this.getResourcesPath()
for (const candidate of resourceCandidates) { const bundledDir = path.join(resourcesPath, 'ffmpeg')
const fullPath = path.join(resourcesPath, candidate) const bundledResolved = resolveBundledFfmpeg(bundledDir, 'bundled ffmpeg')
if (fs.existsSync(fullPath)) { if (bundledResolved) {
if (platform !== 'win32') { return bundledResolved
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
}
} }
if (platform === 'darwin') { if (platform === 'darwin') {
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg'] const commonDirs = ['/opt/homebrew/bin', '/usr/local/bin']
for (const candidate of commonPaths) { for (const candidate of commonDirs) {
if (fs.existsSync(candidate)) { const resolved = resolveBundledFfmpeg(candidate, 'system ffmpeg')
scopedLoggers.engine.info('Using system ffmpeg:', candidate) if (resolved) {
return candidate return resolved
} }
} }
} }
@@ -76,8 +88,10 @@ class FfmpegManager {
try { try {
const systemPath = execSync('which ffmpeg').toString().trim() const systemPath = execSync('which ffmpeg').toString().trim()
if (systemPath && fs.existsSync(systemPath)) { if (systemPath && fs.existsSync(systemPath)) {
scopedLoggers.engine.info('Using system ffmpeg:', systemPath) const resolved = resolveBundledFfmpeg(path.dirname(systemPath), 'system ffmpeg')
return systemPath if (resolved) {
return resolved
}
} }
} catch (_error) { } catch (_error) {
// Ignore error and continue // Ignore error and continue
@@ -88,8 +102,10 @@ class FfmpegManager {
try { try {
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0] const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) { if (output && fs.existsSync(output)) {
scopedLoggers.engine.info('Using system ffmpeg:', output) const resolved = resolveBundledFfmpeg(path.dirname(output), 'system ffmpeg')
return output if (resolved) {
return resolved
}
} }
} catch (_error) { } catch (_error) {
// Ignore error and continue // Ignore error and continue
@@ -97,7 +113,7 @@ class FfmpegManager {
} }
throw new Error( 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.'
) )
} }
} }

View File

@@ -99,6 +99,22 @@ export class SubscriptionManager extends EventEmitter {
return this.attachFeedItems([this.mapRowToRecord(row)])[0] 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 { add(payload: SubscriptionCreatePayload): SubscriptionRule {
const timestamp = Date.now() const timestamp = Date.now()
const keywords = sanitizeList(payload.keywords) const keywords = sanitizeList(payload.keywords)
@@ -301,6 +317,25 @@ export class SubscriptionManager extends EventEmitter {
return this.db 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 { private ensureItemsSchema(): void {
if (!this.sqlite) { if (!this.sqlite) {
return return

View File

@@ -5,7 +5,7 @@ declare global {
interface Window { interface Window {
electron: ElectronAPI electron: ElectronAPI
api: IpcServices & { 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 removeListener: (channel: string, callback: (...args: unknown[]) => void) => void
send: (channel: string, ...args: unknown[]) => void send: (channel: string, ...args: unknown[]) => void
} }

View File

@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router' import { HashRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ErrorBoundary } from './components/error/ErrorBoundary' import { ErrorBoundary } from './components/error/ErrorBoundary'
import { useDownloadEvents } from './hooks/use-download-events'
import { ipcEvents, ipcServices } from './lib/ipc' import { ipcEvents, ipcServices } from './lib/ipc'
import { About } from './pages/About' import { About } from './pages/About'
import { Home } from './pages/Home' import { Home } from './pages/Home'
@@ -62,6 +63,8 @@ function AppContent() {
const currentPage = pathToPage(location.pathname) const currentPage = pathToPage(location.pathname)
const supportedSitesUrl = 'https://vidbee.org/supported-sites/' const supportedSitesUrl = 'https://vidbee.org/supported-sites/'
useDownloadEvents()
const handlePageChange = useCallback( const handlePageChange = useCallback(
(page: Page) => { (page: Page) => {
const targetPath = pageToPath[page] ?? '/' const targetPath = pageToPath[page] ?? '/'

View File

@@ -16,12 +16,7 @@ import { useCallback, useEffect, useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import { ipcEvents, ipcServices } from '../../lib/ipc' import { ipcEvents, ipcServices } from '../../lib/ipc'
import { import { addDownloadAtom, updateDownloadAtom } from '../../store/downloads'
addDownloadAtom,
addHistoryRecordAtom,
removeDownloadAtom,
updateDownloadAtom
} from '../../store/downloads'
import { loadSettingsAtom, settingsAtom } from '../../store/settings' import { loadSettingsAtom, settingsAtom } from '../../store/settings'
import { import {
currentVideoInfoAtom, currentVideoInfoAtom,
@@ -116,8 +111,6 @@ export function DownloadDialog({
const loadSettings = useSetAtom(loadSettingsAtom) const loadSettings = useSetAtom(loadSettingsAtom)
const updateDownload = useSetAtom(updateDownloadAtom) const updateDownload = useSetAtom(updateDownloadAtom)
const addDownload = useSetAtom(addDownloadAtom) const addDownload = useSetAtom(addDownloadAtom)
const addHistoryRecord = useSetAtom(addHistoryRecordAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
const [url, setUrl] = useState('') const [url, setUrl] = useState('')
const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single') const [activeTab, setActiveTab] = useState<'single' | 'playlist'>('single')
@@ -182,21 +175,6 @@ export function DownloadDialog({
) )
}, [playlistInfo, computePlaylistRange, selectedEntryIds]) }, [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 // Listen for deep link events
useEffect(() => { useEffect(() => {
const handleDeepLink = async (data: unknown) => { const handleDeepLink = async (data: unknown) => {
@@ -283,69 +261,8 @@ export function DownloadDialog({
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
// Load settings when dialog opens
loadSettings() loadSettings()
}, [open, 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])
const startOneClickDownload = useCallback( const startOneClickDownload = useCallback(
async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => { async (targetUrl: string, options?: { clearInput?: boolean; setInputValue?: boolean }) => {

View File

@@ -91,7 +91,7 @@ export function PlaylistDownload({
</div> </div>
</div> </div>
<ScrollArea className="flex-1 w-full rounded-md border"> <ScrollArea className="flex-1 w-full rounded-md border max-h-[45vh]">
<div className="p-1"> <div className="p-1">
{playlistInfo.entries.map((entry) => { {playlistInfo.entries.map((entry) => {
const isSelected = selectedEntryIds.has(entry.id) const isSelected = selectedEntryIds.has(entry.id)

View File

@@ -78,11 +78,11 @@ export function PlaylistDownloadGroup({
const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0 const aggregatePercent = totalCount > 0 ? Math.min((totalProgress / totalCount) * 100, 100) : 0
return ( return (
<div className="space-y-2 rounded-md bg-muted/30 px-2.5 py-2 mx-6"> <div className="rounded-md bg-muted/30 mx-6">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<button <button
type="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)} onClick={() => setIsExpanded((prev) => !prev)}
aria-expanded={isExpanded} aria-expanded={isExpanded}
aria-label={toggleLabel} aria-label={toggleLabel}
@@ -118,7 +118,7 @@ export function PlaylistDownloadGroup({
</div> </div>
</div> </div>
</button> </button>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1 pr-3">
{canDeletePlaylist && ( {canDeletePlaylist && (
<Button <Button
type="button" type="button"
@@ -153,17 +153,15 @@ export function PlaylistDownloadGroup({
}} }}
> >
<div className="min-h-0"> <div className="min-h-0">
<div className="space-y-3 pt-1"> {records.map((record) => (
{records.map((record) => ( <div key={`${groupId}:${record.entryType}:${record.id}`}>
<div key={`${groupId}:${record.entryType}:${record.id}`}> <DownloadItem
<DownloadItem download={record}
download={record} isSelected={selectedIds?.has(record.id) ?? false}
isSelected={selectedIds?.has(record.id) ?? false} onToggleSelect={onToggleSelect}
onToggleSelect={onToggleSelect} />
/> </div>
</div> ))}
))}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -113,6 +113,17 @@ const resolveDownloadExtension = (download: DownloadRecord): string => {
return download.type === 'audio' ? 'mp3' : 'mp4' 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 { interface UnifiedDownloadHistoryProps {
onOpenSupportedSites?: () => void onOpenSupportedSites?: () => void
onOpenSettings?: () => void onOpenSettings?: () => void
@@ -163,6 +174,12 @@ export function UnifiedDownloadHistory({
}) })
}, [allRecords, statusFilter]) }, [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 }> = [ const filters: Array<{ key: StatusFilter; label: string; count: number }> = [
{ key: 'all', label: t('download.all'), count: downloadStats.total }, { key: 'all', label: t('download.all'), count: downloadStats.total },
{ key: 'active', label: t('download.active'), count: downloadStats.active }, { 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 } { key: 'error', label: t('download.error'), count: downloadStats.error }
] ]
const selectableIds = useMemo( const selectableIds = useMemo(() => {
() => if (visibleHistoryIds.length === 0) {
filteredRecords.filter((record) => record.entryType === 'history').map((record) => record.id), return []
[filteredRecords] }
) 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 selectableCount = selectableIds.length
const visibleSelectableCount = visibleHistoryIds.length
const selectionSummary = const selectionSummary =
selectableCount === 0 selectableCount === 0
? t('history.selectedCount', { count: selectedCount }) ? 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(() => { useEffect(() => {
if (selectedIds.size === 0) { if (selectedIds.size === 0) {
@@ -216,6 +251,13 @@ export function UnifiedDownloadHistory({
setSelectedIds(new Set()) setSelectedIds(new Set())
} }
const handleSelectAll = () => {
if (selectableIds.length === 0) {
return
}
setSelectedIds(new Set(selectableIds))
}
const handleRequestDeleteSelected = () => { const handleRequestDeleteSelected = () => {
if (selectedIds.size === 0) { if (selectedIds.size === 0) {
return return
@@ -398,6 +440,41 @@ export function UnifiedDownloadHistory({
return { order, groups } return { order, groups }
}, [filteredRecords]) }, [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 ( return (
<div className={cn('flex flex-col h-full')}> <div className={cn('flex flex-col h-full')}>
<CardHeader className="gap-4 p-0 px-6 py-4 z-50 bg-background backdrop-blur"> <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>
<div className="flex items-center gap-2"> <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 <DownloadDialog
onOpenSupportedSites={onOpenSupportedSites} onOpenSupportedSites={onOpenSupportedSites}
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}

View File

@@ -3,6 +3,7 @@ import { ipcServices } from '@renderer/lib/ipc'
import { Github, MessageCircle, Twitter } from 'lucide-react' import { Github, MessageCircle, Twitter } from 'lucide-react'
import { type MouseEvent, useEffect, useMemo, useState } from 'react' import { type MouseEvent, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
type AppInfo = { type AppInfo = {
appVersion: string appVersion: string
@@ -11,12 +12,13 @@ type AppInfo = {
const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' } const DEFAULT_APP_INFO: AppInfo = { appVersion: '', osVersion: '' }
const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee' const FEEDBACK_TWEET_PREFIX = '@nexmoex VidBee'
export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[bug]: Download error report' export const DOWNLOAD_FEEDBACK_ISSUE_TITLE = '[Bug]: Download error report'
const FEEDBACK_UNKNOWN_ERROR = 'Unknown error' const FEEDBACK_UNKNOWN_ERROR = 'Unknown error'
const FEEDBACK_UNKNOWN_VALUE = 'Unknown' const FEEDBACK_UNKNOWN_VALUE = 'Unknown'
const FEEDBACK_SOURCE_LABEL = 'Source URL' const FEEDBACK_SOURCE_LABEL = 'Source URL'
const FEEDBACK_ERROR_LABEL = 'Error' const FEEDBACK_ERROR_LABEL = 'Error'
const FEEDBACK_COMMAND_LABEL = 'yt-dlp command' const FEEDBACK_COMMAND_LABEL = 'yt-dlp command'
const FEEDBACK_MAX_GITHUB_URL_LENGTH = 7000
let cachedAppInfo: AppInfo | null = null let cachedAppInfo: AppInfo | null = null
let appInfoPromise: Promise<AppInfo> | null = null let appInfoPromise: Promise<AppInfo> | null = null
@@ -37,12 +39,12 @@ const buildIssueLogs = (
): string => { ): string => {
const lines: string[] = [] const lines: string[] = []
if (sourceUrl) { if (sourceUrl) {
lines.push(`${urlLabel}: ${sourceUrl}`) lines.push(`**${urlLabel}:**\n${sourceUrl}\n`)
} }
if (ytDlpCommand) { if (ytDlpCommand) {
lines.push(`${commandLabel}: ${ytDlpCommand}`) lines.push(`**${commandLabel}:**\n\`\`\`bash\n${ytDlpCommand}\n\`\`\`\n`)
} }
lines.push(`${errorLabel}: ${errorText}`) lines.push(`**${errorLabel}:**\n${errorText}`)
return lines.join('\n') return lines.join('\n')
} }
@@ -106,12 +108,13 @@ type FeedbackLinkButtonsProps = {
iconClassName?: string iconClassName?: string
onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void onLinkClick?: (event: MouseEvent<HTMLAnchorElement>) => void
ytDlpCommand?: string ytDlpCommand?: string
useSimpleGithubUrl?: boolean
} }
export const FeedbackLinkButtons = ({ export const FeedbackLinkButtons = ({
error, error,
sourceUrl, sourceUrl,
issueTitle = '[bug]: ', issueTitle = '[Bug]: ',
includeAppInfo = false, includeAppInfo = false,
appInfo, appInfo,
buttonVariant = 'outline', buttonVariant = 'outline',
@@ -119,7 +122,8 @@ export const FeedbackLinkButtons = ({
buttonClassName, buttonClassName,
iconClassName, iconClassName,
onLinkClick, onLinkClick,
ytDlpCommand ytDlpCommand,
useSimpleGithubUrl = false
}: FeedbackLinkButtonsProps) => { }: FeedbackLinkButtonsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const fallbackAppInfo = useAppInfo() const fallbackAppInfo = useAppInfo()
@@ -138,43 +142,48 @@ export const FeedbackLinkButtons = ({
const tweetText = encodeURIComponent( const tweetText = encodeURIComponent(
tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix tweetError ? `${tweetPrefix} - ${tweetError}` : tweetPrefix
) )
const issueError = compactError ? clampText(compactError, 800) : FEEDBACK_UNKNOWN_ERROR const issueError = compactError || FEEDBACK_UNKNOWN_ERROR
const resolvedSourceUrl = sourceUrl?.trim() || undefined const resolvedSourceUrl = sourceUrl?.trim() || undefined
const normalizedCommand = ytDlpCommand?.trim() || undefined const normalizedCommand = ytDlpCommand?.trim() || undefined
const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand) const shouldIncludeLogs = Boolean(compactError || resolvedSourceUrl || normalizedCommand)
const issueLogs = shouldIncludeLogs const issueLogs = shouldIncludeLogs
? clampText( ? buildIssueLogs(
buildIssueLogs( issueError,
issueError, resolvedSourceUrl,
resolvedSourceUrl, normalizedCommand,
normalizedCommand, FEEDBACK_SOURCE_LABEL,
FEEDBACK_SOURCE_LABEL, FEEDBACK_ERROR_LABEL,
FEEDBACK_ERROR_LABEL, FEEDBACK_COMMAND_LABEL
FEEDBACK_COMMAND_LABEL
),
800
) )
: null : null
const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE const appVersionValue = appVersion ? `VidBee v${appVersion}` : FEEDBACK_UNKNOWN_VALUE
const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE const osVersionValue = osVersion || FEEDBACK_UNKNOWN_VALUE
const issueParams = new URLSearchParams({
template: 'bug_report.yml',
title: issueTitle
})
if (issueLogs) { let githubUrl: string
issueParams.set('logs', issueLogs) if (useSimpleGithubUrl) {
} githubUrl = 'https://github.com/nexmoe/VidBee/issues/new/choose'
if (includeAppInfo) { } else {
issueParams.set('app_version', appVersionValue) const issueParams = new URLSearchParams({
issueParams.set('os_version', osVersionValue) 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 [ return [
{ {
icon: Github, icon: Github,
label: t('about.resources.githubIssues'), label: t('about.resources.githubIssues'),
href: `https://github.com/nexmoe/VidBee/issues/new?${issueParams.toString()}` href: githubUrl
}, },
{ {
icon: Twitter, icon: Twitter,
@@ -187,7 +196,24 @@ export const FeedbackLinkButtons = ({
href: 'https://discord.gg/uBqXV6QPdm' href: 'https://discord.gg/uBqXV6QPdm'
} }
] ]
}, [appVersion, error, includeAppInfo, issueTitle, osVersion, sourceUrl, t, ytDlpCommand]) }, [
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 ( return (
<> <>
@@ -201,7 +227,12 @@ export const FeedbackLinkButtons = ({
className={buttonClassName} className={buttonClassName}
asChild asChild
> >
<a href={resource.href} target="_blank" rel="noreferrer" onClick={onLinkClick}> <a
href={resource.href}
target="_blank"
rel="noreferrer"
onClick={(event) => handleLinkClick(event, resource.href)}
>
<Icon className={iconClassName} /> <Icon className={iconClassName} />
{resource.label} {resource.label}
</a> </a>

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

View File

@@ -197,7 +197,8 @@
"subscription": "الاشتراك" "subscription": "الاشتراك"
}, },
"feedback": { "feedback": {
"title": "Report this error:" "title": "Report this error:",
"githubUrlTooLong": "رابط GitHub هذا طويل جدًا. إذا لم يفتح، فالرجاء فتح صفحة المشكلة ولصق السجلات يدويًا."
} }
}, },
"error": { "error": {
@@ -370,6 +371,7 @@
"audio": "تفضيلات الصوت", "audio": "تفضيلات الصوت",
"browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه", "browserForCookies": "اختر المتصفح لاستخدام ملفات تعريف الارتباط منه",
"browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة", "browserForCookiesDescription": "المتصفح لاستخراج ملفات تعريف الارتباط منه للمصادقة",
"browserForCookiesWindowsNote": "في Windows، يتم دعم ملفات تعريف الارتباط من Firefox فقط. للمتصفحات الأخرى، يرجى إعداد ملف ملفات تعريف الارتباط يدويًا.",
"browserForCookiesProfile": "اسم الملف الشخصي أو المسار", "browserForCookiesProfile": "اسم الملف الشخصي أو المسار",
"browserForCookiesProfileDescription": "مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.", "browserForCookiesProfileDescription": "مسار الملف الشخصي للمتصفح المحدد أعلاه. يُملأ تلقائيًا عند الإمكان.",
"browserForCookiesProfilePlaceholder": "اسم الملف الشخصي أو المسار الكامل (اختياري)", "browserForCookiesProfilePlaceholder": "اسم الملف الشخصي أو المسار الكامل (اختياري)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار", "itemAlreadyQueued": "هذا الفيديو موجود بالفعل في قائمة الانتظار",
"queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.", "queueError": "فشل في الإضافة إلى قائمة انتظار التحميل.",
"openLinkError": "فشل في فتح رابط الفيديو.", "openLinkError": "فشل في فتح رابط الفيديو.",
"resolveError": "فشل في حل رابط تغذية RSS." "resolveError": "فشل في حل رابط تغذية RSS.",
"duplicateUrl": "تم الاشتراك في موجز RSS هذا بالفعل."
}, },
"detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}", "detectedFeed": "تم اكتشاف تغذية {{platform}} -> {{feed}}",
"detecting": "جاري اكتشاف التغذية...", "detecting": "جاري اكتشاف التغذية...",

View File

@@ -197,7 +197,8 @@
"subscription": "Abonnement" "subscription": "Abonnement"
}, },
"feedback": { "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": { "error": {
@@ -370,6 +371,7 @@
"audio": "Audio-Einstellungen", "audio": "Audio-Einstellungen",
"browserForCookies": "Browser für Cookies auswählen", "browserForCookies": "Browser für Cookies auswählen",
"browserForCookiesDescription": "Browser zum Extrahieren von Cookies für die Authentifizierung", "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", "browserForCookiesProfile": "Profilname oder Pfad",
"browserForCookiesProfileDescription": "Profilpfad für den oben ausgewählten Browser. Wird wenn möglich automatisch ausgefüllt.", "browserForCookiesProfileDescription": "Profilpfad für den oben ausgewählten Browser. Wird wenn möglich automatisch ausgefüllt.",
"browserForCookiesProfilePlaceholder": "Profilname oder vollständiger Pfad (optional)", "browserForCookiesProfilePlaceholder": "Profilname oder vollständiger Pfad (optional)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange", "itemAlreadyQueued": "Dieses Video ist bereits in der Warteschlange",
"queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.", "queueError": "Hinzufügen zur Download-Warteschlange fehlgeschlagen.",
"openLinkError": "Video-Link konnte nicht geöffnet werden.", "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}}", "detectedFeed": "{{platform}} Feed erkannt -> {{feed}}",
"detecting": "Feed wird erkannt...", "detecting": "Feed wird erkannt...",

View File

@@ -131,7 +131,8 @@
"fetch": "Fetch", "fetch": "Fetch",
"fetchingVideoInfo": "Fetching video info...", "fetchingVideoInfo": "Fetching video info...",
"feedback": { "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", "history": "History",
"imageLoadError": "Image failed to load", "imageLoadError": "Image failed to load",
@@ -370,6 +371,7 @@
"audio": "Audio Preferences", "audio": "Audio Preferences",
"browserForCookies": "Select browser to use cookies from", "browserForCookies": "Select browser to use cookies from",
"browserForCookiesDescription": "Browser to extract cookies from for authentication. We'll try to detect a profile automatically.", "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", "browserForCookiesProfile": "Profile name or path",
"browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.", "browserForCookiesProfileDescription": "Profile path for the browser selected above. Auto-filled when possible.",
"browserForCookiesProfilePlaceholder": "Profile name or full path (optional)", "browserForCookiesProfilePlaceholder": "Profile name or full path (optional)",
@@ -538,6 +540,7 @@
"missingUrl": "Please paste a channel link first.", "missingUrl": "Please paste a channel link first.",
"created": "Subscription added", "created": "Subscription added",
"createError": "Failed to add subscription.", "createError": "Failed to add subscription.",
"duplicateUrl": "This RSS feed is already subscribed.",
"refreshStarted": "Refresh started", "refreshStarted": "Refresh started",
"removed": "Subscription removed", "removed": "Subscription removed",
"updated": "Subscription updated", "updated": "Subscription updated",

View File

@@ -197,7 +197,8 @@
"subscription": "Suscripción" "subscription": "Suscripción"
}, },
"feedback": { "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": { "error": {
@@ -370,6 +371,7 @@
"audio": "Preferencias de Audio", "audio": "Preferencias de Audio",
"browserForCookies": "Seleccionar navegador para usar cookies", "browserForCookies": "Seleccionar navegador para usar cookies",
"browserForCookiesDescription": "Navegador del que extraer cookies para autenticación", "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", "browserForCookiesProfile": "Nombre de perfil o ruta",
"browserForCookiesProfileDescription": "Ruta del perfil para el navegador seleccionado arriba. Se completa automáticamente cuando es posible.", "browserForCookiesProfileDescription": "Ruta del perfil para el navegador seleccionado arriba. Se completa automáticamente cuando es posible.",
"browserForCookiesProfilePlaceholder": "Nombre de perfil o ruta completa (opcional)", "browserForCookiesProfilePlaceholder": "Nombre de perfil o ruta completa (opcional)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "Este video ya está en cola", "itemAlreadyQueued": "Este video ya está en cola",
"queueError": "Error al agregar a la cola de descarga.", "queueError": "Error al agregar a la cola de descarga.",
"openLinkError": "Error al abrir el enlace del video.", "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}}", "detectedFeed": "Feed {{platform}} detectado -> {{feed}}",
"detecting": "Detectando feed...", "detecting": "Detectando feed...",

View File

@@ -197,7 +197,8 @@
"subscription": "Abonnement" "subscription": "Abonnement"
}, },
"feedback": { "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": { "error": {
@@ -370,6 +371,7 @@
"audio": "Préférences Audio", "audio": "Préférences Audio",
"browserForCookies": "Sélectionner le navigateur pour utiliser les cookies", "browserForCookies": "Sélectionner le navigateur pour utiliser les cookies",
"browserForCookiesDescription": "Navigateur pour extraire les cookies pour l'authentification", "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", "browserForCookiesProfile": "Nom du profil ou chemin",
"browserForCookiesProfileDescription": "Chemin du profil pour le navigateur sélectionné ci-dessus. Rempli automatiquement si possible.", "browserForCookiesProfileDescription": "Chemin du profil pour le navigateur sélectionné ci-dessus. Rempli automatiquement si possible.",
"browserForCookiesProfilePlaceholder": "Nom du profil ou chemin complet (facultatif)", "browserForCookiesProfilePlaceholder": "Nom du profil ou chemin complet (facultatif)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "Cette vidéo est déjà en file d'attente", "itemAlreadyQueued": "Cette vidéo est déjà en file d'attente",
"queueError": "Échec de l'ajout à la file d'attente de téléchargement.", "queueError": "Échec de l'ajout à la file d'attente de téléchargement.",
"openLinkError": "Échec de l'ouverture du lien vidéo.", "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}}", "detectedFeed": "Flux {{platform}} détecté -> {{feed}}",
"detecting": "Détection du flux...", "detecting": "Détection du flux...",

View File

@@ -197,7 +197,8 @@
"subscription": "Berlangganan" "subscription": "Berlangganan"
}, },
"feedback": { "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": { "error": {
@@ -370,6 +371,7 @@
"audio": "Preferensi Audio", "audio": "Preferensi Audio",
"browserForCookies": "Pilih browser untuk menggunakan cookie", "browserForCookies": "Pilih browser untuk menggunakan cookie",
"browserForCookiesDescription": "Browser untuk mengekstrak cookie untuk autentikasi", "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", "browserForCookiesProfile": "Nama profil atau path",
"browserForCookiesProfileDescription": "Path profil untuk browser yang dipilih di atas. Diisi otomatis bila memungkinkan.", "browserForCookiesProfileDescription": "Path profil untuk browser yang dipilih di atas. Diisi otomatis bila memungkinkan.",
"browserForCookiesProfilePlaceholder": "Nama profil atau path lengkap (opsional)", "browserForCookiesProfilePlaceholder": "Nama profil atau path lengkap (opsional)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "Video ini sudah diantre", "itemAlreadyQueued": "Video ini sudah diantre",
"queueError": "Gagal menambahkan ke antrian unduhan.", "queueError": "Gagal menambahkan ke antrian unduhan.",
"openLinkError": "Gagal membuka tautan video.", "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}}", "detectedFeed": "Feed {{platform}} terdeteksi -> {{feed}}",
"detecting": "Mendeteksi feed...", "detecting": "Mendeteksi feed...",

View File

@@ -197,7 +197,8 @@
"subscription": "Sottoscrizione" "subscription": "Sottoscrizione"
}, },
"feedback": { "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": { "error": {
@@ -370,6 +371,7 @@
"audio": "Preferenze Audio", "audio": "Preferenze Audio",
"browserForCookies": "Seleziona browser per usare i cookie", "browserForCookies": "Seleziona browser per usare i cookie",
"browserForCookiesDescription": "Browser per estrarre i cookie per l'autenticazione", "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", "browserForCookiesProfile": "Nome profilo o percorso",
"browserForCookiesProfileDescription": "Percorso del profilo per il browser selezionato sopra. Compilato automaticamente quando possibile.", "browserForCookiesProfileDescription": "Percorso del profilo per il browser selezionato sopra. Compilato automaticamente quando possibile.",
"browserForCookiesProfilePlaceholder": "Nome profilo o percorso completo (opzionale)", "browserForCookiesProfilePlaceholder": "Nome profilo o percorso completo (opzionale)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "Questo video è già in coda", "itemAlreadyQueued": "Questo video è già in coda",
"queueError": "Impossibile aggiungere alla coda di download.", "queueError": "Impossibile aggiungere alla coda di download.",
"openLinkError": "Impossibile aprire il collegamento video.", "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}}", "detectedFeed": "Feed {{platform}} rilevato -> {{feed}}",
"detecting": "Rilevamento alimentazione...", "detecting": "Rilevamento alimentazione...",

View File

@@ -197,7 +197,8 @@
"subscription": "サブスクリプション" "subscription": "サブスクリプション"
}, },
"feedback": { "feedback": {
"title": "Report this error:" "title": "Report this error:",
"githubUrlTooLong": "このGitHubリンクは非常に長いです。開けない場合は、issueページを開いてログを手動で貼り付けてください。"
} }
}, },
"error": { "error": {
@@ -370,6 +371,7 @@
"audio": "オーディオ設定", "audio": "オーディオ設定",
"browserForCookies": "Cookieに使用するブラウザを選択", "browserForCookies": "Cookieに使用するブラウザを選択",
"browserForCookiesDescription": "認証用のCookieを抽出するブラウザ", "browserForCookiesDescription": "認証用のCookieを抽出するブラウザ",
"browserForCookiesWindowsNote": "Windows では Firefox の Cookie のみ対応しています。他のブラウザは Cookie ファイルを手動で設定してください。",
"browserForCookiesProfile": "プロファイル名またはパス", "browserForCookiesProfile": "プロファイル名またはパス",
"browserForCookiesProfileDescription": "上で選択したブラウザのプロファイルパス。可能な場合は自動入力されます。", "browserForCookiesProfileDescription": "上で選択したブラウザのプロファイルパス。可能な場合は自動入力されます。",
"browserForCookiesProfilePlaceholder": "プロファイル名または完全なパス(任意)", "browserForCookiesProfilePlaceholder": "プロファイル名または完全なパス(任意)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "このビデオはすでにキューに登録されています", "itemAlreadyQueued": "このビデオはすでにキューに登録されています",
"queueError": "ダウンロードキューへの追加に失敗しました。", "queueError": "ダウンロードキューへの追加に失敗しました。",
"openLinkError": "ビデオリンクを開けませんでした。", "openLinkError": "ビデオリンクを開けませんでした。",
"resolveError": "RSS フィード URL を解決できませんでした。" "resolveError": "RSS フィード URL を解決できませんでした。",
"duplicateUrl": "このRSSフィードはすでに登録されています。"
}, },
"detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}", "detectedFeed": "{{platform}} フィードが検出されました -> {{feed}}",
"detecting": "フィードを検出中...", "detecting": "フィードを検出中...",

View File

@@ -197,7 +197,8 @@
"subscription": "신청" "subscription": "신청"
}, },
"feedback": { "feedback": {
"title": "Report this error:" "title": "Report this error:",
"githubUrlTooLong": "이 GitHub 링크가 매우 깁니다. 열리지 않으면 이슈 페이지를 열고 로그를 수동으로 붙여 넣어 주세요."
} }
}, },
"error": { "error": {
@@ -370,6 +371,7 @@
"audio": "오디오 환경설정", "audio": "오디오 환경설정",
"browserForCookies": "쿠키를 사용할 브라우저 선택", "browserForCookies": "쿠키를 사용할 브라우저 선택",
"browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저", "browserForCookiesDescription": "인증을 위한 쿠키 추출 브라우저",
"browserForCookiesWindowsNote": "Windows에서는 Firefox 쿠키만 지원됩니다. 다른 브라우저는 쿠키 파일을 수동으로 설정하세요.",
"browserForCookiesProfile": "프로필 이름 또는 경로", "browserForCookiesProfile": "프로필 이름 또는 경로",
"browserForCookiesProfileDescription": "위에서 선택한 브라우저의 프로필 경로입니다. 가능하면 자동으로 채워집니다.", "browserForCookiesProfileDescription": "위에서 선택한 브라우저의 프로필 경로입니다. 가능하면 자동으로 채워집니다.",
"browserForCookiesProfilePlaceholder": "프로필 이름 또는 전체 경로(선택 사항)", "browserForCookiesProfilePlaceholder": "프로필 이름 또는 전체 경로(선택 사항)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.", "itemAlreadyQueued": "이 동영상은 이미 대기열에 있습니다.",
"queueError": "다운로드 대기열에 추가하지 못했습니다.", "queueError": "다운로드 대기열에 추가하지 못했습니다.",
"openLinkError": "동영상 링크를 열지 못했습니다.", "openLinkError": "동영상 링크를 열지 못했습니다.",
"resolveError": "RSS 피드 URL을 확인하지 못했습니다." "resolveError": "RSS 피드 URL을 확인하지 못했습니다.",
"duplicateUrl": "이 RSS 피드는 이미 구독되어 있습니다."
}, },
"detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}", "detectedFeed": "{{platform}} 피드 감지됨 -> {{feed}}",
"detecting": "피드 감지 중...", "detecting": "피드 감지 중...",

View File

@@ -197,7 +197,8 @@
"subscription": "Subscrição" "subscription": "Subscrição"
}, },
"feedback": { "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": { "error": {
@@ -370,6 +371,7 @@
"audio": "Preferências de Áudio", "audio": "Preferências de Áudio",
"browserForCookies": "Selecionar navegador para usar cookies", "browserForCookies": "Selecionar navegador para usar cookies",
"browserForCookiesDescription": "Navegador para extrair cookies para autenticação", "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", "browserForCookiesProfile": "Nome do perfil ou caminho",
"browserForCookiesProfileDescription": "Caminho do perfil para o navegador selecionado acima. Preenchido automaticamente quando possível.", "browserForCookiesProfileDescription": "Caminho do perfil para o navegador selecionado acima. Preenchido automaticamente quando possível.",
"browserForCookiesProfilePlaceholder": "Nome do perfil ou caminho completo (opcional)", "browserForCookiesProfilePlaceholder": "Nome do perfil ou caminho completo (opcional)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "Este vídeo já está na fila", "itemAlreadyQueued": "Este vídeo já está na fila",
"queueError": "Falha ao adicionar à fila de download.", "queueError": "Falha ao adicionar à fila de download.",
"openLinkError": "Falha ao abrir o link do vídeo.", "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}}", "detectedFeed": "Feed de {{platform}} detectado -> {{feed}}",
"detecting": "Detectando feed...", "detecting": "Detectando feed...",

View File

@@ -197,7 +197,8 @@
"subscription": "Подписка" "subscription": "Подписка"
}, },
"feedback": { "feedback": {
"title": "Report this error:" "title": "Report this error:",
"githubUrlTooLong": "Эта ссылка GitHub очень длинная. Если она не открывается, откройте страницу issue и вставьте логи вручную."
} }
}, },
"error": { "error": {
@@ -370,6 +371,7 @@
"audio": "Настройки аудио", "audio": "Настройки аудио",
"browserForCookies": "Выбрать браузер для использования cookie", "browserForCookies": "Выбрать браузер для использования cookie",
"browserForCookiesDescription": "Браузер для извлечения cookie для аутентификации", "browserForCookiesDescription": "Браузер для извлечения cookie для аутентификации",
"browserForCookiesWindowsNote": "В Windows поддерживаются только cookie Firefox. Для других браузеров вручную укажите файл cookie.",
"browserForCookiesProfile": "Имя профиля или путь", "browserForCookiesProfile": "Имя профиля или путь",
"browserForCookiesProfileDescription": "Путь профиля для выбранного выше браузера. Заполняется автоматически, если возможно.", "browserForCookiesProfileDescription": "Путь профиля для выбранного выше браузера. Заполняется автоматически, если возможно.",
"browserForCookiesProfilePlaceholder": "Имя профиля или полный путь (необязательно)", "browserForCookiesProfilePlaceholder": "Имя профиля или полный путь (необязательно)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "Это видео уже в очереди", "itemAlreadyQueued": "Это видео уже в очереди",
"queueError": "Не удалось добавить в очередь загрузки.", "queueError": "Не удалось добавить в очередь загрузки.",
"openLinkError": "Не удалось открыть ссылку на видео.", "openLinkError": "Не удалось открыть ссылку на видео.",
"resolveError": "Не удалось разрешить URL RSS-канала." "resolveError": "Не удалось разрешить URL RSS-канала.",
"duplicateUrl": "Этот RSS-канал уже добавлен."
}, },
"detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}", "detectedFeed": "Обнаружен канал {{platform}} -> {{feed}}",
"detecting": "Определение канала...", "detecting": "Определение канала...",

View File

@@ -197,7 +197,8 @@
"subscription": "訂閱" "subscription": "訂閱"
}, },
"feedback": { "feedback": {
"title": "Report this error:" "title": "Report this error:",
"githubUrlTooLong": "這個 GitHub 連結很長。如果無法開啟,請開啟 issue 頁面並手動貼上日誌。"
} }
}, },
"error": { "error": {
@@ -370,6 +371,7 @@
"audio": "音訊偏好", "audio": "音訊偏好",
"browserForCookies": "選擇用於讀取 Cookie 的瀏覽器", "browserForCookies": "選擇用於讀取 Cookie 的瀏覽器",
"browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取", "browserForCookiesDescription": "用於身份驗證的瀏覽器 Cookie 提取",
"browserForCookiesWindowsNote": "Windows 僅支援 Firefox 的 cookie其他瀏覽器請手動設定 cookie 檔案。",
"browserForCookiesProfile": "設定檔名稱或路徑", "browserForCookiesProfile": "設定檔名稱或路徑",
"browserForCookiesProfileDescription": "上方選取之瀏覽器的設定檔路徑。如可用將自動填入。", "browserForCookiesProfileDescription": "上方選取之瀏覽器的設定檔路徑。如可用將自動填入。",
"browserForCookiesProfilePlaceholder": "設定檔名稱或完整路徑(選填)", "browserForCookiesProfilePlaceholder": "設定檔名稱或完整路徑(選填)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "該視頻已排隊", "itemAlreadyQueued": "該視頻已排隊",
"queueError": "無法添加到下載隊列。", "queueError": "無法添加到下載隊列。",
"openLinkError": "無法打開視頻鏈接。", "openLinkError": "無法打開視頻鏈接。",
"resolveError": "無法解析 RSS 源 URL。" "resolveError": "無法解析 RSS 源 URL。",
"duplicateUrl": "此 RSS 訂閱已存在。"
}, },
"detectedFeed": "檢測到 {{platform}} feed -> {{feed}}", "detectedFeed": "檢測到 {{platform}} feed -> {{feed}}",
"detecting": "檢測飼料...", "detecting": "檢測飼料...",

View File

@@ -197,7 +197,8 @@
"subscription": "订阅" "subscription": "订阅"
}, },
"feedback": { "feedback": {
"title": "Report this error:" "title": "Report this error:",
"githubUrlTooLong": "这个 GitHub 链接很长。如果无法打开,请打开 issue 页面并手动粘贴日志。"
} }
}, },
"error": { "error": {
@@ -370,6 +371,7 @@
"audio": "音频偏好", "audio": "音频偏好",
"browserForCookies": "选择用于读取 Cookie 的浏览器", "browserForCookies": "选择用于读取 Cookie 的浏览器",
"browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取", "browserForCookiesDescription": "用于身份验证的浏览器 Cookie 提取",
"browserForCookiesWindowsNote": "Windows 仅支持 Firefox 的 cookie其他浏览器请手动配置 cookie 文件。",
"browserForCookiesProfile": "配置文件名称或路径", "browserForCookiesProfile": "配置文件名称或路径",
"browserForCookiesProfileDescription": "上方所选浏览器的配置文件路径。如可用会自动填写。", "browserForCookiesProfileDescription": "上方所选浏览器的配置文件路径。如可用会自动填写。",
"browserForCookiesProfilePlaceholder": "配置文件名称或完整路径(可选)", "browserForCookiesProfilePlaceholder": "配置文件名称或完整路径(可选)",
@@ -545,7 +547,8 @@
"itemAlreadyQueued": "该视频已排队", "itemAlreadyQueued": "该视频已排队",
"queueError": "无法添加到下载队列。", "queueError": "无法添加到下载队列。",
"openLinkError": "无法打开视频链接。", "openLinkError": "无法打开视频链接。",
"resolveError": "无法解析 RSS 源 URL。" "resolveError": "无法解析 RSS 源 URL。",
"duplicateUrl": "该 RSS 订阅已存在。"
}, },
"detectedFeed": "检测到 {{platform}} feed -> {{feed}}", "detectedFeed": "检测到 {{platform}} feed -> {{feed}}",
"detecting": "检测饲料...", "detecting": "检测饲料...",

View File

@@ -445,7 +445,7 @@ export function About() {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<FeedbackLinkButtons <FeedbackLinkButtons
appInfo={{ appVersion, osVersion }} appInfo={{ appVersion, osVersion }}
issueTitle="[bug]: " useSimpleGithubUrl={true}
buttonClassName="gap-2" buttonClassName="gap-2"
iconClassName="h-4 w-4" iconClassName="h-4 w-4"
/> />

View File

@@ -520,30 +520,26 @@ export function Settings() {
<ItemSeparator /> <ItemSeparator />
{platform !== 'darwin' && ( <Item variant="muted">
<> <ItemContent>
<Item variant="muted"> <ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
<ItemContent> <ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle> </ItemContent>
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription> <ItemActions>
</ItemContent> <Switch
<ItemActions> checked={settings.embedThumbnail ?? false}
<Switch onCheckedChange={(value) => {
checked={settings.embedThumbnail ?? false} try {
onCheckedChange={(value) => { handleSettingChange('embedThumbnail', value)
try { } catch (error) {
handleSettingChange('embedThumbnail', value) logger.error('[Settings] Error toggling embedThumbnail:', error)
} catch (error) { }
logger.error('[Settings] Error toggling embedThumbnail:', error) }}
} />
}} </ItemActions>
/> </Item>
</ItemActions>
</Item>
<ItemSeparator /> <ItemSeparator />
</>
)}
<Item variant="muted"> <Item variant="muted">
<ItemContent> <ItemContent>
@@ -676,6 +672,9 @@ export function Settings() {
<ItemContent> <ItemContent>
<ItemTitle>{t('settings.browserForCookies')}</ItemTitle> <ItemTitle>{t('settings.browserForCookies')}</ItemTitle>
<ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription> <ItemDescription>{t('settings.browserForCookiesDescription')}</ItemDescription>
{platform === 'win32' && (
<ItemDescription>{t('settings.browserForCookiesWindowsNote')}</ItemDescription>
)}
</ItemContent> </ItemContent>
<ItemActions> <ItemActions>
{(() => { {(() => {

View File

@@ -35,6 +35,7 @@ import {
updateSubscriptionAtom updateSubscriptionAtom
} from '@renderer/store/subscriptions' } from '@renderer/store/subscriptions'
import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types' import type { DownloadStatus, SubscriptionFeedItem, SubscriptionRule } from '@shared/types'
import { SUBSCRIPTION_DUPLICATE_FEED_ERROR } from '@shared/types'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { Download, Edit, ExternalLink, Plus, Power, RefreshCw, Trash2 } from 'lucide-react' 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' 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({ function SubscriptionTab({
subscription, subscription,
onRefresh, onRefresh,
@@ -252,8 +288,17 @@ export function Subscriptions() {
} }
} }
await updateSubscription({ id, data: updatePayload }) try {
await refreshSubscription(id) 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] [refreshSubscription, updateSubscription, resolveFeed, t]
) )
@@ -281,7 +326,11 @@ export function Subscriptions() {
setAddDialogOpen(false) setAddDialogOpen(false)
} catch (error) { } catch (error) {
console.error('Failed to create subscription:', 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] [createSubscription, t]

View File

@@ -8,6 +8,9 @@ export type DownloadRecord = DownloadItem & {
savedFileName?: string savedFileName?: string
} }
const isFinalStatus = (status: DownloadHistoryItem['status']): boolean =>
status === 'completed' || status === 'error' || status === 'cancelled'
const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}` const recordKey = (entryType: DownloadRecord['entryType'], id: string) => `${entryType}:${id}`
const toActiveRecord = (item: DownloadItem): DownloadRecord => ({ const toActiveRecord = (item: DownloadItem): DownloadRecord => ({
@@ -51,6 +54,7 @@ export const downloadRecordsAtom = atom<Map<string, DownloadRecord>>(new Map())
export const addDownloadAtom = atom(null, (get, set, item: DownloadItem) => { export const addDownloadAtom = atom(null, (get, set, item: DownloadItem) => {
const downloads = new Map(get(downloadRecordsAtom)) const downloads = new Map(get(downloadRecordsAtom))
downloads.delete(recordKey('history', item.id))
downloads.set(recordKey('active', item.id), toActiveRecord(item)) downloads.set(recordKey('active', item.id), toActiveRecord(item))
set(downloadRecordsAtom, downloads) set(downloadRecordsAtom, downloads)
}) })
@@ -87,6 +91,14 @@ export const clearCompletedAtom = atom(null, (get, set) => {
export const addHistoryRecordAtom = atom(null, (get, set, item: DownloadHistoryItem) => { export const addHistoryRecordAtom = atom(null, (get, set, item: DownloadHistoryItem) => {
const downloads = new Map(get(downloadRecordsAtom)) 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)) downloads.set(recordKey('history', item.id), toHistoryRecord(item))
set(downloadRecordsAtom, downloads) set(downloadRecordsAtom, downloads)
}) })

View File

@@ -200,6 +200,8 @@ export type SubscriptionPlatform = 'youtube' | 'bilibili' | 'custom'
export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed' export type SubscriptionStatus = 'idle' | 'checking' | 'up-to-date' | 'failed'
export const SUBSCRIPTION_DUPLICATE_FEED_ERROR = 'SUBSCRIPTION_DUPLICATE_FEED_URL'
export interface SubscriptionRule { export interface SubscriptionRule {
id: string id: string
title: string title: string
@@ -299,7 +301,7 @@ export const defaultSettings: AppSettings = {
oneClickDownload: false, oneClickDownload: false,
oneClickDownloadType: 'video', oneClickDownloadType: 'video',
oneClickQuality: 'best', oneClickQuality: 'best',
closeToTray: false, closeToTray: true,
hideDockIcon: false, hideDockIcon: false,
launchAtLogin: false, launchAtLogin: false,
autoUpdate: true, autoUpdate: true,