273 lines
8.7 KiB
Text
273 lines
8.7 KiB
Text
/* 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 BASE_URL = process.env.BASE_URL; // es: https://api.tuoserver.tld (backend con /auth/login e /photos)
|
||
const EMAIL = process.env.EMAIL;
|
||
const PASSWORD = process.env.PASSWORD;
|
||
|
||
// Opzioni
|
||
const SEND_PHOTOS = (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true'; // invia ogni record via POST /photos
|
||
const WRITE_INDEX = (process.env.WRITE_INDEX || 'true').toLowerCase() === 'true'; // scrivi public/photos/index.json
|
||
const WEB_ROOT = process.env.WEB_ROOT || 'public'; // radice dei file serviti
|
||
const INDEX_PATH = process.env.INDEX_PATH || path.posix.join('photos', 'index.json');
|
||
|
||
// estensioni supportate
|
||
const SUPPORTED_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif']);
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// UTILS
|
||
// -----------------------------------------------------------------------------
|
||
|
||
// usa sempre POSIX per i path web (slash '/')
|
||
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';
|
||
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();
|
||
}
|
||
|
||
// normalizza GPS da {Latitude,Longitude,Altitude} -> {lat,lng,alt}
|
||
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;
|
||
const toNum = (v) => (typeof v === 'number' ? v : Number(v));
|
||
const obj = { lat: toNum(lat), lng: toNum(lng) };
|
||
if (alt != null) obj.alt = toNum(alt);
|
||
return obj;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// 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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// THUMBNAILS
|
||
// -----------------------------------------------------------------------------
|
||
|
||
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)
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function scanDir(dirAbs, 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, results);
|
||
continue;
|
||
}
|
||
|
||
const ext = path.extname(dirent.name).toLowerCase();
|
||
if (!SUPPORTED_EXTS.has(ext)) continue;
|
||
|
||
console.log('Trovato:', absPath);
|
||
|
||
// path relativo (web-safe) rispetto a WEB_ROOT
|
||
const relFile = toPosix(path.relative(WEB_ROOT, absPath)); // es. photos/original/.../IMG_0092.JPG
|
||
const relDir = toPosix(path.posix.dirname(relFile)); // es. photos/original/... (POSIX)
|
||
|
||
// cartella thumbs parallela
|
||
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 extName = path.parse(dirent.name).ext;
|
||
|
||
// path ASSOLUTI (filesystem) per i file thumb
|
||
const absThumbMin = path.join(absThumbDir, `${baseName}_min${extName}`);
|
||
const absThumbAvg = path.join(absThumbDir, `${baseName}_avg${extName}`);
|
||
|
||
// crea thumbnails
|
||
await createThumbnails(absPath, absThumbMin, absThumbAvg);
|
||
|
||
// path RELATIVI (web) dei thumb
|
||
const relThumbMin = toPosix(path.relative(WEB_ROOT, absThumbMin)); // photos/thumbs/..._min.JPG
|
||
const relThumbAvg = toPosix(path.relative(WEB_ROOT, absThumbAvg)); // photos/thumbs/..._avg.JPG
|
||
|
||
// EXIF e metadata immagine
|
||
let tags = {};
|
||
try {
|
||
tags = await ExifReader.load(absPath, { expanded: true });
|
||
} catch (err) {
|
||
// nessun EXIF: ok
|
||
}
|
||
const timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null;
|
||
const takenAtIso = parseExifDateUtc(timeRaw);
|
||
const gps = mapGps(tags?.gps);
|
||
|
||
// dimensioni e peso
|
||
let width = null, height = null, size_bytes = null;
|
||
try {
|
||
const meta = await sharp(absPath).metadata();
|
||
width = meta.width || null;
|
||
height = meta.height || null;
|
||
const st = await fsp.stat(absPath);
|
||
size_bytes = st.size;
|
||
} catch (err) {
|
||
try {
|
||
const st = await fsp.stat(absPath);
|
||
size_bytes = st.size;
|
||
} catch {}
|
||
}
|
||
|
||
const mime_type = inferMimeFromExt(ext);
|
||
const id = sha256(relFile); // id stabile
|
||
|
||
// RECORD allineato all’app
|
||
results.push({
|
||
id, // sha256 del path relativo
|
||
name: dirent.name,
|
||
path: relFile,
|
||
thub1: relThumbMin,
|
||
thub2: relThumbAvg,
|
||
gps, // {lat,lng,alt} oppure null
|
||
data: timeRaw, // EXIF originale (legacy)
|
||
taken_at: takenAtIso, // ISO-8601 UTC
|
||
mime_type,
|
||
width,
|
||
height,
|
||
size_bytes,
|
||
location: null,
|
||
});
|
||
}
|
||
|
||
return results;
|
||
}
|
||
|
||
// -----------------------------------------------------------------------------
|
||
// MAIN
|
||
// -----------------------------------------------------------------------------
|
||
|
||
async function scanPhoto(dir) {
|
||
try {
|
||
console.log('Inizio scansione:', dir);
|
||
|
||
// dir può essere "public/photos/original" o simile
|
||
const absDir = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
|
||
const photos = await scanDir(absDir);
|
||
|
||
console.log('Trovate', photos.length, 'foto');
|
||
|
||
// 1) (opzionale) invio a backend /photos
|
||
if (SEND_PHOTOS && BASE_URL) {
|
||
for (const p of photos) {
|
||
try {
|
||
await postWithAuth(`${BASE_URL}/photos`, p);
|
||
} catch (err) {
|
||
console.error('Errore invio foto:', err.message);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2) (opzionale) scrivo indice statico public/photos/index.json
|
||
if (WRITE_INDEX) {
|
||
const absIndexPath = path.join(WEB_ROOT, INDEX_PATH); // es. public/photos/index.json
|
||
await fsp.mkdir(path.dirname(absIndexPath), { recursive: true });
|
||
await fsp.writeFile(absIndexPath, JSON.stringify(photos, null, 2), 'utf8');
|
||
console.log('Scritto indice:', absIndexPath);
|
||
}
|
||
|
||
console.log('Scansione completata');
|
||
return photos;
|
||
} catch (e) {
|
||
console.error('Errore generale scanPhoto:', e);
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
module.exports = scanPhoto;
|
||
|
||
// Esempio di esecuzione diretta:
|
||
// node -e "require('./api_v1/scanphoto')('public/photos/original')"
|