← back to Butlr

lib/upload-watcher.js

104 lines

// Background folder watcher — polls data/uploads-meta.json.folder every
// intervalMs and imports any new file into the /admin/uploads gallery.
//
// Sharing the same dedup logic (size+mtime per source path) as the
// POST /admin/uploads/folder/sync endpoint. Best-effort — file errors are
// logged + skipped; the next scan retries.
//
// Wired in server.js after attachListenBridge(). HFM_NO_WATCHER=1
// disables (useful for tests).

const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

const UPLOADS_DIR = path.join(__dirname, '..', 'data', 'uploads');
const META_PATH = path.join(__dirname, '..', 'data', 'uploads-meta.json');
const ALLOWED_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf', '.mp3', '.wav', '.mp4', '.txt', '.csv', '.json']);
const MAX_SIZE = 10 * 1024 * 1024;

function readMeta() {
  try { return JSON.parse(fs.readFileSync(META_PATH, 'utf8')); } catch { return { files: [] }; }
}
function writeMeta(meta) {
  const tmp = META_PATH + '.tmp';
  fs.writeFileSync(tmp, JSON.stringify(meta, null, 2));
  fs.renameSync(tmp, META_PATH);
}

function scanOnce() {
  const meta = readMeta();
  if (!meta.folder) return { skipped_no_folder: true };
  if (!fs.existsSync(meta.folder)) return { skipped_missing: meta.folder };

  fs.mkdirSync(UPLOADS_DIR, { recursive: true });
  const existingKeys = new Set((meta.files || [])
    .filter(f => f.source_path)
    .map(f => `${f.source_size}:${f.source_mtime}`));

  let entries;
  try { entries = fs.readdirSync(meta.folder); }
  catch (e) { return { error: 'readdir: ' + e.message }; }

  const newEntries = [];
  let imported = 0, skipped = 0;
  for (const name of entries) {
    if (name.startsWith('.')) continue;
    const srcPath = path.join(meta.folder, name);
    let st;
    try { st = fs.statSync(srcPath); } catch { continue; }
    if (!st.isFile()) continue;
    if (st.size > MAX_SIZE) { continue; }
    const ext = (path.extname(name) || '').toLowerCase();
    if (!ALLOWED_EXT.has(ext)) { skipped++; continue; }
    const key = `${st.size}:${st.mtime.getTime()}`;
    if (existingKeys.has(key)) { skipped++; continue; }
    try {
      const newName = crypto.randomBytes(12).toString('hex') + ext;
      fs.copyFileSync(srcPath, path.join(UPLOADS_DIR, newName));
      newEntries.push({
        filename: newName,
        original_name: name,
        size: st.size,
        type: 'application/octet-stream',
        uploaded_at: new Date().toISOString(),
        source_path: srcPath,
        source_size: st.size,
        source_mtime: st.mtime.getTime(),
        source: 'auto_watcher',
      });
      imported++;
    } catch (e) {
      console.error(`[upload-watcher] copy failed: ${name}: ${e.message}`);
    }
  }
  if (newEntries.length) {
    meta.files = (meta.files || []).concat(newEntries);
    writeMeta(meta);
  }
  return { imported, skipped, folder: meta.folder };
}

let _interval = null;
function start({ intervalMs = 30_000 } = {}) {
  if (process.env.HFM_NO_WATCHER === '1') return;
  if (_interval) return;
  console.log(`[upload-watcher] start · interval=${intervalMs}ms`);
  _interval = setInterval(() => {
    try {
      const r = scanOnce();
      if (r && r.imported > 0) {
        console.log(`[upload-watcher] imported ${r.imported} from ${r.folder} (skipped ${r.skipped})`);
      }
    } catch (e) {
      console.error('[upload-watcher] tick error:', e.message);
    }
  }, intervalMs);
  // immediate first scan
  setTimeout(() => { try { scanOnce(); } catch {} }, 1000);
}

function stop() { if (_interval) { clearInterval(_interval); _interval = null; } }

module.exports = { start, stop, scanOnce };