diff --git a/.vscode/settings.json b/.vscode/settings.json
index 419c9f3..d2b121e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -21,9 +21,20 @@
},
"typescript.tsdk": "node_modules/typescript/lib",
"tailwindCSS.experimental.classRegex": [
- ["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
- ["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
+ [
+ "cva\\(([^)]*)\\)",
+ "[\"'`]([^\"'`]*).*?[\"'`]"
+ ],
+ [
+ "cn\\(([^)]*)\\)",
+ "(?:'|\"|`)([^']*)(?:'|\"|`)"
+ ]
],
- "i18n-ally.localesPaths": ["src/renderer/src/locales"],
- "i18n-ally.keystyle": "nested"
-}
+ "i18n-ally.localesPaths": [
+ "src/renderer/src/locales"
+ ],
+ "i18n-ally.keystyle": "nested",
+ "[css]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ }
+}
\ No newline at end of file
diff --git a/scripts/setup-dev-binaries.js b/scripts/setup-dev-binaries.js
index 7ba6d4b..718f870 100755
--- a/scripts/setup-dev-binaries.js
+++ b/scripts/setup-dev-binaries.js
@@ -15,6 +15,8 @@ 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 GITHUB_TOKEN =
+ process.env.GITHUB_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_API_TOKEN
// Platform configuration
const PLATFORM_CONFIG = {
@@ -27,7 +29,12 @@ const PLATFORM_CONFIG = {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-win64-gpl.zip',
innerPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe',
output: 'ffmpeg.exe',
- extract: 'unzip'
+ extract: 'unzip',
+ release: {
+ repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
+ assetPattern: /win64.*gpl.*\.zip$/i,
+ binaryName: 'ffmpeg.exe'
+ }
}
},
darwin: {
@@ -41,13 +48,21 @@ const PLATFORM_CONFIG = {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-arm64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
- extract: 'unzip'
+ extract: 'unzip',
+ release: {
+ repo: 'eko5624/mpv-mac',
+ assetPattern: /ffmpeg-arm64.*\.zip$/i
+ }
},
x64: {
url: 'https://github.com/eko5624/mpv-mac/releases/download/2025-10-25/ffmpeg-x86_64-defd5f3f64.zip',
innerPath: 'ffmpeg/ffmpeg',
output: 'ffmpeg_macos',
- extract: 'unzip'
+ extract: 'unzip',
+ release: {
+ repo: 'eko5624/mpv-mac',
+ assetPattern: /ffmpeg-x86_64.*\.zip$/i
+ }
}
}
},
@@ -60,7 +75,12 @@ const PLATFORM_CONFIG = {
url: 'https://github.com/yt-dlp/FFmpeg-Builds/releases/latest/download/ffmpeg-master-latest-linux64-gpl.tar.xz',
innerPath: 'ffmpeg-master-latest-linux64-gpl/bin/ffmpeg',
output: 'ffmpeg_linux',
- extract: 'tar'
+ extract: 'tar',
+ release: {
+ repos: ['yt-dlp/FFmpeg-Builds', 'BtbN/FFmpeg-Builds'],
+ assetPattern: /linux64.*gpl.*\.tar\.xz$/i,
+ binaryName: 'ffmpeg'
+ }
}
}
}
@@ -117,6 +137,85 @@ function downloadFile(url, dest) {
})
}
+function fetchJson(url) {
+ return new Promise((resolve, reject) => {
+ const protocol = url.startsWith('https') ? https : http
+ const headers = {
+ 'User-Agent': 'vidbee-setup',
+ Accept: 'application/vnd.github+json'
+ }
+ if (GITHUB_TOKEN) {
+ headers.Authorization = `Bearer ${GITHUB_TOKEN}`
+ }
+
+ protocol
+ .get(url, { headers }, (response) => {
+ if (response.statusCode === 302 || response.statusCode === 301) {
+ return fetchJson(response.headers.location).then(resolve).catch(reject)
+ }
+ if (response.statusCode !== 200) {
+ return reject(new Error(`Failed to fetch ${url}: ${response.statusCode}`))
+ }
+
+ let body = ''
+ response.on('data', (chunk) => {
+ body += chunk
+ })
+ response.on('end', () => {
+ try {
+ resolve(JSON.parse(body))
+ } catch (error) {
+ reject(new Error(`Failed to parse JSON from ${url}: ${error.message}`))
+ }
+ })
+ })
+ .on('error', (err) => {
+ reject(err)
+ })
+ })
+}
+
+function inferFfmpegInnerPath(assetName, binaryName) {
+ if (!assetName) {
+ return null
+ }
+ const match = assetName.match(/^(.*)\.(tar\.xz|zip)$/i)
+ if (!match) {
+ return null
+ }
+ return `${match[1]}/bin/${binaryName}`
+}
+
+async function resolveReleaseAsset(release) {
+ if (!release) {
+ return null
+ }
+ const repoCandidates = release.repos ?? (release.repo ? [release.repo] : [])
+ if (repoCandidates.length === 0) {
+ return null
+ }
+
+ let lastError
+ for (const repo of repoCandidates) {
+ try {
+ const data = await fetchJson(`https://api.github.com/repos/${repo}/releases/latest`)
+ const assets = Array.isArray(data.assets) ? data.assets : []
+ const match = assets.find((asset) => asset?.name && release.assetPattern.test(asset.name))
+ if (match?.browser_download_url) {
+ return { name: match.name, url: match.browser_download_url }
+ }
+ lastError = new Error(`No matching assets found in ${repo}`)
+ } catch (error) {
+ lastError = error
+ }
+ }
+
+ if (lastError) {
+ throw lastError
+ }
+ return null
+}
+
function extractZip(zipPath, extractDir) {
const platform = os.platform()
ensureDir(extractDir)
@@ -186,7 +285,7 @@ async function downloadYtDlp(config) {
}
async function downloadFfmpegWindows(config) {
- const { url, innerPath, output } = config.ffmpeg
+ const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
@@ -197,9 +296,26 @@ async function downloadFfmpegWindows(config) {
log(`Downloading ffmpeg for Windows...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
+ let downloadUrl = fallbackUrl
+ let innerPath = fallbackInnerPath
+
+ if (release) {
+ try {
+ const resolved = await resolveReleaseAsset(release)
+ if (resolved) {
+ downloadUrl = resolved.url
+ const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg.exe')
+ if (inferred) {
+ innerPath = inferred
+ }
+ }
+ } catch (error) {
+ log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
+ }
+ }
try {
- await downloadFile(url, tempZip)
+ await downloadFile(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -229,7 +345,7 @@ async function downloadFfmpegMac(config) {
throw new Error(`Unsupported architecture: ${arch}`)
}
- const { url, innerPath, output } = ffmpegConfig
+ const { url: fallbackUrl, innerPath, output, release } = ffmpegConfig
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
@@ -240,9 +356,21 @@ async function downloadFfmpegMac(config) {
log(`Downloading ffmpeg for macOS (${arch})...`, 'download')
const tempZip = path.join(RESOURCES_DIR, 'ffmpeg-temp.zip')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
+ let downloadUrl = fallbackUrl
+
+ if (release) {
+ try {
+ const resolved = await resolveReleaseAsset(release)
+ if (resolved) {
+ downloadUrl = resolved.url
+ }
+ } catch (error) {
+ log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
+ }
+ }
try {
- await downloadFile(url, tempZip)
+ await downloadFile(downloadUrl, tempZip)
log('Extracting ffmpeg...', 'info')
extractZip(tempZip, extractDir)
@@ -266,7 +394,7 @@ async function downloadFfmpegMac(config) {
}
async function downloadFfmpegLinux(config) {
- const { url, innerPath, output } = config.ffmpeg
+ const { url: fallbackUrl, innerPath: fallbackInnerPath, output, release } = config.ffmpeg
const outputPath = path.join(RESOURCES_DIR, output)
if (fileExists(outputPath)) {
@@ -277,9 +405,26 @@ async function downloadFfmpegLinux(config) {
log(`Downloading ffmpeg for Linux...`, 'download')
const tempTar = path.join(RESOURCES_DIR, 'ffmpeg-temp.tar.xz')
const extractDir = path.join(RESOURCES_DIR, 'ffmpeg-temp')
+ let downloadUrl = fallbackUrl
+ let innerPath = fallbackInnerPath
+
+ if (release) {
+ try {
+ const resolved = await resolveReleaseAsset(release)
+ if (resolved) {
+ downloadUrl = resolved.url
+ const inferred = inferFfmpegInnerPath(resolved.name, release.binaryName ?? 'ffmpeg')
+ if (inferred) {
+ innerPath = inferred
+ }
+ }
+ } catch (error) {
+ log(`Failed to resolve latest ffmpeg asset: ${error.message}`, 'warn')
+ }
+ }
try {
- await downloadFile(url, tempTar)
+ await downloadFile(downloadUrl, tempTar)
log('Extracting ffmpeg...', 'info')
extractTarXz(tempTar, extractDir)
diff --git a/src/main/ipc/services/history-service.ts b/src/main/ipc/services/history-service.ts
index f0f49d3..99e257f 100644
--- a/src/main/ipc/services/history-service.ts
+++ b/src/main/ipc/services/history-service.ts
@@ -25,6 +25,21 @@ class HistoryService extends IpcService {
return historyManager.removeHistoryItem(id)
}
+ @IpcMethod()
+ removeHistoryItems(_context: IpcContext, ids: string[]): number {
+ return historyManager.removeHistoryItems(ids)
+ }
+
+ @IpcMethod()
+ removeHistoryByPlaylistId(_context: IpcContext, playlistId: string): number {
+ return historyManager.removeHistoryByPlaylistId(playlistId)
+ }
+
+ @IpcMethod()
+ clearHistory(_context: IpcContext): void {
+ historyManager.clearHistory()
+ }
+
@IpcMethod()
getHistoryCount(_context: IpcContext): {
active: number
diff --git a/src/main/lib/history-manager.ts b/src/main/lib/history-manager.ts
index 8eb5416..95e3631 100644
--- a/src/main/lib/history-manager.ts
+++ b/src/main/lib/history-manager.ts
@@ -1,7 +1,7 @@
import { existsSync, readFileSync, renameSync } from 'node:fs'
import { join } from 'node:path'
import DatabaseConstructor from 'better-sqlite3'
-import { eq, sql } from 'drizzle-orm'
+import { eq, inArray, sql } from 'drizzle-orm'
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
import { drizzle } from 'drizzle-orm/better-sqlite3'
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
@@ -494,6 +494,61 @@ class HistoryManager {
}
}
+ removeHistoryItems(ids: string[]): number {
+ const uniqueIds = Array.from(new Set(ids)).filter((id) => id.trim().length > 0)
+ if (uniqueIds.length === 0) {
+ return 0
+ }
+ let removedCount = 0
+ try {
+ const database = this.getDatabase()
+ const result = database
+ .delete(downloadHistoryTable)
+ .where(inArray(downloadHistoryTable.id, uniqueIds))
+ .run()
+ for (const id of uniqueIds) {
+ if (this.history.delete(id)) {
+ removedCount++
+ }
+ }
+ if ((result.changes ?? 0) > removedCount) {
+ removedCount = result.changes ?? removedCount
+ }
+ return removedCount
+ } catch (error) {
+ logger.error('history-db failed to delete items', { count: uniqueIds.length, error })
+ return removedCount
+ }
+ }
+
+ removeHistoryByPlaylistId(playlistId: string): number {
+ const normalized = playlistId.trim()
+ if (!normalized) {
+ return 0
+ }
+ let removedCount = 0
+ try {
+ const database = this.getDatabase()
+ const result = database
+ .delete(downloadHistoryTable)
+ .where(eq(downloadHistoryTable.playlistId, normalized))
+ .run()
+ for (const [id, item] of this.history.entries()) {
+ if (item.playlistId === normalized) {
+ this.history.delete(id)
+ removedCount++
+ }
+ }
+ if ((result.changes ?? 0) > removedCount) {
+ removedCount = result.changes ?? removedCount
+ }
+ return removedCount
+ } catch (error) {
+ logger.error('history-db failed to delete playlist items', { playlistId: normalized, error })
+ return removedCount
+ }
+ }
+
clearHistory(): void {
try {
const database = this.getDatabase()
diff --git a/src/renderer/src/assets/theme.css b/src/renderer/src/assets/theme.css
index 046ffc6..a4ce8bd 100644
--- a/src/renderer/src/assets/theme.css
+++ b/src/renderer/src/assets/theme.css
@@ -90,20 +90,6 @@
--font-serif: Georgia, serif;
--font-mono: Menlo, monospace;
--radius: 1.3rem;
- --shadow-x: 0px;
- --shadow-y: 2px;
- --shadow-blur: 0px;
- --shadow-spread: 0px;
- --shadow-opacity: 0;
- --shadow-color: #1da1f2;
- --shadow-2xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
- --shadow-xs: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
- --shadow-sm: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
- --shadow: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 1px 2px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
- --shadow-md: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 2px 4px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
- --shadow-lg: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 4px 6px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
- --shadow-xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00), 0px 8px 10px -1px hsl(202.8169 89.1213% 53.1373% / 0.00);
- --shadow-2xl: 0px 2px 0px 0px hsl(202.8169 89.1213% 53.1373% / 0.00);
}
@theme inline {
@@ -148,13 +134,4 @@
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
-
- --shadow-2xs: var(--shadow-2xs);
- --shadow-xs: var(--shadow-xs);
- --shadow-sm: var(--shadow-sm);
- --shadow: var(--shadow);
- --shadow-md: var(--shadow-md);
- --shadow-lg: var(--shadow-lg);
- --shadow-xl: var(--shadow-xl);
- --shadow-2xl: var(--shadow-2xl);
}
diff --git a/src/renderer/src/components/download/DownloadItem.tsx b/src/renderer/src/components/download/DownloadItem.tsx
index 2c91e13..5e8f8ac 100644
--- a/src/renderer/src/components/download/DownloadItem.tsx
+++ b/src/renderer/src/components/download/DownloadItem.tsx
@@ -1,5 +1,6 @@
import { Badge } from '@renderer/components/ui/badge'
import { Button } from '@renderer/components/ui/button'
+import { Checkbox } from '@renderer/components/ui/checkbox'
import { Progress } from '@renderer/components/ui/progress'
import { RemoteImage } from '@renderer/components/ui/remote-image'
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'
@@ -150,6 +151,8 @@ const getCodecLabel = (download: DownloadRecord): string | undefined => {
interface DownloadItemProps {
download: DownloadRecord
+ isSelected?: boolean
+ onToggleSelect?: (id: string) => void
}
type MetadataDetail = {
@@ -192,7 +195,7 @@ const formatDateShort = (timestamp?: number) => {
})
}
-export function DownloadItem({ download }: DownloadItemProps) {
+export function DownloadItem({ download, isSelected = false, onToggleSelect }: DownloadItemProps) {
const { t } = useTranslation()
const settings = useAtomValue(settingsAtom)
const removeDownload = useSetAtom(removeDownloadAtom)
@@ -203,12 +206,13 @@ export function DownloadItem({ download }: DownloadItemProps) {
const timestamp = download.completedAt ?? download.downloadedAt ?? download.createdAt
const showActionsWithoutHover = isHistory || download.status === 'completed'
const actionsContainerBaseClass =
- 'flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
+ 'relative z-20 flex shrink-0 flex-wrap items-center justify-end gap-1 text-muted-foreground opacity-100 transition-opacity'
const actionsContainerClass = showActionsWithoutHover
? actionsContainerBaseClass
: `${actionsContainerBaseClass} sm:opacity-0 sm:group-hover:opacity-100`
const resolvedExtension = resolveDownloadExtension(download)
const normalizedSavedFileName = normalizeSavedFileName(download.savedFileName)
+ const selectionEnabled = isHistory && Boolean(onToggleSelect)
// Track if the file exists
const [fileExists, setFileExists] = useState(false)
@@ -494,7 +498,7 @@ export function DownloadItem({ download }: DownloadItemProps) {
href={download.url}
target="_blank"
rel="noopener noreferrer"
- className="wrap-break-word text-primary hover:underline"
+ className="relative z-20 wrap-break-word text-primary hover:underline"
>
{download.url}
@@ -638,18 +642,65 @@ export function DownloadItem({ download }: DownloadItemProps) {
const hasMetadataDetails = metadataDetails.length > 0
+ const isSelectedHistory = selectionEnabled && isSelected
+
return (
-
+
+ {isSelectedHistory && (
+
+ )}
+