chore: import upstream snapshot with attribution
Docker Publish / docker (web, apps/web/Dockerfile, web) (push) Failing after 0s
Docker Publish / docker (api, apps/api/Dockerfile, api) (push) Failing after 1s
Publish Extension / detect-version (push) Has been skipped
Publish Extension / submit (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:24:41 +08:00
commit d18ada4ee7
567 changed files with 118275 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env node
import { execSync } from 'node:child_process'
// GitHub issue #368: the desktop app rebuilds the shared, pnpm-hoisted
// better-sqlite3 for Electron's ABI (NODE_MODULE_VERSION 139). The API runs on
// plain Node (ABI 137) and then crashes loading the same binary. This mirrors
// apps/desktop/scripts/ensure-native-deps.mjs for the Node side: detect the ABI
// mismatch and rebuild better-sqlite3 for the current Node before the API boots.
const checkScript =
"const Database=require('better-sqlite3');const db=new Database(':memory:');db.close()"
function canLoadBetterSqlite3WithNode() {
try {
// Run in a child node so a failed native load cannot crash this process.
execSync(`node -e "${checkScript}"`, { stdio: 'pipe' })
return true
} catch (error) {
const details = error.stderr?.toString().trim() || error.message
console.warn(`[native-deps] better-sqlite3 (Node) check failed: ${details}`)
return false
}
}
if (canLoadBetterSqlite3WithNode()) {
process.exit(0)
}
console.log('[native-deps] Rebuilding better-sqlite3 for Node...')
execSync('pnpm rebuild better-sqlite3', { stdio: 'inherit' })
if (!canLoadBetterSqlite3WithNode()) {
throw new Error('[native-deps] better-sqlite3 is still unavailable for Node after rebuild')
}
console.log('[native-deps] better-sqlite3 is ready for Node')
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/env tsx
/**
* One-shot migration: copy every row from the legacy `download_history`
* table into the new task-queue `tasks` table as a terminal record with
* `status_reason = 'legacy-import'`.
*
* Idempotent: re-running it is a no-op for rows that have already been
* imported (matched by id).
*
* Containerized hosts wire this into a `before_start` hook so a fresh
* container picks up legacy history transparently. On migration failure
* the legacy DB is left untouched and the new task-queue DB rolls back
* to the pre-migration state.
*
* Usage:
* tsx apps/api/scripts/migrate-history.ts
*
* Env vars (all optional):
* VIDBEE_HISTORY_STORE_PATH legacy sqlite db path (default uses
* $VIDBEE_DOWNLOAD_DIR/.vidbee/vidbee.db)
* VIDBEE_TASK_QUEUE_DB task-queue sqlite db path (default uses
* $VIDBEE_DOWNLOAD_DIR/.vidbee/task-queue.db)
* VIDBEE_DOWNLOAD_DIR default download directory
*/
import fs from 'node:fs'
import { createRequire } from 'node:module'
import os from 'node:os'
import path from 'node:path'
import { TASK_QUEUE_DDL_V1 } from '@vidbee/db/task-queue'
import {
PRIORITY_BACKGROUND,
type Task,
type TaskOutput,
type TaskStatus
} from '@vidbee/task-queue'
const require = createRequire(import.meta.url)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const Database = require('better-sqlite3') as typeof import('better-sqlite3')
const trim = (v?: string | null): string | undefined => {
const t = v?.trim()
return t && t.length > 0 ? t : undefined
}
const DEFAULT_DOWNLOAD_DIR = path.join(os.homedir(), 'Downloads', 'VidBee')
const downloadDir =
trim(process.env.VIDBEE_DOWNLOAD_DIR) ??
trim(process.env.DOWNLOAD_DIR) ??
DEFAULT_DOWNLOAD_DIR
const legacyDbPath =
trim(process.env.VIDBEE_HISTORY_STORE_PATH) ?? path.join(downloadDir, '.vidbee', 'vidbee.db')
const taskQueueDbPath =
trim(process.env.VIDBEE_TASK_QUEUE_DB) ?? path.join(downloadDir, '.vidbee', 'task-queue.db')
interface LegacyHistoryRow {
id: string
url: string
title: string
thumbnail: string | null
type: string // 'video' | 'audio'
status: string // 'completed' | 'error' | 'cancelled'
download_path: string | null
saved_file_name: string | null
file_size: number | null
duration: number | null
downloaded_at: number
completed_at: number | null
sort_key: number
error: string | null
yt_dlp_command: string | null
yt_dlp_log: string | null
description: string | null
channel: string | null
uploader: string | null
view_count: number | null
tags: string | null
origin: string | null
subscription_id: string | null
selected_format: string | null
playlist_id: string | null
playlist_title: string | null
playlist_index: number | null
playlist_size: number | null
}
const TERMINAL_LEGACY: Record<string, TaskStatus | undefined> = {
completed: 'completed',
error: 'failed',
cancelled: 'cancelled'
}
function rowToTask(row: LegacyHistoryRow): Task | null {
const status = TERMINAL_LEGACY[row.status]
if (!status) return null
const filePath =
row.download_path && row.saved_file_name
? path.join(row.download_path, row.saved_file_name)
: (row.download_path ?? '')
const output: TaskOutput | null =
status === 'completed'
? {
filePath,
size: row.file_size ?? 0,
durationMs: row.duration != null ? row.duration * 1000 : null,
sha256: null
}
: null
const completedAt = row.completed_at ?? row.downloaded_at
return {
id: row.id,
kind: row.type === 'audio' ? 'audio' : 'video',
parentId: null,
input: {
url: row.url,
kind: row.type === 'audio' ? 'audio' : 'video',
title: row.title,
thumbnail: row.thumbnail ?? undefined,
subscriptionId: row.subscription_id ?? undefined,
playlistId: row.playlist_id ?? undefined,
playlistIndex: row.playlist_index ?? undefined,
options: {
type: row.type === 'audio' ? 'audio' : 'video',
title: row.title,
thumbnail: row.thumbnail ?? undefined,
description: row.description ?? undefined,
channel: row.channel ?? undefined,
uploader: row.uploader ?? undefined,
viewCount: row.view_count ?? undefined,
tags: parseTags(row.tags),
duration: row.duration ?? undefined,
playlistTitle: row.playlist_title ?? undefined,
playlistSize: row.playlist_size ?? undefined,
downloadPath: row.download_path ?? undefined,
fileSize: row.file_size ?? undefined,
startedAt: row.downloaded_at,
completedAt
}
},
priority: PRIORITY_BACKGROUND,
groupKey: row.subscription_id ? `sub:${row.subscription_id}` : safeHost(row.url),
status,
prevStatus: null,
statusReason: 'legacy-import',
enteredStatusAt: completedAt,
attempt: 0,
maxAttempts: 0,
nextRetryAt: null,
progress: {
percent: status === 'completed' ? 1 : null,
bytesDownloaded: row.file_size ?? null,
bytesTotal: row.file_size ?? null,
speedBps: null,
etaMs: null,
ticks: 0
},
output,
lastError:
status === 'failed'
? {
category: 'unknown',
exitCode: null,
rawMessage: row.error ?? 'legacy-import',
uiMessageKey: 'errors.legacy_import',
uiActionHints: [],
retryable: false,
suggestedRetryAfterMs: null
}
: null,
pid: null,
pidStartedAt: null,
createdAt: row.downloaded_at,
updatedAt: completedAt
}
}
function parseTags(value: string | null): string[] | undefined {
if (!value) return undefined
const tags = value
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0)
return tags.length > 0 ? tags : undefined
}
function safeHost(url: string): string {
try {
return new URL(url).host || 'unknown'
} catch {
return 'unknown'
}
}
function bindTask(task: Task): unknown[] {
return [
task.id,
task.kind,
task.parentId,
task.status,
task.prevStatus,
task.statusReason,
task.enteredStatusAt,
task.priority,
task.groupKey,
task.attempt,
task.maxAttempts,
task.nextRetryAt,
task.pid,
task.pidStartedAt,
task.createdAt,
task.updatedAt,
JSON.stringify(task.input),
JSON.stringify(task.progress),
task.output ? JSON.stringify(task.output) : null,
task.lastError ? JSON.stringify(task.lastError) : null
]
}
async function main(): Promise<void> {
if (!fs.existsSync(legacyDbPath)) {
// eslint-disable-next-line no-console
console.log(`[migrate-history] no legacy DB at ${legacyDbPath}; nothing to do.`)
return
}
// Warn loudly if persistence is disabled — without it, the runtime API uses
// an in-memory persist adapter and never reads the SQLite file we are about
// to populate. The migration would silently appear to "succeed" but users
// would see zero rows in /rpc/history/list. See bug #5 in NEX-124 review.
const persistFlag = (process.env.VIDBEE_PERSIST_QUEUE ?? '').trim()
const persistEnabled = persistFlag === '1' || persistFlag.toLowerCase() === 'true'
if (!persistEnabled) {
// eslint-disable-next-line no-console
console.warn(
'[migrate-history] WARNING: VIDBEE_PERSIST_QUEUE is not enabled. The ' +
'migrated rows will not be visible at runtime because the API will use ' +
'an in-memory queue. Set VIDBEE_PERSIST_QUEUE=1 (default in the bundled ' +
'Dockerfile) to read this database back.'
)
}
fs.mkdirSync(path.dirname(taskQueueDbPath), { recursive: true })
const legacyDb = new Database(legacyDbPath, { readonly: true, fileMustExist: true })
// Do NOT set journal_mode on a readonly handle — it's a write op and fails
// with SQLITE_READONLY. The legacy DB is opened read-only by design so we
// never mutate user data during migration.
const queueDb = new Database(taskQueueDbPath, { timeout: 5000 })
queueDb.pragma('journal_mode = WAL')
queueDb.pragma('foreign_keys = ON')
queueDb.exec(TASK_QUEUE_DDL_V1)
// Mark in-flight migration so callers can detect partial state.
queueDb
.prepare(
`INSERT INTO schema_meta (key, value) VALUES ('migration_in_progress','api-v1')
ON CONFLICT(key) DO UPDATE SET value = excluded.value`
)
.run()
const tableCheck = legacyDb
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='download_history'")
.get() as { name?: string } | undefined
if (!tableCheck?.name) {
// eslint-disable-next-line no-console
console.log('[migrate-history] legacy download_history table not present; nothing to do.')
queueDb
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
.run()
legacyDb.close()
queueDb.close()
return
}
const rows = legacyDb
.prepare('SELECT * FROM download_history ORDER BY sort_key ASC')
.all() as LegacyHistoryRow[]
const insertOrSkip = queueDb.prepare(`
INSERT INTO tasks (
id, kind, parent_id, status, prev_status, status_reason, entered_status_at,
priority, group_key, attempt, max_attempts, next_retry_at, pid,
pid_started_at, created_at, updated_at, input_json, progress_json,
output_json, last_error_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO NOTHING
`)
const tx = queueDb.transaction((tasks: Task[]) => {
let imported = 0
for (const task of tasks) {
const result = insertOrSkip.run(...bindTask(task))
if (result.changes > 0) imported += 1
}
return imported
})
let mappedTasks = 0
let skippedRows = 0
const tasks: Task[] = []
for (const row of rows) {
const task = rowToTask(row)
if (task) {
tasks.push(task)
mappedTasks += 1
} else {
skippedRows += 1
}
}
let imported = 0
try {
imported = tx(tasks) as number
} catch (err) {
// Roll back the migration marker; leave already-imported rows alone (the
// unique-id INSERT is idempotent and re-running picks up where we stopped).
queueDb
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
.run()
legacyDb.close()
queueDb.close()
throw err
}
queueDb
.prepare("DELETE FROM schema_meta WHERE key = 'migration_in_progress'")
.run()
// Best-effort rename to mark the legacy DB as migrated. Container hosts
// can pre-bind the mount as read-only; ignore EROFS.
const migratedPath = `${legacyDbPath}.migrated`
try {
if (!fs.existsSync(migratedPath)) {
fs.copyFileSync(legacyDbPath, migratedPath)
}
} catch (err) {
// eslint-disable-next-line no-console
console.warn('[migrate-history] could not stamp legacy DB as migrated:', err)
}
legacyDb.close()
queueDb.close()
// eslint-disable-next-line no-console
console.log(
`[migrate-history] legacy rows: ${rows.length}, mapped: ${mappedTasks}, skipped: ${skippedRows}, imported: ${imported} (already-present skipped)`
)
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error('[migrate-history] failed:', err)
process.exit(1)
})