Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7074f698e5 | ||
|
|
b254bb6dba | ||
|
|
1caca0ea9a | ||
|
|
12ec142a77 |
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -68,6 +68,8 @@ jobs:
|
||||
$source = Join-Path 'ffmpeg' '${{ matrix.ffmpeg_inner_path }}'
|
||||
$destination = Join-Path 'resources' '${{ matrix.ffmpeg_output }}'
|
||||
Copy-Item -Path $source -Destination $destination -Force
|
||||
Remove-Item ffmpeg.zip -Force
|
||||
Remove-Item ffmpeg -Recurse -Force
|
||||
|
||||
- name: Download ffmpeg binary (macOS)
|
||||
if: matrix.platform == 'macos'
|
||||
@@ -112,6 +114,7 @@ jobs:
|
||||
tar -xf ffmpeg.tar.xz -C ffmpeg
|
||||
cp "ffmpeg/${{ matrix.ffmpeg_inner_path }}" "resources/${{ matrix.ffmpeg_output }}"
|
||||
chmod +x "resources/${{ matrix.ffmpeg_output }}"
|
||||
rm -rf ffmpeg.tar.xz ffmpeg
|
||||
|
||||
- name: Download yt-dlp binary
|
||||
shell: bash
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -42,3 +42,9 @@ jobs:
|
||||
dist/*.blockmap
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
|
||||
- name: Notify Cloudflare Pages
|
||||
env:
|
||||
CLOUDFLARE_WEBHOOK_URL: ${{ secrets.CLOUDFLARE_WEBHOOK_URL }}
|
||||
run: |
|
||||
curl -X POST "$CLOUDFLARE_WEBHOOK_URL"
|
||||
|
||||
@@ -8,11 +8,16 @@ protocols:
|
||||
- vidbee
|
||||
files:
|
||||
- '!**/.vscode/*'
|
||||
- '!**/.context/**'
|
||||
- '!**/.github/**'
|
||||
- '!src/*'
|
||||
- '!electron.vite.config.{js,ts,mjs,cjs}'
|
||||
- '!{.eslintcache,eslint.config.mjs,dev-app-update.yml,CHANGELOG.md}'
|
||||
- '!{.env,.env.*,.npmrc,pnpm-lock.yaml}'
|
||||
- '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}'
|
||||
- '!extension/**'
|
||||
- '!monkey/**'
|
||||
- '!screenshots/**'
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
win:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vidbee",
|
||||
"version": "1.1.6",
|
||||
"version": "1.1.7",
|
||||
"description": "A modern Electron application for downloading videos and audios",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "VidBee",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const { execSync } = require('node:child_process')
|
||||
const { execSync, spawnSync } = require('node:child_process')
|
||||
const https = require('node:https')
|
||||
const http = require('node:http')
|
||||
|
||||
@@ -33,7 +33,7 @@ const PLATFORM_CONFIG = {
|
||||
extract: 'unzip',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /win64.*gpl.*\.zip$/i,
|
||||
assetPattern: /ffmpeg-master-latest-win64-gpl\.zip$/i,
|
||||
binaryName: 'ffmpeg.exe'
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ const PLATFORM_CONFIG = {
|
||||
extract: 'tar',
|
||||
release: {
|
||||
repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
|
||||
assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
|
||||
assetPattern: /ffmpeg-master-latest-linux64-gpl\.tar\.xz$/i,
|
||||
binaryName: 'ffmpeg'
|
||||
}
|
||||
}
|
||||
@@ -125,24 +125,43 @@ function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http
|
||||
const file = fs.createWriteStream(dest)
|
||||
let downloadedBytes = 0
|
||||
|
||||
const request = protocol.get(url, { headers: getDownloadHeaders(url) }, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
// Handle redirect
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
return downloadFile(response.headers.location, dest).then(resolve).catch(reject)
|
||||
const redirectUrl = response.headers.location
|
||||
if (!redirectUrl) {
|
||||
return reject(new Error(`Redirect without location for ${url}`))
|
||||
}
|
||||
log(`Redirected to ${redirectUrl}`, 'info')
|
||||
return downloadFile(redirectUrl, dest).then(resolve).catch(reject)
|
||||
}
|
||||
|
||||
const contentLength = response.headers['content-length']
|
||||
if (response.statusCode !== 200) {
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
return reject(new Error(`Failed to download: ${response.statusCode}`))
|
||||
return reject(
|
||||
new Error(
|
||||
`Failed to download ${url}: ${response.statusCode} (length: ${contentLength || 'unknown'})`
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedBytes += chunk.length
|
||||
})
|
||||
|
||||
response.pipe(file)
|
||||
file.on('finish', () => {
|
||||
file.close()
|
||||
log(
|
||||
`Downloaded ${formatBytes(downloadedBytes)} from ${url}`,
|
||||
downloadedBytes ? 'success' : 'warn'
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
@@ -154,6 +173,7 @@ function downloadFile(url, dest) {
|
||||
request.on('error', (err) => {
|
||||
file.close()
|
||||
safeUnlink(dest)
|
||||
log(`Download error for ${url}: ${err.message}`, 'error')
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
@@ -163,6 +183,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
|
||||
let lastError
|
||||
for (let attempt = 1; attempt <= retries; attempt += 1) {
|
||||
try {
|
||||
log(`Downloading ${url} (attempt ${attempt}/${retries})...`, 'download')
|
||||
await downloadFile(url, dest)
|
||||
return
|
||||
} catch (error) {
|
||||
@@ -170,7 +191,7 @@ async function downloadFileWithRetry(url, dest, retries = 3, delayMs = 2000) {
|
||||
safeUnlink(dest)
|
||||
if (attempt < retries) {
|
||||
const backoff = delayMs * attempt
|
||||
log(`Download failed (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||
log(`Download failed for ${url} (attempt ${attempt}/${retries}): ${error.message}`, 'warn')
|
||||
await new Promise((resolve) => setTimeout(resolve, backoff))
|
||||
}
|
||||
}
|
||||
@@ -298,6 +319,37 @@ function fileExists(filePath) {
|
||||
return fs.existsSync(filePath)
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) {
|
||||
return 'unknown size'
|
||||
}
|
||||
if (bytes >= 1024 * 1024) {
|
||||
return `${Math.round(bytes / (1024 * 1024))} MB`
|
||||
}
|
||||
return `${Math.round(bytes / 1024)} KB`
|
||||
}
|
||||
|
||||
function checkBinary(filePath, args, label) {
|
||||
const result = spawnSync(filePath, args, {
|
||||
encoding: 'utf8',
|
||||
timeout: 8000,
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
if (result.error) {
|
||||
return { ok: false, message: result.error.message }
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||
return { ok: false, message: output || `exit code ${result.status}` }
|
||||
}
|
||||
|
||||
const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim()
|
||||
const firstLine = output.split(/\r?\n/).find((line) => line.trim())
|
||||
return { ok: true, message: firstLine ? firstLine.trim() : `${label} version check ok` }
|
||||
}
|
||||
|
||||
function getDenoAssetName(platform, arch) {
|
||||
if (platform === 'win32') {
|
||||
if (arch === 'arm64') {
|
||||
@@ -330,6 +382,10 @@ async function downloadYtDlp(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -342,6 +398,11 @@ async function downloadYtDlp(config) {
|
||||
await downloadFileWithRetry(url, tempPath)
|
||||
fs.renameSync(tempPath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['--version'], 'yt-dlp')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
} catch (error) {
|
||||
if (fs.existsSync(tempPath)) {
|
||||
@@ -356,6 +417,10 @@ async function downloadFfmpegWindows(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -392,6 +457,11 @@ async function downloadFfmpegWindows(config) {
|
||||
}
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
@@ -416,6 +486,10 @@ async function downloadFfmpegMac(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -448,6 +522,11 @@ async function downloadFfmpegMac(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
@@ -465,6 +544,10 @@ async function downloadFfmpegLinux(config) {
|
||||
const outputPath = path.join(RESOURCES_DIR, output)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${output} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${output} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -502,6 +585,11 @@ async function downloadFfmpegLinux(config) {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['-version'], 'ffmpeg')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${output} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${output} successfully`, 'success')
|
||||
|
||||
// Cleanup
|
||||
@@ -528,6 +616,10 @@ async function downloadDenoRuntime() {
|
||||
const outputPath = path.join(RESOURCES_DIR, outputName)
|
||||
|
||||
if (fileExists(outputPath)) {
|
||||
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||
if (!validation.ok) {
|
||||
log(`Existing ${outputName} failed version check: ${validation.message}`, 'warn')
|
||||
}
|
||||
log(`${outputName} already exists, skipping download`, 'info')
|
||||
return
|
||||
}
|
||||
@@ -549,6 +641,11 @@ async function downloadDenoRuntime() {
|
||||
|
||||
fs.copyFileSync(sourcePath, outputPath)
|
||||
setExecutable(outputPath)
|
||||
const validation = checkBinary(outputPath, ['--version'], 'deno')
|
||||
if (!validation.ok) {
|
||||
safeUnlink(outputPath)
|
||||
throw new Error(`Downloaded ${outputName} failed version check: ${validation.message}`)
|
||||
}
|
||||
log(`Downloaded ${outputName} successfully`, 'success')
|
||||
|
||||
fs.unlinkSync(tempZip)
|
||||
|
||||
@@ -68,7 +68,7 @@ export const buildDownloadArgs = (
|
||||
settings: AppSettings,
|
||||
jsRuntimeArgs: string[] = []
|
||||
): string[] => {
|
||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||
const args: string[] = ['--no-playlist', '--no-mtime']
|
||||
|
||||
// Add encoding support for proper handling of non-ASCII characters
|
||||
args.push('--encoding', 'utf-8')
|
||||
@@ -95,11 +95,26 @@ export const buildDownloadArgs = (
|
||||
args.push('--download-sections', `*${start}-${end || ''}`)
|
||||
}
|
||||
|
||||
const embedSubs = settings.embedSubs
|
||||
const embedMetadata = settings.embedMetadata
|
||||
const embedChapters = settings.embedChapters
|
||||
|
||||
// Subtitles
|
||||
if (options.downloadSubs) {
|
||||
args.push('--write-subs', '--sub-langs', 'all')
|
||||
if (options.downloadSubs || embedSubs) {
|
||||
args.push('--sub-langs', 'all')
|
||||
}
|
||||
|
||||
if (options.downloadSubs) {
|
||||
args.push('--write-subs')
|
||||
}
|
||||
|
||||
args.push(embedSubs ? '--embed-subs' : '--no-embed-subs')
|
||||
if (process.platform !== 'darwin') {
|
||||
args.push(settings.embedThumbnail ? '--embed-thumbnail' : '--no-embed-thumbnail')
|
||||
}
|
||||
args.push(embedMetadata ? '--embed-metadata' : '--no-embed-metadata')
|
||||
args.push(embedChapters ? '--embed-chapters' : '--no-embed-chapters')
|
||||
|
||||
// Output path with proper encoding handling
|
||||
const baseDownloadPath = options.customDownloadPath?.trim() || downloadPath
|
||||
const filenameTemplate = sanitizeFilenameTemplate(
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"currentLocation": "Current download location - ",
|
||||
"downloadLocation": "Download location",
|
||||
"downloadSubs": "Download subtitles if available",
|
||||
"downloadSubsHint": "Save subtitles as separate files when available",
|
||||
"end": "End",
|
||||
"endHint": "If kept empty, it will be downloaded to the end",
|
||||
"endPlaceholder": "10:00",
|
||||
@@ -420,6 +421,14 @@
|
||||
"launchAtLoginUnsupported": "Auto launch is only available on macOS and Windows.",
|
||||
"enableAnalytics": "Help improve VidBee",
|
||||
"enableAnalyticsDescription": "Share anonymous usage data to help us understand how the app is used and prioritize improvements.",
|
||||
"embedChapters": "Embed chapters",
|
||||
"embedChaptersDescription": "Add chapter markers to the file when available",
|
||||
"embedMetadata": "Embed metadata",
|
||||
"embedMetadataDescription": "Write title, artist, and other metadata when available",
|
||||
"embedSubs": "Embed subtitles",
|
||||
"embedSubsDescription": "Embed subtitles into the video file (mp4, webm, mkv)",
|
||||
"embedThumbnail": "Embed thumbnail",
|
||||
"embedThumbnailDescription": "Add the thumbnail as cover art",
|
||||
"maxConcurrentDownloads": "Maximum number of active downloads",
|
||||
"maxConcurrentDownloadsDescription": "Maximum number of simultaneous downloads",
|
||||
"none": "None",
|
||||
|
||||
@@ -519,6 +519,94 @@ export function Settings() {
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedSubs')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedSubsDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedSubs ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedSubs', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedSubs:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
{platform !== 'darwin' && (
|
||||
<>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedThumbnail')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedThumbnailDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedThumbnail ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedThumbnail', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedThumbnail:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedMetadata')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedMetadataDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedMetadata ?? false}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedMetadata', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedMetadata:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
|
||||
<ItemSeparator />
|
||||
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
<ItemTitle>{t('settings.embedChapters')}</ItemTitle>
|
||||
<ItemDescription>{t('settings.embedChaptersDescription')}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={settings.embedChapters ?? true}
|
||||
onCheckedChange={(value) => {
|
||||
try {
|
||||
handleSettingChange('embedChapters', value)
|
||||
} catch (error) {
|
||||
logger.error('[Settings] Error toggling embedChapters:', error)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Item variant="muted">
|
||||
<ItemContent>
|
||||
|
||||
@@ -271,6 +271,10 @@ export interface AppSettings {
|
||||
autoUpdate: boolean
|
||||
subscriptionOnlyLatestDefault: boolean
|
||||
enableAnalytics: boolean
|
||||
embedSubs: boolean
|
||||
embedThumbnail: boolean
|
||||
embedMetadata: boolean
|
||||
embedChapters: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE = '%(uploader)s/%(title)s.%(ext)s'
|
||||
@@ -294,5 +298,9 @@ export const defaultSettings: AppSettings = {
|
||||
launchAtLogin: false,
|
||||
autoUpdate: true,
|
||||
subscriptionOnlyLatestDefault: true,
|
||||
enableAnalytics: true
|
||||
enableAnalytics: true,
|
||||
embedSubs: true,
|
||||
embedThumbnail: true,
|
||||
embedMetadata: true,
|
||||
embedChapters: true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user