// api_v1/scanphoto.js require('dotenv').config(); const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); const crypto = require('crypto'); const ExifReader = require('exifreader'); const sharp = require('sharp'); const axios = require('axios'); const BASE_URL = process.env.BASE_URL; // es: https://prova.patachina.it/api const EMAIL = process.env.EMAIL; const PASSWORD = process.env.PASSWORD; const WEB_ROOT = 'public'; // cartella radice dei file serviti dal web server // Estensioni supportate (puoi ampliarle) const SUPPORTED_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']); // ----------------------------------------------------- // UTILS // ----------------------------------------------------- function toPosix(p) { // normalizza gli slash per il web (URL-friendly) return p.split(path.sep).join('/'); } function sha256(s) { return crypto.createHash('sha256').update(s).digest('hex'); } function inferMimeFromExt(ext) { switch (ext.toLowerCase()) { case '.jpg': case '.jpeg': return 'image/jpeg'; case '.png': return 'image/png'; case '.webp': return 'image/webp'; case '.heic': case '.heif': return 'image/heic'; default: return 'application/octet-stream'; } } // EXIF "YYYY:MM:DD HH:mm:ss" -> ISO-8601 UTC function parseExifDateUtc(s) { if (!s) return null; const re = /^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})$/; const m = re.exec(s); if (!m) return null; const dt = new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6])); return dt.toISOString(); // es. "2017-08-19T11:30:14.000Z" } function mapGps(gps) { if (!gps) return null; const lat = gps.Latitude; const lng = gps.Longitude; const alt = gps.Altitude; if (lat == null || lng == null) return null; return { lat: typeof lat === 'number' ? lat : Number(lat), lng: typeof lng === 'number' ? lng : Number(lng), alt: (alt == null) ? null : (typeof alt === 'number' ? alt : Number(alt)) }; } // ----------------------------------------------------- // LOGIN: ottieni e riusa il token // ----------------------------------------------------- let cachedToken = null; async function getToken(force = false) { if (cachedToken && !force) return cachedToken; try { const res = await axios.post(`${BASE_URL}/auth/login`, { email: EMAIL, password: PASSWORD }); cachedToken = res.data.token; return cachedToken; } catch (err) { console.error("ERRORE LOGIN:", err.message); return null; } } async function postWithAuth(url, payload) { let token = await getToken(); if (!token) throw new Error('Token assente'); try { return await axios.post(url, payload, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }); } catch (err) { // Se scaduto, prova una volta a rinnovare if (err.response && err.response.status === 401) { token = await getToken(true); if (!token) throw err; return await axios.post(url, payload, { headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }); } throw err; } } // ----------------------------------------------------- // INVIA FOTO AL SERVER // ----------------------------------------------------- async function sendPhoto(json) { try { await postWithAuth(`${BASE_URL}/photos`, json); } catch (err) { console.error("Errore invio foto:", err.message); } } // ----------------------------------------------------- // CREA THUMBNAILS (min: 100px lato lungo, avg: 400px lato lungo) // ----------------------------------------------------- async function createThumbnails(filePath, thumbMinPath, thumbAvgPath) { try { await sharp(filePath) .resize({ width: 100, height: 100, fit: "inside", withoutEnlargement: true }) .withMetadata() .toFile(thumbMinPath); await sharp(filePath) .resize({ width: 400, withoutEnlargement: true }) .withMetadata() .toFile(thumbAvgPath); } catch (err) { console.error("Errore creazione thumbnails:", err.message, filePath); } } // ----------------------------------------------------- // SCANSIONE RICORSIVA (asincrona, con metadati extra) // ----------------------------------------------------- async function scanDir(dir, results = []) { const files = await fsp.readdir(dir, { withFileTypes: true }); for (const dirent of files) { const filePath = path.join(dir, dirent.name); if (dirent.isDirectory()) { await scanDir(filePath, results); continue; } const ext = path.extname(dirent.name).toLowerCase(); if (!SUPPORTED_EXTS.has(ext)) continue; console.log("Trovato:", dirent.name); // Directory relativa rispetto a WEB_ROOT (es. "photos/original/...") const relDir = toPosix(path.relative(WEB_ROOT, dir)); const relFile = toPosix(path.join(relDir, dirent.name)); // path relativo (web-safe) // Cartella thumbs parallela a original const thumbDirAbs = path.join(WEB_ROOT, relDir.replace(/original/i, 'thumbs')); await fsp.mkdir(thumbDirAbs, { recursive: true }); const baseName = path.parse(dirent.name).name; const extName = path.parse(dirent.name).ext; // Path ASSOLUTI dei file thumb sul filesystem const thumbMinAbs = path.join(thumbDirAbs, `${baseName}_min${extName}`); const thumbAvgAbs = path.join(thumbDirAbs, `${baseName}_avg${extName}`); await createThumbnails(filePath, thumbMinAbs, thumbAvgAbs); // Path RELATIVI (web) per il JSON const thumbMinRel = toPosix(path.relative(WEB_ROOT, thumbMinAbs)); // es. "photos/thumbs/..._min.jpg" const thumbAvgRel = toPosix(path.relative(WEB_ROOT, thumbAvgAbs)); // EXIF (expanded: true per avere gps strutturato come nel tuo attuale) let tags = {}; try { tags = await ExifReader.load(filePath, { expanded: true }); } catch (err) { // nessun EXIF, ok } const timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null; const isoUtc = parseExifDateUtc(timeRaw); const gps = mapGps(tags?.gps); // Metadati immagine (width/height/size_bytes, mime) let width = null, height = null, size_bytes = null; try { const meta = await sharp(filePath).metadata(); width = meta.width || null; height = meta.height || null; const st = await fsp.stat(filePath); size_bytes = st.size; } catch (err) { console.warn("Impossibile leggere metadata immagine:", err.message); try { const st = await fsp.stat(filePath); size_bytes = st.size; } catch {} } const mime_type = inferMimeFromExt(ext); // ID stabile (se non vuoi usare quello del DB lato server) const stableId = sha256(relFile); results.push({ // campi "nuovi"/normalizzati id: stableId, // oppure lascia che lo generi il DB e rimuovi questo campo name: dirent.name, path: relFile, // <== RELATIVO, web-safe (non contiene dominio) thub1: thumbMinRel, // <== RELATIVO thub2: thumbAvgRel, // <== RELATIVO gps, // {lat,lng,alt} oppure null data: timeRaw, // EXIF originale (se vuoi mantenerlo) taken_at: isoUtc, // ISO-8601 UTC (comodo per indicizzazione lato app) mime_type, width, height, size_bytes, location: null }); } return results; } // ----------------------------------------------------- // FUNZIONE PRINCIPALE // ----------------------------------------------------- async function scanPhoto(dir) { console.log("Inizio scansione:", dir); const photos = await scanDir(dir); console.log("Trovate", photos.length, "foto"); // invio seriale; se vuoi parallelizzare, limita la concorrenza (es. p-limit) for (const p of photos) { await sendPhoto(p); } console.log("Scansione completata"); } module.exports = scanPhoto; module.exports = scanPhoto;