#!/usr/bin/env node
/**
* Extract media blobs from a hypernote corestore backup.
* Uses RocksDB read-only access to bypass device-file locks.
*
* Usage:
* node scripts/extract-media.js <storage-path> <output-dir> [hash1 hash2 ...]
*
* If no hashes given, extracts ALL media from the index.
*/
import RocksDB from 'rocksdb-native'
import Corestore from 'corestore'
import Hyperbee from 'hyperbee'
import Hyperblobs from 'hyperblobs'
import fs from 'fs'
import path from 'path'
const args = process.argv.slice(2)
if (args.length < 2) {
console.log('Usage: node scripts/extract-media.js <storage-path> <output-dir> [hash1 hash2 ...]')
process.exit(1)
}
const storagePath = args[0]
const outputDir = args[1]
const requestedHashes = args.slice(2)
// Resolve the actual corestore path
let corestorePath = storagePath
if (fs.existsSync(path.join(storagePath, 'corestore'))) {
corestorePath = path.join(storagePath, 'corestore')
}
if (!fs.existsSync(corestorePath)) {
console.error('Storage path not found:', corestorePath)
process.exit(1)
}
fs.mkdirSync(outputDir, { recursive: true })
console.log('Opening corestore:', storagePath)
console.log('Output directory:', outputDir)
// Workaround: remove device-file lock from the backup copy
// This is safe because it's a backup, not the live storage
const dbPath = path.join(corestorePath, 'db')
const lockPath = path.join(dbPath, 'LOCK')
const deviceFiles = []
// Find and temporarily rename LOCK files so Corestore can open
for (const dir of [dbPath, path.join(storagePath, 'db')]) {
const lp = path.join(dir, 'LOCK')
if (fs.existsSync(lp)) {
const tmp = lp + '.bak'
fs.renameSync(lp, tmp)
deviceFiles.push({ from: tmp, to: lp })
}
}
let store
try {
store = new Corestore(storagePath)
await store.ready()
} catch (err) {
// Restore lock files on failure
for (const { from, to } of deviceFiles) {
try { fs.renameSync(from, to) } catch {}
}
console.error('Failed to open corestore:', err.message)
process.exit(1)
}
const MIME_TO_EXT = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'video/mp4': '.mp4',
'video/webm': '.webm',
'audio/mpeg': '.mp3',
'audio/ogg': '.ogg'
}
try {
// Open MediaStore's namespace and cores
const mediaNs = store.namespace('media-store')
const blobCore = mediaNs.get({ name: 'blobs' })
const indexCore = mediaNs.get({ name: 'index' })
await blobCore.ready()
await indexCore.ready()
console.log('Blob core length:', blobCore.length)
console.log('Index core length:', indexCore.length)
const blobs = new Hyperblobs(blobCore)
const index = new Hyperbee(indexCore, { keyEncoding: 'utf-8', valueEncoding: 'json' })
await index.ready()
let hashes = [...requestedHashes]
// If no specific hashes requested, enumerate all from index
if (hashes.length === 0) {
console.log('\nEnumerating all media entries...')
for await (const entry of index.createReadStream()) {
const key = entry.key
if (key.startsWith('blob:')) {
hashes.push(key.substring(5))
}
}
console.log(`Found ${hashes.length} media entries in index`)
}
let extracted = 0
let failed = 0
let skipped = 0
for (const hash of hashes) {
const entry = await index.get('blob:' + hash)
if (!entry || !entry.value) {
console.log(` SKIP ${hash.substring(0, 16)}... (not in index)`)
skipped++
continue
}
const { blobId, size, mimeType } = entry.value
const ext = MIME_TO_EXT[mimeType] || ''
const outFile = path.join(outputDir, hash + ext)
if (fs.existsSync(outFile)) {
console.log(` SKIP ${hash.substring(0, 16)}...${ext} (already exists)`)
skipped++
continue
}
try {
const data = await blobs.get(blobId)
if (!data) {
console.log(` FAIL ${hash.substring(0, 16)}... (blob not found for blobId ${blobId})`)
failed++
continue
}
fs.writeFileSync(outFile, data)
console.log(` OK ${hash.substring(0, 16)}...${ext} (${formatBytes(data.length)}, ${mimeType})`)
extracted++
} catch (err) {
console.log(` FAIL ${hash.substring(0, 16)}... (${err.message})`)
failed++
}
}
console.log(`\nDone: ${extracted} extracted, ${skipped} skipped, ${failed} failed`)
} finally {
try { await store.close() } catch {}
// Restore lock files
for (const { from, to } of deviceFiles) {
try { fs.renameSync(from, to) } catch {}
}
}
function formatBytes (b) {
if (b < 1024) return b + ' B'
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'
return (b / (1024 * 1024)).toFixed(1) + ' MB'
}