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
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:
Executable
+36
@@ -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')
|
||||
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user