380 lines
10 KiB
JavaScript
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;
|
|
|
|
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');
|
|
}
|
|
|
|
return photos;
|
|
|
|
} catch (e) {
|
|
console.error('Errore generale scanPhoto:', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
module.exports = scanPhoto;
|