OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-note / scripts / extract-media.js

170 lines · javascript ref:
#!/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'
}