Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5020605590 | ||
|
|
b15c3d8ce5 | ||
|
|
a51655697d | ||
|
|
f5c26f5f02 | ||
|
|
3bc9d76f6e | ||
|
|
5751d73d5b | ||
|
|
50bd9f1659 |
@@ -3,3 +3,4 @@
|
|||||||
3. Support i18n, only translate the English version of en.json
|
3. Support i18n, only translate the English version of en.json
|
||||||
4. use English for comments&console
|
4. use English for comments&console
|
||||||
5. Follow the ✅ KISS (Keep It Simple, Stupid) & ✅ YAGNI (You Aren't Gonna Need It) principles
|
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.
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -5,11 +5,12 @@
|
|||||||
|
|
||||||
<h3>VidBee</h3>
|
<h3>VidBee</h3>
|
||||||
<p>
|
<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/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?style=flat-square&logo=github&label=Contributors&labelColor=black" /></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&style=flat-square&label=Downloads" /></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&style=flat-square&label=Latest%20Release" /></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&style=flat-square" /></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 />
|
||||||
<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>
|
<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:
|
Feel free to try it using the following methods:
|
||||||
|
|
||||||
| Operating System | Source |
|
<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>
|
||||||
| :--------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
||||||
| 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> |
|
|
||||||
|
|
||||||
### 🍎 macOS Installation Notes
|
### 🍎 macOS Installation Notes
|
||||||
|
|
||||||
|
|||||||
@@ -50,3 +50,5 @@ publish:
|
|||||||
url: https://github.com/nexmoe/vidbee/releases/latest/download
|
url: https://github.com/nexmoe/vidbee/releases/latest/download
|
||||||
electronDownload:
|
electronDownload:
|
||||||
mirror: https://npmmirror.com/mirrors/electron/
|
mirror: https://npmmirror.com/mirrors/electron/
|
||||||
|
electronLanguages:
|
||||||
|
- en
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vidbee",
|
"name": "vidbee",
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"description": "A modern Electron application for downloading videos and audios",
|
"description": "A modern Electron application for downloading videos and audios",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "VidBee",
|
"author": "VidBee",
|
||||||
@@ -15,10 +15,10 @@
|
|||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"setup": "node scripts/setup-dev-binaries.js",
|
"setup": "node scripts/setup-dev-binaries.js",
|
||||||
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
|
"postinstall": "node scripts/setup-dev-binaries.js && electron-builder install-app-deps",
|
||||||
"build:unpack": "pnpm run build && electron-builder --dir",
|
"build:unpack": "pnpm run setup && pnpm run build && electron-builder --dir",
|
||||||
"build:win": "node scripts/check-ytdlp.js win && pnpm run build && electron-builder --win",
|
"build:win": "pnpm run setup && 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:mac": "pnpm run setup && 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: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",
|
"release": "git checkout main && git pull && pnpm run check && bumpp",
|
||||||
"db:generate": "drizzle-kit generate"
|
"db:generate": "drizzle-kit generate"
|
||||||
},
|
},
|
||||||
|
|||||||
2
resources/.gitignore
vendored
2
resources/.gitignore
vendored
@@ -6,6 +6,8 @@ ffmpeg.exe
|
|||||||
ffmpeg_macos
|
ffmpeg_macos
|
||||||
ffmpeg_linux
|
ffmpeg_linux
|
||||||
ffmpeg
|
ffmpeg
|
||||||
|
deno.exe
|
||||||
|
deno
|
||||||
|
|
||||||
# But keep the README
|
# But keep the README
|
||||||
!README.md
|
!README.md
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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
|
- 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.
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ const binaries = [
|
|||||||
linux: 'https://ffmpeg.org/download.html',
|
linux: 'https://ffmpeg.org/download.html',
|
||||||
mac: 'https://github.com/eko5624/mpv-mac/releases/latest'
|
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'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const http = require('node:http')
|
|||||||
// Configuration
|
// Configuration
|
||||||
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
const RESOURCES_DIR = path.join(__dirname, '..', 'resources')
|
||||||
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
const YTDLP_BASE_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download'
|
||||||
|
const DENO_BASE_URL = 'https://github.com/denoland/deno/releases/latest/download'
|
||||||
const GITHUB_TOKEN =
|
const GITHUB_TOKEN =
|
||||||
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
|
||||||
|
|
||||||
@@ -257,6 +258,32 @@ function fileExists(filePath) {
|
|||||||
return fs.existsSync(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
|
// Main download functions
|
||||||
async function downloadYtDlp(config) {
|
async function downloadYtDlp(config) {
|
||||||
const { asset, output } = config.ytdlp
|
const { asset, output } = config.ytdlp
|
||||||
@@ -447,6 +474,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 downloadFile(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
|
// Main setup function
|
||||||
async function setup() {
|
async function setup() {
|
||||||
const platform = os.platform()
|
const platform = os.platform()
|
||||||
@@ -464,6 +537,9 @@ async function setup() {
|
|||||||
// Download yt-dlp
|
// Download yt-dlp
|
||||||
await downloadYtDlp(config)
|
await downloadYtDlp(config)
|
||||||
|
|
||||||
|
// Download JS runtime (Deno)
|
||||||
|
await downloadDenoRuntime()
|
||||||
|
|
||||||
// Download ffmpeg
|
// Download ffmpeg
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
await downloadFfmpegWindows(config)
|
await downloadFfmpegWindows(config)
|
||||||
|
|||||||
@@ -5,15 +5,6 @@ import log from 'electron-log/main'
|
|||||||
* Set log format, file path, transport methods, etc.
|
* Set log format, file path, transport methods, etc.
|
||||||
*/
|
*/
|
||||||
export function configureLogger() {
|
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
|
// Set log levels
|
||||||
// Development: show all logs
|
// Development: show all logs
|
||||||
// Production: show info level and above only
|
// Production: show info level and above only
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export const resolveAudioFormatSelector = (options: DownloadOptions): string =>
|
|||||||
export const buildDownloadArgs = (
|
export const buildDownloadArgs = (
|
||||||
options: DownloadOptions,
|
options: DownloadOptions,
|
||||||
downloadPath: string,
|
downloadPath: string,
|
||||||
settings: AppSettings
|
settings: AppSettings,
|
||||||
|
jsRuntimeArgs: string[] = []
|
||||||
): string[] => {
|
): string[] => {
|
||||||
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
const args: string[] = ['--no-playlist', '--embed-chapters', '--no-mtime']
|
||||||
|
|
||||||
@@ -130,6 +131,10 @@ export const buildDownloadArgs = (
|
|||||||
args.push('--config-location', configPath)
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jsRuntimeArgs.length > 0) {
|
||||||
|
args.push(...jsRuntimeArgs)
|
||||||
|
}
|
||||||
|
|
||||||
args.push(options.url)
|
args.push(options.url)
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|||||||
@@ -317,9 +317,7 @@ function initAutoUpdater(): void {
|
|||||||
|
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
mainWindow.webContents.send('update:show-notification', {
|
mainWindow.webContents.send('update:show-notification', {
|
||||||
title: 'Update Ready',
|
version: info.version
|
||||||
body: `Version ${info.version} has been downloaded and will be installed on restart.`,
|
|
||||||
icon: 'app-icon'
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import { app, BrowserWindow, dialog } from 'electron'
|
import { app, BrowserWindow, dialog } from 'electron'
|
||||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
|
import { scopedLoggers } from '../../utils/logger'
|
||||||
|
|
||||||
class AppService extends IpcService {
|
class AppService extends IpcService {
|
||||||
static readonly groupName = 'app'
|
static readonly groupName = 'app'
|
||||||
@@ -48,7 +49,7 @@ class AppService extends IpcService {
|
|||||||
const base64 = buffer.toString('base64')
|
const base64 = buffer.toString('base64')
|
||||||
return `data:${contentType};base64,${base64}`
|
return `data:${contentType};base64,${base64}`
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch site icon:', error)
|
scopedLoggers.system.error('Failed to fetch site icon:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url'
|
|||||||
import { promisify } from 'node:util'
|
import { promisify } from 'node:util'
|
||||||
import { clipboard, dialog, shell } from 'electron'
|
import { clipboard, dialog, shell } from 'electron'
|
||||||
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
import { type IpcContext, IpcMethod, IpcService } from 'electron-ipc-decorator'
|
||||||
|
import { scopedLoggers } from '../../utils/logger'
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile)
|
const execFileAsync = promisify(execFile)
|
||||||
|
|
||||||
@@ -49,7 +50,10 @@ class FileSystemService extends IpcService {
|
|||||||
return xdgPath
|
return xdgPath
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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()) {
|
if (stats?.isDirectory()) {
|
||||||
const result = await shell.openPath(normalizedPath)
|
const result = await shell.openPath(normalizedPath)
|
||||||
if (result) {
|
if (result) {
|
||||||
console.error('Failed to open directory:', result)
|
scopedLoggers.system.error('Failed to open directory:', result)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
@@ -88,16 +92,16 @@ class FileSystemService extends IpcService {
|
|||||||
if (parentStats?.isDirectory()) {
|
if (parentStats?.isDirectory()) {
|
||||||
const result = await shell.openPath(parentDirectory)
|
const result = await shell.openPath(parentDirectory)
|
||||||
if (result) {
|
if (result) {
|
||||||
console.error('Failed to open parent directory:', result)
|
scopedLoggers.system.error('Failed to open parent directory:', result)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('File or directory does not exist:', normalizedPath)
|
scopedLoggers.system.error('File or directory does not exist:', normalizedPath)
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open file location:', error)
|
scopedLoggers.system.error('Failed to open file location:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,7 +126,7 @@ class FileSystemService extends IpcService {
|
|||||||
|
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to copy file to clipboard:', error)
|
scopedLoggers.system.error('Failed to copy file to clipboard:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,7 +154,7 @@ class FileSystemService extends IpcService {
|
|||||||
await shell.openExternal(url)
|
await shell.openExternal(url)
|
||||||
return true
|
return true
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to open external URL:', error)
|
scopedLoggers.system.error('Failed to open external URL:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +170,10 @@ class FileSystemService extends IpcService {
|
|||||||
])
|
])
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} 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, '\\')
|
const winPath = resolvedPath.replace(/\//g, '\\')
|
||||||
@@ -194,7 +201,10 @@ class FileSystemService extends IpcService {
|
|||||||
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
await execFileAsync('osascript', ['-e', `set the clipboard to (POSIX file "${escaped}")`])
|
||||||
return
|
return
|
||||||
} catch (error) {
|
} 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 = [
|
const entries = [
|
||||||
@@ -243,7 +253,7 @@ class FileSystemService extends IpcService {
|
|||||||
|
|
||||||
return stats?.isFile() ?? false
|
return stats?.isFile() ?? false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to check file existence:', error)
|
scopedLoggers.system.error('Failed to check file existence:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +305,7 @@ class FileSystemService extends IpcService {
|
|||||||
|
|
||||||
return false
|
return false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete file:', error)
|
scopedLoggers.system.error('Failed to delete file:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,13 @@ const resolveHistoryDownloadPath = (
|
|||||||
return path.join(basePath, templateDir)
|
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 {
|
class DownloadEngine extends EventEmitter {
|
||||||
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
private activeDownloads: Map<string, DownloadProcess> = new Map()
|
||||||
private queue: DownloadQueue
|
private queue: DownloadQueue
|
||||||
@@ -194,6 +201,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
args.push('--config-location', configPath)
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendJsRuntimeArgs(args)
|
||||||
args.push(url)
|
args.push(url)
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -290,6 +298,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
args.push('--config-location', configPath)
|
args.push('--config-location', configPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
appendJsRuntimeArgs(args)
|
||||||
args.push(url)
|
args.push(url)
|
||||||
|
|
||||||
type RawPlaylistEntry = {
|
type RawPlaylistEntry = {
|
||||||
@@ -511,7 +520,7 @@ class DownloadEngine extends EventEmitter {
|
|||||||
|
|
||||||
startDownload(id: string, options: DownloadOptions): void {
|
startDownload(id: string, options: DownloadOptions): void {
|
||||||
if (this.activeDownloads.has(id)) {
|
if (this.activeDownloads.has(id)) {
|
||||||
console.warn(`Download ${id} is already active`)
|
scopedLoggers.engine.warn(`Download ${id} is already active`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,7 +653,12 @@ class DownloadEngine extends EventEmitter {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const args = buildDownloadArgs(options, resolvedDownloadPath, settings)
|
const args = buildDownloadArgs(
|
||||||
|
options,
|
||||||
|
resolvedDownloadPath,
|
||||||
|
settings,
|
||||||
|
ytdlpManager.getJsRuntimeArgs()
|
||||||
|
)
|
||||||
|
|
||||||
const captureOutputPath = (rawPath: string | undefined): void => {
|
const captureOutputPath = (rawPath: string | undefined): void => {
|
||||||
if (!rawPath) {
|
if (!rawPath) {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { execSync } from 'node:child_process'
|
|||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { scopedLoggers } from '../utils/logger'
|
||||||
|
|
||||||
class FfmpegManager {
|
class FfmpegManager {
|
||||||
private ffmpegPath: string | null = null
|
private ffmpegPath: string | null = null
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
this.ffmpegPath = await this.findFfmpegBinary()
|
this.ffmpegPath = await this.findFfmpegBinary()
|
||||||
console.log('ffmpeg initialized at:', this.ffmpegPath)
|
scopedLoggers.engine.info('ffmpeg initialized at:', this.ffmpegPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
getPath(): string {
|
getPath(): string {
|
||||||
@@ -30,7 +31,7 @@ class FfmpegManager {
|
|||||||
const resourceCandidates: string[] = []
|
const resourceCandidates: string[] = []
|
||||||
|
|
||||||
if (process.env.FFMPEG_PATH && fs.existsSync(process.env.FFMPEG_PATH)) {
|
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
|
return process.env.FFMPEG_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,10 +51,13 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
fs.chmodSync(fullPath, 0o755)
|
fs.chmodSync(fullPath, 0o755)
|
||||||
} catch (error) {
|
} 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
|
return fullPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,7 +66,7 @@ class FfmpegManager {
|
|||||||
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
const commonPaths = ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg']
|
||||||
for (const candidate of commonPaths) {
|
for (const candidate of commonPaths) {
|
||||||
if (fs.existsSync(candidate)) {
|
if (fs.existsSync(candidate)) {
|
||||||
console.log('Using system ffmpeg:', candidate)
|
scopedLoggers.engine.info('Using system ffmpeg:', candidate)
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +76,7 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
const systemPath = execSync('which ffmpeg').toString().trim()
|
const systemPath = execSync('which ffmpeg').toString().trim()
|
||||||
if (systemPath && fs.existsSync(systemPath)) {
|
if (systemPath && fs.existsSync(systemPath)) {
|
||||||
console.log('Using system ffmpeg:', systemPath)
|
scopedLoggers.engine.info('Using system ffmpeg:', systemPath)
|
||||||
return systemPath
|
return systemPath
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -84,7 +88,7 @@ class FfmpegManager {
|
|||||||
try {
|
try {
|
||||||
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
const output = execSync('where ffmpeg').toString().split(/\r?\n/)[0]
|
||||||
if (output && fs.existsSync(output)) {
|
if (output && fs.existsSync(output)) {
|
||||||
console.log('Using system ffmpeg:', output)
|
scopedLoggers.engine.info('Using system ffmpeg:', output)
|
||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import fsPromises from 'node:fs/promises'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
import { APP_PROTOCOL_SCHEME } from '@shared/constants'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
|
import { scopedLoggers } from '../utils/logger'
|
||||||
|
|
||||||
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif'])
|
||||||
|
|
||||||
@@ -81,7 +82,7 @@ export class ThumbnailCache {
|
|||||||
await fsPromises.writeFile(finalPath, buffer)
|
await fsPromises.writeFile(finalPath, buffer)
|
||||||
return this.toAppProtocolUrl(finalPath)
|
return this.toAppProtocolUrl(finalPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to cache thumbnail:', error)
|
scopedLoggers.thumbnail.error('Failed to cache thumbnail:', error)
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import fs from 'node:fs'
|
|||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type YTDlpWrap from 'yt-dlp-wrap-plus'
|
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
|
// Use require for yt-dlp-wrap-plus to handle CommonJS/ESM compatibility
|
||||||
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
|
const YTDlpWrapModule = require('yt-dlp-wrap-plus')
|
||||||
@@ -12,11 +13,13 @@ type YTDlpWrapInstance = InstanceType<typeof YTDlpWrapCtor>
|
|||||||
class YtDlpManager {
|
class YtDlpManager {
|
||||||
private ytdlpPath: string | null = null
|
private ytdlpPath: string | null = null
|
||||||
private ytdlpInstance: YTDlpWrapInstance | null = null
|
private ytdlpInstance: YTDlpWrapInstance | null = null
|
||||||
|
private jsRuntimeArgs: string[] = []
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
this.ytdlpPath = await this.findOrDownloadYtDlp()
|
this.ytdlpPath = await this.findOrDownloadYtDlp()
|
||||||
this.ytdlpInstance = new YTDlpWrapCtor(this.ytdlpPath)
|
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 {
|
getInstance(): YTDlpWrapInstance {
|
||||||
@@ -33,6 +36,10 @@ class YtDlpManager {
|
|||||||
return this.ytdlpPath
|
return this.ytdlpPath
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getJsRuntimeArgs(): string[] {
|
||||||
|
return [...this.jsRuntimeArgs]
|
||||||
|
}
|
||||||
|
|
||||||
private getResourcesPath(): string {
|
private getResourcesPath(): string {
|
||||||
// In development, read from project root's resources
|
// In development, read from project root's resources
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
@@ -57,7 +64,7 @@ class YtDlpManager {
|
|||||||
|
|
||||||
// Check environment variable first
|
// Check environment variable first
|
||||||
if (process.env.YTDLP_PATH && fs.existsSync(process.env.YTDLP_PATH)) {
|
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
|
return process.env.YTDLP_PATH
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,13 +72,13 @@ class YtDlpManager {
|
|||||||
const resourcesPath = this.getResourcesPath()
|
const resourcesPath = this.getResourcesPath()
|
||||||
const bundledPath = path.join(resourcesPath, bundledName)
|
const bundledPath = path.join(resourcesPath, bundledName)
|
||||||
if (fs.existsSync(bundledPath)) {
|
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
|
// Make executable on Unix-like systems if needed
|
||||||
if (platform !== 'win32') {
|
if (platform !== 'win32') {
|
||||||
try {
|
try {
|
||||||
fs.chmodSync(bundledPath, 0o755)
|
fs.chmodSync(bundledPath, 0o755)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to set executable permission:', error)
|
scopedLoggers.engine.warn('Failed to set executable permission:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return bundledPath
|
return bundledPath
|
||||||
@@ -82,7 +89,7 @@ class YtDlpManager {
|
|||||||
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
|
const possiblePaths = ['/opt/homebrew/bin/yt-dlp', '/usr/local/bin/yt-dlp']
|
||||||
for (const p of possiblePaths) {
|
for (const p of possiblePaths) {
|
||||||
if (fs.existsSync(p)) {
|
if (fs.existsSync(p)) {
|
||||||
console.log('Using system yt-dlp:', p)
|
scopedLoggers.engine.info('Using system yt-dlp:', p)
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +100,7 @@ class YtDlpManager {
|
|||||||
try {
|
try {
|
||||||
const systemPath = execSync('which yt-dlp').toString().trim()
|
const systemPath = execSync('which yt-dlp').toString().trim()
|
||||||
if (systemPath && fs.existsSync(systemPath)) {
|
if (systemPath && fs.existsSync(systemPath)) {
|
||||||
console.log('Using system yt-dlp:', systemPath)
|
scopedLoggers.engine.info('Using system yt-dlp:', systemPath)
|
||||||
return systemPath
|
return systemPath
|
||||||
}
|
}
|
||||||
} catch (_error) {
|
} 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
|
// Removed runtime download/update to avoid network dependency in production builds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import os from 'node:os'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { AppSettings } from '../shared/types'
|
import type { AppSettings } from '../shared/types'
|
||||||
import { defaultSettings } from '../shared/types'
|
import { defaultSettings } from '../shared/types'
|
||||||
|
import { scopedLoggers } from './utils/logger'
|
||||||
|
|
||||||
// Use require for electron-store to avoid CommonJS/ESM issues
|
// Use require for electron-store to avoid CommonJS/ESM issues
|
||||||
const ElectronStore = require('electron-store')
|
const ElectronStore = require('electron-store')
|
||||||
@@ -14,7 +15,7 @@ const ensureDirectoryExists = (dir: string) => {
|
|||||||
try {
|
try {
|
||||||
fs.mkdirSync(dir, { recursive: true })
|
fs.mkdirSync(dir, { recursive: true })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to ensure download directory:', error)
|
scopedLoggers.system.error('Failed to ensure download directory:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +86,7 @@ class SettingsManager {
|
|||||||
}
|
}
|
||||||
ensureDirectoryExists(normalizedDownloadPath)
|
ensureDirectoryExists(normalizedDownloadPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to verify download directory:', error)
|
scopedLoggers.system.error('Failed to verify download directory:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,34 +168,14 @@ function AppContent() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const showRestartPrompt = () => {
|
|
||||||
toast.info(t('about.notifications.restartToUpdate'), {
|
|
||||||
action: {
|
|
||||||
label: t('about.notifications.restartNowAction'),
|
|
||||||
onClick: () => {
|
|
||||||
void ipcServices.update.quitAndInstall()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetDownloadState = () => {
|
const resetDownloadState = () => {
|
||||||
if (updateDownloadInProgressRef.current) {
|
if (updateDownloadInProgressRef.current) {
|
||||||
updateDownloadInProgressRef.current = false
|
updateDownloadInProgressRef.current = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateDownloaded = (rawInfo: unknown) => {
|
const handleUpdateDownloaded = (_rawInfo: unknown) => {
|
||||||
const info = (rawInfo ?? {}) as { version?: string }
|
|
||||||
resetDownloadState()
|
resetDownloadState()
|
||||||
|
|
||||||
const versionLabel = info?.version ?? ''
|
|
||||||
const downloadedMessage = versionLabel
|
|
||||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
|
||||||
: t('about.notifications.updateDownloaded')
|
|
||||||
toast.success(downloadedMessage)
|
|
||||||
|
|
||||||
showRestartPrompt()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateError = (rawMessage: unknown) => {
|
const handleUpdateError = (rawMessage: unknown) => {
|
||||||
@@ -214,13 +194,13 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleUpdateNotification = (rawPayload: unknown) => {
|
const handleUpdateNotification = (rawPayload: unknown) => {
|
||||||
const payload = (rawPayload ?? {}) as { body?: string; version?: string }
|
const payload = (rawPayload ?? {}) as { version?: string }
|
||||||
const versionLabel = payload.version ?? ''
|
const versionLabel = payload?.version ?? ''
|
||||||
const downloadedMessage = versionLabel
|
const downloadedMessage = versionLabel
|
||||||
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
? t('about.notifications.updateDownloadedVersion', { version: versionLabel })
|
||||||
: t('about.notifications.updateDownloaded')
|
: t('about.notifications.updateDownloaded')
|
||||||
|
|
||||||
toast.info(payload?.body ?? downloadedMessage, {
|
toast.info(downloadedMessage, {
|
||||||
action: {
|
action: {
|
||||||
label: t('about.notifications.restartNowAction'),
|
label: t('about.notifications.restartNowAction'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/ui/tabs'
|
||||||
|
|
||||||
import { popularSites } from '@renderer/data/popularSites'
|
import { popularSites } from '@renderer/data/popularSites'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
|
|
||||||
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
import type { AppSettings, OneClickQualityPreset, PlaylistInfo } from '@shared/types'
|
||||||
import { useAtom, useSetAtom } from 'jotai'
|
import { useAtom, useSetAtom } from 'jotai'
|
||||||
@@ -1042,23 +1043,41 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advanced Options Content - Single Video */}
|
{/* Advanced Options Content - Single Video */}
|
||||||
{videoInfo && !loading && singleVideoAdvancedOptionsOpen && (
|
{videoInfo && !loading && (
|
||||||
<div className="w-full pt-4 mt-4 border-t">
|
<div
|
||||||
<AdvancedOptions
|
data-state={singleVideoAdvancedOptionsOpen ? 'open' : 'closed'}
|
||||||
startTime={videoInfoCardState.startTime}
|
className={cn(
|
||||||
endTime={videoInfoCardState.endTime}
|
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||||
downloadSubs={videoInfoCardState.downloadSubs}
|
singleVideoAdvancedOptionsOpen
|
||||||
onStartTimeChange={(value) =>
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
setVideoInfoCardState((prev) => ({ ...prev, startTime: value }))
|
: 'grid-rows-[0fr] opacity-0'
|
||||||
}
|
)}
|
||||||
onEndTimeChange={(value) =>
|
aria-hidden={!singleVideoAdvancedOptionsOpen}
|
||||||
setVideoInfoCardState((prev) => ({ ...prev, endTime: value }))
|
>
|
||||||
}
|
<div
|
||||||
onDownloadSubsChange={(value) =>
|
className={cn(
|
||||||
setVideoInfoCardState((prev) => ({ ...prev, downloadSubs: value }))
|
'min-h-0',
|
||||||
}
|
!singleVideoAdvancedOptionsOpen && 'pointer-events-none'
|
||||||
showAccordion={false}
|
)}
|
||||||
/>
|
>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1191,51 +1210,62 @@ export function DownloadDialog({ onOpenSupportedSites, onOpenSettings }: Downloa
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Advanced Options Content - Playlist */}
|
{/* Advanced Options Content - Playlist */}
|
||||||
{advancedOptionsOpen && (
|
<div
|
||||||
<div className="w-full pt-4 mt-4 border-t">
|
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||||
<div className="space-y-4">
|
className={cn(
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
|
||||||
<div className="space-y-2">
|
advancedOptionsOpen
|
||||||
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
? 'grid-rows-[1fr] opacity-100'
|
||||||
<Select
|
: 'grid-rows-[0fr] opacity-0'
|
||||||
value={downloadType}
|
)}
|
||||||
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
aria-hidden={!advancedOptionsOpen}
|
||||||
disabled={playlistBusy}
|
>
|
||||||
>
|
<div className={cn('min-h-0', !advancedOptionsOpen && 'pointer-events-none')}>
|
||||||
<SelectTrigger id={downloadTypeId}>
|
<div className="w-full pt-4 mt-4 border-t">
|
||||||
<SelectValue />
|
<div className="space-y-4">
|
||||||
</SelectTrigger>
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<SelectContent>
|
<div className="space-y-2">
|
||||||
<SelectItem value="video">{t('download.video')}</SelectItem>
|
<Label htmlFor={downloadTypeId}>{t('playlist.downloadType')}</Label>
|
||||||
<SelectItem value="audio">{t('download.audio')}</SelectItem>
|
<Select
|
||||||
</SelectContent>
|
value={downloadType}
|
||||||
</Select>
|
onValueChange={(v) => setDownloadType(v as 'video' | 'audio')}
|
||||||
</div>
|
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">
|
<div className="space-y-2">
|
||||||
<Label>{t('playlist.range')}</Label>
|
<Label>{t('playlist.range')}</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
value={startIndex}
|
value={startIndex}
|
||||||
onChange={(e) => setStartIndex(e.target.value)}
|
onChange={(e) => setStartIndex(e.target.value)}
|
||||||
className="text-center"
|
className="text-center"
|
||||||
disabled={playlistBusy}
|
disabled={playlistBusy}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-xs">-</span>
|
<span className="text-muted-foreground text-xs">-</span>
|
||||||
<Input
|
<Input
|
||||||
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
placeholder={playlistInfo?.entryCount.toString() || 'End'}
|
||||||
value={endIndex}
|
value={endIndex}
|
||||||
onChange={(e) => setEndIndex(e.target.value)}
|
onChange={(e) => setEndIndex(e.target.value)}
|
||||||
className="text-center"
|
className="text-center"
|
||||||
disabled={playlistBusy}
|
disabled={playlistBusy}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger
|
|
||||||
} from '@renderer/components/ui/accordion'
|
|
||||||
import { Button } from '@renderer/components/ui/button'
|
import { Button } from '@renderer/components/ui/button'
|
||||||
|
import { Checkbox } from '@renderer/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -17,6 +12,7 @@ import { Input } from '@renderer/components/ui/input'
|
|||||||
import { Label } from '@renderer/components/ui/label'
|
import { Label } from '@renderer/components/ui/label'
|
||||||
import { Switch } from '@renderer/components/ui/switch'
|
import { Switch } from '@renderer/components/ui/switch'
|
||||||
import { ipcServices } from '@renderer/lib/ipc'
|
import { ipcServices } from '@renderer/lib/ipc'
|
||||||
|
import { cn } from '@renderer/lib/utils'
|
||||||
import { settingsAtom } from '@renderer/store/settings'
|
import { settingsAtom } from '@renderer/store/settings'
|
||||||
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
import { resolveFeedAtom } from '@renderer/store/subscriptions'
|
||||||
import { DEFAULT_SUBSCRIPTION_FILENAME_TEMPLATE, type SubscriptionRule } from '@shared/types'
|
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 detectTimeout = useRef<NodeJS.Timeout | null>(null)
|
||||||
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
const prevDefaultPathRef = useRef(buildDefaultSubscriptionDirectory(settings.downloadPath))
|
||||||
const urlInputId = useId()
|
const urlInputId = useId()
|
||||||
|
const advancedOptionsId = useId()
|
||||||
|
const [advancedOptionsOpen, setAdvancedOptionsOpen] = useState(false)
|
||||||
|
|
||||||
// Initialize form values based on mode
|
// Initialize form values based on mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,6 +90,8 @@ export function SubscriptionFormDialog({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setAdvancedOptionsOpen(false)
|
||||||
|
|
||||||
if (mode === 'edit' && subscription) {
|
if (mode === 'edit' && subscription) {
|
||||||
setUrl(subscription.feedUrl)
|
setUrl(subscription.feedUrl)
|
||||||
setKeywords(subscription.keywords.join(', '))
|
setKeywords(subscription.keywords.join(', '))
|
||||||
@@ -274,10 +274,16 @@ export function SubscriptionFormDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Accordion type="single" collapsible>
|
<div
|
||||||
<AccordionItem value="advanced">
|
data-state={advancedOptionsOpen ? 'open' : 'closed'}
|
||||||
<AccordionTrigger>{t('advancedOptions.title')}</AccordionTrigger>
|
className={cn(
|
||||||
<AccordionContent className="space-y-3">
|
'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">
|
<div className="space-y-2">
|
||||||
<Label>{t('subscriptions.fields.keywords')}</Label>
|
<Label>{t('subscriptions.fields.keywords')}</Label>
|
||||||
<Input value={keywords} onChange={(event) => setKeywords(event.target.value)} />
|
<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>
|
<p className="text-sm">{t('subscriptions.fields.onlyLatest')}</p>
|
||||||
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
<Switch checked={onlyLatest} onCheckedChange={setOnlyLatest} />
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</div>
|
||||||
</AccordionItem>
|
</div>
|
||||||
</Accordion>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{mode === 'add' && (
|
<div className="flex items-center justify-between w-full gap-4">
|
||||||
<Button variant="outline" onClick={onClose}>
|
<div className="flex items-center gap-2">
|
||||||
{t('download.cancel')}
|
<Checkbox
|
||||||
</Button>
|
id={advancedOptionsId}
|
||||||
)}
|
checked={advancedOptionsOpen}
|
||||||
<Button onClick={() => void handleSave()}>{t(saveButtonKey)}</Button>
|
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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"autoUpdateTitle": "Auto updates",
|
"autoUpdateTitle": "Auto updates",
|
||||||
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
|
"betaProgramDescription": "Receive early builds and upcoming features before everyone else.",
|
||||||
"betaProgramTitle": "Preview channel",
|
"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": {
|
"followAuthorActions": {
|
||||||
"follow": "Follow @nexmoex"
|
"follow": "Follow @nexmoex"
|
||||||
},
|
},
|
||||||
@@ -51,6 +51,10 @@
|
|||||||
"documentationDescription": "Guides, FAQs, and common workflows.",
|
"documentationDescription": "Guides, FAQs, and common workflows.",
|
||||||
"feedback": "Feedback & issues",
|
"feedback": "Feedback & issues",
|
||||||
"feedbackDescription": "Share ideas or report issues on GitHub.",
|
"feedbackDescription": "Share ideas or report issues on GitHub.",
|
||||||
|
"githubIssues": "GitHub Issues",
|
||||||
|
"githubIssuesDescription": "Report bugs or request features on GitHub.",
|
||||||
|
"discord": "Discord Community",
|
||||||
|
"discordDescription": "Join our Discord community for discussions and support.",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"licenseDescription": "Review the open-source license terms.",
|
"licenseDescription": "Review the open-source license terms.",
|
||||||
"website": "Official website",
|
"website": "Official website",
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Github,
|
Github,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
Mail,
|
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ShieldCheck,
|
|
||||||
Twitter
|
Twitter
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
@@ -196,7 +194,7 @@ export function About() {
|
|||||||
|
|
||||||
const shareLinks = useMemo(() => {
|
const shareLinks = useMemo(() => {
|
||||||
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
const encodedUrl = encodeURIComponent(shareTargetUrl)
|
||||||
const encodedText = encodeURIComponent(t('about.description'))
|
const encodedText = encodeURIComponent(`${t('about.description')} @nexmoex`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
facebook: `https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`,
|
||||||
@@ -262,25 +260,18 @@ export function About() {
|
|||||||
href: 'https://github.com/nexmoe/VidBee/releases'
|
href: 'https://github.com/nexmoe/VidBee/releases'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: MessageCircle,
|
icon: Github,
|
||||||
label: t('about.resources.feedback'),
|
label: t('about.resources.githubIssues'),
|
||||||
description: t('about.resources.feedbackDescription'),
|
description: t('about.resources.githubIssuesDescription'),
|
||||||
actionLabel: t('about.actions.feedback'),
|
actionLabel: t('about.actions.feedback'),
|
||||||
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
href: 'https://github.com/nexmoe/VidBee/issues/new/choose'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: ShieldCheck,
|
icon: MessageCircle,
|
||||||
label: t('about.resources.license'),
|
label: t('about.resources.discord'),
|
||||||
description: t('about.resources.licenseDescription'),
|
description: t('about.resources.discordDescription'),
|
||||||
actionLabel: t('about.actions.view'),
|
actionLabel: t('about.actions.visit'),
|
||||||
href: 'https://github.com/nexmoe/VidBee/blob/main/LICENSE'
|
href: 'https://discord.gg/uBqXV6QPdm'
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Mail,
|
|
||||||
label: t('about.resources.contact'),
|
|
||||||
description: t('about.resources.contactDescription'),
|
|
||||||
actionLabel: t('about.actions.email'),
|
|
||||||
href: 'mailto:nexmoex@gmail.com'
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[t]
|
[t]
|
||||||
@@ -415,10 +406,6 @@ export function About() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('about.resourcesTitle')}</CardTitle>
|
|
||||||
<CardDescription>{t('about.resourcesDescription')}</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="flex flex-col divide-y">
|
<div className="flex flex-col divide-y">
|
||||||
{aboutResources.map((resource) => {
|
{aboutResources.map((resource) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user