/* eslint-disable no-console */ 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 { exec } = require('child_process'); // IMPORT GEO.JS const loc = require('./geo.js'); const BASE_URL = process.env.BASE_URL; const EMAIL = process.env.EMAIL; const PASSWORD = process.env.PASSWORD; const SEND_PHOTOS = (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true'; const WRITE_INDEX = (process.env.WRITE_INDEX || 'true').toLowerCase() === 'true'; const WEB_ROOT = process.env.WEB_ROOT || 'public'; const INDEX_PATH = process.env.INDEX_PATH || path.posix.join('photos', 'index.json'); const SUPPORTED_EXTS = new Set([ '.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif', '.mp4', '.mov', '.m4v' ]); // ----------------------------------------------------------------------------- // UTILS // ----------------------------------------------------------------------------- function toPosix(p) { 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'; case '.mp4': return 'video/mp4'; case '.mov': return 'video/quicktime'; case '.m4v': return 'video/x-m4v'; default: return 'application/octet-stream'; } } 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(); } // ----------------------------------------------------------------------------- // GPS — FOTO // ----------------------------------------------------------------------------- function extractGpsFromExif(tags) { if (!tags?.gps) return null; const lat = tags.gps.Latitude; const lng = tags.gps.Longitude; const alt = tags.gps.Altitude; if (lat == null || lng == null) return null; return { lat: Number(lat), lng: Number(lng), alt: alt != null ? Number(alt) : null }; } // ----------------------------------------------------------------------------- // GPS — VIDEO (exiftool) // ----------------------------------------------------------------------------- function extractGpsWithExiftool(videoPath) { return new Promise((resolve) => { const cmd = `exiftool -n -G1 -a -gps:all -quicktime:all -user:all "${videoPath}"`; exec(cmd, (err, stdout) => { if (err || !stdout) return resolve(null); const userData = stdout.match(/GPS Coordinates\s*:\s*([0-9\.\-]+)\s+([0-9\.\-]+)/i); if (userData) { return resolve({ lat: Number(userData[1]), lng: Number(userData[2]), alt: null }); } const lat1 = stdout.match(/GPSLatitude\s*:\s*([0-9\.\-]+)/i); const lng1 = stdout.match(/GPSLongitude\s*:\s*([0-9\.\-]+)/i); if (lat1 && lng1) { return resolve({ lat: Number(lat1[1]), lng: Number(lng1[1]), alt: null }); } const coords = stdout.match(/GPSCoordinates\s*:\s*([0-9\.\-]+)\s+([0-9\.\-]+)/i); if (coords) { return resolve({ lat: Number(coords[1]), lng: Number(coords[2]), alt: null }); } resolve(null); }); }); } // ----------------------------------------------------------------------------- // AUTH / POST // ----------------------------------------------------------------------------- let cachedToken = null; async function getToken(force = false) { if (!SEND_PHOTOS) return null; 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) { if (!SEND_PHOTOS) return; let token = await getToken(); if (!token) throw new Error('Token assente'); try { await axios.post(url, payload, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 20000, }); } catch (err) { if (err.response && err.response.status === 401) { token = await getToken(true); if (!token) throw err; await axios.post(url, payload, { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 20000, }); } else { throw err; } } } // ----------------------------------------------------------------------------- // VIDEO: ffmpeg thumbnail // ----------------------------------------------------------------------------- function createVideoThumbnail(videoPath, thumbMinPath, thumbAvgPath) { return new Promise((resolve) => { const cmd = ` ffmpeg -y -i "${videoPath}" -ss 00:00:01.000 -vframes 1 "${thumbAvgPath}" && ffmpeg -y -i "${thumbAvgPath}" -vf "scale=100:-1" "${thumbMinPath}" `; exec(cmd, () => resolve()); }); } // ----------------------------------------------------------------------------- // VIDEO: ffprobe metadata // ----------------------------------------------------------------------------- function probeVideo(videoPath) { return new Promise((resolve) => { const cmd = `ffprobe -v quiet -print_format json -show_format -show_streams "${videoPath}"`; exec(cmd, (err, stdout) => { if (err) return resolve({}); try { resolve(JSON.parse(stdout)); } catch { resolve({}); } }); }); } // ----------------------------------------------------------------------------- // THUMBNAILS IMMAGINI // ----------------------------------------------------------------------------- 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 // ----------------------------------------------------------------------------- async function scanDir(dirAbs, userName, results = []) { const dirEntries = await fsp.readdir(dirAbs, { withFileTypes: true }); for (const dirent of dirEntries) { const absPath = path.join(dirAbs, dirent.name); if (dirent.isDirectory()) { await scanDir(absPath, userName, results); continue; } const ext = path.extname(dirent.name).toLowerCase(); if (!SUPPORTED_EXTS.has(ext)) continue; console.log("Elaboro:", absPath); const isVideo = ['.mp4', '.mov', '.m4v'].includes(ext); const relFile = toPosix(path.relative(WEB_ROOT, absPath)); const relDir = toPosix(path.posix.dirname(relFile)); const relThumbDir = relDir.replace(/original/i, 'thumbs'); const absThumbDir = path.join(WEB_ROOT, relThumbDir); await fsp.mkdir(absThumbDir, { recursive: true }); const baseName = path.parse(dirent.name).name; const absThumbMin = path.join(absThumbDir, `${baseName}_min.jpg`); const absThumbAvg = path.join(absThumbDir, `${baseName}_avg.jpg`); if (isVideo) { await createVideoThumbnail(absPath, absThumbMin, absThumbAvg); } else { await createThumbnails(absPath, absThumbMin, absThumbAvg); } const relThumbMin = toPosix(path.relative(WEB_ROOT, absThumbMin)); const relThumbAvg = toPosix(path.relative(WEB_ROOT, absThumbAvg)); let tags = {}; try { tags = await ExifReader.load(absPath, { expanded: true }); } catch {} const timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null; const takenAtIso = parseExifDateUtc(timeRaw); let gps = null; if (isVideo) { gps = await extractGpsWithExiftool(absPath); } else { gps = extractGpsFromExif(tags); } let width = null, height = null, size_bytes = null, duration = null; const st = await fsp.stat(absPath); size_bytes = st.size; if (isVideo) { const info = await probeVideo(absPath); const stream = info.streams?.find(s => s.width && s.height); if (stream) { width = stream.width; height = stream.height; } duration = info.format?.duration || null; } else { try { const meta = await sharp(absPath).metadata(); width = meta.width || null; height = meta.height || null; } catch {} } const mime_type = inferMimeFromExt(ext); const id = sha256(relFile); const location = gps ? await loc(gps.lng, gps.lat) : null; results.push({ id, name: dirent.name, path: relFile, thub1: relThumbMin, thub2: relThumbAvg, gps, data: timeRaw, taken_at: takenAtIso, mime_type, width, height, size_bytes, duration: isVideo ? duration : null, location, user: userName }); } return results; } // ----------------------------------------------------------------------------- // MAIN // ----------------------------------------------------------------------------- async function scanPhoto(dir, userName) { try { const absDir = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir); const photos = await scanDir(absDir, userName); if (SEND_PHOTOS && BASE_URL) { for (const p of photos) { try { await postWithAuth(`${BASE_URL}/photos`, p); } catch (err) { console.error('Errore invio:', err.message); } } } /* if (WRITE_INDEX) { const absIndexPath = path.join(WEB_ROOT, INDEX_PATH); await fsp.mkdir(path.dirname(absIndexPath), { recursive: true }); await fsp.writeFile(absIndexPath, JSON.stringify(photos, null, 2), 'utf8'); } */ await new Promise(r => setTimeout(r, 500)); return photos; } catch (e) { console.error('Errore generale scanPhoto:', e); throw e; } } module.exports = scanPhoto;