openreader/scripts/migrate-fs-v2.mjs
Richard R fa406a8cb2 refactor(deps): remove ffprobe-static and fix playwright flakiness
Replace ffprobe-static with ffmpeg-static for metadata extraction in
migration scripts. This removes the requirement for ffprobe binaries
across Docker, CI, and Vercel environments, simplifying the dependency
graph.
2026-02-16 01:41:54 -07:00

930 lines
31 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import fsp from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { spawn } from 'node:child_process';
import { createRequire } from 'node:module';
import * as dotenv from 'dotenv';
import {
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
const require = createRequire(import.meta.url);
const { Pool } = require('pg');
const BetterSqlite3 = require('better-sqlite3');
const ffmpegStatic = require('ffmpeg-static');
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const DOCUMENTS_V1_DIR = path.join(DOCSTORE_DIR, 'documents_v1');
const AUDIOBOOKS_V1_DIR = path.join(DOCSTORE_DIR, 'audiobooks_v1');
const UNCLAIMED_USER_ID = 'unclaimed';
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
const SAFE_AUDIOBOOK_ID_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
const DOCUMENT_ID_REGEX = /^[a-f0-9]{64}$/i;
function loadEnvFiles() {
const envPath = path.join(process.cwd(), '.env');
const envLocalPath = path.join(process.cwd(), '.env.local');
if (fs.existsSync(envPath)) dotenv.config({ path: envPath });
if (fs.existsSync(envLocalPath)) dotenv.config({ path: envLocalPath, override: true });
}
function parseBool(value, fallback = false) {
if (value == null || String(value).trim() === '') return fallback;
const normalized = String(value).trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return fallback;
}
function parseArgs(argv) {
const args = {
dryRun: false,
deleteLocal: false,
namespace: null,
};
for (let i = 0; i < argv.length; i += 1) {
const raw = argv[i];
if (raw === '--dry-run' && argv[i + 1]) {
args.dryRun = parseBool(argv[i + 1], true);
i += 1;
continue;
}
if (raw.startsWith('--dry-run=')) {
args.dryRun = parseBool(raw.slice('--dry-run='.length), true);
continue;
}
if (raw === '--delete-local' && argv[i + 1]) {
args.deleteLocal = parseBool(argv[i + 1], true);
i += 1;
continue;
}
if (raw.startsWith('--delete-local=')) {
args.deleteLocal = parseBool(raw.slice('--delete-local='.length), true);
continue;
}
if (raw === '--namespace' && argv[i + 1]) {
args.namespace = sanitizeNamespace(argv[i + 1]);
i += 1;
continue;
}
if (raw.startsWith('--namespace=')) {
args.namespace = sanitizeNamespace(raw.slice('--namespace='.length));
}
}
return args;
}
function sanitizeNamespace(namespace) {
if (!namespace) return null;
const safe = String(namespace).trim();
if (!safe || !SAFE_NAMESPACE_REGEX.test(safe)) return null;
return safe;
}
function applyNamespacePath(baseDir, namespace) {
if (!namespace) return baseDir;
const resolved = path.resolve(baseDir, namespace);
if (!resolved.startsWith(path.resolve(baseDir) + path.sep)) return baseDir;
return resolved;
}
function getUnclaimedUserIdForNamespace(namespace) {
if (!namespace) return UNCLAIMED_USER_ID;
return `${UNCLAIMED_USER_ID}::${namespace}`;
}
function normalizePrefix(prefix) {
const base = String(prefix || 'openreader').trim();
if (!base) return 'openreader';
return base.replace(/^\/+|\/+$/g, '');
}
function parseS3ConfigFromEnv() {
const bucket = process.env.S3_BUCKET?.trim();
const region = process.env.S3_REGION?.trim();
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim();
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim();
const endpoint = process.env.S3_ENDPOINT?.trim();
if (!bucket || !region || !accessKeyId || !secretAccessKey) {
throw new Error('S3 is not configured. Required env vars: S3_BUCKET, S3_REGION, S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY.');
}
return {
bucket,
region,
endpoint: endpoint || undefined,
accessKeyId,
secretAccessKey,
forcePathStyle: parseBool(process.env.S3_FORCE_PATH_STYLE, false),
prefix: normalizePrefix(process.env.S3_PREFIX),
};
}
function createS3Client(config) {
return new S3Client({
region: config.region,
endpoint: config.endpoint,
forcePathStyle: config.forcePathStyle,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
});
}
function isPreconditionFailed(error) {
if (!error || typeof error !== 'object') return false;
return error?.$metadata?.httpStatusCode === 412 || error?.name === 'PreconditionFailed';
}
function documentKey(s3Config, id, namespace) {
if (!DOCUMENT_ID_REGEX.test(id)) {
throw new Error(`Invalid document id: ${id}`);
}
const nsSegment = namespace ? `ns/${namespace}/` : '';
return `${s3Config.prefix}/documents_v1/${nsSegment}${id}`;
}
function audiobookKey(s3Config, bookId, userId, fileName, namespace) {
if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) throw new Error(`Invalid audiobook id: ${bookId}`);
if (!userId) throw new Error('Missing user id for audiobook key');
if (!fileName || fileName.includes('/') || fileName.includes('\\')) throw new Error(`Invalid audiobook file name: ${fileName}`);
const nsSegment = namespace ? `ns/${namespace}/` : '';
return `${s3Config.prefix}/audiobooks_v1/${nsSegment}users/${encodeURIComponent(String(userId))}/${bookId}-audiobook/${fileName}`;
}
async function putObjectIfMissing(s3Client, s3Config, key, body, contentType) {
await s3Client.send(new PutObjectCommand({
Bucket: s3Config.bucket,
Key: key,
Body: body,
ContentType: contentType,
IfNoneMatch: '*',
}));
}
function isLegacyDocumentMetadata(value) {
if (!value || typeof value !== 'object') return false;
const v = value;
return typeof v.id === 'string'
&& typeof v.name === 'string'
&& typeof v.size === 'number'
&& typeof v.lastModified === 'number'
&& typeof v.type === 'string';
}
function isValidDocumentId(id) {
return DOCUMENT_ID_REGEX.test(id);
}
function extractIdFromFileName(fileName) {
const match = /^([a-f0-9]{64})__/i.exec(fileName);
if (!match) return null;
const id = match[1].toLowerCase();
return isValidDocumentId(id) ? id : null;
}
function decodeNameFromFileName(fileName, id) {
const prefix = `${id}__`;
if (!fileName.startsWith(prefix)) return fileName;
const encoded = fileName.slice(prefix.length);
try {
return decodeURIComponent(encoded);
} catch {
return fileName;
}
}
function sniffBinaryDocumentType(bytes) {
if (bytes.length >= 5 && bytes.subarray(0, 5).toString('ascii') === '%PDF-') {
return 'pdf';
}
const isZip = bytes.length >= 4
&& bytes[0] === 0x50
&& bytes[1] === 0x4b
&& (bytes[2] === 0x03 || bytes[2] === 0x05 || bytes[2] === 0x07)
&& (bytes[3] === 0x04 || bytes[3] === 0x06 || bytes[3] === 0x08);
if (!isZip) return null;
const probe = bytes.subarray(0, Math.min(bytes.length, 1024 * 1024)).toString('latin1');
if (probe.includes('application/epub+zip') || probe.includes('META-INF/container.xml')) return 'epub';
if (probe.includes('[Content_Types].xml') && probe.includes('word/')) return 'docx';
return null;
}
function toDocumentTypeFromName(name) {
const ext = path.extname(name).toLowerCase();
if (ext === '.pdf') return 'pdf';
if (ext === '.epub') return 'epub';
if (ext === '.docx') return 'docx';
return 'html';
}
function safeDocumentName(rawName, fallback) {
const baseName = path.basename(rawName || fallback);
return baseName.replaceAll('\u0000', '').slice(0, 240) || fallback;
}
function normalizeNameForType(name, id, type) {
if (type === 'html') return name;
const expectedExt = type === 'pdf' ? '.pdf' : type === 'epub' ? '.epub' : '.docx';
if (name.toLowerCase().endsWith(expectedExt)) return name;
const base = name.replace(/\.bin$/i, '');
return `${base || id}${expectedExt}`;
}
function contentTypeForName(name) {
const ext = path.extname(name).toLowerCase();
if (ext === '.pdf') return 'application/pdf';
if (ext === '.epub') return 'application/epub+zip';
if (ext === '.docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (ext === '.md' || ext === '.mdown' || ext === '.markdown') return 'text/markdown; charset=utf-8';
if (ext === '.html' || ext === '.htm') return 'text/html; charset=utf-8';
return 'text/plain; charset=utf-8';
}
function contentTypeForDocument(type, name) {
if (type === 'pdf') return 'application/pdf';
if (type === 'epub') return 'application/epub+zip';
if (type === 'docx') return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
return contentTypeForName(name);
}
function sanitizeTagValue(value) {
return String(value || '').replaceAll('\u0000', '').replaceAll(/\r?\n/g, ' ').trim();
}
function sanitizeFileStem(value) {
return sanitizeTagValue(value)
.replaceAll(/[\\/]/g, ' ')
.replaceAll(/[<>:"|?*\u0000]/g, '')
.replaceAll(/\s+/g, ' ')
.trim()
.slice(0, 180);
}
function encodeChapterFileName(index, title, format) {
const oneBased = String(index + 1).padStart(4, '0');
const safeTitle = sanitizeFileStem(title) || `Chapter ${index + 1}`;
return `${oneBased}__${encodeURIComponent(safeTitle)}.${format}`;
}
function decodeChapterFileName(fileName) {
const match = /^(\d{1,6})__(.+)\.(mp3|m4b)$/i.exec(fileName);
if (!match) return null;
const oneBased = Number(match[1]);
if (!Number.isInteger(oneBased) || oneBased <= 0) return null;
const format = match[3].toLowerCase();
try {
const title = decodeURIComponent(match[2]);
return { index: oneBased - 1, title: title || `Chapter ${oneBased}`, format };
} catch {
return { index: oneBased - 1, title: match[2], format };
}
}
function decodeChapterTitleTag(tag) {
const raw = sanitizeTagValue(tag);
if (!raw) return null;
const match = /^(\d{1,6})\s*[-.:]\s*(.+)$/.exec(raw);
if (!match) return null;
const oneBased = Number(match[1]);
if (!Number.isFinite(oneBased) || !Number.isInteger(oneBased) || oneBased <= 0) return null;
return { index: oneBased - 1, title: match[2].trim() || `Chapter ${oneBased}` };
}
function chooseBinary(preferred, bundled, envVarName, packageName) {
const envValue = preferred ? String(preferred).trim() : '';
if (envValue) {
if ((envValue.includes('/') || envValue.includes('\\')) && !fs.existsSync(envValue)) {
throw new Error(`${envVarName} points to a missing binary: ${envValue}`);
}
return envValue;
}
const bundledValue = bundled ? String(bundled).trim() : '';
if (!bundledValue) {
throw new Error(`${packageName} binary is unavailable on this platform. Set ${envVarName} to an installed binary path.`);
}
if ((bundledValue.includes('/') || bundledValue.includes('\\')) && !fs.existsSync(bundledValue)) {
throw new Error(`${packageName} resolved to a missing binary path: ${bundledValue}`);
}
return bundledValue;
}
function getFFmpegPath() {
return chooseBinary(process.env.FFMPEG_BIN || null, ffmpegStatic || null, 'FFMPEG_BIN', 'ffmpeg-static');
}
async function ffprobeTitleTag(filePath) {
return new Promise((resolve) => {
const child = spawn(getFFmpegPath(), [
'-i', filePath,
'-f', 'ffmetadata',
'-',
]);
let stdout = '';
child.stdout.on('data', (data) => { stdout += data.toString(); });
child.on('error', () => resolve(null));
child.on('close', () => {
const line = stdout.split(/\r?\n/).find((l) => l.startsWith('title='));
if (!line) {
resolve(null);
return;
}
const raw = line.slice('title='.length).trim();
resolve(raw.length > 0 ? raw : null);
});
});
}
function isPersistedAudiobookFileName(fileName) {
if (fileName === 'audiobook.meta.json') return true;
if (fileName === 'complete.mp3' || fileName === 'complete.m4b') return true;
if (/^complete\.(mp3|m4b)\.manifest\.json$/i.test(fileName)) return true;
return decodeChapterFileName(fileName) !== null;
}
function isTransientAudiobookFileName(fileName) {
if (/^-?\d+-input\.mp3$/i.test(fileName)) return true;
if (/\.tmp\./i.test(fileName)) return true;
return false;
}
function contentTypeForAudiobookFileName(fileName) {
if (fileName.endsWith('.mp3')) return 'audio/mpeg';
if (fileName.endsWith('.m4b')) return 'audio/mp4';
if (fileName.endsWith('.json')) return 'application/json; charset=utf-8';
return 'application/octet-stream';
}
async function collectDocumentCandidates(docsDir, docstoreDir) {
const byId = new Map();
let filesScanned = 0;
let skippedInvalid = 0;
if (fs.existsSync(docsDir)) {
const entries = await fsp.readdir(docsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
filesScanned += 1;
const fullPath = path.join(docsDir, entry.name);
const bytes = await fsp.readFile(fullPath);
const st = await fsp.stat(fullPath);
const extractedId = extractIdFromFileName(entry.name);
const id = extractedId ?? createHash('sha256').update(bytes).digest('hex');
if (!isValidDocumentId(id)) {
skippedInvalid += 1;
continue;
}
const inferredName = decodeNameFromFileName(entry.name, id);
const inferredType = toDocumentTypeFromName(inferredName);
const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType;
const normalizedName = normalizeNameForType(inferredName, id, type);
const contentType = contentTypeForDocument(type, normalizedName);
const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now();
if (!byId.has(id)) {
byId.set(id, {
id,
name: normalizedName,
type,
size: bytes.length,
lastModified,
contentType,
bytes,
localPaths: new Set([fullPath]),
});
} else {
byId.get(id).localPaths.add(fullPath);
}
}
}
if (fs.existsSync(docstoreDir)) {
const entries = await fsp.readdir(docstoreDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
const metadataPath = path.join(docstoreDir, entry.name);
let parsed;
try {
parsed = JSON.parse(await fsp.readFile(metadataPath, 'utf8'));
} catch {
continue;
}
if (!isLegacyDocumentMetadata(parsed)) continue;
const contentPath = path.join(docstoreDir, `${parsed.id}.${parsed.type}`);
if (!fs.existsSync(contentPath)) continue;
filesScanned += 1;
const bytes = await fsp.readFile(contentPath);
const st = await fsp.stat(contentPath);
const id = createHash('sha256').update(bytes).digest('hex');
if (!isValidDocumentId(id)) {
skippedInvalid += 1;
continue;
}
const fallbackName = `${id}.${parsed.type}`;
const normalizedInputName = safeDocumentName(parsed.name, fallbackName);
const inferredType = toDocumentTypeFromName(normalizedInputName);
const type = inferredType === 'html' ? (sniffBinaryDocumentType(bytes) ?? inferredType) : inferredType;
const normalizedName = normalizeNameForType(normalizedInputName, id, type);
const contentType = contentTypeForDocument(type, normalizedName);
const lastModified = Number.isFinite(st.mtimeMs) ? Math.floor(st.mtimeMs) : Date.now();
if (!byId.has(id)) {
byId.set(id, {
id,
name: normalizedName,
type,
size: bytes.length,
lastModified,
contentType,
bytes,
localPaths: new Set([contentPath, metadataPath]),
});
} else {
byId.get(id).localPaths.add(contentPath);
byId.get(id).localPaths.add(metadataPath);
}
}
}
return {
candidates: Array.from(byId.values()).map((item) => ({
...item,
localPaths: Array.from(item.localPaths),
})),
filesScanned,
skippedInvalid,
};
}
async function collectAudiobookCandidates(audiobooksDir, docstoreDir) {
const stats = {
booksScanned: 0,
filesScanned: 0,
skippedTransient: 0,
};
const sourceDirs = new Map();
if (fs.existsSync(audiobooksDir)) {
const entries = await fsp.readdir(audiobooksDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue;
const bookId = entry.name.slice(0, -'-audiobook'.length);
if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue;
sourceDirs.set(`${bookId}::${path.join(audiobooksDir, entry.name)}`, {
bookId,
dirPath: path.join(audiobooksDir, entry.name),
});
}
}
if (fs.existsSync(docstoreDir)) {
const entries = await fsp.readdir(docstoreDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || !entry.name.endsWith('-audiobook')) continue;
const bookId = entry.name.slice(0, -'-audiobook'.length);
if (!SAFE_AUDIOBOOK_ID_REGEX.test(bookId)) continue;
sourceDirs.set(`${bookId}::${path.join(docstoreDir, entry.name)}`, {
bookId,
dirPath: path.join(docstoreDir, entry.name),
});
}
}
const grouped = new Map();
for (const source of sourceDirs.values()) {
if (!grouped.has(source.bookId)) grouped.set(source.bookId, []);
grouped.get(source.bookId).push(source.dirPath);
}
const books = [];
for (const [bookId, dirPaths] of grouped.entries()) {
stats.booksScanned += 1;
const uploads = [];
const dedupedChapterByIndex = new Map();
let title = 'Unknown Title';
for (const dirPath of dirPaths) {
const entries = await fsp.readdir(dirPath, { withFileTypes: true }).catch(() => []);
const chapterMetaByIndex = new Map();
for (const entry of entries) {
if (!entry.isFile()) continue;
if (entry.name === 'audiobook.meta.json') continue;
if (!entry.name.endsWith('.meta.json')) continue;
const metaPath = path.join(dirPath, entry.name);
try {
const raw = JSON.parse(await fsp.readFile(metaPath, 'utf8'));
const idx = Number(raw?.index ?? entry.name.replace(/\.meta\.json$/i, ''));
const chapterTitle = typeof raw?.title === 'string' ? raw.title : null;
if (Number.isInteger(idx) && idx >= 0 && chapterTitle) {
chapterMetaByIndex.set(idx, chapterTitle);
}
} catch { }
}
const unresolvedSha = [];
const usedIndices = new Set();
const chapterUploads = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const fileName = entry.name;
stats.filesScanned += 1;
if (isTransientAudiobookFileName(fileName)) {
stats.skippedTransient += 1;
continue;
}
const fullPath = path.join(dirPath, fileName);
const st = await fsp.stat(fullPath).catch(() => null);
const mtimeMs = Number(st?.mtimeMs ?? 0);
const canonical = decodeChapterFileName(fileName);
if (canonical) {
const targetFileName = encodeChapterFileName(canonical.index, canonical.title, canonical.format);
chapterUploads.push({
index: canonical.index,
title: canonical.title,
format: canonical.format,
targetFileName,
sourcePath: fullPath,
mtimeMs,
});
usedIndices.add(canonical.index);
continue;
}
if (isPersistedAudiobookFileName(fileName)) {
uploads.push({
sourcePath: fullPath,
targetFileName: fileName,
contentType: contentTypeForAudiobookFileName(fileName),
});
continue;
}
const legacy = /^(\d+)-chapter\.(mp3|m4b)$/i.exec(fileName);
if (legacy) {
const index = Number(legacy[1]);
const format = legacy[2].toLowerCase();
const chapterTitle = chapterMetaByIndex.get(index) ?? `Chapter ${index + 1}`;
const targetFileName = encodeChapterFileName(index, chapterTitle, format);
chapterUploads.push({
index,
title: chapterTitle,
format,
targetFileName,
sourcePath: fullPath,
mtimeMs,
});
usedIndices.add(index);
continue;
}
const sha = /^[a-f0-9]{64}\.(mp3|m4b)$/i.test(fileName);
if (sha) {
unresolvedSha.push({
sourcePath: fullPath,
format: fileName.toLowerCase().endsWith('.mp3') ? 'mp3' : 'm4b',
mtimeMs,
});
continue;
}
}
unresolvedSha.sort((a, b) => a.sourcePath.localeCompare(b.sourcePath));
const nextIndex = () => {
let idx = 0;
while (usedIndices.has(idx)) idx += 1;
usedIndices.add(idx);
return idx;
};
for (const item of unresolvedSha) {
let decoded = null;
const titleTag = await ffprobeTitleTag(item.sourcePath);
if (titleTag) decoded = decodeChapterTitleTag(titleTag);
const index = decoded?.index ?? nextIndex();
const chapterTitle = decoded?.title ?? `Chapter ${index + 1}`;
const targetFileName = encodeChapterFileName(index, chapterTitle, item.format);
chapterUploads.push({
index,
title: chapterTitle,
format: item.format,
targetFileName,
sourcePath: item.sourcePath,
mtimeMs: item.mtimeMs,
});
}
for (const chapter of chapterUploads) {
uploads.push({
sourcePath: chapter.sourcePath,
targetFileName: chapter.targetFileName,
contentType: contentTypeForAudiobookFileName(chapter.targetFileName),
});
const current = dedupedChapterByIndex.get(chapter.index);
if (!current || chapter.targetFileName > current.fileName || chapter.mtimeMs > current.mtimeMs) {
dedupedChapterByIndex.set(chapter.index, {
index: chapter.index,
title: chapter.title,
format: chapter.format,
fileName: chapter.targetFileName,
mtimeMs: chapter.mtimeMs,
});
}
}
}
const chapters = Array.from(dedupedChapterByIndex.values())
.sort((a, b) => a.index - b.index)
.map((chapter) => ({
index: chapter.index,
title: chapter.title,
format: chapter.format,
fileName: chapter.fileName,
}));
if (chapters.length > 0) title = chapters[0].title || title;
books.push({
id: bookId,
title,
chapters,
uploads,
sourceDirs: dirPaths,
});
}
return { books, stats };
}
function openDatabase() {
if (process.env.POSTGRES_URL) {
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
});
return {
mode: 'pg',
async query(sql, values = []) {
return pool.query(sql, values);
},
async close() {
await pool.end();
},
};
}
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
const sqlite = new BetterSqlite3(dbPath);
return {
mode: 'sqlite',
async query(sql, values = []) {
if (/^\s*select/i.test(sql)) {
const rows = sqlite.prepare(sql).all(...values);
return { rows, rowCount: rows.length };
}
const info = sqlite.prepare(sql).run(...values);
return { rows: [], rowCount: Number(info.changes ?? 0) };
},
async close() {
sqlite.close();
},
};
}
async function migrateDatabaseRows(database, userId, docCandidates, audiobookBooks, dryRun) {
const mismatched = await database.query('SELECT COUNT(*) AS count FROM documents WHERE file_path <> id');
const rowsUpdated = Number(mismatched.rows[0]?.count ?? mismatched.rows[0]?.COUNT ?? 0);
if (!dryRun && rowsUpdated > 0) {
await database.query('UPDATE documents SET file_path = id WHERE file_path <> id');
}
// Ensure the user exists to satisfy foreign key constraints (cascade delete)
if (!dryRun) {
const now = Date.now();
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES (?, ?, ?, ?, ?, ?, ?)',
[userId, 'System User', `${userId}@local`, 0, now, now, 0]
);
} else {
await database.query(
"INSERT INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES ($1, $2, $3, $4, to_timestamp($5 / 1000.0), to_timestamp($6 / 1000.0), $7) ON CONFLICT (id) DO NOTHING",
[userId, 'System User', `${userId}@local`, false, now, now, false]
);
}
}
const existingDocs = database.mode === 'sqlite'
? await database.query('SELECT id FROM documents WHERE user_id = ?', [userId])
: await database.query('SELECT id FROM documents WHERE user_id = $1', [userId]);
const existingDocIds = new Set(existingDocs.rows.map((row) => row.id));
const toInsertDocs = docCandidates.filter((candidate) => !existingDocIds.has(candidate.id));
if (!dryRun) {
for (const candidate of toInsertDocs) {
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES (?, ?, ?, ?, ?, ?, ?)',
[candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id],
);
} else {
await database.query(
'INSERT INTO documents (id, user_id, name, type, size, last_modified, file_path) VALUES ($1, $2, $3, $4, $5, $6, $7) ON CONFLICT DO NOTHING',
[candidate.id, userId, candidate.name, candidate.type, candidate.size, candidate.lastModified, candidate.id],
);
}
}
for (const book of audiobookBooks) {
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO audiobooks (id, user_id, title, duration) VALUES (?, ?, ?, ?)',
[book.id, userId, book.title || 'Unknown Title', 0],
);
} else {
await database.query(
'INSERT INTO audiobooks (id, user_id, title, duration) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
[book.id, userId, book.title || 'Unknown Title', 0],
);
}
for (const chapter of book.chapters) {
const chapterId = `${book.id}-${chapter.index}`;
if (database.mode === 'sqlite') {
await database.query(
'INSERT OR IGNORE INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format],
);
} else {
await database.query(
'INSERT INTO audiobook_chapters (id, book_id, user_id, chapter_index, title, duration, file_path, format) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT DO NOTHING',
[chapterId, book.id, userId, chapter.index, chapter.title, 0, chapter.fileName, chapter.format],
);
}
}
}
}
return {
dbRowsUpdated: rowsUpdated,
dbRowsSeeded: toInsertDocs.length,
audiobookDbBooksSeeded: audiobookBooks.length,
audiobookDbChaptersSeeded: audiobookBooks.reduce((sum, book) => sum + book.chapters.length, 0),
};
}
async function main() {
loadEnvFiles();
const { dryRun, deleteLocal, namespace } = parseArgs(process.argv.slice(2));
const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace);
const docsDir = applyNamespacePath(DOCUMENTS_V1_DIR, namespace);
const audiobooksDir = applyNamespacePath(AUDIOBOOKS_V1_DIR, namespace);
const docstoreDir = applyNamespacePath(DOCSTORE_DIR, namespace);
const s3Config = parseS3ConfigFromEnv();
const s3Client = createS3Client(s3Config);
const { candidates: documentCandidates, filesScanned, skippedInvalid } = await collectDocumentCandidates(docsDir, docstoreDir);
const { books: audiobookBooks, stats: audiobookStats } = await collectAudiobookCandidates(audiobooksDir, docstoreDir);
let uploaded = 0;
let alreadyPresent = 0;
let deletedLocal = 0;
for (const candidate of documentCandidates) {
if (!dryRun) {
try {
await putObjectIfMissing(
s3Client,
s3Config,
documentKey(s3Config, candidate.id, namespace),
candidate.bytes,
candidate.contentType,
);
uploaded += 1;
} catch (error) {
if (isPreconditionFailed(error)) {
alreadyPresent += 1;
} else {
throw error;
}
}
}
if (deleteLocal && !dryRun) {
for (const localPath of candidate.localPaths) {
const removed = await fsp.unlink(localPath).then(() => true).catch(() => false);
if (removed) deletedLocal += 1;
}
}
}
let audiobookUploaded = 0;
let audiobookAlreadyPresent = 0;
let audiobookDeletedLocal = 0;
for (const book of audiobookBooks) {
for (const upload of book.uploads) {
const bytes = await fsp.readFile(upload.sourcePath);
if (!dryRun) {
try {
await putObjectIfMissing(
s3Client,
s3Config,
audiobookKey(s3Config, book.id, unclaimedUserId, upload.targetFileName, namespace),
bytes,
upload.contentType,
);
audiobookUploaded += 1;
} catch (error) {
if (isPreconditionFailed(error)) {
audiobookAlreadyPresent += 1;
} else {
throw error;
}
}
}
if (deleteLocal && !dryRun) {
const removed = await fsp.unlink(upload.sourcePath).then(() => true).catch(() => false);
if (removed) audiobookDeletedLocal += 1;
}
}
if (deleteLocal && !dryRun) {
for (const dirPath of book.sourceDirs) {
await fsp.rm(dirPath, { recursive: true, force: true }).catch(() => { });
}
}
}
const database = openDatabase();
try {
const dbStats = await migrateDatabaseRows(
database,
unclaimedUserId,
documentCandidates,
audiobookBooks,
dryRun,
);
const result = {
success: true,
dryRun,
deleteLocal,
docsDir,
audiobooksDir,
namespace,
filesScanned,
uploaded,
alreadyPresent,
skippedInvalid,
deletedLocal,
dbRowsUpdated: dbStats.dbRowsUpdated,
dbRowsSeeded: dbStats.dbRowsSeeded,
audiobookBooksScanned: audiobookStats.booksScanned,
audiobookFilesScanned: audiobookStats.filesScanned,
audiobookUploaded,
audiobookAlreadyPresent,
audiobookDeletedLocal,
audiobookSkippedTransient: audiobookStats.skippedTransient,
audiobookDbBooksSeeded: dbStats.audiobookDbBooksSeeded,
audiobookDbChaptersSeeded: dbStats.audiobookDbChaptersSeeded,
};
console.log(JSON.stringify(result, null, 2));
} finally {
await database.close();
}
}
main().catch((error) => {
console.error('Error running v2 migration script:', error);
process.exit(1);
});