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.
930 lines
31 KiB
JavaScript
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);
|
|
});
|