photo_server_json_flutter/api_v1/scanphoto.js
2026-02-22 23:38:33 +01:00

380 lines
10 KiB
JavaScript

/* 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, 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("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);
// GEOLOCATION
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
});
}
return results;
}
// -----------------------------------------------------------------------------
// MAIN
// -----------------------------------------------------------------------------
async function scanPhoto(dir) {
try {
const absDir = path.isAbsolute(dir) ? dir : path.join(process.cwd(), dir);
const photos = await scanDir(absDir);
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;