// api_v1/scanner/scanPhoto.js const path = require('path'); const fsp = require('fs/promises'); const { log } = require('./logger'); const scanCartella = require('./scanCartella'); const processFile = require('./processFile'); const postWithAuth = require('./postWithAuth'); const { sha256 } = require('./utils'); const { WEB_ROOT, SEND_PHOTOS, BASE_URL, SUPPORTED_EXTS } = require('../config'); const createCleanupFunctions = require('./orphanCleanup'); const LOG_VERBOSE = (process.env.LOG_VERBOSE === "true"); function formatTime(ms) { const sec = Math.floor(ms / 1000); const h = String(Math.floor(sec / 3600)).padStart(2, '0'); const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0'); const s = String(sec % 60).padStart(2, '0'); return `${h}:${m}:${s}`; } async function countFilesFast(rootDir) { let count = 0; async function walk(dir) { let entries = []; try { entries = await fsp.readdir(dir, { withFileTypes: true }); } catch { return; } for (const e of entries) { const abs = path.join(dir, e.name); if (e.isDirectory()) { await walk(abs); } else { const ext = path.extname(e.name).toLowerCase(); if (SUPPORTED_EXTS.has(ext)) { count++; } } } } await walk(rootDir); return count; } async function scanPhoto(dir, userName, db) { const start = Date.now(); log(`🔵 Inizio scan globale per user=${userName}`); const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db); const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos'); const userDir = path.join(photosRoot, userName, 'original'); let newFiles = []; const TOTAL_FILES = await countFilesFast(userDir); if (TOTAL_FILES === 0) { log(`Nessun file trovato per user=${userName}`); return []; } log(`📦 File totali da processare: ${TOTAL_FILES}`); let CURRENT = 0; async function updateStatusFile() { const now = Date.now(); const elapsedMs = now - start; const avg = elapsedMs / Math.max(CURRENT, 1); const remainingMs = (TOTAL_FILES - CURRENT) * avg; const status = { current: CURRENT, total: TOTAL_FILES, percent: Number((CURRENT / TOTAL_FILES * 100).toFixed(2)), eta: formatTime(remainingMs), elapsed: formatTime(elapsedMs) }; const statusPath = path.resolve(__dirname, "..", "..", "public/photos/scan_status.json"); await fsp.writeFile(statusPath, JSON.stringify(status)); } // --------------------------------------------------------- // SCAN DI UNA SINGOLA CARTELLA // --------------------------------------------------------- async function scanSingleFolder(user, cartella, absCartella) { log(`📁 Inizio cartella: ${cartella}`); const rows = await db('photos') .where({ user, cartella }) .select('id'); const idsSet = new Set(rows.map(r => r.id)); for await (const f of scanCartella(user, cartella, absCartella, db)) { CURRENT++; await updateStatusFile(); const prefix = `[${CURRENT}/${TOTAL_FILES}]`; const fileName = f.name; const id = f.id; const st = f.stat; let prev = await db("photos") .select("id", "size_bytes", "mtimeMs", "_indexHash", "fast_hash", "path") .where({ id }) .first(); if (prev && LOG_VERBOSE) { log(`${prefix} ⚪ Invariato: ${fileName}`); } const fastHash = sha256(`${st.size}-${st.mtimeMs}`); // --------------------------------------------------------- // PATH CAMBIATO = NUOVO FILE // --------------------------------------------------------- if (prev && prev.path !== f.path) { log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`); prev = null; } // --------------------------------------------------------- // NUOVO FILE // --------------------------------------------------------- if (!prev) { log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`); const meta = await processFile( user, cartella, f.relPath, f.absPath, f.ext, st ); meta.id = id; meta.path = f.path; // INSERT CORRETTO (senza colonna gps) await db("photos").insert({ id: meta.id, user, cartella, name: meta.name, path: meta.path, thub1: meta.thub1, thub2: meta.thub2, mime_type: meta.mime_type, width: meta.width, height: meta.height, rotation: meta.rotation, size_bytes: meta.size_bytes, mtimeMs: meta.mtimeMs, duration_ms: meta.duration_ms, taken_at: meta.taken_at, data: meta.data, lat: meta.gps?.lat ?? null, lon: meta.gps?.lng ?? null, alt: meta.gps?.alt ?? null, location: meta.location ? JSON.stringify(meta.location) : null, _indexHash: meta._indexHash, fast_hash: fastHash }); await db('photo_changes').insert({ photo_id: meta.id, user, change_type: 'added', timestamp: new Date().toISOString() }); idsSet.delete(id); newFiles.push(meta); //log(`🟢 [PUSH newFiles] id=${meta.id} path=${meta.path}`); continue; } // --------------------------------------------------------- // FAST-SIZE-SKIP // --------------------------------------------------------- if (prev.size_bytes === st.size) { //log(`🔵 [FAST-SIZE-SKIP] id=${id}`); idsSet.delete(id); await db("photos") .where({ id }) .update({ path: f.path, size_bytes: st.size, mtimeMs: st.mtimeMs, fast_hash: fastHash }); continue; } // --------------------------------------------------------- // FAST-HASH-SKIP // --------------------------------------------------------- if (prev.fast_hash === fastHash) { //log(`🔵 [FAST-HASH-SKIP] id=${id}`); idsSet.delete(id); await db("photos") .where({ id }) .update({ path: f.path, size_bytes: st.size, mtimeMs: st.mtimeMs }); continue; } // --------------------------------------------------------- // MODIFICATO // --------------------------------------------------------- //log(`🟠 [FULL-SCAN] id=${id}`); log(`${prefix} 🟠 Nuovo/Modificato: ${fileName}`); const meta = await processFile( user, cartella, f.relPath, f.absPath, f.ext, st ); meta.id = id; meta.path = f.path; await db("photos") .insert({ id: meta.id, user, cartella, name: meta.name, path: meta.path, thub1: meta.thub1, thub2: meta.thub2, mime_type: meta.mime_type, width: meta.width, height: meta.height, rotation: meta.rotation, size_bytes: meta.size_bytes, mtimeMs: meta.mtimeMs, duration_ms: meta.duration_ms, taken_at: meta.taken_at, data: meta.data, lat: meta.gps?.lat ?? null, lon: meta.gps?.lng ?? null, alt: meta.gps?.alt ?? null, location: meta.location ? JSON.stringify(meta.location) : null, _indexHash: meta._indexHash, fast_hash: fastHash }) .onConflict("id") .merge(); await db('photo_changes').insert({ photo_id: meta.id, user, change_type: 'updated', timestamp: new Date().toISOString() }); idsSet.delete(id); newFiles.push(meta); log(`${prefix} 🟠 Nuovo/Modificato al server ${fileName}`); } // --------------------------------------------------------- // ORFANI // --------------------------------------------------------- for (const orphanId of idsSet) { log(` 🔴 Cancellato ${orphanId}`); await deleteThumbsById(orphanId); await deleteFromDB(orphanId, user); } } // --------------------------------------------------------- // SCAN DI TUTTE LE CARTELLE // --------------------------------------------------------- let entries = []; try { entries = await fsp.readdir(userDir, { withFileTypes: true }); } catch { log(`Nessuna directory per utente ${userName}`); return []; } for (const e of entries) { if (!e.isDirectory()) continue; const cartella = e.name; const absCartella = path.join(userDir, cartella); await scanSingleFolder(userName, cartella, absCartella); } // --------------------------------------------------------- // INVIO AL SERVER REMOTO // --------------------------------------------------------- if (SEND_PHOTOS && BASE_URL && newFiles.length > 0) { log(`📤 [SEND START] newFiles=${newFiles.length}`); for (const p of newFiles) { try { await postWithAuth(`${BASE_URL}/photos`, p); log(`📥 [SENT TO SERVER] ${p.name}`); } catch (err) { log(`❌ [SERVER SENT ERROR] ${p.name}→ ${err.message}`); } } } else { log(`⚠️ [NO SEND] newFiles=${newFiles.length}`); } const elapsed = ((Date.now() - start) / 1000).toFixed(2); log(`🟣 Scan COMPLETATO in ${elapsed}s`); return newFiles; } module.exports = scanPhoto;