photo_server_json_flutter/api_v1/scanphoto.js.ok
2026-02-22 22:25:00 +01:00

273 lines
8.7 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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 allapp
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')"