Compare commits

...

19 Commits

Author SHA1 Message Date
Nexmoe
e2ab8dce60 chore: release v1.1.4 2026-01-09 21:39:01 +08:00
Nexmoe
c1806e5321 fix(main): hide window on autostart (#68) 2026-01-09 21:03:25 +08:00
Nexmoe
92494966c6 fix(settings): merge defaults for getAll (#66) 2026-01-09 20:53:08 +08:00
Nexmoe
f04680b8c2 chore: release v1.1.3 2026-01-02 16:17:42 +08:00
Nexmoe
a5b94411fe fix(download): handle empty format selections (#64) 2026-01-02 16:16:04 +08:00
Nexmoe
71ae4a4425 fix(download): add format fallback selectors (#63) 2026-01-01 14:41:59 +08:00
Nexmoe
e8413aefae feat(ui): show update indicator on About (#58)
* feat(ui): add update indicator for about

* fix(ci): remove unused import

* fix(ci): retry binary downloads
2025-12-26 16:46:55 +08:00
Nexmoe
b229225b6e chore: release v1.1.2 2025-12-26 13:04:52 +08:00
Nexmoe
5497ed6245 chore(templates): simplify bug report (#57) 2025-12-26 13:04:30 +08:00
Nexmoe
155bf652f2 fix(ci): harden download steps (#56) 2025-12-26 13:02:21 +08:00
Nexmoe
86bd77d995 fix(download): improve format fallbacks (#55) 2025-12-26 13:02:07 +08:00
Nexmoe
b38b356c22 feat(issue): add issue templates (#54) 2025-12-26 12:53:51 +08:00
Nexmoe
5020605590 chore: release v1.1.1 2025-12-25 22:20:18 +08:00
Nexmoe
b15c3d8ce5 feat(about): refresh description and links (#52) 2025-12-25 22:18:04 +08:00
Nexmoe
a51655697d fix(update): streamline update notification (#51) 2025-12-23 09:43:14 +08:00
Nexmoe
f5c26f5f02 Animate advanced options panels (#47) 2025-12-21 13:32:46 +08:00
Nexmoe
3bc9d76f6e Add JS runtime support for yt-dlp #36 (#46)
* Add yt-dlp JS runtime support

* Update build scripts and logging

* fix: update README to include DeepWiki badge and correct contributor badge logo
2025-12-21 10:38:04 +08:00
Nexmoe
5751d73d5b Limit electron languages to English (#45) 2025-12-20 22:34:06 +08:00
Nexmoe
50bd9f1659 fix: update README download links to point to new VidBee website
* Replace individual OS download links with a single link to the VidBee download page for improved clarity and accessibility.
2025-12-20 21:49:46 +08:00
33 changed files with 1152 additions and 467 deletions

37
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Bug Report
description: Report a problem or regression
title: "[Bug]: "
labels:
- bug
body:
- type: input
id: app_version
attributes:
label: App version
description: The VidBee version (e.g., 1.2.3)
placeholder: 1.2.3
validations:
required: true
- type: input
id: os_version
attributes:
label: OS version
description: Your operating system and version (e.g., macOS 14.2, Windows 11 23H2)
placeholder: macOS 14.2
validations:
required: true
- type: textarea
id: actual
attributes:
label: Observed behavior
placeholder: What actually happened
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs or screenshots
description: Paste relevant logs or add screenshots
placeholder: Attach files or paste logs here
validations:
required: false

View File

@@ -0,0 +1,38 @@
name: Feature Request
description: Suggest an idea or improvement
title: "[Feature]: "
labels:
- enhancement
body:
- type: textarea
id: problem
attributes:
label: Problem to solve
description: What problem are you trying to solve?
placeholder: I want to...
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed solution
description: Describe the feature or change you want
placeholder: It would be great if...
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other solutions or workarounds you considered
placeholder: I tried...
validations:
required: false
- type: textarea
id: extra
attributes:
label: Additional context
description: Add any other context or screenshots
placeholder: Links, screenshots, or related issues
validations:
required: false

View File

@@ -76,10 +76,10 @@ jobs:
FFMPEG_OUTPUT: ${{ matrix.ffmpeg_output }}
run: |
set -euo pipefail
curl -L "${{ 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
unzip -q ffmpeg-arm.zip -d ffmpeg-arm
curl -L "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_x86_url }}" -o ffmpeg-x86.zip
unzip -q ffmpeg-x86.zip -d ffmpeg-x86
arm_bin="ffmpeg-arm/${{ matrix.ffmpeg_inner_path }}"
@@ -103,7 +103,11 @@ jobs:
shell: bash
run: |
set -euo pipefail
curl -L "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "${{ matrix.ffmpeg_url }}" -o ffmpeg.tar.xz
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
echo "::error::Downloaded ffmpeg archive is not a valid tar.xz"
exit 1
fi
mkdir ffmpeg
tar -xf ffmpeg.tar.xz -C ffmpeg
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
@@ -112,7 +116,7 @@ jobs:
- name: Download yt-dlp binary
shell: bash
run: |
curl -L "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
curl -fL --retry 3 --retry-delay 2 --retry-connrefused "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${{ matrix.ytdlp_asset }}" -o "resources/${{ matrix.ytdlp_output }}"
if [[ "${{ matrix.platform }}" == "linux" ]] || [[ "${{ matrix.platform }}" == "macos" ]]; then
chmod +x "resources/${{ matrix.ytdlp_output }}"
fi
@@ -130,4 +134,3 @@ jobs:
name: dist-${{ matrix.os }}
path: dist/
retention-days: 1

View File

@@ -3,3 +3,4 @@
3. Support i18n, only translate the English version of en.json
4. use English for comments&console
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
6. Use Conventional Commits format for commit messages: `type(scope): subject`. Common types: feat, fix, docs, style, refactor, test, chore, perf, ci, build, revert. PR titles should also follow this format.

View File

@@ -5,11 +5,12 @@
<h3>VidBee</h3>
<p>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&style=flat-square&logo=github&label=Stars" /></a>
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?style=flat-square&logo=github&label=Contributors&labelColor=black" /></a>
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&style=flat-square&label=Downloads" /></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&style=flat-square&label=Latest%20Release" /></a>
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black&style=flat-square" /></a>
<a href="https://github.com/nexmoe/VidBee/stargazers"><img src="https://img.shields.io/github/stars/nexmoe/VidBee?color=ffcb47&labelColor=black&logo=github&label=Stars" /></a>
<a href="https://github.com/nexmoe/VidBee/graphs/contributors"><img src="https://img.shields.io/github/contributors/nexmoe/VidBee?ogo=github&label=Contributors&labelColor=black" /></a>
<a href="https://github.com/nexmoe/VidBee/releases"><img src="https://img.shields.io/github/downloads/nexmoe/VidBee/total?color=369eff&labelColor=black&logo=github&label=Downloads" /></a>
<a href="https://github.com/nexmoe/VidBee/releases/latest"><img src="https://img.shields.io/github/v/release/nexmoe/VidBee?color=369eff&labelColor=black&logo=github&label=Latest%20Release" /></a>
<a href="https://x.com/intent/follow?screen_name=nexmoex"><img src="https://img.shields.io/badge/Follow-blue?color=1d9bf0&logo=x&labelColor=black" /></a>
<a href="https://deepwiki.com/nexmoe/VidBee"><img src="https://deepwiki.com/badge.svg" alt="Ask DeepWiki"></a>
<br />
<br />
<a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="screenshots/main-interface.png" alt="VidBee Desktop" width="46%"/></a>
@@ -27,11 +28,7 @@ VidBee is currently under active development, and feedback is welcome for any [i
Feel free to try it using the following methods:
| Operating System | Source |
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Windows | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Windows-0078D6?style=flat-square&logo=windows&logoColor=white&labelColor=black" height="55"/></a> |
| macOS | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-macOS-000000?style=flat-square&logo=apple&logoColor=white&labelColor=black" height="55"/></a> |
| Linux | <a href="https://github.com/nexmoe/VidBee/releases/latest" target="_blank"><img src="https://img.shields.io/badge/Download-Linux-FCC624?style=flat-square&logo=linux&logoColor=white&labelColor=black" height="55"/></a> |
<a href="https://vidbee.org/download/" target="_blank"><img src="https://img.shields.io/badge/Download-VidBee-369eff?style=flat-square&logo=github&logoColor=white&labelColor=black" height="55"/></a>
### 🍎 macOS Installation Notes

View File

@@ -1,6 +1,6 @@
{
"scripts": {
"setup": "cp $CONDUCTOR_ROOT_PATH/resources/ resources/ && pnpm install",
"setup": "rm -rf resources && cp -r $CONDUCTOR_ROOT_PATH/resources resources && pnpm install",
"run": "pnpm run dev"
}
}

View File

@@ -50,3 +50,5 @@ publish:
url: https://github.com/nexmoe/vidbee/releases/latest/download
electronDownload:
mirror: https://npmmirror.com/mirrors/electron/
electronLanguages:
- en

View File

@@ -1,6 +1,6 @@
{
"name": "vidbee",
"version": "1.1.0",
"version": "1.1.4",
"description": "A modern Electron application for downloading videos and audios",
"main": "./out/main/index.js",
"author": "VidBee",
@@ -15,10 +15,10 @@
"build": "electron-vite build",
"setup": "node scripts/setup-dev-binaries.js",
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
"build:unpack": "pnpm run build && electron-builder --dir",
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir",
"build:win": "pnpm run setup && node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
"build:mac": "pnpm run setup && node scripts/check-ytdlp.js mac && pnpm run build && electron-builder --mac --x64 --arm64",
"build:linux": "pnpm run setup && node scripts/check-ytdlp.js linux && pnpm run build && electron-builder --linux",
"release": "git checkout main && git pull && pnpm run check && bumpp",
"db:generate": "drizzle-kit generate"
},
@@ -33,6 +33,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
@@ -47,6 +48,7 @@
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.18",
"drizzle-orm": "^0.44.7",
"electron-ipc-decorator": "^0.2.0",

60
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
'@radix-ui/react-label':
specifier: ^2.1.7
version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-progress':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -80,6 +83,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
dayjs:
specifier: ^1.11.18
version: 1.11.18
@@ -999,6 +1005,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -1896,6 +1915,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -4481,6 +4506,29 @@ snapshots:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.2.0)
aria-hidden: 1.2.6
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
react-remove-scroll: 2.7.1(@types/react@19.2.2)(react@19.2.0)
optionalDependencies:
'@types/react': 19.2.2
'@types/react-dom': 19.2.2(@types/react@19.2.2)
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -5429,6 +5477,18 @@ snapshots:
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.2.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
color-convert@2.0.1:
dependencies:
color-name: 1.1.4

View File

@@ -6,6 +6,8 @@ ffmpeg.exe
ffmpeg_macos
ffmpeg_linux
ffmpeg
deno.exe
deno
# But keep the README
!README.md

View File

@@ -69,3 +69,23 @@ ffmpeg is required for merging audio/video streams and audio extraction. Bundle
- Bundled binaries are required for Windows builds. On macOS/Linux the app can also use ffmpeg/yt-dlp from the system PATH.
- You can override the lookup paths via the `YTDLP_PATH` or `FFMPEG_PATH` environment variables if you prefer custom locations.
- File sizes: ~10-15 MB per yt-dlp binary, ~40-80 MB per ffmpeg binary
## JS Runtime (Deno)
yt-dlp uses an external JS runtime (Deno by default) for some extractors. Bundle a Deno binary so the app can run without system dependencies.
### Required Files
1. **Windows**: `deno.exe`
2. **macOS**: `deno`
3. **Linux**: `deno`
### How to Download
- Visit: <https://github.com/denoland/deno/releases/latest>
- Download the matching platform archive and extract the `deno` (or `deno.exe`) binary into `resources/`.
- On macOS/Linux ensure the file is executable: `chmod +x resources/deno`
### Note
- You can override the runtime path via `YTDLP_JS_RUNTIME_PATH` if needed.

View File

@@ -44,6 +44,17 @@ const binaries = [
linux: 'https://ffmpeg.org/download.html',
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
}
},
{
label: 'deno',
filenameMap: {
win: 'deno.exe',
mac: 'deno',
linux: 'deno'
},
help: {
default: 'https://github.com/denoland/deno/releases/latest'
}
}
]

View File

@@ -15,6 +15,7 @@ const http = require('node:http')
// Configuration
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
const GITHUB_TOKEN =
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
@@ -103,40 +104,80 @@ function ensureDir(dir) {
}
}
function safeUnlink(filePath) {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath)
}
}
function getDownloadHeaders(url) {
const headers = {
'User-Agent': 'vidbee-setup',
Accept: '*/*'
}
if (GITHUB_TOKEN && /github\.com|githubusercontent\.com/.test(url)) {
headers.Authorization = `Bearer ${GITHUB_TOKEN}`
}
return headers
}
function downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
const file = fs.createWriteStream(dest)
protocol
.get(url, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
fs.unlinkSync(dest)
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
}
if (response.statusCode !== 200) {
file.close()
fs.unlinkSync(dest)
return reject(new Error(`Failed to download: ${response.statusCode}`))
}
response.pipe(file)
file.on('finish', () => {
file.close()
resolve()
})
})
.on('error', (err) => {
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
if (response.statusCode === 302 || response.statusCode === 301) {
// Handle redirect
file.close()
fs.unlinkSync(dest)
reject(err)
safeUnlink(dest)
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
}
if (response.statusCode !== 200) {
file.close()
safeUnlink(dest)
return reject(new Error(`Failed to download: ${response.statusCode}`))
}
response.pipe(file)
file.on('finish', () => {
file.close()
resolve()
})
})
request.setTimeout(30000, () => {
request.destroy(new Error('Download timeout'))
})
request.on('error', (err) => {
file.close()
safeUnlink(dest)
reject(err)
})
})
}
async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
let lastError
for (let attempt = 1; attempt <= retries; attempt += 1) {
try {
await downloadFile(url, dest)
return
} catch (error) {
lastError = error
safeUnlink(dest)
if (attempt < retries) {
const backoff = delayMs * attempt
log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
await new Promise((resolve) => setTimeout(resolve, backoff))
}
}
}
throw lastError
}
function fetchJson(url) {
return new Promise((resolve, reject) => {
const protocol = url.startsWith('https') ? https : http
@@ -257,6 +298,32 @@ function fileExists(filePath) {
return fs.existsSync(filePath)
}
function getDenoAssetName(platform, arch) {
if (platform === 'win32') {
if (arch === 'arm64') {
return 'deno-aarch64-pc-windows-msvc.zip'
}
return 'deno-x86_64-pc-windows-msvc.zip'
}
if (platform === 'darwin') {
if (arch === 'arm64') {
return 'deno-aarch64-apple-darwin.zip'
}
return 'deno-x86_64-apple-darwin.zip'
}
if (platform === 'linux') {
if (arch === 'arm64') {
return 'deno-aarch64-unknown-linux-gnu.zip'
}
return 'deno-x86_64-unknown-linux-gnu.zip'
}
return null
}
function getDenoOutputName(platform) {
return platform === 'win32' ? 'deno.exe' : 'deno'
}
// Main download functions
async function downloadYtDlp(config) {
const { asset, output } = config.ytdlp
@@ -272,7 +339,7 @@ async function downloadYtDlp(config) {
const tempPath = path.join(RESOURCES_DIR, `.${asset}.tmp`)
try {
await downloadFile(url, tempPath)
await downloadFileWithRetry(url, tempPath)
fs.renameSync(tempPath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${output} successfully`, 'success')
@@ -315,7 +382,7 @@ async function downloadFfmpegWindows(config) {
}
try {
await downloadFile(downloadUrl, tempZip)
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -370,7 +437,7 @@ async function downloadFfmpegMac(config) {
}
try {
await downloadFile(downloadUrl, tempZip)
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -424,7 +491,7 @@ async function downloadFfmpegLinux(config) {
}
try {
await downloadFile(downloadUrl, tempTar)
await downloadFileWithRetry(downloadUrl, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
@@ -447,6 +514,52 @@ async function downloadFfmpegLinux(config) {
}
}
async function downloadDenoRuntime() {
const platform = os.platform()
const arch = os.arch()
const assetName = getDenoAssetName(platform, arch)
if (!assetName) {
log(`Skipping Deno runtime: unsupported platform/arch ${platform}/${arch}`, 'warn')
return
}
const outputName = getDenoOutputName(platform)
const outputPath = path.join(RESOURCES_DIR, outputName)
if (fileExists(outputPath)) {
log(`${outputName} already exists, skipping download`, 'info')
return
}
log(`Downloading Deno runtime (${platform}/${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'deno-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'deno-temp')
const downloadUrl = `${DENO_BASE_URL}/${assetName}`
try {
await downloadFileWithRetry(downloadUrl, tempZip)
log('Extracting Deno runtime...', 'info')
extractZip(tempZip, extractDir)
const sourcePath = path.join(extractDir, outputName)
if (!fileExists(sourcePath)) {
throw new Error(`Deno binary not found at ${sourcePath}`)
}
fs.copyFileSync(sourcePath, outputPath)
setExecutable(outputPath)
log(`Downloaded ${outputName} successfully`, 'success')
fs.unlinkSync(tempZip)
fs.rmSync(extractDir, { recursive: true, force: true })
} catch (error) {
if (fs.existsSync(tempZip)) fs.unlinkSync(tempZip)
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true })
throw error
}
}
// Main setup function
async function setup() {
const platform = os.platform()
@@ -464,6 +577,9 @@ async function setup() {
// Download yt-dlp
await downloadYtDlp(config)
// Download JS runtime (Deno)
await downloadDenoRuntime()
// Download ffmpeg
if (platform === 'win32') {
await downloadFfmpegWindows(config)

View File

@@ -5,15 +5,6 @@ import log from 'electron-log/main'
* Set log format, file path, transport methods, etc.
*/
export function configureLogger() {
// Configure console output format - support colors and scope, time in gray
log.transports.console.format = '%c{h}:{i}:{s}%c [{level}]{scope} {text}'
// Enable console colors
log.transports.console.useStyles = true
// Configure file output format - include scope information
log.transports.file.format = '[{y}-{m}-{d} {h}:{i}:{s}] [{level}] {scope} {text}'
// Set log levels
// Development: show all logs
// Production: show info level and above only

View File

@@ -21,6 +21,10 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
const format = options.format
const audioFormat = options.audioFormat
if (format && audioFormat === '') {
return format
}
if (format && (format.includes('/') || (audioFormat === undefined && format.includes('+')))) {
return format
}
@@ -30,8 +34,8 @@ export const resolveVideoFormatSelector = (options: DownloadOptions): string =>
return 'bestvideo+none'
}
if (!audioFormat || audioFormat === 'best') {
// Use bestvideo+bestaudio to ensure video and audio are merged into a single file
return 'bestvideo+bestaudio'
// Prefer merged formats, but allow single-file "best" for sites without separate streams.
return 'bestvideo+bestaudio/best'
}
return `bestvideo+${audioFormat}`
}
@@ -61,7 +65,8 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
export const buildDownloadArgs = (
options: DownloadOptions,
downloadPath: string,
settings: AppSettings
settings: AppSettings,
jsRuntimeArgs: string[] = []
): string[] => {
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
@@ -130,6 +135,10 @@ export const buildDownloadArgs = (
args.push('--config-location', configPath)
}
if (jsRuntimeArgs.length > 0) {
args.push(...jsRuntimeArgs)
}
args.push(options.url)
return args

View File

@@ -129,6 +129,7 @@ subscriptionManager.on('subscriptions:updated', (subscriptions) => {
export function createWindow(): void {
const isMac = process.platform === 'darwin'
const isWindows = process.platform === 'win32'
const shouldStartHidden = isWindows && app.getLoginItemSettings().wasOpenedAtLogin
const windowOptions: BrowserWindowConstructorOptions = {
width: 1200,
@@ -168,6 +169,9 @@ export function createWindow(): void {
})
mainWindow.on('ready-to-show', () => {
if (shouldStartHidden) {
return
}
mainWindow?.show()
})
@@ -317,9 +321,7 @@ function initAutoUpdater(): void {
if (mainWindow) {
mainWindow.webContents.send('update:show-notification', {
title: 'Update Ready',
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
icon: 'app-icon'
version: info.version
})
}
})

View File

@@ -1,6 +1,7 @@
import os from 'node:os'
import { app, BrowserWindow, dialog } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { scopedLoggers } from '../../utils/logger'
class AppService extends IpcService {
static readonly groupName = 'app'
@@ -48,7 +49,7 @@ class AppService extends IpcService {
const base64 = buffer.toString('base64')
return `data:${contentType};base64,${base64}`
} catch (error) {
console.error('Failed to fetch site icon:', error)
scopedLoggers.system.error('Failed to fetch site icon:', error)
return null
}
}

View File

@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import { clipboard, dialog, shell } from 'electron'
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
import { scopedLoggers } from '../../utils/logger'
const execFileAsync = promisify(execFile)
@@ -49,7 +50,10 @@ class FileSystemService extends IpcService {
return xdgPath
}
} catch (error) {
console.warn('Unable to resolve XDG download directory, falling back to default:', error)
scopedLoggers.system.warn(
'Unable to resolve XDG download directory, falling back to default:',
error
)
}
}
@@ -75,7 +79,7 @@ class FileSystemService extends IpcService {
if (stats?.isDirectory()) {
const result = await shell.openPath(normalizedPath)
if (result) {
console.error('Failed to open directory:', result)
scopedLoggers.system.error('Failed to open directory:', result)
return false
}
return true
@@ -88,16 +92,16 @@ class FileSystemService extends IpcService {
if (parentStats?.isDirectory()) {
const result = await shell.openPath(parentDirectory)
if (result) {
console.error('Failed to open parent directory:', result)
scopedLoggers.system.error('Failed to open parent directory:', result)
return false
}
return true
}
console.error('File or directory does not exist:', normalizedPath)
scopedLoggers.system.error('File or directory does not exist:', normalizedPath)
return false
} catch (error) {
console.error('Failed to open file location:', error)
scopedLoggers.system.error('Failed to open file location:', error)
return false
}
}
@@ -122,7 +126,7 @@ class FileSystemService extends IpcService {
return true
} catch (error) {
console.error('Failed to copy file to clipboard:', error)
scopedLoggers.system.error('Failed to copy file to clipboard:', error)
return false
}
}
@@ -150,7 +154,7 @@ class FileSystemService extends IpcService {
await shell.openExternal(url)
return true
} catch (error) {
console.error('Failed to open external URL:', error)
scopedLoggers.system.error('Failed to open external URL:', error)
return false
}
}
@@ -166,7 +170,10 @@ class FileSystemService extends IpcService {
])
return
} catch (error) {
console.error('PowerShell clipboard copy failed, falling back to manual buffer:', error)
scopedLoggers.system.error(
'PowerShell clipboard copy failed, falling back to manual buffer:',
error
)
}
const winPath = resolvedPath.replace(/\//g, '\\')
@@ -194,7 +201,10 @@ class FileSystemService extends IpcService {
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
return
} catch (error) {
console.error('osascript clipboard copy failed, falling back to manual buffer:', error)
scopedLoggers.system.error(
'osascript clipboard copy failed, falling back to manual buffer:',
error
)
}
const entries = [
@@ -243,7 +253,7 @@ class FileSystemService extends IpcService {
return stats?.isFile() ?? false
} catch (error) {
console.error('Failed to check file existence:', error)
scopedLoggers.system.error('Failed to check file existence:', error)
return false
}
}
@@ -295,7 +305,7 @@ class FileSystemService extends IpcService {
return false
} catch (error) {
console.error('Failed to delete file:', error)
scopedLoggers.system.error('Failed to delete file:', error)
return false
}
}

View File

@@ -36,6 +36,19 @@ interface DownloadProcess {
process: YTDlpEventEmitter
}
const formatYtDlpCommand = (args: string[]): string => {
const quoted = args.map((arg) => {
if (arg === '') {
return '""'
}
if (/[\s"'\\]/.test(arg)) {
return `"${arg.replace(/(["\\])/g, '\\$1')}"`
}
return arg
})
return `yt-dlp ${quoted.join(' ')}`
}
const ensureDirectoryExists = (dir?: string): void => {
if (!dir) {
return
@@ -145,6 +158,13 @@ const resolveHistoryDownloadPath = (
return path.join(basePath, templateDir)
}
const appendJsRuntimeArgs = (args: string[]): void => {
const runtimeArgs = ytdlpManager.getJsRuntimeArgs()
if (runtimeArgs.length > 0) {
args.push(...runtimeArgs)
}
}
class DownloadEngine extends EventEmitter {
private activeDownloads: Map<string, DownloadProcess> = new Map()
private queue: DownloadQueue
@@ -194,6 +214,7 @@ class DownloadEngine extends EventEmitter {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
return new Promise((resolve, reject) => {
@@ -290,6 +311,7 @@ class DownloadEngine extends EventEmitter {
args.push('--config-location', configPath)
}
appendJsRuntimeArgs(args)
args.push(url)
type RawPlaylistEntry = {
@@ -511,7 +533,7 @@ class DownloadEngine extends EventEmitter {
startDownload(id: string, options: DownloadOptions): void {
if (this.activeDownloads.has(id)) {
console.warn(`Download ${id} is already active`)
scopedLoggers.engine.warn(`Download ${id} is already active`)
return
}
@@ -644,7 +666,12 @@ class DownloadEngine extends EventEmitter {
return true
}
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
const args = buildDownloadArgs(
options,
resolvedDownloadPath,
settings,
ytdlpManager.getJsRuntimeArgs()
)
const captureOutputPath = (rawPath: string | undefined): void => {
if (!rawPath) {
@@ -718,6 +745,8 @@ class DownloadEngine extends EventEmitter {
args.push('--ffmpeg-location', ffmpegPath)
args.push(urlArg)
scopedLoggers.download.info('yt-dlp command:', formatYtDlpCommand(args))
const controller = new AbortController()
const ytdlpProcess = ytdlp.exec(args, {
signal: controller.signal

View File

@@ -2,13 +2,14 @@ import { execSync } from 'node:child_process'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { scopedLoggers } from '../utils/logger'
class FfmpegManager {
private ffmpegPath: string | null = null
async initialize(): Promise<void> {
this.ffmpegPath = await this.findFfmpegBinary()
console.log('ffmpeg initialized at:', this.ffmpegPath)
scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)
}
getPath(): string {
@@ -30,7 +31,7 @@ class FfmpegManager {
const resourceCandidates: string[] = []
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
console.log('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
scopedLoggers.engine.info('Using ffmpeg from FFMPEG_PATH:', process.env.FFMPEG_PATH)
return process.env.FFMPEG_PATH
}
@@ -50,10 +51,13 @@ class FfmpegManager {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
console.warn('Failed to set executable permission on ffmpeg binary:', error)
scopedLoggers.engine.warn(
'Failed to set executable permission on ffmpeg binary:',
error
)
}
}
console.log('Using bundled ffmpeg:', fullPath)
scopedLoggers.engine.info('Using bundled ffmpeg:', fullPath)
return fullPath
}
}
@@ -62,7 +66,7 @@ class FfmpegManager {
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
for (const candidate of commonPaths) {
if (fs.existsSync(candidate)) {
console.log('Using system ffmpeg:', candidate)
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
return candidate
}
}
@@ -72,7 +76,7 @@ class FfmpegManager {
try {
const systemPath = execSync('which ffmpeg').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system ffmpeg:', systemPath)
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
return systemPath
}
} catch (_error) {
@@ -84,7 +88,7 @@ class FfmpegManager {
try {
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
console.log('Using system ffmpeg:', output)
scopedLoggers.engine.info('Using system ffmpeg:', output)
return output
}
} catch (_error) {

View File

@@ -4,6 +4,7 @@ import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
import { app } from 'electron'
import { scopedLoggers } from '../utils/logger'
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
@@ -81,7 +82,7 @@ export class ThumbnailCache {
await fsPromises.writeFile(finalPath, buffer)
return this.toAppProtocolUrl(finalPath)
} catch (error) {
console.error('Failed to cache thumbnail:', error)
scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)
return null
}
}

View File

@@ -3,6 +3,7 @@ import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import type YTDlpWrap from 'yt-dlp-wrap-plus'
import { scopedLoggers } from '../utils/logger'
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
@@ -12,11 +13,13 @@ type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
class YtDlpManager {
private ytdlpPath: string | null = null
private ytdlpInstance: YTDlpWrapInstance | null = null
private jsRuntimeArgs: string[] = []
async initialize(): Promise<void> {
this.ytdlpPath = await this.findOrDownloadYtDlp()
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
console.log('yt-dlp initialized at:', this.ytdlpPath)
this.jsRuntimeArgs = this.resolveJsRuntimeArgs()
scopedLoggers.engine.info('yt-dlp initialized at:', this.ytdlpPath)
}
getInstance(): YTDlpWrapInstance {
@@ -33,6 +36,10 @@ class YtDlpManager {
return this.ytdlpPath
}
getJsRuntimeArgs(): string[] {
return [...this.jsRuntimeArgs]
}
private getResourcesPath(): string {
// In development, read from project root's resources
if (process.env.NODE_ENV === 'development') {
@@ -57,7 +64,7 @@ class YtDlpManager {
// Check environment variable first
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
console.log('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
scopedLoggers.engine.info('Using yt-dlp from YTDLP_PATH:', process.env.YTDLP_PATH)
return process.env.YTDLP_PATH
}
@@ -65,13 +72,13 @@ class YtDlpManager {
const resourcesPath = this.getResourcesPath()
const bundledPath = path.join(resourcesPath, bundledName)
if (fs.existsSync(bundledPath)) {
console.log('Using bundled yt-dlp:', bundledPath)
scopedLoggers.engine.info('Using bundled yt-dlp:', bundledPath)
// Make executable on Unix-like systems if needed
if (platform !== 'win32') {
try {
fs.chmodSync(bundledPath, 0o755)
} catch (error) {
console.warn('Failed to set executable permission:', error)
scopedLoggers.engine.warn('Failed to set executable permission:', error)
}
}
return bundledPath
@@ -82,7 +89,7 @@ class YtDlpManager {
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
console.log('Using system yt-dlp:', p)
scopedLoggers.engine.info('Using system yt-dlp:', p)
return p
}
}
@@ -93,7 +100,7 @@ class YtDlpManager {
try {
const systemPath = execSync('which yt-dlp').toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
console.log('Using system yt-dlp:', systemPath)
scopedLoggers.engine.info('Using system yt-dlp:', systemPath)
return systemPath
}
} catch (_error) {
@@ -115,6 +122,92 @@ class YtDlpManager {
)
}
private resolveJsRuntimeArgs(): string[] {
const runtime = (process.env.YTDLP_JS_RUNTIME || 'deno').trim()
if (!runtime || runtime === 'none') {
return []
}
const runtimePath = this.resolveJsRuntimePath(runtime)
if (runtimePath) {
return ['--js-runtimes', `${runtime}:${runtimePath}`]
}
if (process.env.YTDLP_JS_RUNTIME) {
scopedLoggers.engine.warn(
`Requested JS runtime "${runtime}" was not found. Falling back to yt-dlp default detection.`
)
} else {
scopedLoggers.engine.warn(
'JS runtime not found. YouTube support may be limited without an external JS runtime.'
)
}
return process.env.YTDLP_JS_RUNTIME ? ['--js-runtimes', runtime] : []
}
private resolveJsRuntimePath(runtime: string): string | null {
const envPath = process.env.YTDLP_JS_RUNTIME_PATH?.trim()
if (envPath && fs.existsSync(envPath)) {
scopedLoggers.engine.info('Using JS runtime from YTDLP_JS_RUNTIME_PATH:', envPath)
return envPath
}
const platform = os.platform()
const resourcesPath = this.getResourcesPath()
const resourceCandidates: string[] = []
if (runtime === 'deno') {
resourceCandidates.push(platform === 'win32' ? 'deno.exe' : 'deno')
} else if (runtime === 'node') {
resourceCandidates.push(platform === 'win32' ? 'node.exe' : 'node')
} else if (runtime === 'bun') {
resourceCandidates.push(platform === 'win32' ? 'bun.exe' : 'bun')
} else if (runtime === 'quickjs') {
resourceCandidates.push(platform === 'win32' ? 'qjs.exe' : 'qjs')
} else {
resourceCandidates.push(runtime)
if (platform === 'win32' && !runtime.endsWith('.exe')) {
resourceCandidates.push(`${runtime}.exe`)
}
}
for (const candidate of resourceCandidates) {
const fullPath = path.join(resourcesPath, candidate)
if (fs.existsSync(fullPath)) {
if (platform !== 'win32') {
try {
fs.chmodSync(fullPath, 0o755)
} catch (error) {
scopedLoggers.engine.warn('Failed to set executable permission on JS runtime:', error)
}
}
scopedLoggers.engine.info('Using bundled JS runtime:', fullPath)
return fullPath
}
}
try {
if (platform === 'win32') {
const output = execSync(`where ${runtime}`).toString().split(/\r?\n/)[0]
if (output && fs.existsSync(output)) {
scopedLoggers.engine.info('Using system JS runtime:', output)
return output
}
} else {
const systemPath = execSync(`which ${runtime}`).toString().trim()
if (systemPath && fs.existsSync(systemPath)) {
scopedLoggers.engine.info('Using system JS runtime:', systemPath)
return systemPath
}
}
} catch (_error) {
// Runtime not found in PATH
}
return null
}
// Removed runtime download/update to avoid network dependency in production builds
}

View File

@@ -3,6 +3,7 @@ import os from 'node:os'
import path from 'node:path'
import type { AppSettings } from '../shared/types'
import { defaultSettings } from '../shared/types'
import { scopedLoggers } from './utils/logger'
// Use require for electron-store to avoid CommonJS/ESM issues
const ElectronStore = require('electron-store')
@@ -14,7 +15,7 @@ const ensureDirectoryExists = (dir: string) => {
try {
fs.mkdirSync(dir, { recursive: true })
} catch (error) {
console.error('Failed to ensure download directory:', error)
scopedLoggers.system.error('Failed to ensure download directory:', error)
}
}
@@ -52,7 +53,11 @@ class SettingsManager {
}
getAll(): AppSettings {
return this.store.store
return {
...defaultSettings,
downloadPath: DEFAULT_DOWNLOAD_PATH,
...this.store.store
}
}
setAll(settings: Partial<AppSettings>): void {
@@ -85,7 +90,7 @@ class SettingsManager {
}
ensureDirectoryExists(normalizedDownloadPath)
} catch (error) {
console.error('Failed to verify download directory:', error)
scopedLoggers.system.error('Failed to verify download directory:', error)
}
}
}

View File

@@ -16,6 +16,7 @@ import { Settings } from './pages/Settings'
import { Subscriptions } from './pages/Subscriptions'
import { loadSettingsAtom, settingsAtom } from './store/settings'
import { loadSubscriptionsAtom, setSubscriptionsAtom } from './store/subscriptions'
import { updateAvailableAtom, updateReadyAtom } from './store/update'
type Page = 'home' | 'subscriptions' | 'settings' | 'about'
@@ -51,6 +52,8 @@ function AppContent() {
const setSubscriptions = useSetAtom(setSubscriptionsAtom)
const [settings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
const setUpdateReady = useSetAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const { t } = useTranslation()
const updateDownloadInProgressRef = useRef(false)
const analyticsScriptRef = useRef<HTMLScriptElement | null>(null)
@@ -168,34 +171,31 @@ function AppContent() {
return
}
const showRestartPrompt = () => {
toast.info(t('about.notifications.restartToUpdate'), {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
void ipcServices.update.quitAndInstall()
}
}
})
}
const resetDownloadState = () => {
if (updateDownloadInProgressRef.current) {
updateDownloadInProgressRef.current = false
}
}
const handleUpdateAvailable = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
setUpdateAvailable({
available: true,
version: info.version
})
}
const handleUpdateDownloaded = (rawInfo: unknown) => {
const info = (rawInfo ?? {}) as { version?: string }
resetDownloadState()
const versionLabel = info?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.success(downloadedMessage)
showRestartPrompt()
setUpdateReady({
ready: true,
version: info.version
})
setUpdateAvailable({
available: true,
version: info.version
})
}
const handleUpdateError = (rawMessage: unknown) => {
@@ -214,13 +214,13 @@ function AppContent() {
}
const handleUpdateNotification = (rawPayload: unknown) => {
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
const versionLabel = payload.version ?? ''
const payload = (rawPayload ?? {}) as { version?: string }
const versionLabel = payload?.version ?? ''
const downloadedMessage = versionLabel
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
: t('about.notifications.updateDownloaded')
toast.info(payload?.body ?? downloadedMessage, {
toast.info(downloadedMessage, {
action: {
label: t('about.notifications.restartNowAction'),
onClick: () => {
@@ -231,19 +231,21 @@ function AppContent() {
}
// Only listen to update events that should be shown globally
// update:available is handled in About page only
// update:available shows a visual indicator in the sidebar
ipcEvents.on('update:available', handleUpdateAvailable)
ipcEvents.on('update:downloaded', handleUpdateDownloaded)
ipcEvents.on('update:error', handleUpdateError)
ipcEvents.on('update:download-progress', handleDownloadProgress)
ipcEvents.on('update:show-notification', handleUpdateNotification)
return () => {
ipcEvents.removeListener('update:available', handleUpdateAvailable)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
ipcEvents.removeListener('update:error', handleUpdateError)
ipcEvents.removeListener('update:download-progress', handleDownloadProgress)
ipcEvents.removeListener('update:show-notification', handleUpdateNotification)
}
}, [t])
}, [setUpdateAvailable, setUpdateReady, t])
return (
<div className="flex flex-row h-screen">

View File

@@ -20,6 +20,7 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { popularSites } from '@renderer/data/popularSites'
import { cn } from '@renderer/lib/utils'
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
@@ -77,11 +78,11 @@ const getQualityPreset = (settings: AppSettings): OneClickQualityPreset =>
const buildAudioSelectors = (preset: OneClickQualityPreset): string[] => {
if (preset === 'worst') {
return ['worstaudio']
return dedupe(['worstaudio', 'bestaudio'])
}
const abrLimit = qualityPresetToAudioAbr[preset]
// Remove 'best' fallback to ensure merging - only use 'bestaudio' variants
// Prefer audio-only selectors so video+audio merges remain valid.
return dedupe([abrLimit ? `bestaudio[abr<=${abrLimit}]` : undefined, 'bestaudio'])
}
@@ -89,8 +90,8 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
const preset = getQualityPreset(settings)
if (preset === 'worst') {
// Use worstvideo+worstaudio as fallback instead of 'worst' to ensure merging
return 'worstvideo+worstaudio'
// Prefer separate streams, then fall back to single-file selectors.
return 'worstvideo+worstaudio/worst/best'
}
const maxHeight = qualityPresetToVideoHeight[preset]
@@ -113,16 +114,18 @@ const buildVideoFormatPreference = (settings: AppSettings): string => {
combinations.push(video)
}
} else {
// Use bestvideo+bestaudio as fallback instead of 'best' to ensure merging
// Prefer merged formats, then allow 'best' as a compatibility fallback.
combinations.push('bestvideo+bestaudio')
}
combinations.push('best')
return dedupe(combinations).join('/')
}
const buildAudioFormatPreference = (settings: AppSettings): string => {
const selectors = buildAudioSelectors(getQualityPreset(settings))
return selectors.join('/')
return dedupe([...selectors, 'best']).join('/')
}
interface DownloadDialogProps {
@@ -787,11 +790,12 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
type,
format:
type === 'video'
? videoInfoCardState.selectedVideoFormat
? videoInfoCardState.selectedVideoFormat || undefined
: type === 'extract'
? undefined
: videoInfoCardState.selectedAudioFormat,
audioFormat: type === 'video' ? videoInfoCardState.selectedAudioForVideo : undefined,
: videoInfoCardState.selectedAudioFormat || undefined,
audioFormat:
type === 'video' ? videoInfoCardState.selectedAudioForVideo || undefined : undefined,
extractFormat:
type === 'extract' ? videoInfoCardState.audioExtractor.extractFormat : undefined,
extractQuality:
@@ -934,29 +938,33 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
{t('sites.viewAll')}
</Button>
</div>
{settings.oneClickDownload && (
<div className="flex items-center gap-2 text-xs text-primary">
<div className="w-1.5 h-1.5 rounded-full bg-primary" />
<span>{t('download.oneClickDownloadEnabled')}</span>
{onOpenSettings && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-auto"
onClick={() => {
setOpen(false)
onOpenSettings()
}}
>
{t('download.goToSettings')}
</Button>
)}
</div>
)}
</div>
{/* One-click download indicator */}
{settings.oneClickDownload && (
<div className="w-full">
<div className="rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t('download.oneClickDownloadEnabled')}</span>
{onOpenSettings && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-xs h-auto"
onClick={() => {
setOpen(false)
onOpenSettings()
}}
>
{t('download.goToSettings')}
</Button>
)}
</div>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3">
@@ -1042,23 +1050,41 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
)}
{/* Advanced Options Content - Single Video */}
{videoInfo && !loading && singleVideoAdvancedOptionsOpen && (
<div className="w-full pt-4 mt-4 border-t">
<AdvancedOptions
startTime={videoInfoCardState.startTime}
endTime={videoInfoCardState.endTime}
downloadSubs={videoInfoCardState.downloadSubs}
onStartTimeChange={(value) =>
setVideoInfoCardState((prev) => ({ ...prev, startTime: value }))
}
onEndTimeChange={(value) =>
setVideoInfoCardState((prev) => ({ ...prev, endTime: value }))
}
onDownloadSubsChange={(value) =>
setVideoInfoCardState((prev) => ({ ...prev, downloadSubs: value }))
}
showAccordion={false}
/>
{videoInfo && !loading && (
<div
data-state={singleVideoAdvancedOptionsOpen ? 'open' : 'closed'}
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
singleVideoAdvancedOptionsOpen
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
)}
aria-hidden={!singleVideoAdvancedOptionsOpen}
>
<div
className={cn(
'min-h-0',
!singleVideoAdvancedOptionsOpen && 'pointer-events-none'
)}
>
<div className="w-full pt-4 mt-4 border-t">
<AdvancedOptions
startTime={videoInfoCardState.startTime}
endTime={videoInfoCardState.endTime}
downloadSubs={videoInfoCardState.downloadSubs}
onStartTimeChange={(value) =>
setVideoInfoCardState((prev) => ({ ...prev, startTime: value }))
}
onEndTimeChange={(value) =>
setVideoInfoCardState((prev) => ({ ...prev, endTime: value }))
}
onDownloadSubsChange={(value) =>
setVideoInfoCardState((prev) => ({ ...prev, downloadSubs: value }))
}
showAccordion={false}
/>
</div>
</div>
</div>
)}
</div>
@@ -1191,51 +1217,62 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
)}
{/* Advanced Options Content - Playlist */}
{advancedOptionsOpen && (
<div className="w-full pt-4 mt-4 border-t">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistBusy}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div
data-state={advancedOptionsOpen ? 'open' : 'closed'}
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
advancedOptionsOpen
? 'grid-rows-[1fr] opacity-100'
: 'grid-rows-[0fr] opacity-0'
)}
aria-hidden={!advancedOptionsOpen}
>
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
<div className="w-full pt-4 mt-4 border-t">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
<Select
value={downloadType}
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
disabled={playlistBusy}
>
<SelectTrigger id={downloadTypeId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="video">{t('download.video')}</SelectItem>
<SelectItem value="audio">{t('download.audio')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{t('playlist.range')}</Label>
<div className="flex items-center gap-2">
<Input
placeholder="1"
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
className="text-center"
disabled={playlistBusy}
/>
<span className="text-muted-foreground text-xs">-</span>
<Input
placeholder={playlistInfo?.entryCount.toString() || 'End'}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
className="text-center"
disabled={playlistBusy}
/>
<div className="space-y-2">
<Label>{t('playlist.range')}</Label>
<div className="flex items-center gap-2">
<Input
placeholder="1"
value={startIndex}
onChange={(e) => setStartIndex(e.target.value)}
className="text-center"
disabled={playlistBusy}
/>
<span className="text-muted-foreground text-xs">-</span>
<Input
placeholder={playlistInfo?.entryCount.toString() || 'End'}
value={endIndex}
onChange={(e) => setEndIndex(e.target.value)}
className="text-center"
disabled={playlistBusy}
/>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</TabsContent>
</ScrollArea>

View File

@@ -1,10 +1,5 @@
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger
} from '@renderer/components/ui/accordion'
import { Button } from '@renderer/components/ui/button'
import { Checkbox } from '@renderer/components/ui/checkbox'
import {
Dialog,
DialogContent,
@@ -17,6 +12,7 @@ import { Input } from '@renderer/components/ui/input'
import { Label } from '@renderer/components/ui/label'
import { Switch } from '@renderer/components/ui/switch'
import { ipcServices } from '@renderer/lib/ipc'
import { cn } from '@renderer/lib/utils'
import { settingsAtom } from '@renderer/store/settings'
import { resolveFeedAtom } from '@renderer/store/subscriptions'
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
@@ -85,6 +81,8 @@ export function SubscriptionFormDialog({
const detectTimeout = useRef<NodeJS.Timeout | null>(null)
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
const urlInputId = useId()
const advancedOptionsId = useId()
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
// Initialize form values based on mode
useEffect(() => {
@@ -92,6 +90,8 @@ export function SubscriptionFormDialog({
return
}
setAdvancedOptionsOpen(false)
if (mode === 'edit' && subscription) {
setUrl(subscription.feedUrl)
setKeywords(subscription.keywords.join(', '))
@@ -274,10 +274,16 @@ export function SubscriptionFormDialog({
</Button>
</div>
</div>
<Accordion type="single" collapsible>
<AccordionItem value="advanced">
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
<AccordionContent className="space-y-3">
<div
data-state={advancedOptionsOpen ? 'open' : 'closed'}
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
advancedOptionsOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
)}
aria-hidden={!advancedOptionsOpen}
>
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
<div className="space-y-3 border-t pt-4">
<div className="space-y-2">
<Label>{t('subscriptions.fields.keywords')}</Label>
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
@@ -299,17 +305,31 @@ export function SubscriptionFormDialog({
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
</div>
<DialogFooter>
{mode === 'add' && (
<Button variant="outline" onClick={onClose}>
{t('download.cancel')}
</Button>
)}
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
<div className="flex items-center justify-between w-full gap-4">
<div className="flex items-center gap-2">
<Checkbox
id={advancedOptionsId}
checked={advancedOptionsOpen}
onCheckedChange={(checked) => setAdvancedOptionsOpen(checked === true)}
/>
<Label htmlFor={advancedOptionsId} className="cursor-pointer">
{t('advancedOptions.title')}
</Label>
</div>
<div className="ml-auto flex gap-2">
{mode === 'add' && (
<Button variant="outline" onClick={onClose}>
{t('download.cancel')}
</Button>
)}
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -0,0 +1,39 @@
import * as PopoverPrimitive from '@radix-ui/react-popover'
import { cn } from '@renderer/lib/utils'
import type * as React from 'react'
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = 'center',
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -1,22 +1,13 @@
import { Button } from '@renderer/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@renderer/components/ui/dropdown-menu'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
import { saveSettingAtom } from '@renderer/store/settings'
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
import { useSetAtom } from 'jotai'
import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import '../../assets/title-bar.css'
import { updateAvailableAtom } from '@renderer/store/update'
import MingcuteCheckCircleFill from '~icons/mingcute/check-circle-fill'
import MingcuteCheckCircleLine from '~icons/mingcute/check-circle-line'
import MingcuteDownload3Fill from '~icons/mingcute/download-3-fill'
import MingcuteDownload3Line from '~icons/mingcute/download-3-line'
import MingcuteGlobeLine from '~icons/mingcute/globe-2-line'
import MingcuteInformationFill from '~icons/mingcute/information-fill'
import MingcuteInformationLine from '~icons/mingcute/information-line'
import MingcuteRssFill from '~icons/mingcute/rss-fill'
@@ -53,10 +44,8 @@ interface SidebarProps {
}
export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: SidebarProps) {
const { t, i18n } = useTranslation()
const saveSetting = useSetAtom(saveSettingAtom)
const languageOptions = languageList
const { t } = useTranslation()
const [updateAvailable] = useAtom(updateAvailableAtom)
const navigationItems: NavigationItem[] = [
{
@@ -105,20 +94,6 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
}
]
const activeLanguageCode = normalizeLanguageCode(i18n.language)
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const handleLanguageChange = async (value: LanguageCode) => {
if (activeLanguageCode === value) {
return
}
await saveSetting({ key: 'language', value })
await i18n.changeLanguage(value)
toast.success(t('notifications.settingsSaved'))
}
const renderNavigationItem = (item: NavigationItem, showLabel = true) => {
const isActive = item.id !== 'supported-sites' && currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
@@ -161,47 +136,11 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
<div className="flex-1" />
{/* Language Selector */}
<div className="flex flex-col items-center gap-1">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="no-drag rounded-2xl w-12 h-12">
<MingcuteGlobeLine className="h-5! w-5!" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p>{t('settings.language')}</p>
</TooltipContent>
</Tooltip>
<DropdownMenuContent side="right" align="end">
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<DropdownMenuItem
key={option.value}
onClick={() => void handleLanguageChange(option.value)}
className={isActive ? 'font-semibold bg-muted focus:bg-muted' : undefined}
aria-current={isActive}
>
<div className="flex items-center gap-2">
<span className={`${option.flag} rounded-xs text-base`} aria-hidden="true" />
<span lang={option.hreflang}>{option.name}</span>
</div>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Bottom Navigation Items */}
{bottomNavigationItems.map((item) => {
const isActive = currentPage === item.id
const IconComponent = isActive ? item.icon.active : item.icon.inactive
const showUpdateDot = item.id === 'about' && updateAvailable.available
return (
<div key={item.id} className="flex flex-col items-center gap-1">
@@ -211,9 +150,14 @@ export function Sidebar({ currentPage, onPageChange, onOpenSupportedSites }: Sid
variant="ghost"
size="icon"
onClick={() => onPageChange(item.id)}
className={`no-drag rounded-2xl w-12 h-12 ${isActive ? 'bg-primary/10' : ''}`}
className={`no-drag rounded-2xl w-12 h-12 relative ${
isActive ? 'bg-primary/10' : ''
}`}
>
<IconComponent className={`h-5! w-5! ${isActive ? 'text-primary' : ''}`} />
{showUpdateDot ? (
<span className="absolute top-2 right-2 h-2 w-2 rounded-full bg-red-500" />
) : null}
</Button>
</TooltipTrigger>
<TooltipContent side="right">

View File

@@ -73,23 +73,31 @@ export function FormatSelector({
useEffect(() => {
// Filter and sort formats
// Exclude m3u8/HLS formats as they are streaming formats not suitable for direct download
const videos = formats.filter(
(f) =>
f.video_ext !== 'none' &&
f.vcodec &&
f.vcodec !== 'none' &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
const isVideoFormat = (format: VideoFormat) =>
format.video_ext !== 'none' && format.vcodec && format.vcodec !== 'none'
const isAudioFormat = (format: VideoFormat) =>
format.acodec &&
format.acodec !== 'none' &&
(format.video_ext === 'none' || !format.video_ext)
const isHlsFormat = (format: VideoFormat) =>
format.protocol === 'm3u8' || format.protocol === 'm3u8_native'
const videoCandidates = formats.filter(
(format) => isVideoFormat(format) && !isHlsFormat(format)
)
const audios = formats.filter(
(f) =>
f.acodec &&
f.acodec !== 'none' &&
(f.video_ext === 'none' || !f.video_ext) &&
f.protocol !== 'm3u8' &&
f.protocol !== 'm3u8_native'
const audioCandidates = formats.filter(
(format) => isAudioFormat(format) && !isHlsFormat(format)
)
const videos =
videoCandidates.length > 0
? videoCandidates
: formats.filter((format) => isVideoFormat(format))
const audios =
audioCandidates.length > 0
? audioCandidates
: formats.filter((format) => isAudioFormat(format))
// Apply showMoreFormats filter
const filteredVideos = settings.showMoreFormats
? videos
@@ -99,6 +107,9 @@ export function FormatSelector({
? audios
: audios.filter((f) => f.ext !== 'webm')
const finalVideos = filteredVideos.length > 0 ? filteredVideos : videos
const finalAudios = filteredAudios.length > 0 ? filteredAudios : audios
// Sort formats by quality (best first)
const sortVideoFormatsByQuality = (a: VideoFormat, b: VideoFormat) => {
// Sort by height (higher is better)
@@ -138,23 +149,34 @@ export function FormatSelector({
return 0
}
filteredVideos.sort(sortVideoFormatsByQuality)
filteredAudios.sort(sortAudioFormatsByQuality)
finalVideos.sort(sortVideoFormatsByQuality)
finalAudios.sort(sortAudioFormatsByQuality)
setVideoFormats(filteredVideos)
setAudioFormats(filteredAudios)
setVideoFormats(finalVideos)
setAudioFormats(finalAudios)
// Auto-select best format based on preferences
if (filteredVideos.length > 0 && !selectedVideo) {
const preferred = pickVideoFormatForPreset(filteredVideos, settings.oneClickQuality)
// Auto-select best format based on preferences.
// If no separate audio formats exist, prefer muxed video formats (with audio).
const videosWithAudio = finalVideos.filter(
(format) => format.acodec && format.acodec !== 'none'
)
const autoVideos =
finalAudios.length > 0
? finalVideos
: videosWithAudio.length > 0
? videosWithAudio
: finalVideos
if (autoVideos.length > 0 && !selectedVideo) {
const preferred = pickVideoFormatForPreset(autoVideos, settings.oneClickQuality)
if (preferred) {
setSelectedVideo(preferred.format_id)
onVideoFormatChange?.(preferred.format_id)
}
}
if (filteredAudios.length > 0 && !selectedAudio) {
const best = filteredAudios[0]
if (finalAudios.length > 0 && !selectedAudio) {
const best = finalAudios[0]
setSelectedAudio(best.format_id)
onAudioFormatChange?.(best.format_id)
}
@@ -215,67 +237,79 @@ export function FormatSelector({
}
if (type === 'video') {
if (videoFormats.length === 0 && audioFormats.length === 0) {
return null
}
return (
<div className="space-y-5">
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{videoFormats.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectVideoFormat')}</Label>
<Select
value={selectedVideo}
onValueChange={(value) => {
setSelectedVideo(value)
onVideoFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
{videoFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatVideoLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectItem value="none" className="cursor-pointer py-2.5">
<span className="text-sm">{t('download.noAudio')}</span>
</SelectItem>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
{audioFormats.length > 0 && (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectAudioFormat')}</Label>
<Select
value={selectedAudio}
onValueChange={(value) => {
setSelectedAudio(value)
onAudioFormatChange?.(value)
}}
>
<SelectTrigger className="h-11">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px] p-1.5">
<SelectItem value="none" className="cursor-pointer py-2.5">
<span className="text-sm">{t('download.noAudio')}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{audioFormats.map((format) => (
<SelectItem
key={format.format_id}
value={format.format_id}
className="cursor-pointer py-2.5"
>
<span className="text-sm">{formatAudioLabel(format)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)
}
// Audio only
if (audioFormats.length === 0) {
return null
}
return (
<div className="space-y-3">
<Label className="text-sm font-semibold">{t('download.selectFormat')}</Label>

View File

@@ -15,7 +15,7 @@
"autoUpdateTitle": "Auto updates",
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
"betaProgramTitle": "Preview channel",
"description": "VidBee is a free, open-source downloader built with Electron and powered by yt-dlp.",
"description": "VidBee - Free and open-source automated video downloader with RSS auto-download, batch processing, and support for 1000+ platforms.",
"followAuthorActions": {
"follow": "Follow @nexmoex"
},
@@ -50,7 +50,13 @@
"documentation": "Help center",
"documentationDescription": "Guides, FAQs, and common workflows.",
"feedback": "Feedback & issues",
"feedbackDescription": "Share ideas or report issues on GitHub.",
"feedbackDescription": "Share ideas, report issues, or provide feedback through multiple channels.",
"githubIssues": "GitHub",
"githubIssuesDescription": "Report bugs or request features on GitHub.",
"xFeedback": "Twitter",
"xFeedbackDescription": "Share feedback or suggestions on X by mentioning @nexmoex.",
"discord": "Discord",
"discordDescription": "Join our Discord community for discussions and support.",
"license": "License",
"licenseDescription": "Review the open-source license terms.",
"website": "Official website",
@@ -375,6 +381,7 @@
"fileSelectError": "Failed to select file",
"general": "General",
"language": "Language",
"languageDescription": "Choose your preferred language for the application interface",
"light": "Light",
"hideDockIcon": "Hide Dock icon",
"hideDockIconDescription": "Remove VidBee from the macOS Dock. Use the menu bar or tray icon to reopen the app.",

View File

@@ -1,12 +1,6 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@renderer/components/ui/card'
import { Card, CardContent, CardHeader, CardTitle } from '@renderer/components/ui/card'
import { Progress } from '@renderer/components/ui/progress'
import { Switch } from '@renderer/components/ui/switch'
import { useAtom, useSetAtom } from 'jotai'
@@ -17,17 +11,17 @@ import {
FileText,
Github,
Link as LinkIcon,
Mail,
MessageCircle,
MessageSquare,
RefreshCw,
ShieldCheck,
Twitter
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { ipcEvents, ipcServices } from '../lib/ipc'
import { saveSettingAtom, settingsAtom } from '../store/settings'
import { updateAvailableAtom, updateReadyAtom } from '../store/update'
interface AboutResource {
icon: LucideIcon
@@ -47,6 +41,8 @@ type LatestVersionState =
export function About() {
const { t } = useTranslation()
const [settings, _setSettings] = useAtom(settingsAtom)
const [updateReady] = useAtom(updateReadyAtom)
const setUpdateAvailable = useSetAtom(updateAvailableAtom)
const [appVersion, setAppVersion] = useState<string>('—')
const [latestVersionState, setLatestVersionState] = useState<LatestVersionState>(null)
const [updateDownloadProgress, setUpdateDownloadProgress] = useState<number | null>(null)
@@ -90,6 +86,10 @@ export function About() {
status: 'available',
version: versionLabel
})
setUpdateAvailable({
available: true,
version: versionLabel
})
// Reset download progress when new update is available
setUpdateDownloadProgress(0)
}
@@ -115,7 +115,7 @@ export function About() {
ipcEvents.removeListener('update:download-progress', handleUpdateDownloadProgress)
ipcEvents.removeListener('update:downloaded', handleUpdateDownloaded)
}
}, [t])
}, [setUpdateAvailable, t])
const handleSettingChange = async (
key: keyof typeof settings,
@@ -137,6 +137,10 @@ export function About() {
status: 'available',
version: result.version ?? ''
})
setUpdateAvailable({
available: true,
version: result.version
})
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({
@@ -149,6 +153,10 @@ export function About() {
status: 'uptodate',
version: result.version ?? appVersion
})
setUpdateAvailable({
available: false,
version: undefined
})
}
} catch (error) {
console.error('Failed to check for updates:', error)
@@ -161,6 +169,10 @@ export function About() {
openShareUrl('https://vidbee.org/download/')
}
const handleRestartToUpdate = () => {
void ipcServices.update.quitAndInstall()
}
const handleCheckForUpdates = async () => {
try {
toast.info(t('about.notifications.checkingUpdates'))
@@ -172,6 +184,10 @@ export function About() {
status: 'available',
version: result.version ?? ''
})
setUpdateAvailable({
available: true,
version: result.version
})
} else if (result.error) {
toast.error(t('about.notifications.updateError', { error: result.error }))
setLatestVersionState({
@@ -184,6 +200,10 @@ export function About() {
status: 'uptodate',
version: result.version ?? appVersion
})
setUpdateAvailable({
available: false,
version: undefined
})
}
} catch (error) {
console.error('Failed to check for updates:', error)
@@ -196,7 +216,7 @@ export function About() {
const shareLinks = useMemo(() => {
const encodedUrl = encodeURIComponent(shareTargetUrl)
const encodedText = encodeURIComponent(t('about.description'))
const encodedText = encodeURIComponent(`${t('about.description')} @nexmoex`)
return {
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
@@ -204,13 +224,13 @@ export function About() {
}
}, [t])
const openShareUrl = (url: string) => {
const openShareUrl = useCallback((url: string) => {
if (typeof window === 'undefined') {
return
}
window.open(url, '_blank', 'noopener,noreferrer')
}
}, [])
const handleShareTwitter = () => {
openShareUrl(shareLinks.twitter)
@@ -245,6 +265,39 @@ export function About() {
: 'text-muted-foreground'
const latestVersionStatusText = latestVersionStatusKey ? t(latestVersionStatusKey) : null
const handleXFeedback = useCallback(() => {
const versionText = appVersion !== '—' ? ` VidBee v${appVersion}` : ' VidBee'
const tweetText = encodeURIComponent(`@nexmoex${versionText}`)
openShareUrl(`https://x.com/intent/tweet?text=${tweetText}`)
}, [appVersion, openShareUrl])
const feedbackResources = useMemo<AboutResource[]>(
() => [
{
icon: Github,
label: t('about.resources.githubIssues'),
description: t('about.resources.githubIssuesDescription'),
actionLabel: t('about.actions.feedback'),
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
},
{
icon: Twitter,
label: t('about.resources.xFeedback'),
description: t('about.resources.xFeedbackDescription'),
actionLabel: t('about.actions.feedback'),
onClick: handleXFeedback
},
{
icon: MessageCircle,
label: t('about.resources.discord'),
description: t('about.resources.discordDescription'),
actionLabel: t('about.actions.visit'),
href: 'https://discord.gg/uBqXV6QPdm'
}
],
[t, handleXFeedback]
)
const aboutResources = useMemo<AboutResource[]>(
() => [
{
@@ -260,27 +313,6 @@ export function About() {
description: t('about.resources.changelogDescription'),
actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/releases'
},
{
icon: MessageCircle,
label: t('about.resources.feedback'),
description: t('about.resources.feedbackDescription'),
actionLabel: t('about.actions.feedback'),
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
},
{
icon: ShieldCheck,
label: t('about.resources.license'),
description: t('about.resources.licenseDescription'),
actionLabel: t('about.actions.view'),
href: 'https://github.com/nexmoe/VidBee/blob/main/LICENSE'
},
{
icon: Mail,
label: t('about.resources.contact'),
description: t('about.resources.contactDescription'),
actionLabel: t('about.actions.email'),
href: 'mailto:nexmoex@gmail.com'
}
],
[t]
@@ -291,69 +323,87 @@ export function About() {
<div className="container mx-auto max-w-5xl p-6 space-y-6">
<Card>
<CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
<img src="./app-icon.png" alt="VidBee" className="h-16 w-16 rounded-2xl" />
<div className="space-y-2">
<div>
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
{latestVersionBadgeText ? (
<Badge variant="outline">{latestVersionBadgeText}</Badge>
) : null}
{latestVersionStatusText ? (
<span className={`text-sm ${latestVersionStatusClass}`}>
{latestVersionStatusText}
</span>
) : null}
</div>
) : null}
</div>
{updateDownloadProgress !== null && (
<div className="space-y-2 w-full">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
{t('about.downloadingUpdate')}
</span>
<span className="text-sm font-medium">
{updateDownloadProgress.toFixed(1)}%
</span>
</div>
<Progress value={updateDownloadProgress} className="h-2" />
<img src="./app-icon.png" alt="VidBee" className="h-18 w-18 rounded-2xl" />
<div className="flex-1 space-y-2">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<h2 className="text-2xl font-semibold leading-tight">{t('about.appName')}</h2>
<Badge variant="secondary">
{t('about.versionLabel', { version: appVersion })}
</Badge>
{latestVersionState ? (
<div className="flex flex-wrap items-center gap-2">
{latestVersionBadgeText ? (
<Badge variant="outline">{latestVersionBadgeText}</Badge>
) : null}
{latestVersionStatusText ? (
<span className={`text-sm ${latestVersionStatusClass}`}>
{latestVersionStatusText}
</span>
) : null}
</div>
) : null}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-3.5 w-3.5" />
</a>
</Button>
{updateReady.ready ? (
<Button
onClick={handleRestartToUpdate}
variant="default"
size="sm"
className="gap-2"
>
<RefreshCw className="h-3.5 w-3.5" />
{t('about.notifications.restartNowAction')}
</Button>
) : null}
{latestVersionState?.status === 'available' ? (
<Button
onClick={handleGoToDownload}
variant="default"
size="sm"
className="gap-2"
>
<Download className="h-3.5 w-3.5" />
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} size="sm" className="gap-2">
<RefreshCw className="h-3.5 w-3.5" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div>
<p className="text-sm text-muted-foreground">{t('about.description')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<a
href="https://github.com/nexmoe/vidbee"
target="_blank"
rel="noreferrer"
aria-label={t('about.actions.openRepo')}
>
<Github className="h-4 w-4" />
</a>
</Button>
{latestVersionState?.status === 'available' ? (
<Button onClick={handleGoToDownload} variant="default" className="gap-2">
<Download className="h-4 w-4" />
{t('about.actions.goToDownload')}
</Button>
) : null}
<Button onClick={handleCheckForUpdates} className="gap-2">
<RefreshCw className="h-4 w-4" />
{t('about.actions.checkUpdates')}
</Button>
</div>
</div>
{updateDownloadProgress !== null && (
<div className="flex flex-col gap-3 pt-4">
<div className="space-y-2 w-full">
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
{t('about.downloadingUpdate')}
</span>
<span className="text-sm font-medium">
{updateDownloadProgress.toFixed(1)}%
</span>
</div>
<Progress value={updateDownloadProgress} className="h-2" />
</div>
</div>
)}
<div className="flex items-center justify-between gap-4 pt-6">
<div className="space-y-1">
<p className="font-medium leading-none">{t('about.autoUpdateTitle')}</p>
@@ -370,7 +420,6 @@ export function About() {
<Card>
<CardHeader>
<CardTitle>{t('about.shareTitle')}</CardTitle>
<CardDescription>{t('about.shareDescription')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
@@ -415,12 +464,47 @@ export function About() {
</Card>
<Card>
<CardHeader>
<CardTitle>{t('about.resourcesTitle')}</CardTitle>
<CardDescription>{t('about.resourcesDescription')}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<div className="flex flex-col divide-y">
{/* Feedback section - merged into one row */}
<div className="flex items-center justify-between gap-4 px-6 py-4">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted/60">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="font-medium leading-none">{t('about.resources.feedback')}</p>
<p className="text-sm text-muted-foreground">
{t('about.resources.feedbackDescription')}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
{feedbackResources.map((resource) => {
const Icon = resource.icon
return resource.href ? (
<Button key={resource.label} variant="outline" size="sm" asChild>
<a href={resource.href} target="_blank" rel="noreferrer" className="gap-2">
<Icon className="h-4 w-4" />
{resource.label}
</a>
</Button>
) : (
<Button
key={resource.label}
variant="outline"
size="sm"
onClick={resource.onClick}
className="gap-2"
>
<Icon className="h-4 w-4" />
{resource.label}
</Button>
)
})}
</div>
</div>
{/* Other resources */}
{aboutResources.map((resource) => {
const Icon = resource.icon
return (

View File

@@ -18,6 +18,7 @@ import {
} from '@renderer/components/ui/select'
import { Switch } from '@renderer/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
import { type LanguageCode, languageList, normalizeLanguageCode } from '@shared/languages'
import type { OneClickQualityPreset } from '@shared/types'
import { useAtom, useSetAtom } from 'jotai'
import { useTheme } from 'next-themes'
@@ -35,7 +36,7 @@ const clampSubscriptionInterval = (value: string) => {
}
export function Settings() {
const { t } = useTranslation()
const { t, i18n: i18nInstance } = useTranslation()
const { theme, setTheme } = useTheme()
const [settings, _setSettings] = useAtom(settingsAtom)
const loadSettings = useSetAtom(loadSettingsAtom)
@@ -131,6 +132,21 @@ export function Settings() {
await handleSettingChange('theme', value)
}
const languageOptions = languageList
const activeLanguageCode = normalizeLanguageCode(i18nInstance.language)
const currentLanguage =
languageOptions.find((option) => option.value === activeLanguageCode) ?? languageOptions[0]
const handleLanguageChange = async (value: LanguageCode) => {
if (activeLanguageCode === value) {
return
}
await saveSetting({ key: 'language', value })
await i18nInstance.changeLanguage(value)
toast.success(t('notifications.settingsSaved'))
}
return (
<div className="h-full bg-background">
<div className="container mx-auto max-w-4xl p-6 space-y-6">
@@ -185,6 +201,53 @@ export function Settings() {
</Select>
</ItemActions>
</Item>
<ItemSeparator />
<Item variant="muted">
<ItemContent>
<ItemTitle>{t('settings.language')}</ItemTitle>
<ItemDescription>{t('settings.languageDescription')}</ItemDescription>
</ItemContent>
<ItemActions>
<Select
value={currentLanguage.value}
onValueChange={(value) => void handleLanguageChange(value as LanguageCode)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder={currentLanguage.name}>
<div className="flex items-center gap-2">
<span
className={`${currentLanguage.flag} rounded-xs text-base`}
aria-hidden="true"
/>
<span lang={currentLanguage.hreflang}>{currentLanguage.name}</span>
</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{languageOptions.map((option) => {
const isActive = option.value === currentLanguage.value
return (
<SelectItem
key={option.value}
value={option.value}
className={isActive ? 'font-semibold bg-muted' : undefined}
>
<div className="flex items-center gap-2">
<span
className={`${option.flag} rounded-xs text-base`}
aria-hidden="true"
/>
<span lang={option.hreflang}>{option.name}</span>
</div>
</SelectItem>
)
})}
</SelectContent>
</Select>
</ItemActions>
</Item>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,21 @@
import { atom } from 'jotai'
type UpdateReadyState = {
ready: boolean
version?: string
}
type UpdateAvailableState = {
available: boolean
version?: string
}
export const updateReadyAtom = atom<UpdateReadyState>({
ready: false,
version: undefined
})
export const updateAvailableAtom = atom<UpdateAvailableState>({
available: false,
version: undefined
})