Initial import s190js

This commit is contained in:
Fabio 2026-10-26 11:00:14 +01:00
commit 73b44d24e5
296 changed files with 102988 additions and 0 deletions

45
.env Normal file
View file

@ -0,0 +1,45 @@
BASE_URL=https://prova.patachina.it
SERVER_PORT=4000
# cartella dove scrivere gli output HLS (relativa alla root del progetto)
HLS_BASE=./hls_output
EMAIL=fabio@gmail.com
PASSWORD=master66
JWT_SECRET=123456789
JWT_EXPIRES=1h
# Dove si trova la cartella public (relativa alla root del progetto)
WEB_ROOT=public
# Percorso relativo di index.json dentro public/
INDEX_PATH=photos/index.json
# true = restituisce path assoluti nei record
PATH_FULL=true
# Logging
LOG_MODE=both # console | file | both
LOG_FILE=scan.log
LOG_DIR=public
LOG_VERBOSE=true
# POLLING_TIME in secondi
# Funzionamento:
# - POLLING_TIME = 0 → polling disattivato (solo WebSocket)
# - POLLING_TIME > 0 → esegue incrementalSync() ogni N secondi
# Perché esiste?
# - Serve come "rete di sicurezza" se il WebSocket cade,
# se la tab viene sospesa, o se qualche evento viene perso.
# - Google Photos, Dropbox e iCloud usano lo stesso approccio.
GALLERY_REFRESH_SECONDS=30
WS_PORT=4002
WS_HOST=0.0.0.0
PHOTO_RETENTION_DAYS=30
MAPBOX_PUBLIC_TOKEN=pk.eyJ1IjoicGlwcG9jYWwiLCJhIjoiY2xka28zdWVjMHBkZzNwbnoyMmlxYjA3MyJ9.9D5EB0SWydvAQZr-SdI64Q
WORKER_STATUS_REFRESH_SECONDS=1

65
.gitignore vendored Normal file
View file

@ -0,0 +1,65 @@
# Node
node_modules/
# Python
__pycache__/
*.pyc
.venv/
.venv-clustering/
# SQLite
*.sqlite
*.sqlite-shm
*.sqlite-wal
api_v1/database.sqlite.pre-clear-*
# Logs
*.log
# Locks
*.lock
# Media
public/photos/
public/activities/
public/cast/
public/del_photos/
public/del_activities/
del_photos/
del_activities/
# Runtime
hls_output/
out/
out_fast/
out_libx264/
thumbs/
var/
# Face runtime binaries
native/face-knn/bin/
# C/C++
build/
*.o
*.a
*.so
# Backup
*.bak*
*.old
*.orig
*.before_*
*.before-*
*.ok
# OS
.DS_Store
Thumbs.db
# Backup e versioni temporanee sorgenti
*.js.*
*.css.*
*.html.*
*.sqlite.*

129
README.md Normal file
View file

@ -0,0 +1,129 @@
# Galleria con json-server e protetto con JWT S6J
## Installazione
clonare questa repo e installare tutte le dipendenze con `npm ci`
## Start/Stop servers
| Description | Script |
| ------------------------- | -------------------- |
| Start server senza auth | `npm start-no-auth` |
| Start server con auth | `npm run start` |
## Tools
| Description | Script |
| ------------------------------ | ------------------- |
| Generate user hashed passwords | `npm run hash` |
[json-server api reference](https://github.com/typicode/json-server)
## Come usarlo
clonare e poi installare con
```
npm ci
```
nel file .env ci sono tutti i dati da modificare
poi inserire in user.json user e password utilizzati per fare il login
la password da inserire è criptata e viene generata con npm run hash
il nome viene utilizzato come cartella da scansionare, si trova dentro photos
es:
```
name: Fabio
public/photos
└── Fabio
└── original
└── 2017Irlanda19-29ago
├── IMG_0092.JPG
├── IMG_0099.JPG
├── IMG_0100.JPG
```
poi dentro Fabio genererà thumbs con tutti i thumbs
- npm run start
- su IP:4000 ci sarà la galleria e andando su impostazioni si potrà fare lo scan di tutte le foto
dopo aver fatto lo scan è possibile richiedere il json al server con tutte le informazioni anche senza autorizzazione
basta farlo partire con npm run start-no-auth e le info si possono vedere con
ip:4000/photos
- npm start
---
#### FIT parse (utilizzo di python)
Per avere il parse dei fit installare python, pipx e i seguenyi packages
```
sudo apt install pipx
pipx ensurepath
pipx install fitdecode
```
usa per sapere dove è installato python
```
pipx list
```
in api_v1/scanner/parsers/parseFit.js va inserito il riferimento
in questo caso
```
async function parseFit(absPath) {
const frames = await new Promise((resolve, reject) => {
execFile(
"/home/orangepi/.local/pipx/venvs/fitdecode/bin/python",
["api_v1/scanner/parsers/parse_fit.py", absPath],
{ maxBuffer: 1024 * 1024 * 50 }, // 50 MB
```
Inspired in this [post](https://www.techiediaries.com/fake-api-jwt-json-server/) by [Techiediaries](https://www.techiediaries.com/)
#### Interrogazioni manuali
per vedere il token
```
curl -s -X POST https://prova.patachina.it/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"fabio@gmail.com","password":"master66"}'
```
per salvare il token
```
TOKEN=$(curl -s -X POST https://prova.patachina.it/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"fabio@gmail.com","password":"master66"}' \
| jq -r '.token')
```
per interrogare le foto
```
curl -s "https://prova.patachina.it/photos/" \
-H "Authorization: Bearer $TOKEN"
```
per interrogare le attività
```
curl -s "https://prova.patachina.it/activities/" \
-H "Authorization: Bearer $TOKEN"
```

4
api_v1/admin_secret.json Normal file
View file

@ -0,0 +1,4 @@
{
"email": "admin@gmail.com",
"password": "master66"
}

36
api_v1/config.js Normal file
View file

@ -0,0 +1,36 @@
require('dotenv').config();
module.exports = {
BASE_URL: process.env.BASE_URL,
EMAIL: process.env.EMAIL,
PASSWORD: process.env.PASSWORD,
SEND_PHOTOS: (process.env.SEND_PHOTOS || 'true').toLowerCase() === 'true',
// Cartella public (root dei file statici)
WEB_ROOT: process.env.WEB_ROOT || 'public',
// PATH_FULL ora è un boolean, non un path
PATH_FULL: (process.env.PATH_FULL || 'false').toLowerCase() === 'true',
// Estensioni supportate per FOTO/VIDEO
SUPPORTED_EXTS: new Set([
'.jpg', '.jpeg', '.png', '.webp', '.heic', '.heif',
'.mp4', '.mov', '.m4v'
]),
// Estensioni supportate per ATTIVITÀ (FIT/GPX/TCX)
SUPPORTED_ACTIVITY_EXTS: new Set([
'.fit',
'.gpx',
'.tcx'
]),
// 🔥 NUOVO: intervallo refresh gallery (in secondi)
GALLERY_REFRESH_SECONDS: Number(process.env.GALLERY_REFRESH_SECONDS || 30),
WORKER_STATUS_REFRESH_SECONDS: Number(process.env.WORKER_STATUS_REFRESH_SECONDS || 1),
// 🔥 PORTA SERVER
PORT: parseInt(process.env.SERVER_PORT || "4000", 10)
};

128
api_v1/geo.js Normal file
View file

@ -0,0 +1,128 @@
// api_v1/geo.js
const axios = require("axios");
async function loc(lng, lat) {
const primary = await place(lng, lat);
if (
primary &&
primary.city &&
primary.country &&
primary.address
) {
return primary;
}
const fallback = await placePhoton(lng, lat);
if (!primary) return fallback;
if (!primary.city && fallback?.city) {
primary.city = fallback.city;
}
if (!primary.postcode && fallback?.postcode) {
primary.postcode = fallback.postcode;
}
if (!primary.address && fallback?.address) {
primary.address = fallback.address;
}
if (!primary.region && fallback?.region) {
primary.region = fallback.region;
}
if (!primary.country_code && fallback?.country_code) {
primary.country_code = fallback.country_code;
}
if (!primary.county && fallback?.county) {
primary.county = fallback.county;
}
if (!primary.county_code && fallback?.county_code) {
primary.county_code = fallback.county_code;
}
return primary;
}
function normCountryCode(v) {
if (!v) return undefined;
const s = String(v).trim().toUpperCase();
return s.length ? s : undefined;
}
async function place(lng, lat) {
const apiKey = "6dc7fb95a3b246cfa0f3bcef5ce9ed9a";
const url = `https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lng}&apiKey=${apiKey}`;
try {
const r = await axios.get(url);
if (r.status !== 200) return undefined;
if (!r.data.features || r.data.features.length === 0) return undefined;
const k = r.data.features[0].properties;
return {
continent: k?.timezone?.name?.split("/")?.[0] || undefined,
// Country / Paese
country: k?.country || undefined,
country_code: normCountryCode(k?.country_code),
// County / contea
county: k?.county || undefined,
county_code: k?.county_code || undefined,
// Regione / stato amministrativo
region: k?.state || undefined,
postcode: k?.postcode || undefined,
city: k?.city || k?.town || k?.village || undefined,
address: k?.address_line1 || undefined,
timezone: k?.timezone?.name || undefined,
time: k?.timezone?.offset_STD || undefined,
};
} catch (err) {
return undefined;
}
}
async function placePhoton(lng, lat) {
try {
const url = `https://photon.patachina.it/reverse?lon=${lng}&lat=${lat}`;
const r = await axios.get(url);
if (!r.data || !r.data.features || r.data.features.length === 0) {
return undefined;
}
const p = r.data.features[0].properties;
return {
continent: undefined,
// Country / Paese
country: p.country || undefined,
country_code: normCountryCode(p.countrycode || p.country_code),
// County / contea, se disponibile
county: p.county || undefined,
county_code: p.county_code || undefined,
region: p.state || undefined,
postcode: p.postcode || undefined,
city: p.city || p.town || p.village || undefined,
address: p.street ? `${p.street} ${p.housenumber || ""}`.trim() : undefined,
timezone: undefined,
time: undefined,
};
} catch (err) {
return undefined;
}
}
module.exports = loc;

View file

@ -0,0 +1,527 @@
'use strict';
const crypto = require('crypto');
const {
withSqliteBusyRetry,
} = require('../../db/sqliteRetry');
const TABLE = 'activity_ingest_jobs';
function makeJobId(user, cartella, relPath) {
return crypto
.createHash('sha256')
.update(JSON.stringify([user, cartella, relPath]))
.digest('hex');
}
async function ensureActivityIngestQueue(db) {
await withSqliteBusyRetry(
'activity queue ensure',
async () => {
if (!(await db.schema.hasTable(TABLE))) {
await db.schema.createTable(TABLE, (t) => {
t.string('id').primary();
t.string('user').notNullable();
t.string('cartella').notNullable();
t.string('rel_path').notNullable();
t.integer('source_mtime_ms').notNullable();
t.integer('source_size_bytes').notNullable();
t.string('status')
.notNullable()
.defaultTo('pending');
t.integer('attempts')
.notNullable()
.defaultTo(0);
t.integer('rescan_requested')
.notNullable()
.defaultTo(0);
t.text('last_error').nullable();
t.datetime('started_at').nullable();
t.datetime('completed_at').nullable();
t.datetime('created_at')
.notNullable()
.defaultTo(db.fn.now());
t.datetime('updated_at')
.notNullable()
.defaultTo(db.fn.now());
t.index(
['status', 'created_at'],
'idx_activity_ingest_status_created'
);
t.index(
['user', 'cartella'],
'idx_activity_ingest_folder'
);
});
}
if (
!(await db.schema.hasColumn(
TABLE,
'rescan_requested'
))
) {
await db.schema.alterTable(TABLE, (t) => {
t.integer('rescan_requested')
.notNullable()
.defaultTo(0);
});
}
}
);
}
async function enqueueActivityIngestJob(db, input) {
const {
user,
cartella,
relPath,
mtimeMs,
sizeBytes,
} = input;
if (
typeof user !== 'string' || !user ||
typeof cartella !== 'string' || !cartella ||
typeof relPath !== 'string' || !relPath ||
!Number.isFinite(mtimeMs) || mtimeMs < 0 ||
!Number.isFinite(sizeBytes) || sizeBytes < 0
) {
throw new TypeError(
'Dati non validi per accodare activity'
);
}
await ensureActivityIngestQueue(db);
const id = makeJobId(user, cartella, relPath);
const mtime = Math.trunc(mtimeMs);
const size = Math.trunc(sizeBytes);
return withSqliteBusyRetry(
'activity queue enqueue',
() => db.transaction(async (trx) => {
const existing = await trx(TABLE)
.where({ id })
.first();
if (existing) {
const unchanged =
Number(existing.source_mtime_ms) === mtime &&
Number(existing.source_size_bytes) === size;
if (unchanged) {
return {
id,
status: existing.status,
unchanged: true,
};
}
if (existing.status === 'processing') {
await trx(TABLE)
.where({
id,
status: 'processing',
})
.update({
source_mtime_ms: mtime,
source_size_bytes: size,
rescan_requested: 1,
updated_at: trx.fn.now(),
});
return {
id,
status: 'processing',
changedWhileProcessing: true,
};
}
await trx(TABLE)
.where({ id })
.update({
source_mtime_ms: mtime,
source_size_bytes: size,
status: 'pending',
attempts: 0,
rescan_requested: 0,
last_error: null,
started_at: null,
completed_at: null,
updated_at: trx.fn.now(),
});
return {
id,
status: 'pending',
updated: true,
};
}
await trx(TABLE).insert({
id,
user,
cartella,
rel_path: relPath,
source_mtime_ms: mtime,
source_size_bytes: size,
status: 'pending',
attempts: 0,
rescan_requested: 0,
created_at: trx.fn.now(),
updated_at: trx.fn.now(),
});
return {
id,
status: 'pending',
inserted: true,
};
})
);
}
async function enqueueActivityIngestJobs(db, inputs) {
if (!Array.isArray(inputs)) {
throw new TypeError('Il batch deve essere un array');
}
if (inputs.length === 0) return [];
for (const input of inputs) {
const { user, cartella, relPath, mtimeMs, sizeBytes } = input || {};
if (
typeof user !== 'string' || !user ||
typeof cartella !== 'string' || !cartella ||
typeof relPath !== 'string' || !relPath ||
!Number.isFinite(mtimeMs) || mtimeMs < 0 ||
!Number.isFinite(sizeBytes) || sizeBytes < 0
) {
throw new TypeError('Dati non validi nel batch activity');
}
}
await ensureActivityIngestQueue(db);
return withSqliteBusyRetry(
'activity queue enqueue batch',
() => db.transaction(async (trx) => {
const results = [];
for (const input of inputs) {
const { user, cartella, relPath, mtimeMs, sizeBytes } = input;
const id = makeJobId(user, cartella, relPath);
const mtime = Math.trunc(mtimeMs);
const size = Math.trunc(sizeBytes);
const existing = await trx(TABLE).where({ id }).first();
if (existing) {
const unchanged =
Number(existing.source_mtime_ms) === mtime &&
Number(existing.source_size_bytes) === size;
if (unchanged) {
results.push({
id, status: existing.status, unchanged: true,
});
continue;
}
if (existing.status === 'processing') {
await trx(TABLE)
.where({ id, status: 'processing' })
.update({
source_mtime_ms: mtime,
source_size_bytes: size,
rescan_requested: 1,
updated_at: trx.fn.now(),
});
results.push({
id, status: 'processing', changedWhileProcessing: true,
});
continue;
}
await trx(TABLE).where({ id }).update({
source_mtime_ms: mtime,
source_size_bytes: size,
status: 'pending',
attempts: 0,
rescan_requested: 0,
last_error: null,
started_at: null,
completed_at: null,
updated_at: trx.fn.now(),
});
results.push({ id, status: 'pending', updated: true });
continue;
}
await trx(TABLE).insert({
id,
user,
cartella,
rel_path: relPath,
source_mtime_ms: mtime,
source_size_bytes: size,
status: 'pending',
attempts: 0,
rescan_requested: 0,
created_at: trx.fn.now(),
updated_at: trx.fn.now(),
});
results.push({ id, status: 'pending', inserted: true });
}
return results;
})
);
}
async function claimNextActivityIngestJob(db) {
await ensureActivityIngestQueue(db);
return withSqliteBusyRetry(
'activity queue claim',
() => db.transaction(async (trx) => {
const job = await trx(TABLE)
.where({ status: 'pending' })
.orderBy('created_at', 'asc')
.first();
if (!job) return null;
const changed = await trx(TABLE)
.where({
id: job.id,
status: 'pending',
})
.update({
status: 'processing',
attempts: trx.raw('attempts + 1'),
rescan_requested: 0,
started_at: trx.fn.now(),
completed_at: null,
last_error: null,
updated_at: trx.fn.now(),
});
if (!changed) return null;
return {
...job,
status: 'processing',
attempts: Number(job.attempts || 0) + 1,
rescan_requested: 0,
};
})
);
}
async function completeActivityIngestJob(db, id) {
return withSqliteBusyRetry(
'activity queue complete',
() => db.transaction(async (trx) => {
const job = await trx(TABLE)
.where({
id,
status: 'processing',
})
.first();
if (!job) {
return { updated: false };
}
if (Number(job.rescan_requested) === 1) {
await trx(TABLE)
.where({
id,
status: 'processing',
})
.update({
status: 'pending',
rescan_requested: 0,
started_at: null,
completed_at: null,
last_error: null,
updated_at: trx.fn.now(),
});
return {
updated: true,
status: 'pending',
requeued: true,
};
}
await trx(TABLE)
.where({
id,
status: 'processing',
})
.update({
status: 'done',
completed_at: trx.fn.now(),
updated_at: trx.fn.now(),
last_error: null,
});
return {
updated: true,
status: 'done',
requeued: false,
};
})
);
}
async function failActivityIngestJob(
db,
id,
error
) {
const message = String(
error?.stack ||
error?.message ||
error
).slice(0, 12000);
return withSqliteBusyRetry(
'activity queue fail',
() => db.transaction(async (trx) => {
const job = await trx(TABLE)
.where({
id,
status: 'processing',
})
.first();
if (!job) {
return { updated: false };
}
if (Number(job.rescan_requested) === 1) {
await trx(TABLE)
.where({
id,
status: 'processing',
})
.update({
status: 'pending',
rescan_requested: 0,
started_at: null,
completed_at: null,
last_error: message,
updated_at: trx.fn.now(),
});
return {
updated: true,
status: 'pending',
requeued: true,
};
}
await trx(TABLE)
.where({
id,
status: 'processing',
})
.update({
status: 'error',
last_error: message,
completed_at: trx.fn.now(),
updated_at: trx.fn.now(),
});
return {
updated: true,
status: 'error',
requeued: false,
};
})
);
}
async function resetStaleActivityIngestJobs(
db,
staleMinutes = 30
) {
await ensureActivityIngestQueue(db);
const cutoff = new Date(
Date.now() - staleMinutes * 60 * 1000
)
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, '');
return withSqliteBusyRetry(
'activity queue stale reset',
() => db(TABLE)
.where({ status: 'processing' })
.whereNotNull('started_at')
.where('started_at', '<', cutoff)
.update({
status: 'pending',
rescan_requested: 0,
started_at: null,
completed_at: null,
last_error:
'Recuperato dopo interruzione del worker',
updated_at: db.fn.now(),
})
);
}
async function getActivityIngestQueueCounts(db) {
await ensureActivityIngestQueue(db);
const rows = await db(TABLE)
.select('status')
.count({ total: '*' })
.groupBy('status');
const counts = {
pending: 0,
processing: 0,
done: 0,
error: 0,
};
for (const row of rows) {
if (Object.hasOwn(counts, row.status)) {
counts[row.status] = Number(row.total);
}
}
return counts;
}
module.exports = {
ensureActivityIngestQueue,
enqueueActivityIngestJob,
enqueueActivityIngestJobs,
claimNextActivityIngestJob,
completeActivityIngestJob,
failActivityIngestJob,
resetStaleActivityIngestJobs,
getActivityIngestQueueCounts,
};

View file

@ -0,0 +1,420 @@
'use strict';
const path = require('path');
const fs = require('fs/promises');
const {
WEB_ROOT,
SUPPORTED_ACTIVITY_EXTS,
SEND_PHOTOS,
BASE_URL,
} = require('../config');
const scanActivitySingle =
require('./scanActivitySingle');
const postWithAuth =
require('./postWithAuth');
const { sha256 } = require('./utils');
const {
enqueueServerEvent,
} = require('../../db/serverEventOutbox');
const {
ensureActivityIngestQueue,
claimNextActivityIngestJob,
enqueueActivityIngestJob,
completeActivityIngestJob,
failActivityIngestJob,
resetStaleActivityIngestJobs,
} = require('./activityIngestQueue');
function sameVersion(stat, job) {
return (
Math.trunc(stat.mtimeMs) ===
Math.trunc(Number(job.source_mtime_ms)) &&
stat.size ===
Number(job.source_size_bytes)
);
}
function isInside(parent, child) {
const relative = path.relative(parent, child);
return (
relative === '' ||
(
!relative.startsWith(`..${path.sep}`) &&
relative !== '..' &&
!path.isAbsolute(relative)
)
);
}
async function resolveActivityJobFile(job) {
for (const value of [
job.user,
job.cartella,
job.rel_path,
]) {
if (
typeof value !== 'string' ||
!value.trim()
) {
throw new Error(
'Percorso activity job non valido'
);
}
}
if (
path.isAbsolute(job.rel_path) ||
job.rel_path.includes('\\') ||
job.rel_path
.split('/')
.some(part =>
!part ||
part === '.' ||
part === '..'
)
) {
throw new Error(
'Percorso relativo activity non sicuro'
);
}
if (
job.user === '.' ||
job.user === '..' ||
job.cartella === '.' ||
job.cartella === '..' ||
job.user.includes('/') ||
job.user.includes('\\') ||
job.cartella.includes('/') ||
job.cartella.includes('\\')
) {
throw new Error(
'Utente o cartella activity non validi'
);
}
const activitiesRoot = path.resolve(
__dirname,
'..',
'..',
WEB_ROOT,
'activities'
);
const originalRoot = path.resolve(
activitiesRoot,
job.user,
'original',
job.cartella
);
const absPath = path.resolve(
originalRoot,
...job.rel_path.split('/')
);
if (!isInside(originalRoot, absPath)) {
throw new Error(
'Activity file esce dalla cartella prevista'
);
}
const realRoot = await fs.realpath(
originalRoot
);
const realFile = await fs.realpath(
absPath
);
if (!isInside(realRoot, realFile)) {
throw new Error(
'Activity file risolve fuori dalla cartella prevista'
);
}
const ext = path
.extname(absPath)
.toLowerCase();
if (!SUPPORTED_ACTIVITY_EXTS.has(ext)) {
throw new Error(
`Estensione activity non supportata: ${ext}`
);
}
const stat = await fs.stat(realFile);
if (!stat.isFile()) {
throw new Error(
'Activity path non e un file regolare'
);
}
return {
id: sha256(
`${job.user}/${job.cartella}/${job.rel_path}`
),
user: job.user,
cartella: job.cartella,
file_name: path.basename(absPath),
name: path.basename(absPath),
relPath: job.rel_path,
absPath: realFile,
ext,
stat,
path:
`/activities/${job.user}/original/` +
`${job.cartella}/${job.rel_path}`,
};
}
async function runOneJob(db) {
await ensureActivityIngestQueue(db);
const job =
await claimNextActivityIngestJob(db);
if (!job) {
return { processed: false };
}
console.log(
`[ActivityIngest] Avvio job ${job.id}: ` +
`${job.rel_path}`
);
try {
const file =
await resolveActivityJobFile(job);
/*
* Se il file e' gia cambiato prima di iniziare
* l'elaborazione, aggiorna la versione in queue.
*/
if (!sameVersion(file.stat, job)) {
await enqueueActivityIngestJob(db, {
user: job.user,
cartella: job.cartella,
relPath: job.rel_path,
mtimeMs: file.stat.mtimeMs,
sizeBytes: file.stat.size,
});
const result =
await completeActivityIngestJob(
db,
job.id
);
return {
processed: true,
status: result.status,
requeued: Boolean(result.requeued),
};
}
// Memorizza lo stato precedente per distinguere inserimenti e aggiornamenti.
const previousActivity = await db('activities')
.select('id', 'deleted_at')
.where({ id: file.id })
.first();
const newActivities = [];
const scannedActivity =
await scanActivitySingle(
db,
job.user,
job.cartella,
file,
newActivities
);
/*
* Ricontrolla il file dopo il parsing:
* se e' cambiato mentre veniva elaborato,
* richiede una seconda scansione.
*/
/*
* Sincronizzazione verso BASE_URL.
*
* La semantica resta quella del vecchio scanActivity:
* un errore remoto viene registrato ma NON rende fallito
* l'ingest locale.
*/
if (
scannedActivity &&
SEND_PHOTOS &&
BASE_URL
) {
try {
scannedActivity.user = job.user;
await postWithAuth(
`${BASE_URL}/activities`,
scannedActivity
);
console.log(
`[ActivityIngest] Activity ${scannedActivity.id} ` +
`inviata al server remoto`
);
} catch (remoteError) {
console.error(
`[ActivityIngest] Invio remoto fallito ` +
`${scannedActivity.id}:`,
remoteError.message
);
}
}
const statAfter =
await fs.stat(file.absPath);
if (
Math.trunc(statAfter.mtimeMs) !==
Math.trunc(file.stat.mtimeMs) ||
statAfter.size !== file.stat.size
) {
await enqueueActivityIngestJob(db, {
user: job.user,
cartella: job.cartella,
relPath: job.rel_path,
mtimeMs: statAfter.mtimeMs,
sizeBytes: statAfter.size,
});
}
const result =
await completeActivityIngestJob(
db,
job.id
);
console.log(
`[ActivityIngest] Job ${job.id} ` +
`completato; stato: ${result.status}`
);
// Notifica la galleria solo dopo il completamento effettivo dell'ingest.
if (
scannedActivity &&
scannedActivity.id &&
!result.requeued &&
result.status === 'done'
) {
await enqueueServerEvent(db, {
channel: 'activity',
user: job.user,
payload: {
type:
previousActivity && !previousActivity.deleted_at
? 'updated'
: 'added',
id: scannedActivity.id,
entity: 'activity',
mode: 'activities',
},
});
console.log(
`[ActivityIngest] Evento gallery accodato ` +
`activity=${scannedActivity.id}`
);
}
return {
processed: true,
status: result.status,
requeued: Boolean(result.requeued),
activityId:
scannedActivity?.id || file.id,
};
} catch (error) {
console.error(
`[ActivityIngest] Errore job ${job.id}:`,
error.message
);
try {
await failActivityIngestJob(
db,
job.id,
error
);
} catch (queueError) {
console.error(
'[ActivityIngest] Errore aggiornando queue:',
queueError.message
);
}
return {
processed: true,
error: error.message,
};
}
}
async function runWorker(
db,
options = {}
) {
const pollMs = Math.max(
250,
Number(options.pollMs) || 2000
);
const staleMinutes = Math.max(
1,
Number(options.staleMinutes) || 30
);
await ensureActivityIngestQueue(db);
await resetStaleActivityIngestJobs(
db,
staleMinutes
);
console.log(
`[ActivityIngest] Worker avviato; ` +
`polling ${pollMs} ms`
);
while (true) {
try {
const result = await runOneJob(db);
if (!result.processed) {
await new Promise(
resolve => setTimeout(resolve, pollMs)
);
}
} catch (error) {
console.error(
'[ActivityIngest] Errore worker:',
error
);
await new Promise(
resolve => setTimeout(resolve, pollMs)
);
}
}
}
module.exports = {
runOneJob,
runWorker,
resolveActivityJobFile,
sameVersion,
};

View file

@ -0,0 +1,57 @@
const RETENTION_DAYS = 30;
module.exports = function createActivityOrphanCleanup(db) {
async function deleteFromDB(id, user) {
const now = new Date();
const nowIso = now.toISOString();
const rec = await db('activities')
.where({ id, user })
.first();
if (!rec) return false;
// Primo rilevamento: soft delete.
if (!rec.deleted_at) {
await db('activities')
.where({ id, user })
.update({
deleted_at: nowIso,
updated_at: nowIso,
});
console.log(`🟡 Soft delete attività orfana: ${id}`);
return true;
}
// Hard delete solo dopo il periodo di retention.
const deletedAt = new Date(rec.deleted_at);
const ageMs = now.getTime() - deletedAt.getTime();
const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (!Number.isFinite(deletedAt.getTime()) || ageMs <= retentionMs) {
console.log(`🟡 Attività in retention: ${id}`);
return true;
}
await db.transaction(async (trx) => {
await trx('activities')
.where({ id, user })
.del();
await trx('deleted_hard_activities')
.insert({
id,
user,
deleted_at: nowIso,
})
.onConflict('id')
.merge();
});
console.log(`🔴 Hard delete attività orfana: ${id}`);
return true;
}
return { deleteFromDB };
};

View file

@ -0,0 +1,92 @@
'use strict';
const activityIngestQueue = require('./activityIngestQueue');
let epoch = null;
async function beginGlobalActivityScan(db) {
if (!epoch || !epoch.active) {
const created = {
active: true,
baselineDone: 0,
enumerationsInProgress: 0,
initPromise: null,
};
epoch = created;
created.initPromise = activityIngestQueue
.getActivityIngestQueueCounts(db)
.then(counts => {
created.baselineDone = Number(counts.done || 0);
})
.catch(err => {
if (epoch === created) created.active = false;
throw err;
});
}
const current = epoch;
current.enumerationsInProgress++;
try {
await current.initPromise;
} catch (err) {
current.enumerationsInProgress = Math.max(
0, current.enumerationsInProgress - 1
);
throw err;
}
}
function finishGlobalActivityScan() {
if (epoch) {
epoch.enumerationsInProgress = Math.max(
0, epoch.enumerationsInProgress - 1
);
}
}
async function getGlobalActivityScanStatus(db) {
const counts = await activityIngestQueue.getActivityIngestQueueCounts(db);
const pending = Number(counts.pending || 0);
const processing = Number(counts.processing || 0);
const done = Number(counts.done || 0);
const errors = Number(counts.error || 0);
const remaining = pending + processing;
const totalCurrent = pending + processing + done + errors;
let current;
let total;
let percent;
let baselineDone = done;
if (epoch && epoch.active) {
baselineDone = epoch.baselineDone;
total = Math.max(0, totalCurrent - baselineDone);
current = Math.max(0, (done - baselineDone) + errors);
percent = total <= 0
? 100
: Math.max(0, Math.min(100, Math.round(current / total * 100)));
// Chiude il ciclo quando tutte le scansioni hanno terminato
// l'accodamento e non restano job pending o processing.
if (epoch.enumerationsInProgress === 0 && remaining === 0) {
epoch.active = false;
}
} else {
total = totalCurrent;
current = totalCurrent;
percent = 100;
}
return {
pending, processing, done, errors, remaining,
current, total, percent, baselineDone,
};
}
module.exports = {
beginGlobalActivityScan,
finishGlobalActivityScan,
getGlobalActivityScanStatus,
};

View file

@ -0,0 +1,64 @@
// api_v1/scanner/debugVideoDates.js
const path = require('path');
const { probeVideo } = require('./video');
async function debugVideo(absPath) {
console.log("🎥 File:", absPath);
const info = await probeVideo(absPath);
console.log("\n=== RAW probeVideo(info) ===\n");
console.dir(info, { depth: 6 });
// Estrazione campi data come nello scanner
const formatTags = info.format?.tags || {};
const stream0Tags = info.streams?.[0]?.tags || {};
const stream1Tags = info.streams?.[1]?.tags || {};
const creationFormat = formatTags.creation_time || null;
const creationStream0 = stream0Tags.creation_time || null;
const creationStream1 = stream1Tags.creation_time || null;
console.log("\n=== DATE CANDIDATE ===");
console.log("format.tags.creation_time :", creationFormat);
console.log("streams[0].tags.creation_time:", creationStream0);
console.log("streams[1].tags.creation_time:", creationStream1);
// Simulazione logica attuale dello scanner (ma esplicita)
let videoDate =
creationFormat ||
creationStream0 ||
creationStream1 ||
null;
console.log("\nScelta videoDate (prima di qualsiasi conversione):", videoDate);
if (videoDate) {
// Variante 1: SENZA conversione (quella che ti ho suggerito)
const takenAtRaw = videoDate;
// Variante 2: CON conversione (quella che ti sballa il giorno)
const takenAtIso = new Date(videoDate).toISOString();
console.log("\n=== CONFRONTO ===");
console.log("takenAtRaw (usato così com'è) :", takenAtRaw);
console.log("takenAtIso (new Date().toISOString):", takenAtIso);
} else {
console.log("\n⚠ Nessuna data trovata nei tag video.");
}
}
async function main() {
const arg = process.argv[2];
if (!arg) {
console.error("Uso: node debugVideoDates.js /percorso/al/video.mp4");
process.exit(1);
}
const absPath = path.resolve(arg);
await debugVideo(absPath);
}
main().catch(err => {
console.error("❌ Errore debugVideoDates:", err);
});

View file

@ -0,0 +1,73 @@
// api_v1/scanner/deleteFolder.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT } = require('../config');
module.exports = function createDeleteFolderFunctions(db, RETENTION_DAYS = 30) {
const createCleanupFunctions = require('./orphanCleanup');
const { deleteFromDB, deleteThumbsById } = createCleanupFunctions(db, RETENTION_DAYS);
/**
* Cancella una cartella per un utente:
* - soft delete immediato di tutte le foto
* - hard delete se retention scaduta
* - registra hard delete in deleted_hard
* - elimina thumbs solo per hard delete
*/
async function deleteFolderForUser(userName, folderName) {
// 1) Recupera tutti gli ID nel DB
const rows = await db('photos')
.where({ user: userName, cartella: folderName })
.select('id');
let softCount = 0;
let hardCount = 0;
// 2) Soft delete / Hard delete per ogni foto
for (const r of rows) {
const id = r.id;
const result = await deleteFromDB(id, userName);
if (result === true) {
// deleteFromDB decide se è soft o hard
// possiamo verificare se la foto esiste ancora nel DB
const stillExists = await db("photos")
.where({ id, user: userName })
.first();
if (stillExists) {
softCount++;
} else {
hardCount++;
}
}
}
// 3) Cancella la cartella thumbs residua (solo se hard delete totale)
const thumbsDir = path.join(
WEB_ROOT,
userName,
"thumbs",
folderName
);
try {
await fsp.rm(thumbsDir, { recursive: true, force: true });
console.log(`🧹 Rimossa cartella thumbs residua: ${thumbsDir}`);
} catch (err) {
console.log(`⚠️ Errore rimozione thumbs dir: ${thumbsDir}`, err);
}
return {
totalPhotos: rows.length,
softDeleted: softCount,
hardDeleted: hardCount,
removedThumbsDir: thumbsDir
};
}
return { deleteFolderForUser };
};

View file

@ -0,0 +1,19 @@
// scanner/deleteWithAuth.js
const { API_KEY } = require('../config');
module.exports = async function deleteWithAuth(url) {
// import dinamico compatibile con CommonJS
const fetch = (await import('node-fetch')).default;
const res = await fetch(url, {
method: 'DELETE',
headers: {
'x-api-key': API_KEY,
'Content-Type': 'application/json'
}
});
if (!res.ok) {
throw new Error(`DELETE failed: ${res.status}`);
}
};

View file

@ -0,0 +1,81 @@
// api_v1/scanner/elevation.js
// ---------------------------------------------------------
// Provider 1: OpenElevation
// ---------------------------------------------------------
async function tryOpenElevation(lat, lon) {
try {
const url = `https://api.open-elevation.com/api/v1/lookup?locations=${lat},${lon}`;
const res = await fetch(url, { timeout: 5000 });
if (!res.ok) {
console.log("OpenElevation status:", res.status);
return null;
}
const text = await res.text();
// Se non è JSON → errore HTML → fallback
if (!text.startsWith('{')) {
console.log("OpenElevation non-JSON:", text.slice(0, 60));
return null;
}
const data = JSON.parse(text);
return data?.results?.[0]?.elevation ?? null;
} catch (err) {
console.log("Errore OpenElevation:", err.message);
return null;
}
}
// ---------------------------------------------------------
// Provider 2: OpenTopoData (fallback)
// ---------------------------------------------------------
async function tryOpenTopoData(lat, lon) {
try {
const url = `https://api.opentopodata.org/v1/eudem25m?locations=${lat},${lon}`;
const res = await fetch(url, { timeout: 5000 });
if (!res.ok) {
console.log("OpenTopoData status:", res.status);
return null;
}
const text = await res.text();
if (!text.startsWith('{')) {
console.log("OpenTopoData non-JSON:", text.slice(0, 60));
return null;
}
const data = JSON.parse(text);
return data?.results?.[0]?.elevation ?? null;
} catch (err) {
console.log("Errore OpenTopoData:", err.message);
return null;
}
}
// ---------------------------------------------------------
// Funzione principale con fallback
// ---------------------------------------------------------
async function getElevation(lat, lon) {
// 1️⃣ Prova OpenElevation
const elev1 = await tryOpenElevation(lat, lon);
if (elev1 !== null) return elev1;
console.log("⚠️ OpenElevation fallito → uso OpenTopoData");
// 2️⃣ Prova OpenTopoData
const elev2 = await tryOpenTopoData(lat, lon);
if (elev2 !== null) return elev2;
console.log("❌ Nessun provider di elevazione disponibile");
return null;
}
module.exports = getElevation;

View file

@ -0,0 +1,492 @@
const db = require('../../db/knex');
const {
scanPhotoFacesById,
} = require('./faceScanner');
function serializeError(error) {
const value =
error?.stack
|| error?.message
|| String(error);
return String(value).slice(0, 16000);
}
async function claimNextPendingJob() {
return db.transaction(async (trx) => {
const job = await trx('face_scan_jobs')
.where({ status: 'pending' })
.orderBy('created_at', 'asc')
.orderBy('photo_id', 'asc')
.first();
if (!job) {
return null;
}
const updated = await trx('face_scan_jobs')
.where({
photo_id: job.photo_id,
status: 'pending',
})
.update({
status: 'processing',
attempts: trx.raw('attempts + 1'),
faces_detected: null,
execution_ms: null,
applied_rotation: null,
last_error: null,
started_at: trx.fn.now(),
completed_at: null,
updated_at: trx.fn.now(),
});
if (updated !== 1) {
return null;
}
return trx('face_scan_jobs')
.where({ photo_id: job.photo_id })
.first();
});
}
async function completeJob(photoId, result) {
return db.transaction(async (trx) => {
const job = await trx('face_scan_jobs')
.where({
photo_id: photoId,
status: 'processing',
})
.first();
if (!job) {
throw new Error(
`Job processing non trovato: photo_id=${photoId}`,
);
}
if (Number(job.rescan_requested) === 1) {
const updated = await trx('face_scan_jobs')
.where({
photo_id: photoId,
status: 'processing',
})
.update({
status: 'pending',
rescan_requested: 0,
faces_detected: null,
execution_ms: null,
applied_rotation: null,
last_error: null,
started_at: null,
completed_at: null,
updated_at: trx.fn.now(),
});
if (updated !== 1) {
throw new Error(
`Impossibile riaccodare il job photo_id=${photoId}`,
);
}
return 'pending';
}
const updated = await trx('face_scan_jobs')
.where({
photo_id: photoId,
status: 'processing',
})
.update({
status: 'done',
rescan_requested: 0,
faces_detected: result.faces_detected,
execution_ms: result.execution_ms,
applied_rotation: result.applied_rotation,
last_error: null,
completed_at: trx.fn.now(),
updated_at: trx.fn.now(),
});
if (updated !== 1) {
throw new Error(
`Impossibile completare il job photo_id=${photoId}`,
);
}
return 'done';
});
}
async function failJob(photoId, error) {
return db.transaction(async (trx) => {
const job = await trx('face_scan_jobs')
.where({
photo_id: photoId,
status: 'processing',
})
.first();
if (!job) {
console.error(
'[faceScanWorker] job processing non trovato:',
photoId,
);
return 'missing';
}
if (Number(job.rescan_requested) === 1) {
const updated = await trx('face_scan_jobs')
.where({
photo_id: photoId,
status: 'processing',
})
.update({
status: 'pending',
rescan_requested: 0,
faces_detected: null,
execution_ms: null,
applied_rotation: null,
last_error: null,
started_at: null,
completed_at: null,
updated_at: trx.fn.now(),
});
if (updated !== 1) {
throw new Error(
`Impossibile riaccodare il job photo_id=${photoId}`,
);
}
return 'pending';
}
const updated = await trx('face_scan_jobs')
.where({
photo_id: photoId,
status: 'processing',
})
.update({
status: 'error',
rescan_requested: 0,
last_error: serializeError(error),
completed_at: trx.fn.now(),
updated_at: trx.fn.now(),
});
if (updated !== 1) {
console.error(
'[faceScanWorker] impossibile registrare errore:',
photoId,
);
return 'missing';
}
return 'error';
});
}
async function completeJobWithRetry(photoId, result) {
const delays = [0, 250, 500, 1000, 2000];
for (let attempt = 0; attempt < delays.length; attempt += 1) {
if (delays[attempt] > 0) {
await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
}
try {
return await completeJob(photoId, result);
} catch (error) {
const message = String(error?.message || error);
const code = String(error?.code || '');
const isBusy =
code === 'SQLITE_BUSY'
|| code === 'SQLITE_LOCKED'
|| message.includes('database is locked')
|| message.includes('SQLITE_BUSY')
|| message.includes('SQLITE_LOCKED');
if (!isBusy || attempt === delays.length - 1) {
throw error;
}
console.warn('[faceScanWorker] SQLite occupato: nuovo tentativo di completamento', {
photo_id: photoId,
retry: attempt + 1,
next_delay_ms: delays[attempt + 1],
});
}
}
throw new Error('Retry completamento esauriti');
}
async function processClaimedJob(job) {
if (!job || !job.photo_id) {
throw new Error('Job non valido');
}
const workerStartedAt = Date.now();
console.log(
'[faceScanWorker] processing',
{
photo_id: job.photo_id,
attempt: job.attempts,
},
);
try {
const result = await scanPhotoFacesById(
job.photo_id,
);
let finalStatus;
try {
finalStatus = await completeJobWithRetry(
job.photo_id,
result,
);
} catch (writeError) {
const message = String(writeError?.message || writeError);
const code = String(writeError?.code || '');
const isBusy =
code === 'SQLITE_BUSY'
|| code === 'SQLITE_LOCKED'
|| message.includes('database is locked')
|| message.includes('SQLITE_BUSY')
|| message.includes('SQLITE_LOCKED');
if (isBusy) {
console.error('[faceScanWorker] impossibile salvare il risultato dopo i retry', {
photo_id: job.photo_id,
attempt: job.attempts,
error: message,
});
try {
const updated = await db('face_scan_jobs')
.where({
photo_id: job.photo_id,
status: 'processing',
})
.update({
status: 'pending',
last_error: 'Retry salvataggio risultato esauriti: ' + message,
started_at: null,
completed_at: null,
updated_at: db.fn.now(),
});
if (updated === 1) {
return {
status: 'pending',
photo_id: job.photo_id,
attempt: job.attempts,
rescan_required: true,
worker_ms: Date.now() - workerStartedAt,
};
}
} catch (requeueError) {
console.error('[faceScanWorker] impossibile riaccodare il job; sarà recuperato come obsoleto', {
photo_id: job.photo_id,
error: String(requeueError?.message || requeueError),
});
}
return {
status: 'processing',
photo_id: job.photo_id,
attempt: job.attempts,
error: 'Salvataggio fallito; job recuperabile tramite reset stale',
rescan_required: false,
worker_ms: Date.now() - workerStartedAt,
};
}
throw writeError;
}
const summary = {
status: finalStatus,
photo_id: job.photo_id,
attempt: job.attempts,
faces_detected:
finalStatus === 'done'
? result.faces_detected
: null,
applied_rotation:
finalStatus === 'done'
? result.applied_rotation
: null,
execution_ms:
finalStatus === 'done'
? result.execution_ms
: null,
rescan_required:
finalStatus === 'pending',
worker_ms: Date.now() - workerStartedAt,
};
console.log(
'[faceScanWorker] done',
summary,
);
return summary;
} catch (error) {
const finalStatus = await failJob(
job.photo_id,
error,
);
const summary = {
status: finalStatus,
photo_id: job.photo_id,
attempt: job.attempts,
error:
finalStatus === 'error'
? serializeError(error)
: null,
rescan_required:
finalStatus === 'pending',
worker_ms: Date.now() - workerStartedAt,
};
console.error(
'[faceScanWorker] error',
summary,
);
return summary;
}
}
async function processNextJob() {
const job = await claimNextPendingJob();
if (!job) {
return null;
}
return processClaimedJob(job);
}
async function runWorker(options = {}) {
const maxJobs =
options.maxJobs == null
? null
: Number(options.maxJobs);
if (
maxJobs !== null
&& (
!Number.isInteger(maxJobs)
|| maxJobs <= 0
)
) {
throw new Error(
'maxJobs deve essere un intero positivo',
);
}
const shouldStop =
typeof options.shouldStop === 'function'
? options.shouldStop
: () => false;
const results = [];
while (
!shouldStop()
&& (
maxJobs === null
|| results.length < maxJobs
)
) {
const result = await processNextJob();
if (!result) {
break;
}
results.push(result);
}
const succeeded = results.filter(
(result) => result.status === 'done',
).length;
const failed = results.filter(
(result) => result.status === 'error',
).length;
const rescheduled = results.filter(
(result) => result.status === 'pending',
).length;
return {
jobs_processed: results.length,
jobs_succeeded: succeeded,
jobs_failed: failed,
jobs_rescheduled: rescheduled,
stopped: shouldStop(),
results,
};
}
async function resetStaleProcessingJobs(
staleMinutes = 30,
) {
const minutes = Number(staleMinutes);
if (
!Number.isFinite(minutes)
|| minutes <= 0
) {
throw new Error(
'staleMinutes deve essere positivo',
);
}
const modifier =
`-${Math.floor(minutes)} minutes`;
const updated = await db('face_scan_jobs')
.where({ status: 'processing' })
.where(
'started_at',
'<',
db.raw(
"datetime('now', ?)",
[modifier],
),
)
.update({
status: 'pending',
last_error:
'Job recuperato dopo arresto del worker',
started_at: null,
completed_at: null,
updated_at: db.fn.now(),
});
return updated;
}
module.exports = {
serializeError,
claimNextPendingJob,
completeJob,
failJob,
processClaimedJob,
processNextJob,
runWorker,
resetStaleProcessingJobs,
};

View file

@ -0,0 +1,519 @@
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const db = require('../../db/knex');
const {
resolveFilePathForEntryId,
} = require('../../utils/resolveFilePath');
const execFileAsync = promisify(execFile);
const EMBEDDING_DIM = 128;
const EMBEDDING_BYTES = EMBEDDING_DIM * 4;
const PROJECT_ROOT =
path.resolve(__dirname, '../..');
function getFaceScanBinary() {
if (process.env.FACE_SCAN_BINARY) {
return path.resolve(
process.env.FACE_SCAN_BINARY,
);
}
return path.join(
PROJECT_ROOT,
'bin',
'face_scan',
);
}
function isFiniteNumber(value) {
return (
typeof value === 'number'
&& Number.isFinite(value)
);
}
function validateImage(image) {
if (!image || typeof image !== 'object') {
throw new Error(
'Risultato face_scan non valido: image mancante',
);
}
if (
!Number.isInteger(image.width)
|| image.width <= 0
) {
throw new Error(
`Larghezza immagine non valida: ${image.width}`,
);
}
if (
!Number.isInteger(image.height)
|| image.height <= 0
) {
throw new Error(
`Altezza immagine non valida: ${image.height}`,
);
}
if (typeof image.path !== 'string') {
throw new Error(
'Percorso immagine non valido nel risultato face_scan',
);
}
}
function validateBoundingBox(bbox, faceIndex) {
if (!bbox || typeof bbox !== 'object') {
throw new Error(
`Bounding box mancante per face_index=${faceIndex}`,
);
}
for (const key of ['x', 'y', 'width', 'height']) {
if (!isFiniteNumber(bbox[key])) {
throw new Error(
`bbox.${key} non valido per face_index=${faceIndex}`,
);
}
}
if (bbox.width < 0 || bbox.height < 0) {
throw new Error(
`Bounding box negativa per face_index=${faceIndex}`,
);
}
}
function validateLandmarks(landmarks, faceIndex) {
if (!Array.isArray(landmarks)) {
throw new Error(
`Landmark non validi per face_index=${faceIndex}`,
);
}
if (landmarks.length !== 5) {
throw new Error(
`Landmark non validi per face_index=${faceIndex}: `
+ `attesi 5, ricevuti ${landmarks.length}`,
);
}
landmarks.forEach((point, landmarkIndex) => {
if (
!Array.isArray(point)
|| point.length !== 2
|| !isFiniteNumber(point[0])
|| !isFiniteNumber(point[1])
) {
throw new Error(
`Landmark ${landmarkIndex} non valido `
+ `per face_index=${faceIndex}`,
);
}
});
}
function validateEmbedding(embedding, faceIndex) {
if (!Array.isArray(embedding)) {
throw new Error(
`Embedding non valido per face_index=${faceIndex}`,
);
}
if (embedding.length !== EMBEDDING_DIM) {
throw new Error(
`Embedding non valido per face_index=${faceIndex}: `
+ `attesi ${EMBEDDING_DIM}, `
+ `ricevuti ${embedding.length}`,
);
}
for (
let embeddingIndex = 0;
embeddingIndex < embedding.length;
embeddingIndex += 1
) {
if (!isFiniteNumber(embedding[embeddingIndex])) {
throw new Error(
`Embedding non finito per face_index=${faceIndex}, `
+ `indice=${embeddingIndex}`,
);
}
}
const normSquared = embedding.reduce(
(sum, value) => sum + (value * value),
0,
);
const norm = Math.sqrt(normSquared);
if (
!Number.isFinite(norm)
|| Math.abs(norm - 1) > 0.001
) {
throw new Error(
`Embedding non normalizzato per face_index=${faceIndex}: `
+ `norm=${norm}`,
);
}
}
function validateFace(face, expectedIndex) {
if (!face || typeof face !== 'object') {
throw new Error(
`Volto non valido all'indice ${expectedIndex}`,
);
}
if (
!Number.isInteger(face.face_index)
|| face.face_index !== expectedIndex
) {
throw new Error(
`face_index non valido: atteso ${expectedIndex}, `
+ `ricevuto ${face.face_index}`,
);
}
if (!isFiniteNumber(face.score)) {
throw new Error(
`Score non valido per face_index=${expectedIndex}`,
);
}
validateBoundingBox(face.bbox, expectedIndex);
validateLandmarks(face.landmarks, expectedIndex);
validateEmbedding(face.embedding, expectedIndex);
}
function validateFaceScanResult(result) {
if (!result || typeof result !== 'object') {
throw new Error(
'Risultato face_scan non valido',
);
}
validateImage(result.image);
if (!Array.isArray(result.faces)) {
throw new Error(
'Risultato face_scan non valido: faces non è un array',
);
}
result.faces.forEach((face, faceIndex) => {
validateFace(face, faceIndex);
});
return result;
}
function embeddingToBlob(embedding) {
if (
!Array.isArray(embedding)
|| embedding.length !== EMBEDDING_DIM
) {
throw new Error(
`Embedding non valido: attesi ${EMBEDDING_DIM} valori`,
);
}
const blob = Buffer.allocUnsafe(EMBEDDING_BYTES);
embedding.forEach((value, index) => {
if (!isFiniteNumber(value)) {
throw new Error(
`Valore embedding non valido all'indice ${index}`,
);
}
blob.writeFloatLE(value, index * 4);
});
if (blob.length !== EMBEDDING_BYTES) {
throw new Error(
`Dimensione BLOB non valida: ${blob.length}`,
);
}
return blob;
}
async function verifyFaceScanBinary() {
const binaryPath = getFaceScanBinary();
try {
await fs.promises.access(
binaryPath,
fs.constants.X_OK,
);
} catch {
throw new Error(
`face_scan non trovato o non eseguibile: ${binaryPath}`,
);
}
return binaryPath;
}
async function runFaceScan(
imagePath,
rotationDegrees = 0,
) {
const binaryPath = await verifyFaceScanBinary();
const normalizedRotation =
Number(rotationDegrees);
if (
!Number.isInteger(normalizedRotation)
|| ![0, 90, 180, 270].includes(normalizedRotation)
) {
throw new Error(
`Rotazione foto non valida: ${rotationDegrees}`,
);
}
const startedAt = Date.now();
let stdout;
let stderr;
try {
const result = await execFileAsync(
binaryPath,
[
'--rotation',
String(normalizedRotation),
imagePath,
],
{
encoding: 'utf8',
timeout: Number(
process.env.FACE_SCAN_TIMEOUT_MS || 300000,
),
maxBuffer: Number(
process.env.FACE_SCAN_MAX_BUFFER
|| 64 * 1024 * 1024,
),
windowsHide: true,
},
);
stdout = result.stdout;
stderr = result.stderr;
} catch (error) {
const diagnostics = [
error.message,
error.stderr,
]
.filter(Boolean)
.join('\n')
.trim();
throw new Error(
`face_scan fallito per ${imagePath}`
+ (diagnostics ? `\n${diagnostics}` : ''),
);
}
if (stderr && stderr.trim()) {
console.warn(
'[faceScanner] face_scan stderr:',
stderr.trim(),
);
}
if (!stdout || !stdout.trim()) {
throw new Error(
`face_scan non ha prodotto JSON per ${imagePath}`,
);
}
let parsed;
try {
parsed = JSON.parse(stdout);
} catch (error) {
throw new Error(
`JSON face_scan non valido per ${imagePath}: `
+ error.message,
);
}
validateFaceScanResult(parsed);
if (
parsed.image.rotation !== normalizedRotation
) {
throw new Error(
`Rotazione face_scan inattesa: richiesta `
+ `${normalizedRotation}, ricevuta `
+ `${parsed.image.rotation}`,
);
}
return {
...parsed,
execution_ms: Date.now() - startedAt,
};
}
function faceToDatabaseRow(photoId, face) {
const landmarks = face.landmarks;
return {
id: `face:${photoId}:${face.face_index}`,
photo_id: photoId,
face_index: face.face_index,
x: face.bbox.x,
y: face.bbox.y,
width: face.bbox.width,
height: face.bbox.height,
score: face.score,
landmark_0_x: landmarks[0][0],
landmark_0_y: landmarks[0][1],
landmark_1_x: landmarks[1][0],
landmark_1_y: landmarks[1][1],
landmark_2_x: landmarks[2][0],
landmark_2_y: landmarks[2][1],
landmark_3_x: landmarks[3][0],
landmark_3_y: landmarks[3][1],
landmark_4_x: landmarks[4][0],
landmark_4_y: landmarks[4][1],
embedding: embeddingToBlob(face.embedding),
cluster_id: null,
person_id: null,
status: 'detected',
created_at: db.fn.now(),
updated_at: db.fn.now(),
};
}
async function replacePhotoFaces(photoId, faces) {
const rows = faces.map((face) => (
faceToDatabaseRow(photoId, face)
));
await db.transaction(async (trx) => {
await trx('faces')
.where({ photo_id: photoId })
.del();
if (rows.length > 0) {
await trx('faces').insert(rows);
}
});
return rows.length;
}
async function scanPhotoFacesById(photoId) {
const cleanPhotoId = String(photoId || '').trim();
if (!cleanPhotoId) {
throw new Error('photoId mancante');
}
const photo = await db('photos')
.where({ id: cleanPhotoId })
.whereNull('deleted_at')
.first();
if (!photo) {
throw new Error(
`Foto non trovata o eliminata: ${cleanPhotoId}`,
);
}
if (
photo.mime_type
&& !String(photo.mime_type).startsWith('image/')
) {
throw new Error(
`Il record non è un'immagine: `
+ `${photo.mime_type}`,
);
}
const imagePath =
await resolveFilePathForEntryId(cleanPhotoId);
if (!imagePath) {
throw new Error(
`Percorso fisico non risolto per photo_id=${cleanPhotoId}`,
);
}
let fileStat;
try {
fileStat = await fs.promises.stat(imagePath);
} catch (error) {
throw new Error(
`File non accessibile: ${imagePath}: ${error.message}`,
);
}
if (!fileStat.isFile()) {
throw new Error(
`Il percorso non è un file: ${imagePath}`,
);
}
const rotationDegrees =
photo.rotation == null
? 0
: Number(photo.rotation);
const result = await runFaceScan(
imagePath,
rotationDegrees,
);
const insertedFaces = await replacePhotoFaces(
cleanPhotoId,
result.faces,
);
return {
photo_id: cleanPhotoId,
image_path: imagePath,
image_width: result.image.width,
image_height: result.image.height,
photo_rotation: photo.rotation ?? null,
applied_rotation: result.image.rotation,
faces_detected: result.faces.length,
faces_inserted: insertedFaces,
execution_ms: result.execution_ms,
};
}
module.exports = {
EMBEDDING_DIM,
EMBEDDING_BYTES,
embeddingToBlob,
validateFaceScanResult,
runFaceScan,
replacePhotoFaces,
scanPhotoFacesById,
};

View file

@ -0,0 +1,108 @@
const crypto = require('crypto');
const db = require('../../db/knex');
function normalizeCoordinate(value) {
if (value == null || value === '') {
return null;
}
const coordinate = Number(value);
if (!Number.isFinite(coordinate)) {
return null;
}
return coordinate;
}
async function enqueueGeocodeJob({
entityType,
entityId,
lat,
lon,
}) {
if (
entityType !== 'photo'
&& entityType !== 'activity'
) {
throw new Error(
`Tipo entità geocode non valido: ${entityType}`
);
}
if (!entityId) {
throw new Error(
'entityId geocode mancante'
);
}
const normalizedLat = normalizeCoordinate(lat);
const normalizedLon = normalizeCoordinate(lon);
if (
normalizedLat == null
|| normalizedLon == null
) {
return {
queued: false,
reason: 'coordinates_missing',
};
}
if (
normalizedLat < -90
|| normalizedLat > 90
|| normalizedLon < -180
|| normalizedLon > 180
) {
return {
queued: false,
reason: 'coordinates_invalid',
};
}
const now = db.fn.now();
await db('geocode_jobs')
.insert({
id: crypto.randomUUID(),
entity_type: entityType,
entity_id: String(entityId),
lat: normalizedLat,
lon: normalizedLon,
status: 'pending',
attempts: 0,
last_error: null,
started_at: null,
completed_at: null,
created_at: now,
updated_at: now,
})
.onConflict([
'entity_type',
'entity_id',
])
.merge({
lat: normalizedLat,
lon: normalizedLon,
status: 'pending',
attempts: 0,
last_error: null,
started_at: null,
completed_at: null,
updated_at: now,
});
return {
queued: true,
entity_type: entityType,
entity_id: String(entityId),
lat: normalizedLat,
lon: normalizedLon,
};
}
module.exports = {
enqueueGeocodeJob,
};

View file

@ -0,0 +1,551 @@
const db = require('../../db/knex');
const loc = require('../geo');
const getElevation = require('./elevation');
function serializeError(error) {
const value =
error?.stack
|| error?.message
|| String(error);
return String(value).slice(0, 16000);
}
function buildCacheKey(lat, lon) {
return (
`${Number(lat).toFixed(4)}:`
+ `${Number(lon).toFixed(4)}`
);
}
function parseLocation(value) {
if (!value) {
return null;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch {
return null;
}
}
function serializeLocation(value) {
if (!value) {
return null;
}
if (typeof value === 'string') {
return value;
}
return JSON.stringify(value);
}
async function claimNextPendingJob() {
return db.transaction(async (trx) => {
const job = await trx('geocode_jobs')
.where({ status: 'pending' })
.orderBy('created_at', 'asc')
.orderBy('id', 'asc')
.first();
if (!job) {
return null;
}
const updated = await trx('geocode_jobs')
.where({
id: job.id,
status: 'pending',
})
.update({
status: 'processing',
attempts: trx.raw('attempts + 1'),
last_error: null,
started_at: trx.fn.now(),
completed_at: null,
updated_at: trx.fn.now(),
});
if (updated !== 1) {
return null;
}
return trx('geocode_jobs')
.where({ id: job.id })
.first();
});
}
async function completeJob(jobId) {
const updated = await db('geocode_jobs')
.where({
id: jobId,
status: 'processing',
})
.update({
status: 'done',
last_error: null,
completed_at: db.fn.now(),
updated_at: db.fn.now(),
});
if (updated !== 1) {
throw new Error(
`Impossibile completare il job id=${jobId}`,
);
}
return 'done';
}
async function failJob(jobId, error) {
const updated = await db('geocode_jobs')
.where({
id: jobId,
status: 'processing',
})
.update({
status: 'error',
last_error: serializeError(error),
completed_at: db.fn.now(),
updated_at: db.fn.now(),
});
if (updated !== 1) {
console.error(
'[geocodeWorker] impossibile registrare errore:',
jobId,
);
return 'missing';
}
return 'error';
}
async function loadEntity(job) {
if (job.entity_type === 'photo') {
return db('photos')
.select([
'id',
'lat',
'lon',
'alt',
'location',
])
.where({ id: job.entity_id })
.whereNull('deleted_at')
.first();
}
if (job.entity_type === 'activity') {
return db('activities')
.select([
'id',
'center_latitude',
'center_longitude',
'location',
])
.where({ id: job.entity_id })
.whereNull('deleted_at')
.first();
}
throw new Error(
`Tipo entità non valido: ${job.entity_type}`,
);
}
async function loadCache(cacheKey) {
return db('geocode_cache')
.where({ cache_key: cacheKey })
.first();
}
async function saveCache({
cacheKey,
lat,
lon,
alt,
location,
}) {
const existing = await loadCache(cacheKey);
const finalAlt =
alt != null
? Number(alt)
: existing?.alt ?? null;
const finalLocation =
serializeLocation(location)
|| existing?.location
|| null;
const now = db.fn.now();
await db('geocode_cache')
.insert({
cache_key: cacheKey,
lat,
lon,
alt: finalAlt,
location: finalLocation,
hits: existing?.hits ?? 0,
created_at:
existing?.created_at || now,
updated_at: now,
})
.onConflict('cache_key')
.merge({
lat,
lon,
alt: finalAlt,
location: finalLocation,
updated_at: now,
});
}
async function recordCacheHit(cacheKey) {
await db('geocode_cache')
.where({ cache_key: cacheKey })
.update({
hits: db.raw('hits + 1'),
updated_at: db.fn.now(),
});
}
async function updateEntity({
job,
alt,
location,
}) {
const locationJson =
serializeLocation(location);
if (job.entity_type === 'photo') {
const updated = await db('photos')
.where({ id: job.entity_id })
.whereNull('deleted_at')
.update({
alt: alt ?? null,
location: locationJson,
updated_at: db.fn.now(),
});
if (updated !== 1) {
throw new Error(
`Foto non trovata: ${job.entity_id}`,
);
}
return;
}
if (job.entity_type === 'activity') {
const updated = await db('activities')
.where({ id: job.entity_id })
.whereNull('deleted_at')
.update({
location: locationJson,
updated_at: db.fn.now(),
});
if (updated !== 1) {
throw new Error(
`Attività non trovata: ${job.entity_id}`,
);
}
return;
}
throw new Error(
`Tipo entità non valido: ${job.entity_type}`,
);
}
async function processClaimedJob(job) {
if (!job?.id || !job?.entity_id) {
throw new Error('Job geocode non valido');
}
const workerStartedAt = Date.now();
console.log(
'[geocodeWorker] processing',
{
job_id: job.id,
entity_type: job.entity_type,
entity_id: job.entity_id,
attempt: job.attempts,
},
);
try {
const lat = Number(job.lat);
const lon = Number(job.lon);
if (
!Number.isFinite(lat)
|| !Number.isFinite(lon)
|| lat < -90
|| lat > 90
|| lon < -180
|| lon > 180
) {
throw new Error(
`Coordinate non valide: lat=${job.lat}, lon=${job.lon}`,
);
}
const entity = await loadEntity(job);
if (!entity) {
throw new Error(
`Entità non trovata: `
+ `${job.entity_type}/${job.entity_id}`,
);
}
const cacheKey = buildCacheKey(lat, lon);
const cached = await loadCache(cacheKey);
const altitudeFromCache =
job.entity_type === 'photo'
&& entity.alt == null
&& cached?.alt != null;
const locationFromCache =
!parseLocation(entity.location)
&& Boolean(
parseLocation(cached?.location)
);
const cacheUsed =
altitudeFromCache
|| locationFromCache;
let altitude =
job.entity_type === 'photo'
? entity.alt ?? cached?.alt ?? null
: null;
let location =
parseLocation(entity.location)
|| parseLocation(cached?.location)
|| null;
if (cacheUsed) {
await recordCacheHit(cacheKey);
}
if (
job.entity_type === 'photo'
&& altitude == null
) {
altitude = await getElevation(
lat,
lon,
);
}
if (!location) {
location = await loc(
lon,
lat,
);
}
if (!location) {
throw new Error(
`Reverse geocoding senza risultato: `
+ `lat=${lat}, lon=${lon}`,
);
}
await saveCache({
cacheKey,
lat,
lon,
alt: altitude,
location,
});
await updateEntity({
job,
alt: altitude,
location,
});
const finalStatus =
await completeJob(job.id);
const summary = {
status: finalStatus,
job_id: job.id,
entity_type: job.entity_type,
entity_id: job.entity_id,
attempt: job.attempts,
cache_key: cacheKey,
cache_used: cacheUsed,
altitude_from_cache: altitudeFromCache,
location_from_cache: locationFromCache,
altitude:
job.entity_type === 'photo'
? altitude
: null,
worker_ms:
Date.now() - workerStartedAt,
};
console.log(
'[geocodeWorker] done',
summary,
);
return summary;
} catch (error) {
const finalStatus = await failJob(
job.id,
error,
);
const summary = {
status: finalStatus,
job_id: job.id,
entity_type: job.entity_type,
entity_id: job.entity_id,
attempt: job.attempts,
error: serializeError(error),
worker_ms:
Date.now() - workerStartedAt,
};
console.error(
'[geocodeWorker] error',
summary,
);
return summary;
}
}
async function processNextJob() {
const job = await claimNextPendingJob();
if (!job) {
return null;
}
return processClaimedJob(job);
}
async function runWorker(options = {}) {
const maxJobs =
options.maxJobs == null
? null
: Number(options.maxJobs);
if (
maxJobs !== null
&& (
!Number.isInteger(maxJobs)
|| maxJobs <= 0
)
) {
throw new Error(
'maxJobs deve essere un intero positivo',
);
}
const shouldStop =
typeof options.shouldStop === 'function'
? options.shouldStop
: () => false;
const results = [];
while (
!shouldStop()
&& (
maxJobs === null
|| results.length < maxJobs
)
) {
const result = await processNextJob();
if (!result) {
break;
}
results.push(result);
}
return {
jobs_processed: results.length,
jobs_succeeded: results.filter(
(result) => result.status === 'done',
).length,
jobs_failed: results.filter(
(result) => result.status === 'error',
).length,
stopped: shouldStop(),
results,
};
}
async function resetStaleProcessingJobs(
staleMinutes = 30,
) {
const minutes = Number(staleMinutes);
if (
!Number.isFinite(minutes)
|| minutes <= 0
) {
throw new Error(
'staleMinutes deve essere positivo',
);
}
const modifier =
`-${Math.floor(minutes)} minutes`;
return db('geocode_jobs')
.where({ status: 'processing' })
.where(
'started_at',
'<',
db.raw(
"datetime('now', ?)",
[modifier],
),
)
.update({
status: 'pending',
last_error:
'Job recuperato dopo arresto del worker',
started_at: null,
completed_at: null,
updated_at: db.fn.now(),
});
}
module.exports = {
serializeError,
buildCacheKey,
claimNextPendingJob,
completeJob,
failJob,
processClaimedJob,
processNextJob,
runWorker,
resetStaleProcessingJobs,
};

69
api_v1/scanner/gps.js Normal file
View file

@ -0,0 +1,69 @@
const { exec } = require('child_process');
// -----------------------------------------------------------------------------
// FOTO: GPS da ExifReader
// -----------------------------------------------------------------------------
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
};
}
// -----------------------------------------------------------------------------
// VIDEO: GPS via exiftool (VERSIONE ORIGINALE CHE FUNZIONA)
// -----------------------------------------------------------------------------
function extractGpsWithExiftool(videoPath) {
//console.log(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);
// 1) GPS Coordinates : <lat> <lng>
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
});
}
// 2) GPSLatitude / GPSLongitude
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
});
}
// 3) GPSCoordinates : <lat> <lng>
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);
});
});
}
module.exports = { extractGpsFromExif, extractGpsWithExiftool };

43
api_v1/scanner/logger.js Normal file
View file

@ -0,0 +1,43 @@
// scanner/logger.js
const fs = require('fs');
const path = require('path');
const LOG_MODE = process.env.LOG_MODE || "console"; // console | file | both
const LOG_DIR = process.env.LOG_DIR || null;
const LOG_FILE = process.env.LOG_FILE || "scan.log";
let stream = null;
function resolveLogPath() {
if (LOG_DIR) {
return path.resolve(__dirname, "..", "..", LOG_DIR, LOG_FILE);
}
return path.resolve(__dirname, "..", "..", LOG_FILE);
}
if (LOG_MODE === "file" || LOG_MODE === "both") {
const logPath = resolveLogPath();
// crea la directory se non esiste
fs.mkdirSync(path.dirname(logPath), { recursive: true });
stream = fs.createWriteStream(logPath, { flags: "a" });
}
function ts() {
return new Date().toISOString().replace("T", " ").split(".")[0];
}
function log(message) {
const line = `${ts()} ${message}\n`;
if (LOG_MODE === "console" || LOG_MODE === "both") {
process.stdout.write(line);
}
if (LOG_MODE === "file" || LOG_MODE === "both") {
stream.write(line);
}
}
module.exports = { log };

View file

@ -0,0 +1,109 @@
// api_v1/scanner/orphanCleanup.js
const fsp = require("fs/promises");
const path = require("path");
module.exports = function createCleanupFunctions(db, RETENTION_DAYS = 30) {
// 1) Recupera gli ID dal DB per una cartella specifica
async function buildIdsListForFolder(userName, cartella) {
try {
const rows = await db("photos")
.select("id")
.where({ user: userName, cartella });
return rows.map((r) => r.id);
} catch (err) {
console.error("Errore buildIdsListForFolder:", err);
return [];
}
}
// 2) Rimuove un ID dalla lista (invariati)
function removeIdFromList(idsIndex, id) {
return idsIndex.filter((x) => x !== id);
}
// 3) Cancella thumbs dal filesystem
async function deleteThumbsById(id) {
const rec = await db("photos").where({ id }).first();
if (!rec) return false;
const thumbs = [rec.thub1, rec.thub2, rec.cast].filter(Boolean);
let deleted = false;
for (const t of thumbs) {
const abs = path.resolve(__dirname, "..", "..", "public", t);
// 🔍 Log del path reale
console.log(` 📁 Tentativo cancellazione thumb: ${abs}`);
try {
await fsp.rm(abs, { force: true });
console.log(` 🔴 Thumb eliminato: ${abs}`);
deleted = true;
} catch {}
}
return deleted;
}
// 4) Soft delete + Hard delete con retention
async function deleteFromDB(id, userName) {
const now = new Date();
const nowIso = now.toISOString();
// Recupera record attuale
const rec = await db("photos").where({ id, user: userName }).first();
if (!rec) {
console.log(`⚠️ deleteFromDB: foto ${id} non trovata`);
return false;
}
// Se NON era già soft-deleted → SOFT DELETE
if (!rec.deleted_at) {
await db("photos").where({ id, user: userName }).update({
deleted_at: nowIso,
updated_at: nowIso,
});
console.log(`🟡 Soft delete → id=${id}`);
return true;
}
// Se ERA già soft-deleted → controlla retention
const deletedAt = new Date(rec.deleted_at);
const retentionMs = RETENTION_DAYS * 24 * 60 * 60 * 1000;
const shouldHardDelete = now - deletedAt > retentionMs;
if (!shouldHardDelete) {
console.log(`🟡 Soft delete già presente → id=${id} (in retention)`);
return true;
}
// 🔥 HARD DELETE
console.log(`🔴 HARD DELETE → id=${id}`);
// 1) Elimina thumbs
await deleteThumbsById(id);
// 2) Elimina record dal DB
await db("photos").where({ id, user: userName }).del();
// 3) Registra hard delete per progressive sync
await db("deleted_hard").insert({
id,
user: userName,
deleted_at: nowIso,
});
return true;
}
return {
buildIdsListForFolder,
removeIdFromList,
deleteThumbsById,
deleteFromDB,
};
};

View file

@ -0,0 +1,100 @@
// api_v1/scanner/parseActivityFile.js
const path = require('path');
// Parser specifici
const parseFit = require('./parsers/parseFit');
const parseGpx = require('./parsers/parseGpx');
const parseTcx = require('./parsers/parseTcx');
// ---------------------------------------------------------
// parseActivityFile → rileva tipo e chiama parser specifico
// ---------------------------------------------------------
async function parseActivityFile(absPath) {
const ext = path.extname(absPath).toLowerCase();
let parsed;
if (ext === '.fit') {
parsed = await parseFit(absPath);
} else if (ext === '.gpx') {
parsed = await parseGpx(absPath);
} else if (ext === '.tcx') {
parsed = await parseTcx(absPath);
} else {
throw new Error(`Formato attività non supportato: ${ext}`);
}
// ---------------------------------------------------------
// Normalizzazione campi base
// ---------------------------------------------------------
const fileName = path.basename(absPath);
const fileExtension = ext.replace('.', '');
return {
file_name: fileName,
file_extension: fileExtension,
format: parsed.format || fileExtension.toUpperCase(),
display_name: parsed.display_name || fileName,
route_name: parsed.route_name || null,
title: parsed.title || null,
description: parsed.description || null,
notes: parsed.notes || null,
sport: parsed.sport || null,
activity_type: parsed.activity_type || null,
device_manufacturer: parsed.device_manufacturer || null,
device_model: parsed.device_model || null,
source_app: parsed.source_app || null,
start_time_millis: parsed.start_time_millis ?? null,
end_time_millis: parsed.end_time_millis ?? null,
duration_millis: parsed.duration_millis ?? null,
distance_meters: parsed.distance_meters ?? null,
elevation_gain_meters: parsed.elevation_gain_meters ?? null,
elevation_loss_meters: parsed.elevation_loss_meters ?? null,
min_elevation_meters: parsed.min_elevation_meters ?? null,
max_elevation_meters: parsed.max_elevation_meters ?? null,
file_distance_meters: parsed.file_distance_meters ?? null,
file_elevation_gain_meters: parsed.file_elevation_gain_meters ?? null,
computed_distance_meters: parsed.computed_distance_meters ?? null,
computed_elevation_gain_meters: parsed.computed_elevation_gain_meters ?? null,
average_speed_mps: parsed.average_speed_mps ?? null,
max_speed_mps: parsed.max_speed_mps ?? null,
average_pace_sec_per_km: parsed.average_pace_sec_per_km ?? null,
average_heart_rate: parsed.average_heart_rate ?? null,
max_heart_rate: parsed.max_heart_rate ?? null,
average_cadence: parsed.average_cadence ?? null,
max_cadence: parsed.max_cadence ?? null,
calories: parsed.calories ?? null,
power_avg: parsed.power_avg ?? null,
power_max: parsed.power_max ?? null,
point_count: parsed.point_count ?? 0,
segment_count: parsed.segment_count ?? 0,
lap_count: parsed.lap_count ?? 0,
min_latitude: parsed.min_latitude ?? null,
max_latitude: parsed.max_latitude ?? null,
min_longitude: parsed.min_longitude ?? null,
max_longitude: parsed.max_longitude ?? null,
center_latitude: parsed.center_latitude ?? null,
center_longitude: parsed.center_longitude ?? null,
tags: parsed.tags || [],
// IMPORTANTISSIMO: preview percorso per thumb gallery
track_preview: parsed.track_preview || [],
diagnostics: parsed.diagnostics || null,
};
}
module.exports = parseActivityFile;

View file

@ -0,0 +1,408 @@
// api_v1/scanner/parsers/parseFit.js
const { execFile } = require("child_process");
function readFitFrames(absPath) {
return new Promise(
(resolve, reject) => {
execFile(
"/home/orangepi/.local/pipx/venvs/fitdecode/bin/python",
[
"api_v1/scanner/parsers/parse_fit.py",
absPath,
],
{
maxBuffer:
1024 * 1024 * 50,
},
(error, stdout) => {
if (error) {
reject(error);
return;
}
try {
resolve(
JSON.parse(stdout),
);
} catch (parseError) {
reject(
new Error(
"Errore nel parsing JSON FIT: " +
parseError.message,
),
);
}
},
);
},
);
}
// FIT position_lat / position_long sono in semicircles.
// Conversione ufficiale: degrees = semicircles * 180 / 2^31
function fitSemicirclesToDegrees(value) {
if (value == null) return null;
const n = Number(value);
if (!Number.isFinite(n)) return null;
return n * (180 / 2147483648);
}
function buildTrackPointsFromFitRecords(
records,
) {
if (!Array.isArray(records)) {
return [];
}
const points = [];
for (const record of records) {
const fields =
record?.fields || {};
const rawLat =
fields.position_lat ??
fields.latitude ??
fields.lat;
const rawLng =
fields.position_long ??
fields.position_lon ??
fields.longitude ??
fields.lng ??
fields.lon;
const lat =
fitSemicirclesToDegrees(
rawLat,
);
const lng =
fitSemicirclesToDegrees(
rawLng,
);
if (
!Number.isFinite(lat) ||
!Number.isFinite(lng)
) {
continue;
}
const point = {
lat,
lng,
};
const altitudeRaw =
fields.altitude ??
fields.enhanced_altitude;
const altitude =
altitudeRaw != null
? Number(altitudeRaw)
: null;
if (
Number.isFinite(altitude)
) {
point.alt = altitude;
}
points.push(point);
}
return points;
}
function buildTrackPreviewFromFitRecords(
records,
maxPoints = 300,
) {
const points =
buildTrackPointsFromFitRecords(
records,
);
if (
points.length <= maxPoints
) {
return points;
}
const step =
Math.ceil(
points.length / maxPoints,
);
const preview = [];
for (
let index = 0;
index < points.length;
index += step
) {
preview.push(
points[index],
);
}
const last =
points.at(-1);
const previewLast =
preview.at(-1);
if (
last &&
(
!previewLast ||
previewLast.lat !==
last.lat ||
previewLast.lng !==
last.lng
)
) {
preview.push(last);
}
return preview;
}
async function parseFit(absPath) {
const frames =
await readFitFrames(
absPath,
);
// -------------------------------
// 1. Trova i frame principali
// -------------------------------
const sessions = frames.filter(f => f.name === "session");
const laps = frames.filter(f => f.name === "lap");
const records = frames.filter(f => f.name === "record");
const fileId = frames.find(f => f.name === "file_id");
const session = sessions[0]?.fields || {};
const file = fileId?.fields || {};
// -------------------------------
// 2. Tempi
// -------------------------------
const startTime = session.start_time || session.start_time_local || null;
const endTime = session.total_timer_time && startTime
? new Date(new Date(startTime).getTime() + session.total_timer_time * 1000)
: null;
const startMillis = startTime ? new Date(startTime).getTime() : null;
const endMillis = endTime ? endTime.getTime() : null;
const durationMillis = session.total_timer_time
? session.total_timer_time * 1000
: endMillis && startMillis
? endMillis - startMillis
: null;
// -------------------------------
// 3. Distanza / Elevazione
// -------------------------------
const distance = session.total_distance || null;
const elevGain = session.total_ascent || null;
const elevLoss = session.total_descent || null;
// -------------------------------
// 4. HR / Cadenza / Velocità
// -------------------------------
const avgHr = session.avg_heart_rate || null;
const maxHr = session.max_heart_rate || null;
const avgCad = session.avg_cadence || null;
const maxCad = session.max_cadence || null;
const avgSpeed = session.avg_speed || null;
const maxSpeed = session.max_speed || null;
// -------------------------------
// 5. Bounding box GPS
// -------------------------------
let minLat = null;
let maxLat = null;
let minLon = null;
let maxLon = null;
let minElev = null;
let maxElev = null;
for (const r of records) {
const f = r.fields || {};
const lat = fitSemicirclesToDegrees(f.position_lat);
const lon = fitSemicirclesToDegrees(f.position_long);
const eleRaw =
f.altitude ??
f.enhanced_altitude;
const ele = eleRaw != null ? Number(eleRaw) : null;
if (Number.isFinite(lat) && Number.isFinite(lon)) {
minLat = minLat == null ? lat : Math.min(minLat, lat);
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
minLon = minLon == null ? lon : Math.min(minLon, lon);
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
}
if (Number.isFinite(ele)) {
minElev = minElev == null ? ele : Math.min(minElev, ele);
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
}
}
const centerLat =
minLat != null && maxLat != null ? (minLat + maxLat) / 2 : null;
const centerLon =
minLon != null && maxLon != null ? (minLon + maxLon) / 2 : null;
// -------------------------------
// 6. Track preview per gallery
// -------------------------------
const trackPreview = buildTrackPreviewFromFitRecords(records, 300);
// Log utile temporaneo. Puoi rimuoverlo dopo il test.
console.log("[parseFit] track preview", {
file: absPath,
records: records.length,
preview: trackPreview.length,
first: trackPreview[0],
minLat,
maxLat,
minLon,
maxLon,
});
// -------------------------------
// 7. Costruzione oggetto attività finale
// -------------------------------
return {
format: "FIT",
display_name: session.sport || null,
route_name: session.sport || null,
title: session.sport || null,
description: null,
notes: null,
sport: session.sport || null,
activity_type: session.sub_sport || null,
device_manufacturer: file.manufacturer || null,
device_model: file.product || null,
source_app: null,
start_time_millis: startMillis,
end_time_millis: endMillis,
duration_millis: durationMillis,
distance_meters: distance,
elevation_gain_meters: elevGain,
elevation_loss_meters: elevLoss,
min_elevation_meters: minElev,
max_elevation_meters: maxElev,
file_distance_meters: distance,
file_elevation_gain_meters: elevGain,
computed_distance_meters: distance,
computed_elevation_gain_meters: elevGain,
average_speed_mps: avgSpeed,
max_speed_mps: maxSpeed,
average_pace_sec_per_km: avgSpeed ? 1000 / avgSpeed : null,
average_heart_rate: avgHr,
max_heart_rate: maxHr,
average_cadence: avgCad,
max_cadence: maxCad,
calories: session.total_calories || null,
power_avg: session.avg_power || null,
power_max: session.max_power || null,
point_count: records.length,
segment_count: laps.length,
lap_count: laps.length,
min_latitude: minLat,
max_latitude: maxLat,
min_longitude: minLon,
max_longitude: maxLon,
center_latitude: centerLat,
center_longitude: centerLon,
// Preview ridotta del percorso per i thumb della gallery
track_preview: trackPreview,
tags: [],
diagnostics: {
source: "FIT",
sessions: sessions.length,
laps: laps.length,
records: records.length,
track_preview_points: trackPreview.length,
coordinates_converted_from_semicircles: true,
},
};
}
async function parseFitTrackPoints(
absPath,
) {
if (!absPath) {
return [];
}
const frames =
await readFitFrames(
absPath,
);
const records =
frames.filter(
(frame) =>
frame?.name ===
"record",
);
const points =
buildTrackPointsFromFitRecords(
records,
);
/*
console.log(
"[parseFitTrackPoints] traccia completa",
{
file:
absPath,
records:
records.length,
gpsPoints:
points.length,
},
);
*/
return points;
}
module.exports =
parseFit;
module.exports.parseFitTrackPoints =
parseFitTrackPoints;

View file

@ -0,0 +1,204 @@
// api_v1/scanner/parsers/parseGpx.js
const fsp = require('fs/promises');
const xml2js = require('xml2js'); // npm install xml2js
function toMillis(date) {
if (!date) return null;
const d = new Date(date);
return isNaN(d.getTime()) ? null : d.getTime();
}
function buildTrackPreviewFromGpxPoints(points, maxPoints = 300) {
if (!Array.isArray(points)) return [];
const clean = [];
for (const p of points) {
const lat = p.$?.lat != null ? Number(p.$.lat) : null;
const lng = p.$?.lon != null ? Number(p.$.lon) : null;
const alt = p.ele != null ? Number(p.ele) : null;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const point = { lat, lng };
if (Number.isFinite(alt)) {
point.alt = alt;
}
clean.push(point);
}
if (clean.length <= maxPoints) return clean;
const step = Math.ceil(clean.length / maxPoints);
const preview = [];
for (let i = 0; i < clean.length; i += step) {
preview.push(clean[i]);
}
const last = clean[clean.length - 1];
const currentLast = preview[preview.length - 1];
if (
!currentLast ||
currentLast.lat !== last.lat ||
currentLast.lng !== last.lng
) {
preview.push(last);
}
return preview;
}
async function parseGpx(absPath) {
const xml = await fsp.readFile(absPath, 'utf8');
const parser = new xml2js.Parser({ explicitArray: false });
const gpx = await parser.parseStringPromise(xml);
const root = gpx.gpx || gpx;
const trk = Array.isArray(root.trk) ? root.trk[0] : root.trk;
const name = trk?.name || null;
const segments = trk?.trkseg
? (Array.isArray(trk.trkseg) ? trk.trkseg : [trk.trkseg])
: [];
const points = [];
for (const seg of segments) {
const segPts = seg.trkpt
? (Array.isArray(seg.trkpt) ? seg.trkpt : [seg.trkpt])
: [];
points.push(...segPts);
}
let minLat = null;
let maxLat = null;
let minLon = null;
let maxLon = null;
let minElev = null;
let maxElev = null;
let startTime = null;
let endTime = null;
let distance = null;
for (const p of points) {
const lat = p.$?.lat != null ? Number(p.$.lat) : null;
const lon = p.$?.lon != null ? Number(p.$.lon) : null;
const ele = p.ele != null ? Number(p.ele) : null;
const time = p.time || null;
if (Number.isFinite(lat) && Number.isFinite(lon)) {
minLat = minLat == null ? lat : Math.min(minLat, lat);
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
minLon = minLon == null ? lon : Math.min(minLon, lon);
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
}
if (Number.isFinite(ele)) {
minElev = minElev == null ? ele : Math.min(minElev, ele);
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
}
if (time) {
if (!startTime) startTime = time;
endTime = time;
}
}
const start_time_millis = toMillis(startTime);
const end_time_millis = toMillis(endTime);
const duration_millis =
start_time_millis && end_time_millis
? end_time_millis - start_time_millis
: null;
const centerLat =
minLat != null && maxLat != null
? (minLat + maxLat) / 2
: null;
const centerLon =
minLon != null && maxLon != null
? (minLon + maxLon) / 2
: null;
const trackPreview = buildTrackPreviewFromGpxPoints(points, 300);
return {
format: 'GPX',
display_name: name,
route_name: name,
title: name,
description: root.metadata?.desc || null,
notes: null,
sport: root.metadata?.type || null,
activity_type: null,
device_manufacturer: root.metadata?.author?.name || null,
device_model: null,
source_app: root.metadata?.author?.name || null,
start_time_millis,
end_time_millis,
duration_millis,
distance_meters: distance,
elevation_gain_meters: null,
elevation_loss_meters: null,
min_elevation_meters: minElev,
max_elevation_meters: maxElev,
file_distance_meters: distance,
file_elevation_gain_meters: null,
computed_distance_meters: distance,
computed_elevation_gain_meters: null,
average_speed_mps: null,
max_speed_mps: null,
average_pace_sec_per_km: null,
average_heart_rate: null,
max_heart_rate: null,
average_cadence: null,
max_cadence: null,
calories: null,
power_avg: null,
power_max: null,
point_count: points.length,
segment_count: segments.length,
lap_count: 0,
min_latitude: minLat,
max_latitude: maxLat,
min_longitude: minLon,
max_longitude: maxLon,
center_latitude: centerLat,
center_longitude: centerLon,
// Preview ridotta del percorso per i thumb della gallery
track_preview: trackPreview,
tags: [],
diagnostics: {
source: 'GPX',
segments: segments.length,
points: points.length,
track_preview_points: trackPreview.length
}
};
}
module.exports = parseGpx;

View file

@ -0,0 +1,298 @@
// api_v1/scanner/parsers/parseTcx.js
const fsp = require('fs/promises');
const xml2js = require('xml2js');
function toMillis(date) {
if (!date) return null;
const d = new Date(date);
return isNaN(d.getTime()) ? null : d.getTime();
}
function buildTrackPreviewFromTcxPoints(points, maxPoints = 300) {
if (!Array.isArray(points)) return [];
const clean = [];
for (const p of points) {
const pos = p.Position || {};
const lat = pos.LatitudeDegrees != null
? Number(pos.LatitudeDegrees)
: null;
const lng = pos.LongitudeDegrees != null
? Number(pos.LongitudeDegrees)
: null;
const alt = p.AltitudeMeters != null
? Number(p.AltitudeMeters)
: null;
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const point = { lat, lng };
if (Number.isFinite(alt)) {
point.alt = alt;
}
clean.push(point);
}
if (clean.length <= maxPoints) return clean;
const step = Math.ceil(clean.length / maxPoints);
const preview = [];
for (let i = 0; i < clean.length; i += step) {
preview.push(clean[i]);
}
const last = clean[clean.length - 1];
const currentLast = preview[preview.length - 1];
if (
!currentLast ||
currentLast.lat !== last.lat ||
currentLast.lng !== last.lng
) {
preview.push(last);
}
return preview;
}
async function parseTcx(absPath) {
const xml = await fsp.readFile(absPath, 'utf8');
const parser = new xml2js.Parser({ explicitArray: false });
const tcx = await parser.parseStringPromise(xml);
const root = tcx.TrainingCenterDatabase || tcx;
const activities = root.Activities?.Activity
? (
Array.isArray(root.Activities.Activity)
? root.Activities.Activity
: [root.Activities.Activity]
)
: [];
const activity = activities[0] || {};
const sport = activity.$?.Sport || null;
const laps = activity.Lap
? (
Array.isArray(activity.Lap)
? activity.Lap
: [activity.Lap]
)
: [];
const tracks = [];
for (const lap of laps) {
const track = lap.Track
? (
Array.isArray(lap.Track)
? lap.Track
: [lap.Track]
)
: [];
tracks.push(...track);
}
const points = [];
for (const t of tracks) {
const tp = t.Trackpoint
? (
Array.isArray(t.Trackpoint)
? t.Trackpoint
: [t.Trackpoint]
)
: [];
points.push(...tp);
}
let minLat = null;
let maxLat = null;
let minLon = null;
let maxLon = null;
let minElev = null;
let maxElev = null;
let startTime = null;
let endTime = null;
let totalDistance = 0;
let totalCalories = 0;
let avgHr = null;
let maxHr = null;
for (const lap of laps) {
if (!startTime && lap.StartTime) {
startTime = lap.StartTime;
}
if (lap.TotalTimeSeconds && lap.StartTime) {
const lapEnd = new Date(lap.StartTime);
lapEnd.setSeconds(lapEnd.getSeconds() + Number(lap.TotalTimeSeconds));
endTime = lapEnd.toISOString();
}
if (lap.DistanceMeters) {
totalDistance += Number(lap.DistanceMeters);
}
if (lap.Calories) {
totalCalories += Number(lap.Calories);
}
const lapAvgHr = lap.AverageHeartRateBpm?.Value
? Number(lap.AverageHeartRateBpm.Value)
: null;
const lapMaxHr = lap.MaximumHeartRateBpm?.Value
? Number(lap.MaximumHeartRateBpm.Value)
: null;
if (Number.isFinite(lapAvgHr)) {
avgHr = avgHr == null ? lapAvgHr : (avgHr + lapAvgHr) / 2;
}
if (Number.isFinite(lapMaxHr)) {
maxHr = maxHr == null ? lapMaxHr : Math.max(maxHr, lapMaxHr);
}
}
for (const p of points) {
const pos = p.Position || {};
const lat = pos.LatitudeDegrees != null
? Number(pos.LatitudeDegrees)
: null;
const lon = pos.LongitudeDegrees != null
? Number(pos.LongitudeDegrees)
: null;
const ele = p.AltitudeMeters != null
? Number(p.AltitudeMeters)
: null;
const time = p.Time || null;
if (Number.isFinite(lat) && Number.isFinite(lon)) {
minLat = minLat == null ? lat : Math.min(minLat, lat);
maxLat = maxLat == null ? lat : Math.max(maxLat, lat);
minLon = minLon == null ? lon : Math.min(minLon, lon);
maxLon = maxLon == null ? lon : Math.max(maxLon, lon);
}
if (Number.isFinite(ele)) {
minElev = minElev == null ? ele : Math.min(minElev, ele);
maxElev = maxElev == null ? ele : Math.max(maxElev, ele);
}
if (time) {
if (!startTime) startTime = time;
endTime = time;
}
}
const start_time_millis = toMillis(startTime);
const end_time_millis = toMillis(endTime);
const duration_millis =
start_time_millis && end_time_millis
? end_time_millis - start_time_millis
: null;
const centerLat =
minLat != null && maxLat != null
? (minLat + maxLat) / 2
: null;
const centerLon =
minLon != null && maxLon != null
? (minLon + maxLon) / 2
: null;
const trackPreview = buildTrackPreviewFromTcxPoints(points, 300);
return {
format: 'TCX',
display_name: activity.Notes || null,
route_name: activity.Notes || null,
title: activity.Notes || null,
description: null,
notes: activity.Notes || null,
sport,
activity_type: null,
device_manufacturer: root.Author?.Name || null,
device_model: root.Author?.Build?.Version?.VersionMajor || null,
source_app: root.Author?.Name || null,
start_time_millis,
end_time_millis,
duration_millis,
distance_meters: totalDistance || null,
elevation_gain_meters: null,
elevation_loss_meters: null,
min_elevation_meters: minElev,
max_elevation_meters: maxElev,
file_distance_meters: totalDistance || null,
file_elevation_gain_meters: null,
computed_distance_meters: totalDistance || null,
computed_elevation_gain_meters: null,
average_speed_mps: null,
max_speed_mps: null,
average_pace_sec_per_km: null,
average_heart_rate: avgHr,
max_heart_rate: maxHr,
average_cadence: null,
max_cadence: null,
calories: totalCalories || null,
power_avg: null,
power_max: null,
point_count: points.length,
segment_count: tracks.length,
lap_count: laps.length,
min_latitude: minLat,
max_latitude: maxLat,
min_longitude: minLon,
max_longitude: maxLon,
center_latitude: centerLat,
center_longitude: centerLon,
// Preview ridotta del percorso per i thumb della gallery
track_preview: trackPreview,
tags: [],
diagnostics: {
source: 'TCX',
laps: laps.length,
tracks: tracks.length,
points: points.length,
track_preview_points: trackPreview.length
}
};
}
module.exports = parseTcx;

View file

@ -0,0 +1,38 @@
# parse_fit.py
import fitdecode
import json
import sys
from datetime import datetime
path = sys.argv[1]
def to_json_safe(value):
# Converte datetime → stringa ISO
if isinstance(value, datetime):
return value.isoformat()
# Converte liste/tuple ricorsivamente
if isinstance(value, (list, tuple)):
return [to_json_safe(v) for v in value]
# Converte dict ricorsivamente
if isinstance(value, dict):
return {k: to_json_safe(v) for k, v in value.items()}
# Tutto il resto viene restituito così com'è
return value
frames = []
with fitdecode.FitReader(path) as fit:
for frame in fit:
if isinstance(frame, fitdecode.FitDataMessage):
msg = {
"name": frame.name,
"fields": {}
}
for field in frame.fields:
msg["fields"][field.name] = to_json_safe(field.value)
frames.append(msg)
print(json.dumps(frames))

View file

@ -0,0 +1,348 @@
'use strict';
const crypto = require('crypto');
const TABLE = 'photo_ingest_jobs';
const SQLITE_BUSY_RETRY_DELAYS_MS = [
25,
50,
100,
200,
400,
800,
1600,
];
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function isSqliteBusy(error) {
if (!error) return false;
if (
error.code === 'SQLITE_BUSY' ||
error.code === 'SQLITE_LOCKED'
) {
return true;
}
const message = String(error.message || error).toLowerCase();
return (
message.includes('database is locked') ||
message.includes('database table is locked')
);
}
async function withSqliteBusyRetry(label, operation) {
let retry = 0;
while (true) {
try {
return await operation();
} catch (error) {
if (
!isSqliteBusy(error) ||
retry >= SQLITE_BUSY_RETRY_DELAYS_MS.length
) {
throw error;
}
const delay = SQLITE_BUSY_RETRY_DELAYS_MS[retry];
retry += 1;
console.warn(
`[PhotoIngestQueue] SQLITE_BUSY ${label}; ` +
`retry=${retry}/${SQLITE_BUSY_RETRY_DELAYS_MS.length} ` +
`delay=${delay}ms`
);
await sleep(delay);
}
}
}
function makeJobId(user, cartella, relPath) {
return crypto
.createHash('sha256')
.update(JSON.stringify([user, cartella, relPath]))
.digest('hex');
}
async function ensurePhotoIngestQueue(db) {
if (!(await db.schema.hasTable(TABLE))) {
await db.schema.createTable(TABLE, (t) => {
t.string('id').primary();
t.string('user').notNullable();
t.string('cartella').notNullable();
t.string('rel_path').notNullable();
t.integer('source_mtime_ms').notNullable();
t.integer('source_size_bytes').notNullable();
t.string('status').notNullable().defaultTo('pending');
t.integer('attempts').notNullable().defaultTo(0);
t.integer('rescan_requested').notNullable().defaultTo(0);
t.text('last_error').nullable();
t.datetime('started_at').nullable();
t.datetime('completed_at').nullable();
t.datetime('created_at').notNullable().defaultTo(db.fn.now());
t.datetime('updated_at').notNullable().defaultTo(db.fn.now());
t.index(['status', 'created_at'], 'idx_photo_ingest_status_created');
t.index(['user', 'cartella'], 'idx_photo_ingest_folder');
});
}
if (!(await db.schema.hasColumn(TABLE, 'rescan_requested'))) {
await db.schema.alterTable(TABLE, (t) => {
t.integer('rescan_requested').notNullable().defaultTo(0);
});
}
}
async function enqueuePhotoIngestJob(db, input) {
const { user, cartella, relPath, mtimeMs, sizeBytes } = input;
if (
typeof user !== 'string' || !user ||
typeof cartella !== 'string' || !cartella ||
typeof relPath !== 'string' || !relPath ||
!Number.isFinite(mtimeMs) || mtimeMs < 0 ||
!Number.isFinite(sizeBytes) || sizeBytes < 0
) {
throw new TypeError('Dati non validi per accodare la foto');
}
await ensurePhotoIngestQueue(db);
const id = makeJobId(user, cartella, relPath);
const mtime = Math.trunc(mtimeMs);
const size = Math.trunc(sizeBytes);
return withSqliteBusyRetry('enqueue', () =>
db.transaction(async (trx) => {
const existing = await trx(TABLE).where({ id }).first();
if (existing) {
const unchanged =
Number(existing.source_mtime_ms) === mtime &&
Number(existing.source_size_bytes) === size;
if (unchanged) {
// Un errore non viene riavviato da eventi duplicati.
return { id, status: existing.status, unchanged: true };
}
if (existing.status === 'processing') {
// Conserva la versione più recente richiesta.
// Il worker finirà il lavoro corrente e poi riaccoderà.
await trx(TABLE).where({ id, status: 'processing' }).update({
source_mtime_ms: mtime,
source_size_bytes: size,
rescan_requested: 1,
updated_at: trx.fn.now(),
});
return {
id,
status: 'processing',
changedWhileProcessing: true,
};
}
await trx(TABLE).where({ id }).update({
source_mtime_ms: mtime,
source_size_bytes: size,
status: 'pending',
attempts: 0,
rescan_requested: 0,
last_error: null,
started_at: null,
completed_at: null,
updated_at: trx.fn.now(),
});
return { id, status: 'pending', updated: true };
}
await trx(TABLE).insert({
id,
user,
cartella,
rel_path: relPath,
source_mtime_ms: mtime,
source_size_bytes: size,
status: 'pending',
attempts: 0,
rescan_requested: 0,
created_at: trx.fn.now(),
updated_at: trx.fn.now(),
});
return { id, status: 'pending', inserted: true };
})
);
}
async function claimNextPhotoIngestJob(db) {
await ensurePhotoIngestQueue(db);
return withSqliteBusyRetry('claim', () =>
db.transaction(async (trx) => {
const job = await trx(TABLE)
.where({ status: 'pending' })
.orderBy('created_at', 'asc')
.first();
if (!job) return null;
const changed = await trx(TABLE)
.where({ id: job.id, status: 'pending' })
.update({
status: 'processing',
attempts: trx.raw('attempts + 1'),
rescan_requested: 0,
started_at: trx.fn.now(),
completed_at: null,
last_error: null,
updated_at: trx.fn.now(),
});
if (!changed) return null;
return {
...job,
status: 'processing',
attempts: Number(job.attempts || 0) + 1,
rescan_requested: 0,
};
})
);
}
async function completePhotoIngestJob(db, id) {
return withSqliteBusyRetry('complete', () =>
db.transaction(async (trx) => {
const job = await trx(TABLE)
.where({ id, status: 'processing' })
.first();
if (!job) return { updated: false };
if (Number(job.rescan_requested) === 1) {
await trx(TABLE).where({ id, status: 'processing' }).update({
status: 'pending',
rescan_requested: 0,
started_at: null,
completed_at: null,
last_error: null,
updated_at: trx.fn.now(),
});
return { updated: true, status: 'pending', requeued: true };
}
await trx(TABLE).where({ id, status: 'processing' }).update({
status: 'done',
completed_at: trx.fn.now(),
updated_at: trx.fn.now(),
last_error: null,
});
return { updated: true, status: 'done', requeued: false };
})
);
}
async function failPhotoIngestJob(db, id, error) {
const message = String(error?.stack || error?.message || error)
.slice(0, 12000);
return withSqliteBusyRetry('fail', () =>
db.transaction(async (trx) => {
const job = await trx(TABLE)
.where({ id, status: 'processing' })
.first();
if (!job) return { updated: false };
if (Number(job.rescan_requested) === 1) {
await trx(TABLE).where({ id, status: 'processing' }).update({
status: 'pending',
rescan_requested: 0,
started_at: null,
completed_at: null,
last_error: message,
updated_at: trx.fn.now(),
});
return { updated: true, status: 'pending', requeued: true };
}
await trx(TABLE).where({ id, status: 'processing' }).update({
status: 'error',
last_error: message,
completed_at: trx.fn.now(),
updated_at: trx.fn.now(),
});
return { updated: true, status: 'error', requeued: false };
})
);
}
async function resetStalePhotoIngestJobs(db, staleMinutes = 30) {
await ensurePhotoIngestQueue(db);
const cutoff = new Date(Date.now() - staleMinutes * 60 * 1000)
.toISOString()
.replace('T', ' ')
.replace(/\.\d{3}Z$/, '');
return db(TABLE)
.where({ status: 'processing' })
.whereNotNull('started_at')
.where('started_at', '<', cutoff)
.update({
status: 'pending',
rescan_requested: 0,
started_at: null,
completed_at: null,
last_error: 'Recuperato dopo interruzione del worker',
updated_at: db.fn.now(),
});
}
async function getPhotoIngestQueueCounts(db) {
await ensurePhotoIngestQueue(db);
const rows = await db(TABLE)
.select('status')
.count({ total: '*' })
.groupBy('status');
const counts = {
pending: 0,
processing: 0,
done: 0,
error: 0,
};
for (const row of rows) {
if (Object.hasOwn(counts, row.status)) {
counts[row.status] = Number(row.total);
}
}
return counts;
}
module.exports = {
ensurePhotoIngestQueue,
enqueuePhotoIngestJob,
claimNextPhotoIngestJob,
completePhotoIngestJob,
failPhotoIngestJob,
resetStalePhotoIngestJobs,
getPhotoIngestQueueCounts,
};

View file

@ -0,0 +1,263 @@
'use strict';
const path = require('path');
const fs = require('fs/promises');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
const scanPhotoSingle = require('./scanPhotoSingle');
const {
enqueueServerEvent,
} = require('../../db/serverEventOutbox');
const { sha256 } = require('./utils');
const {
ensurePhotoIngestQueue,
claimNextPhotoIngestJob,
enqueuePhotoIngestJob,
completePhotoIngestJob,
failPhotoIngestJob,
resetStalePhotoIngestJobs,
} = require('./photoIngestQueue');
function sameVersion(stat, job) {
return (
Math.trunc(stat.mtimeMs) === Math.trunc(Number(job.source_mtime_ms)) &&
stat.size === Number(job.source_size_bytes)
);
}
function isInside(parent, child) {
const relative = path.relative(parent, child);
return relative === '' ||
(!relative.startsWith(`..${path.sep}`) &&
relative !== '..' &&
!path.isAbsolute(relative));
}
async function resolveJobFile(job) {
for (const value of [job.user, job.cartella, job.rel_path]) {
if (typeof value !== 'string' || !value.trim()) {
throw new Error('Percorso del job non valido');
}
}
if (
path.isAbsolute(job.rel_path) ||
job.rel_path.includes('\\') ||
job.rel_path.split('/').some(part =>
!part || part === '.' || part === '..'
)
) {
throw new Error('Percorso relativo non sicuro');
}
if (
job.user === '.' || job.user === '..' ||
job.cartella === '.' || job.cartella === '..' ||
job.user.includes('/') || job.user.includes('\\') ||
job.cartella.includes('/') || job.cartella.includes('\\')
) {
throw new Error('Utente o cartella non validi');
}
const photosRoot = path.resolve(
__dirname, '..', '..', WEB_ROOT, 'photos'
);
const originalRoot = path.resolve(
photosRoot, job.user, 'original', job.cartella
);
const absPath = path.resolve(originalRoot, ...job.rel_path.split('/'));
if (!isInside(originalRoot, absPath)) {
throw new Error('Il file esce dalla cartella originale prevista');
}
const realRoot = await fs.realpath(originalRoot);
const realFile = await fs.realpath(absPath);
if (!isInside(realRoot, realFile)) {
throw new Error('Il file risolve fuori dalla cartella originale');
}
const ext = path.extname(absPath).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
throw new Error(`Estensione non supportata: ${ext}`);
}
const stat = await fs.stat(realFile);
if (!stat.isFile()) {
throw new Error('Il percorso non è un file regolare');
}
return {
id: sha256(`${job.user}/${job.cartella}/${job.rel_path}`),
user: job.user,
cartella: job.cartella,
name: path.basename(absPath),
relPath: job.rel_path,
absPath: realFile,
ext,
stat,
path: `/photos/${job.user}/original/${job.cartella}/${job.rel_path}`,
};
}
async function runOneJob(db) {
await ensurePhotoIngestQueue(db);
const job = await claimNextPhotoIngestJob(db);
if (!job) {
return { processed: false };
}
console.log(`[PhotoIngest] Avvio job ${job.id}: ${job.rel_path}`);
try {
const file = await resolveJobFile(job);
// Se la versione è già cambiata, aggiorna il job mentre è
// processing: completePhotoIngestJob lo riporterà a pending.
if (!sameVersion(file.stat, job)) {
await enqueuePhotoIngestJob(db, {
user: job.user,
cartella: job.cartella,
relPath: job.rel_path,
mtimeMs: file.stat.mtimeMs,
sizeBytes: file.stat.size,
});
const result = await completePhotoIngestJob(db, job.id);
return {
processed: true,
status: result.status,
requeued: Boolean(result.requeued),
};
}
const previous = await db('photos')
.select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path')
.where({ id: file.id })
.first();
const scannedPhoto = await scanPhotoSingle(
db,
job.user,
job.cartella,
file,
[],
'',
previous || null
);
// Se il file è scomparso, non eliminare il record fotografico.
// Il job va in errore e l'evento di cancellazione resta responsabilità
// del watcher.
const statAfter = await fs.stat(file.absPath);
// Se è cambiato durante la lavorazione, aggiorna la versione in coda
// prima di completare il job: rescan_requested garantisce il retry.
if (
Math.trunc(statAfter.mtimeMs) !== Math.trunc(file.stat.mtimeMs) ||
statAfter.size !== file.stat.size
) {
await enqueuePhotoIngestJob(db, {
user: job.user,
cartella: job.cartella,
relPath: job.rel_path,
mtimeMs: statAfter.mtimeMs,
sizeBytes: statAfter.size,
});
}
const result = await completePhotoIngestJob(db, job.id);
console.log(
`[PhotoIngest] Job ${job.id} completato; stato: ${result.status}`
);
/*
* Notifica una foto realmente nuova soltanto dopo che il
* job e' stato completato definitivamente.
*
* IMPORTANTE:
* qui NON viene importato ws-server.
* Il daemon scrive soltanto nella outbox SQLite.
*/
if (
!previous &&
!result.requeued &&
result.status === 'done' &&
scannedPhoto &&
scannedPhoto.id
) {
await enqueueServerEvent(db, {
channel: 'photo',
user: job.user,
payload: {
type: 'added',
id: scannedPhoto.id,
},
});
console.log(
`[PhotoIngest] Evento gallery accodato photo=${scannedPhoto.id}`
);
}
return {
processed: true,
status: result.status,
requeued: Boolean(result.requeued),
};
} catch (err) {
console.error(`[PhotoIngest] Errore job ${job.id}:`, err.message);
try {
await failPhotoIngestJob(db, job.id, err.message);
} catch (queueErr) {
console.error(
'[PhotoIngest] Errore aggiornando la coda:',
queueErr.message
);
}
return { processed: true, error: err.message };
}
}
async function runWorker(db, options = {}) {
const pollMs = Math.max(250, Number(options.pollMs) || 2000);
const staleMinutes = Math.max(
1, Number(options.staleMinutes) || 30
);
await ensurePhotoIngestQueue(db);
await resetStalePhotoIngestJobs(db, staleMinutes);
console.log(`[PhotoIngest] Worker avviato; polling ${pollMs} ms`);
while (true) {
try {
const result = await runOneJob(db);
if (!result.processed) {
await new Promise(resolve => setTimeout(resolve, pollMs));
}
} catch (err) {
console.error('[PhotoIngest] Errore worker:', err);
await new Promise(resolve => setTimeout(resolve, pollMs));
}
}
}
module.exports = {
runOneJob,
runWorker,
resolveJobFile,
sameVersion,
};

View file

@ -0,0 +1,91 @@
'use strict';
const photoIngestQueue = require('./photoIngestQueue');
let epoch = null;
async function beginGlobalPhotoScan(db) {
if (!epoch || !epoch.active) {
const created = {
active: true,
baselineDone: 0,
enumerationsInProgress: 0,
initPromise: null,
};
epoch = created;
created.initPromise = photoIngestQueue
.getPhotoIngestQueueCounts(db)
.then(counts => {
created.baselineDone = Number(counts.done || 0);
})
.catch(err => {
if (epoch === created) created.active = false;
throw err;
});
}
const current = epoch;
current.enumerationsInProgress++;
try {
await current.initPromise;
} catch (err) {
current.enumerationsInProgress = Math.max(
0, current.enumerationsInProgress - 1
);
throw err;
}
}
function finishGlobalPhotoScan() {
if (epoch) {
epoch.enumerationsInProgress = Math.max(
0, epoch.enumerationsInProgress - 1
);
}
}
async function getGlobalPhotoScanStatus(db) {
const counts = await photoIngestQueue.getPhotoIngestQueueCounts(db);
const pending = Number(counts.pending || 0);
const processing = Number(counts.processing || 0);
const done = Number(counts.done || 0);
const errors = Number(counts.error || 0);
const remaining = pending + processing;
const totalCurrent = pending + processing + done + errors;
let current;
let total;
let percent;
let baselineDone = done;
if (epoch && epoch.active) {
baselineDone = epoch.baselineDone;
total = Math.max(0, totalCurrent - baselineDone);
current = Math.max(0, (done - baselineDone) + errors);
percent = total <= 0
? 100
: Math.max(0, Math.min(100, Math.round(current / total * 100)));
// Chiude il ciclo solo quando tutte le richieste di scansione
// hanno terminato l'accodamento e la coda è vuota.
if (epoch.enumerationsInProgress === 0 && remaining === 0) {
epoch.active = false;
}
} else {
total = totalCurrent;
current = totalCurrent;
percent = 100;
}
return {
pending, processing, done, errors, remaining,
current, total, percent, baselineDone,
};
}
module.exports = {
beginGlobalPhotoScan,
finishGlobalPhotoScan,
getGlobalPhotoScanStatus,
};

View file

@ -0,0 +1,24 @@
// scanner/postWithAuth.js
// Versione locale: niente HTTP, niente token, solo DB
module.exports = function createPostToDB(db) {
/**
* Inserisce o aggiorna un record nel DB SQLite
* (sostituisce completamente axios.post)
*/
async function postToDB(record) {
if (!record || !record.id) {
throw new Error("Record non valido");
}
await db('photos')
.insert(record)
.onConflict('id')
.merge();
return true;
}
return postToDB;
};

View file

@ -0,0 +1,188 @@
// api_v1/scanner/processActivityFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { log } = require('./logger');
const parseActivityFile = require('./parseActivityFile'); // 🔥 parser FIT/GPX/TCX
const { WEB_ROOT, PATH_FULL } = require('../config');
// ---------------------------------------------------------
// processActivityFile → equivalente di processFile per le attività
// ---------------------------------------------------------
async function processActivityFile(userName, cartella, fileRelPath, absPath, ext, st) {
// 🔥 PARSING FIT/GPX/TCX
const parsed = await parseActivityFile(absPath);
// parsed contiene:
// {
// file_name,
// file_extension,
// format,
// display_name,
// route_name,
// title,
// description,
// notes,
// sport,
// activity_type,
// device_manufacturer,
// device_model,
// source_app,
// start_time_millis,
// end_time_millis,
// duration_millis,
// distance_meters,
// elevation_gain_meters,
// elevation_loss_meters,
// min_elevation_meters,
// max_elevation_meters,
// file_distance_meters,
// file_elevation_gain_meters,
// computed_distance_meters,
// computed_elevation_gain_meters,
// average_speed_mps,
// max_speed_mps,
// average_pace_sec_per_km,
// average_heart_rate,
// max_heart_rate,
// average_cadence,
// max_cadence,
// calories,
// power_avg,
// power_max,
// point_count,
// segment_count,
// lap_count,
// min_latitude,
// max_latitude,
// min_longitude,
// max_longitude,
// center_latitude,
// center_longitude,
// tags,
// diagnostics
// }
// ---------------------------------------------------------
// GPS
//
// I dati altimetrici dell'attività provengono direttamente
// dal file FIT, GPX o TCX. Il reverse geocoding viene
// completato successivamente dal geocode worker.
// ---------------------------------------------------------
let gps = null;
if (
parsed.center_latitude != null
&& parsed.center_longitude != null
) {
gps = {
lat: parsed.center_latitude,
lng: parsed.center_longitude,
alt: parsed.min_elevation_meters ?? null,
};
}
const location = null;
// ---------------------------------------------------------
// PATH FULL / RELATIVI
// ---------------------------------------------------------
const relPath = fileRelPath;
const fullPath = PATH_FULL
? path.posix.join('/activities', userName, cartella, fileRelPath)
: relPath;
// ---------------------------------------------------------
// ID deterministico
// ---------------------------------------------------------
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
// ---------------------------------------------------------
// COSTRUZIONE META (identico a normalizeActivity)
// ---------------------------------------------------------
return {
id,
user: userName,
cartella,
taken_at: new Date(parsed.start_time_millis).toISOString(),
file_name: parsed.file_name,
file_extension: parsed.file_extension,
format: parsed.format,
display_name: parsed.display_name,
route_name: parsed.route_name,
title: parsed.title,
description: parsed.description,
notes: parsed.notes,
sport: parsed.sport,
activity_type: parsed.activity_type,
device_manufacturer: parsed.device_manufacturer,
device_model: parsed.device_model,
source_app: parsed.source_app,
start_time_millis: parsed.start_time_millis,
end_time_millis: parsed.end_time_millis,
duration_millis: parsed.duration_millis,
distance_meters: parsed.distance_meters,
elevation_gain_meters: parsed.elevation_gain_meters,
elevation_loss_meters: parsed.elevation_loss_meters,
min_elevation_meters: parsed.min_elevation_meters,
max_elevation_meters: parsed.max_elevation_meters,
file_distance_meters: parsed.file_distance_meters,
file_elevation_gain_meters: parsed.file_elevation_gain_meters,
computed_distance_meters: parsed.computed_distance_meters,
computed_elevation_gain_meters: parsed.computed_elevation_gain_meters,
average_speed_mps: parsed.average_speed_mps,
max_speed_mps: parsed.max_speed_mps,
average_pace_sec_per_km: parsed.average_pace_sec_per_km,
average_heart_rate: parsed.average_heart_rate,
max_heart_rate: parsed.max_heart_rate,
average_cadence: parsed.average_cadence,
max_cadence: parsed.max_cadence,
calories: parsed.calories,
power_avg: parsed.power_avg,
power_max: parsed.power_max,
point_count: parsed.point_count,
segment_count: parsed.segment_count,
lap_count: parsed.lap_count,
min_latitude: parsed.min_latitude,
max_latitude: parsed.max_latitude,
min_longitude: parsed.min_longitude,
max_longitude: parsed.max_longitude,
center_latitude: parsed.center_latitude,
center_longitude: parsed.center_longitude,
size_bytes: st.size,
last_modified_millis: st.mtimeMs,
date_added_millis: Date.now(),
date_modified_millis: Date.now(),
last_scan_millis: Date.now(),
is_favorite: false,
is_hidden: false,
rating: 0,
tags: parsed.tags || [],
track_preview: parsed.track_preview || [],
scan_status: "ok",
scan_error: null,
diagnostics: parsed.diagnostics || null,
gps,
location,
path: fullPath
};
}
module.exports = processActivityFile;

View file

@ -0,0 +1,224 @@
const path = require("path");
const fsp = require("fs/promises");
const ExifReader = require("exifreader");
const sharp = require("sharp");
const { sha256, inferMimeFromExt, parseExifDateUtc } = require("./utils");
const { extractGpsFromExif, extractGpsWithExiftool } = require("./gps");
const { createVideoThumbnail, createThumbnails } = require("./thumbs");
const { probeVideo } = require("./video");
const { WEB_ROOT, PATH_FULL } = require("../config");
async function processFile(userName, cartella, fileRelPath, absPath, ext, st) {
const isVideo = [".mp4", ".mov", ".m4v"].includes(ext);
const thumbBase = path.join(
WEB_ROOT,
"photos",
userName,
"thumbs",
cartella,
path.dirname(fileRelPath),
);
await fsp.mkdir(thumbBase, { recursive: true });
// --- Nome file + estensione (dal path ASSOLUTO, sempre corretto) ---
const parsed = path.parse(absPath);
const baseName = parsed.name; // IMG_0249
const extName = parsed.ext; // .JPG
const absThumbMin = path.join(thumbBase, `${baseName}_min.jpg`);
const absThumbAvg = path.join(thumbBase, `${baseName}_avg.jpg`);
const thumbStart = process.hrtime.bigint();
if (isVideo) {
await createVideoThumbnail(absPath, absThumbMin, absThumbAvg);
} else {
await createThumbnails(absPath, absThumbMin, absThumbAvg);
}
const thumbMs =
Number(process.hrtime.bigint() - thumbStart) / 1e6;
console.log(
`⏱️ [PROFILE processFile] ${path.basename(absPath)} thumbnails=${thumbMs.toFixed(2)} ms`
);
const exifStart = process.hrtime.bigint();
// --- EXIF ---
let tags = {};
try {
tags = await ExifReader.load(absPath, { expanded: true });
} catch {}
let timeRaw = tags?.exif?.DateTimeOriginal?.value?.[0] || null;
const exifMs =
Number(process.hrtime.bigint() - exifStart) / 1e6;
console.log(
`⏱️ [PROFILE processFile] ${path.basename(absPath)} exif=${exifMs.toFixed(2)} ms`
);
let takenAtIso = parseExifDateUtc(timeRaw);
// Fallback per i video
if (isVideo) {
const info = await probeVideo(absPath);
// Cerca la data nei punti standard
const creationFormat = info.format?.tags?.creation_time || null;
const creationStream0 = info.streams?.[0]?.tags?.creation_time || null;
const creationStream1 = info.streams?.[1]?.tags?.creation_time || null;
// Scegli la prima disponibile
const videoDate =
creationFormat || creationStream0 || creationStream1 || null;
if (videoDate) {
// NON convertire, NON usare new Date()
takenAtIso = videoDate;
timeRaw = videoDate;
} else {
// Fallback finale: mtime del file
const fallback = new Date(st.mtimeMs).toISOString();
takenAtIso = fallback;
timeRaw = fallback;
}
}
// --- GPS ---
let gps = null;
if (isVideo) {
// i video usano exiftool
gps = await extractGpsWithExiftool(absPath);
} else {
// le foto usano exifreader
gps = extractGpsFromExif(tags);
}
// L'altitudine EXIF viene mantenuta.
// Se assente, sarà recuperata dal geocode worker.
// --- DIMENSIONI & ROTAZIONE ---
let width = null,
height = null,
duration = null,
duration_ms = null,
rotation = 0;
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;
rotation = 0;
if (stream?.tags?.rotate) {
rotation = Number(stream.tags.rotate);
}
const sdl = stream?.side_data_list;
if (sdl && Array.isArray(sdl)) {
const rotEntry = sdl.find((d) => d.rotation !== undefined);
if (rotEntry) rotation = Number(rotEntry.rotation);
const matrixEntry = sdl.find(
(d) => typeof d.displaymatrix === "string",
);
if (matrixEntry) {
const match = matrixEntry.displaymatrix.match(
/rotation of ([\-0-9]+) degrees/i,
);
if (match) rotation = Number(match[1]);
}
}
rotation = ((rotation % 360) + 360) % 360;
}
duration = info.format?.duration || null;
duration_ms = duration ? Math.round(duration * 1000) : null;
} else {
const metadataStart = process.hrtime.bigint();
try {
const meta = await sharp(absPath).metadata();
width = meta.width || null;
height = meta.height || null;
} catch {}
const metadataMs =
Number(process.hrtime.bigint() - metadataStart) / 1e6;
console.log(
`⏱️ [PROFILE processFile] ${path.basename(absPath)} metadata=${metadataMs.toFixed(2)} ms`
);
try {
const raw =
tags?.exif?.Orientation?.value ??
tags?.image?.Orientation?.value ??
tags?.ifd0?.Orientation?.value ??
null;
const val = Array.isArray(raw) ? raw[0] : raw;
const map = { 1: 0, 3: 180, 6: 90, 8: 270 };
rotation = map[val] ?? 0;
} catch {}
}
const mime_type = inferMimeFromExt(ext);
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
// Reverse geocoding eseguito in modo asincrono.
const location = null;
//
// --- GESTIONE PATH FULL / RELATIVI ---
//
const relPath = fileRelPath;
const relThub1 = fileRelPath.replace(/\.[^.]+$/, "_min.jpg");
const relThub2 = fileRelPath.replace(/\.[^.]+$/, "_avg.jpg");
const fullPath = PATH_FULL
? path.posix.join("/photos", userName, cartella, fileRelPath)
: relPath;
const fullThub1 = PATH_FULL
? path.posix.join("/photos", userName, "thumbs", cartella, relThub1)
: relThub1;
const fullThub2 = PATH_FULL
? path.posix.join("/photos", userName, "thumbs", cartella, relThub2)
: relThub2;
const fullCast = "";
return {
id,
user: userName,
cartella,
name: baseName + extName, // 👈 SEMPRE CORRETTO
path: fullPath,
thub1: fullThub1,
thub2: fullThub2,
cast: fullCast,
gps,
data: timeRaw,
taken_at: takenAtIso,
mime_type,
width,
height,
rotation,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
duration_ms: isVideo ? duration_ms : null,
location,
};
}
module.exports = processFile;

View file

@ -0,0 +1,97 @@
// api_v1/scanner/recordChange.js
const { log } = require('./logger');
const wss = require('../../ws-server'); // IMPORTANTE
async function recordChange(db, photo_id, user, change_type) {
const timestamp = new Date().toISOString();
// -----------------------------------------------------
// DEDUPLICA SOLO PER "removed"
// -----------------------------------------------------
if (change_type === 'removed') {
const already = await db('photo_changes')
.where({ photo_id, user, change_type: 'removed' })
.first();
if (already) {
log(`📘 [CHANGES] removed già registrato → ${photo_id}, ma invio comunque WS`);
// -----------------------------------------------------
// 🔥 INVIA WS ANCHE SE GIÀ REGISTRATO
// -----------------------------------------------------
console.log("WS → invio evento (USER) [removed già registrato]");
try {
wss.broadcastToUser(user, {
type: "removed",
id: photo_id
});
console.log("WS → OK USER (removed già registrato)");
} catch (e) {
console.error("WS → ERRORE USER (removed già registrato):", e);
}
console.log("WS → invio evento (ADMIN) [removed già registrato]");
try {
wss.broadcastToAdmins({
type: "removed",
id: photo_id,
user
});
console.log("WS → OK ADMIN (removed già registrato)");
} catch (e) {
console.error("WS → ERRORE ADMIN (removed già registrato):", e);
}
return;
}
}
// -----------------------------------------------------
// SALVA NEL DB (added o primo removed)
// -----------------------------------------------------
await db("photo_changes").insert({
photo_id,
user,
change_type,
timestamp
});
log(`📘 [CHANGES] ${change_type} → ${photo_id}`);
// -----------------------------------------------------
// LOG PRIMA DEL BROADCAST
// -----------------------------------------------------
console.log("WS → invio evento:", { user, change_type, photo_id });
// -----------------------------------------------------
// 🔥 BROADCAST A USER
// -----------------------------------------------------
console.log("WS → invio evento (USER)");
try {
wss.broadcastToUser(user, {
type: change_type,
id: photo_id
});
console.log("WS → OK USER");
} catch (e) {
console.error("WS → ERRORE USER:", e);
}
// -----------------------------------------------------
// 🔥 BROADCAST A ADMIN
// -----------------------------------------------------
console.log("WS → invio evento (ADMIN)");
try {
wss.broadcastToAdmins({
type: change_type,
id: photo_id,
user
});
console.log("WS → OK ADMIN");
} catch (e) {
console.error("WS → ERRORE ADMIN:", e);
}
}
module.exports = recordChange;

249
api_v1/scanner/scan.md Normal file
View file

@ -0,0 +1,249 @@
scanPhotosUser(userName, db)
│
├── individua:
│ photos/<user>/original/
│
├── legge le entry dentro original/
│
├── prende SOLO le directory
│
├── countFilesUser(userDir)
│ └── ricorsivamente guarda tutti i file
│ └── conta solo quelli con estensione in SUPPORTED_EXTS
│
└── per ogni cartella:
scanPhoto(cartella, userName, db, ...)
│
└── QUI viene probabilmente
deciso cosa fare col singolo file
scanPhotosUser
│
│ per ogni cartella
▼
scanPhoto()
│
▼
scanPhotoCartella()
│
└── QUI probabilmente avviene la decisione sul file
Quindi scanPhoto():
costruisce il percorso:
const absCartella = path.join(userDir, cartella);
passa la cartella a scanPhotoCartella().
scanPhotoCartella() riempie newFiles.
solo dopo, scanPhoto() manda al server i file presenti in newFiles:
scanCartella.js non decide se la foto è già stata scansionata: decide solo quali file sono candidati alla scansione.
cartella
↓
walk() ricorsivo
↓
è directory?
├─ sì → entra dentro
└─ no
↓
estensione supportata?
├─ no → SKIP
└─ sì
↓
crea ID deterministico
↓
stat() del file
↓
yield metadata
Il valore di SUPPORTED_EXTS è quindi importante. Lo trovi in:
api_v1/config.js
La cosa ancora più interessante: l'ID
Per ogni file accettato crea:
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
Quindi l'ID non dipende dal contenuto della foto.
Dipende da:
user
+
cartella
+
percorso relativo
scanPhotoUser
↓
scanPhoto
↓
scanPhotoCartella
↓
scanCartella
↓
per ogni file con estensione supportata
↓
scanPhotoSingle(...)
↓
QUI viene probabilmente deciso:
├── già presente → non fare certe operazioni
├── nuovo → processa
└── modificato → eventualmente aggiorna
Quindi non è scanPhotoCartella a decidere se processare o meno la foto.
È scanPhotoSingle.
4. Ma c'è un altro meccanismo importante: gli orfani
Dopo scanPhotoSingle trovi:
idsSet.delete(f.id);
Questo significa:
"Ho trovato fisicamente questo file durante la scansione, quindi il suo ID non è un orfano."
Alla fine:
for (const orphanId of idsSet) {
log(`🔴 Orfano: ${orphanId}`);
await deleteThumbsById(orphanId);
await deleteFromDB(orphanId, user);
}
DB all'inizio
│
├── A
├── B
└── C
↓
scansione filesystem
↓
trovo A → idsSet.delete(A)
trovo C → idsSet.delete(C)
↓
rimane B
↓
B non esiste più sul filesystem
↓
B = ORFANO
↓
cancella thumbnail + DB
Una foto viene presa in considerazione se:
si trova dentro una cartella sotto original;
è un file, non una directory;
la sua estensione è presente in SUPPORTED_EXTS.
Poi viene generato un ID:
sha256(`${userName}/${cartella}/${fileRelPath}`)
e il file viene passato a:
scanPhotoSingle(...)
La decisione è tutta qui
Quando trova un file, fa:
let prev = await db('photos')
.select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path')
.where({ id })
.first();
Quindi cerca nel DB una foto con lo stesso ID.
L'ID, come abbiamo visto prima, è:
sha256(`${userName}/${cartella}/${fileRelPath}`)
Quindi normalmente identifica quel preciso percorso del file.
Poi calcola:
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
Quindi il contenuto non viene hashato: il "fast hash" dipende solo da:
dimensione file + data modifica
Le 3 situazioni
1. Foto nuova
Se:
if (!prev)
cioè non esiste nel DB un record con quell'ID, fa:
const meta = await processFile(...)
Questa è la parte che legge/elabora veramente la foto.
Poi la inserisce nel DB:
await db('photos').insert(...)
e soprattutto:
newFiles.push(meta);
Quindi una foto nuova viene processata e considerata newFile.
2. Foto già presente e invariata
Questa è la situazione che probabilmente ti interessava:
if (prev.fast_hash === fastHash) {
In questo caso NON chiama processFile().
Fa solamente:
await db('photos')
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
fast_hash: fastHash,
updated_at: new Date().toISOString(),
});
return null;
Quindi:
Se l'ID esiste e size + mtimeMs sono uguali, la foto viene saltata.
Non viene riletta, non vengono rigenerate thumbnail, non viene ricalcolato GPS/location, ecc.
Questo è il vero "skip".
3. Foto modificata
Se esiste prev, ma:
prev.fast_hash !== fastHash
allora:
log(`${prefix} 🟠 Modificato: ${fileName}`);
e viene nuovamente chiamato:
const meta = await processFile(...)
Quindi la foto viene riprocessata completamente.
C'è però un caso speciale: path cambiato
Prima del controllo fast_hash c'è:
if (prev && prev.path !== f.path) {
log(`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`);
prev = null;
}
Questo significa:
se trovo lo stesso id ma il path nel DB è diverso da quello attuale, tratto il file come nuovo.
E quindi finisce qui:
if (!prev) {
...
await processFile(...)
...
await db('photos').insert(...)
}
Ma qui vedo una possibile anomalia
Se prev esiste già nel DB e fai:
prev = null;
poi fai:
await db('photos').insert({
id: meta.id,
...
});
Se id è una chiave unica, potresti avere un errore di duplicate key invece di un vero aggiornamento.
Però per capire se questo caso è realmente possibile bisogna vedere come viene costruito f.path e come viene gestito il DB.
TROVO FILE
│
▼
estensione supportata?
│ │
NO SI
│ │
SKIP ▼
calcolo ID
│
▼
cerco ID nel DB
│
┌────────────┴────────────┐
│ │
non esiste esiste
│ │
▼ ▼
processFile() path diverso?
│ │ │
│ SI NO
│ │ │
│ ▼ ▼
│ prev=null confronto
│ fastHash
│ │ │
│ UGUALE DIVERSO
│ │ │
│ ▼ ▼
│ SKIP processFile()
│ │
└──────────────────────────────────────┘

View file

@ -0,0 +1,116 @@
// api_v1/scanner/scanActivitiesUser.js
const path = require('path');
const fsp = require('fs/promises');
const scanActivity = require('./scanActivity');
const { log } = require('./logger');
const { WEB_ROOT, SUPPORTED_ACTIVITY_EXTS } = require('../config');
// ---------------------------------------------------------
// PRIMA PASSATA: conta TUTTI i file reali ricorsivamente
// ---------------------------------------------------------
async function countActivityFilesUser(rootDir) {
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_ACTIVITY_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// SECONDA PASSATA: scansiona SOLO le cartelle vere
// ---------------------------------------------------------
async function scanActivitiesUser(userName, db) {
log(`🔵 Inizio scan ATTIVITÀ per user=${userName}`);
const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities');
const userDir = path.join(activitiesRoot, userName, 'original');
let entries = [];
try {
entries = await fsp.readdir(userDir, { withFileTypes: true });
} catch {
log(`❌ Nessuna directory attività per utente ${userName}`);
return [];
}
// Filtra SOLO cartelle vere dentro "original"
const folders = entries
.filter(e => e.isDirectory())
.map(e => e.name);
// ---------------------------------------------------------
// RIMOZIONE CARTELLE CANCELLATE DAL FILESYSTEM
// ---------------------------------------------------------
const createDeleteFolderFunctions = require('./deleteFolder');
const { deleteFolderForUser } = createDeleteFolderFunctions(db);
const dbFolders = await db('activities')
.where({ user: userName })
.distinct('cartella')
.pluck('cartella');
for (const dbFolder of dbFolders) {
if (!folders.includes(dbFolder)) {
log(`🗑️ Cartella attività rimossa dal filesystem: ${dbFolder}`);
await deleteFolderForUser(userName, dbFolder);
}
}
// PRIMA PASSATA: conta i file reali
const TOTAL_FILES = await countActivityFilesUser(userDir);
const CURRENT = { value: 0 };
const start = Date.now();
log(`📊 Attività totali da scansionare: ${TOTAL_FILES}`);
const allNewActivities = [];
// SECONDA PASSATA: UNA SOLA SCANSIONE PER CARTELLA
for (const cartella of folders) {
log(`📁 Scan cartella attività: ${cartella}`);
const newActivities = await scanActivity(
cartella,
userName,
db,
CURRENT,
TOTAL_FILES,
start
);
if (newActivities?.length) {
allNewActivities.push(...newActivities);
}
}
log(`🟣 Scan ATTIVITÀ COMPLETATO per user=${userName}. Nuove attività: ${allNewActivities.length}`);
return allNewActivities;
}
module.exports = scanActivitiesUser;

View file

@ -0,0 +1,52 @@
// api_v1/scanner/scanActivity.js
const path = require('path');
const { log } = require('./logger');
const scanActivityCartella = require('./scanActivityCartella');
const createActivityOrphanCleanup = require('./activityOrphanCleanup');
const {
WEB_ROOT
} = require('../config');
// Il progresso globale viene letto dalla coda SQLite.
// Mantiene la callback richiesta da scanActivityCartella.
async function updateStatusFile() {}
// ---------------------------------------------------------
// SCAN UNA SOLA CARTELLA DI ATTIVITÀ
// ---------------------------------------------------------
async function scanActivity(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
const newActivities = [];
const { deleteFromDB } = createActivityOrphanCleanup(db);
const activitiesRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'activities');
const userDir = path.join(activitiesRoot, userName, 'original');
const cartella = dir;
const absCartella = path.join(userDir, cartella);
log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`);
await scanActivityCartella(
db,
userName,
cartella,
absCartella,
newActivities,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteFromDB,
onProgress
);
// Parsing e sincronizzazione remota sono ora responsabilita
// dell'ActivityIngestDaemon.
return newActivities;
}
module.exports = scanActivity;

View file

@ -0,0 +1,84 @@
// api_v1/scanner/scanActivityCartella.js
const scanActivityFiles = require('./scanActivityFiles');
const {
enqueueActivityIngestJobs,
} = require('./activityIngestQueue');
const { sha256 } = require('./utils');
const { log } = require('./logger');
const BATCH_SIZE = 100;
async function scanActivityCartella(
db,
user,
cartella,
absCartella,
newActivities,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteFromDB,
onProgress
) {
log(`📁 [SCAN ACTIVITY CARTELLA] ${cartella}`);
// Se la lettura del filesystem fallisce, lo scan deve fallire:
// non dobbiamo interpretare tutti i record come orfani.
const rows = await db('activities')
.where({ user, cartella })
.select('id');
const idsSet = new Set(rows.map(r => r.id));
const files = [];
// Prima scopriamo tutti i file. Nessun orfano viene cancellato
// se la scoperta o l'accodamento falliscono.
for await (const f of scanActivityFiles(user, cartella, absCartella, db)) {
files.push(f);
}
for (let offset = 0; offset < files.length; offset += BATCH_SIZE) {
const batch = files.slice(offset, offset + BATCH_SIZE);
const jobs = batch.map(f => ({
user,
cartella,
relPath: f.relPath,
mtimeMs: f.stat.mtimeMs,
sizeBytes: f.stat.size,
}));
// Una transazione per batch, anziché una per file.
const results = await enqueueActivityIngestJobs(db, jobs);
for (let i = 0; i < batch.length; i++) {
const f = batch[i];
const result = results[i];
CURRENT.value++;
idsSet.delete(sha256(`${user}/${cartella}/${f.relPath}`));
log(
`📥 [ACTIVITY QUEUE] ${f.relPath} ` +
`status=${result.status}` +
`${result.inserted ? ' inserted' : ''}` +
`${result.updated ? ' updated' : ''}` +
`${result.unchanged ? ' unchanged' : ''}`
);
}
// Aggiorna l'interfaccia una volta per batch, non per file.
await updateStatusFile(CURRENT, TOTAL_FILES, start);
if (onProgress) await onProgress();
}
// Questa sezione viene raggiunta soltanto se tutti i batch
// sono stati accodati correttamente.
for (const orphanId of idsSet) {
log(`🔴 [ORPHAN ACTIVITY] ${orphanId}`);
await deleteFromDB(orphanId, user);
}
}
module.exports = scanActivityCartella;

View file

@ -0,0 +1,45 @@
// api_v1/scanner/scanActivityFiles.js
const fsp = require('fs/promises');
const path = require('path');
const SUPPORTED_ACTIVITY_EXTS = new Set(['.fit', '.gpx', '.tcx']);
async function* scanActivityFiles(user, cartella, absCartella, db) {
async function* walk(dir) {
// Gli errori non vengono nascosti: una scansione incompleta
// non deve causare la cancellazione delle attività esistenti.
const entries = await fsp.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
const absPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
yield* walk(absPath);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!SUPPORTED_ACTIVITY_EXTS.has(ext)) continue;
const stat = await fsp.stat(absPath);
const relPath = path.relative(absCartella, absPath)
.split(path.sep).join('/');
yield {
id: `${cartella}/${relPath}`,
file_name: entry.name,
relPath,
absPath,
ext,
stat,
path: `/activities/${user}/original/${cartella}/${relPath}`,
};
}
}
yield* walk(absCartella);
}
module.exports = scanActivityFiles;

View file

@ -0,0 +1,367 @@
// api_v1/scanner/scanActivitySingle.js
const processActivityFile = require('./processActivityFile'); // parser FIT/GPX/TCX
const {
enqueueGeocodeJob,
} = require('./geocodeQueue');
const { sha256 } = require('./utils');
const { log } = require('./logger');
const {
withSqliteBusyRetry,
} = require('../../db/sqliteRetry');
// Se vuoi forzare il ricalcolo anche di file invariati:
// ACTIVITY_FORCE_REPARSE=1 node ...
const FORCE_REPARSE = process.env.ACTIVITY_FORCE_REPARSE === '1';
// Normalizza location come per le foto
function normCountryCode(value) {
if (!value) return undefined;
const s = String(value).trim().toUpperCase();
return s.length ? s : undefined;
}
function normalizeLocation(location) {
if (!location) return null;
const countryCode = normCountryCode(
location.country_code ||
location.countryCode ||
location.isoCountryCode
);
const countyCode =
location.county_code ||
location.countyCode ||
undefined;
return {
...location,
country: location.country || undefined,
country_code: countryCode,
county: location.county || undefined,
county_code: countyCode,
region: location.region || undefined,
city: location.city || undefined,
postcode: location.postcode || undefined,
address: location.address || undefined,
timezone: location.timezone || undefined,
time: location.time || undefined,
};
}
function serializeJson(value, fallback = null) {
if (value == null) return fallback;
try {
return JSON.stringify(value);
} catch {
return fallback;
}
}
async function scanActivitySingle(db, user, cartella, f, newActivities, prefix = '') {
// const fileName = f.name;
const fileName = f.file_name ?? f.name ?? f.relPath ?? 'unknown';
const st = f.stat;
// ---------------------------------------------------------
// ID e fastHash PRIMA del parsing pesante
// Deve essere identico a processActivityFile:
// sha256(`${userName}/${cartella}/${fileRelPath}`)
// ---------------------------------------------------------
const id = sha256(`${user}/${cartella}/${f.relPath}`);
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
// ---------------------------------------------------------
// HARD DELETED → non reimportare attività cancellate
// ---------------------------------------------------------
const hardDeleted = await db('deleted_hard_activities')
.where({ id })
.first();
if (hardDeleted) {
log(`${prefix} ♻️ Activity restored: ${fileName}`);
await withSqliteBusyRetry(
'activity hard-delete restore',
() => db('deleted_hard_activities')
.where({ id })
.del()
);
}
// ---------------------------------------------------------
// Recupero precedente usando id deterministico
// ---------------------------------------------------------
let prev = await db('activities')
.select('id', 'size_bytes', 'last_modified_millis', 'fast_hash', 'path', 'deleted_at')
.where({ id })
.first();
// ---------------------------------------------------------
// PATH CAMBIATO = tratta come nuovo/modificato
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(`${prefix} 🟠 Percorso attività cambiato: ${fileName}`);
// Manteniamo prev: l'ID esiste già e il ramo di aggiornamento
// aggiornerà path senza tentare un INSERT duplicato.
}
// ---------------------------------------------------------
// IDENTICO → SKIP IMMEDIATO, senza parsing FIT/GPX/TCX
// ---------------------------------------------------------
if (prev && prev.fast_hash === fastHash && !FORCE_REPARSE) {
log(`${prefix} ⏭️ Activity invariata, skip parser: ${fileName}`);
// Aggiorno solo dati leggeri. Non tocco date_modified_millis,
// così non genero changes inutili nel progressive sync.
await withSqliteBusyRetry(
'activity unchanged update',
() => db('activities')
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
last_modified_millis: st.mtimeMs,
last_scan_millis: Date.now(),
fast_hash: fastHash,
deleted_at: null,
updated_at: new Date().toISOString(),
})
);
return null;
}
// ---------------------------------------------------------
// Solo qui facciamo parsing completo FIT/GPX/TCX
// ---------------------------------------------------------
const meta = await processActivityFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.path = f.path;
meta.location = normalizeLocation(meta.location);
const trackPreviewJson = serializeJson(meta.track_preview || [], null);
const tagsJson = serializeJson(meta.tags || [], null);
const diagnosticsJson = serializeJson(meta.diagnostics || null, null);
const locationJson = serializeJson(meta.location || null, null);
// ---------------------------------------------------------
// NUOVA ATTIVITÀ
// ---------------------------------------------------------
if (!prev) {
log(`${prefix} 🟢 Nuova activity: ${fileName}`);
await withSqliteBusyRetry(
'activity insert',
() => db('activities').insert({
id: meta.id,
user,
cartella,
file_name: meta.file_name,
file_extension: meta.file_extension,
format: meta.format,
display_name: meta.display_name,
route_name: meta.route_name,
title: meta.title,
description: meta.description,
notes: meta.notes,
sport: meta.sport,
activity_type: meta.activity_type,
device_manufacturer: meta.device_manufacturer,
device_model: meta.device_model,
source_app: meta.source_app,
start_time_millis: meta.start_time_millis,
end_time_millis: meta.end_time_millis,
duration_millis: meta.duration_millis,
distance_meters: meta.distance_meters,
elevation_gain_meters: meta.elevation_gain_meters,
elevation_loss_meters: meta.elevation_loss_meters,
min_elevation_meters: meta.min_elevation_meters,
max_elevation_meters: meta.max_elevation_meters,
file_distance_meters: meta.file_distance_meters,
file_elevation_gain_meters: meta.file_elevation_gain_meters,
computed_distance_meters: meta.computed_distance_meters,
computed_elevation_gain_meters: meta.computed_elevation_gain_meters,
average_speed_mps: meta.average_speed_mps,
max_speed_mps: meta.max_speed_mps,
average_pace_sec_per_km: meta.average_pace_sec_per_km,
average_heart_rate: meta.average_heart_rate,
max_heart_rate: meta.max_heart_rate,
average_cadence: meta.average_cadence,
max_cadence: meta.max_cadence,
calories: meta.calories,
power_avg: meta.power_avg,
power_max: meta.power_max,
point_count: meta.point_count,
segment_count: meta.segment_count,
lap_count: meta.lap_count,
min_latitude: meta.min_latitude,
max_latitude: meta.max_latitude,
min_longitude: meta.min_longitude,
max_longitude: meta.max_longitude,
center_latitude: meta.center_latitude,
center_longitude: meta.center_longitude,
size_bytes: meta.size_bytes,
last_modified_millis: meta.last_modified_millis,
date_added_millis: meta.date_added_millis,
date_modified_millis: meta.date_modified_millis,
last_scan_millis: meta.last_scan_millis,
is_favorite: meta.is_favorite,
is_hidden: meta.is_hidden,
rating: meta.rating,
tags: tagsJson,
track_preview: trackPreviewJson,
scan_status: meta.scan_status,
scan_error: meta.scan_error,
path: meta.path,
diagnostics: diagnosticsJson,
location: locationJson,
fast_hash: fastHash,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
);
await enqueueGeocodeJob({
entityType: 'activity',
entityId: meta.id,
lat: meta.gps?.lat,
lon: meta.gps?.lng,
});
newActivities.push(meta);
return meta;
}
// ---------------------------------------------------------
// MODIFICATO oppure FORCE_REPARSE
// ---------------------------------------------------------
if (FORCE_REPARSE) {
log(`${prefix} 🔁 Force reparse activity: ${fileName}`);
} else {
log(`${prefix} 🟠 Modificato activity: ${fileName}`);
}
await withSqliteBusyRetry(
'activity modified update',
() => db('activities')
.where({ id })
.update({
file_name: meta.file_name,
file_extension: meta.file_extension,
format: meta.format,
display_name: meta.display_name,
route_name: meta.route_name,
title: meta.title,
description: meta.description,
notes: meta.notes,
sport: meta.sport,
activity_type: meta.activity_type,
device_manufacturer: meta.device_manufacturer,
device_model: meta.device_model,
source_app: meta.source_app,
start_time_millis: meta.start_time_millis,
end_time_millis: meta.end_time_millis,
duration_millis: meta.duration_millis,
distance_meters: meta.distance_meters,
elevation_gain_meters: meta.elevation_gain_meters,
elevation_loss_meters: meta.elevation_loss_meters,
min_elevation_meters: meta.min_elevation_meters,
max_elevation_meters: meta.max_elevation_meters,
file_distance_meters: meta.file_distance_meters,
file_elevation_gain_meters: meta.file_elevation_gain_meters,
computed_distance_meters: meta.computed_distance_meters,
computed_elevation_gain_meters: meta.computed_elevation_gain_meters,
average_speed_mps: meta.average_speed_mps,
max_speed_mps: meta.max_speed_mps,
average_pace_sec_per_km: meta.average_pace_sec_per_km,
average_heart_rate: meta.average_heart_rate,
max_heart_rate: meta.max_heart_rate,
average_cadence: meta.average_cadence,
max_cadence: meta.max_cadence,
calories: meta.calories,
power_avg: meta.power_avg,
power_max: meta.power_max,
point_count: meta.point_count,
segment_count: meta.segment_count,
lap_count: meta.lap_count,
min_latitude: meta.min_latitude,
max_latitude: meta.max_latitude,
min_longitude: meta.min_longitude,
max_longitude: meta.max_longitude,
center_latitude: meta.center_latitude,
center_longitude: meta.center_longitude,
size_bytes: meta.size_bytes,
last_modified_millis: meta.last_modified_millis,
date_added_millis: meta.date_added_millis,
date_modified_millis: meta.date_modified_millis,
last_scan_millis: meta.last_scan_millis,
is_favorite: meta.is_favorite,
is_hidden: meta.is_hidden,
rating: meta.rating,
tags: tagsJson,
track_preview: trackPreviewJson,
scan_status: meta.scan_status,
scan_error: meta.scan_error,
path: meta.path,
diagnostics: diagnosticsJson,
location: locationJson,
fast_hash: fastHash,
deleted_at: null,
updated_at: new Date().toISOString(),
})
);
await enqueueGeocodeJob({
entityType: 'activity',
entityId: meta.id,
lat: meta.gps?.lat,
lon: meta.gps?.lng,
});
newActivities.push(meta);
return meta;
}
module.exports = scanActivitySingle;

211
api_v1/scanner/scanAuto.js Normal file
View file

@ -0,0 +1,211 @@
const path = require('path');
const fsp = require('fs/promises');
const scanCartella = require('./scanCartella');
const processFile = require('./processFile');
const { sha256 } = require('./utils');
const createCleanupFunctions = require('./orphanCleanup');
const { WEB_ROOT } = require('../config');
const { log } = require('./logger');
// ---------------------------------------------------------
// NORMALIZZAZIONE PATH (toglie /app/...)
// ---------------------------------------------------------
function normalizeRelPath(user, dirPath, fileName) {
if (!dirPath) return "";
let rel = dirPath.replace(/\\/g, "/");
// Rimuove tutto fino a /photos/
rel = rel.replace(/^.*\/photos\//, "");
// Ora rel = "Fabio/original/2017Irlanda/.../"
const prefix = `${user}/original/`;
if (rel.startsWith(prefix)) {
rel = rel.slice(prefix.length);
}
if (rel.startsWith("/")) rel = rel.slice(1);
// Aggiunge il file
if (fileName) {
rel = `${rel}/${fileName}`.replace(/\/+/g, "/");
}
return rel; // es: "2017Irlanda19-29ago/IMG_0120.JPG"
}
// ---------------------------------------------------------
// CALCOLO ID IDENTICO AL VECCHIO SCAN
// ---------------------------------------------------------
function computeId(user, relPath) {
const parts = relPath.split('/').filter(Boolean);
const cartella = parts[0];
const fileRelPath = parts.slice(1).join('/');
const id = sha256(`${user}/${cartella}/${fileRelPath}`);
return { id, cartella, fileRelPath };
}
// ---------------------------------------------------------
// 1) ADD FILE
// ---------------------------------------------------------
async function handleAddFile(user, dirPath, fileName, db) {
const relPath = normalizeRelPath(user, dirPath, fileName);
log(`🟦 [ADD] user=${user} file=${relPath}`);
// 🔥 Ricostruzione identica al vecchio scanPhoto
const parts = relPath.split('/');
const cartella = parts[0]; // es: "2017Irlanda19-29ago"
const fileRelPath = parts.slice(1).join('/'); // es: "IMG_0116.JPG"
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const absPath = path.join(photosRoot, user, 'original', cartella, fileRelPath);
const ext = path.extname(fileName).toLowerCase();
const st = await fsp.stat(absPath);
// 🔥 FIX: processFile deve ricevere fileRelPath completo di "original/"
const meta = await processFile(
user,
cartella,
path.posix.join('original', cartella, fileRelPath),
absPath,
ext,
st
);
// Inserimento identico al vecchio scanPhoto
const row = {
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
cast: meta.cast,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms ?? null,
taken_at: meta.taken_at ?? null,
data: meta.data ?? null,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null
};
await db('photos')
.insert(row)
.onConflict('id')
.merge();
log(`🟢 [ADD] Inserito/aggiornato id=${meta.id}`);
return row;
}
// ---------------------------------------------------------
// 2) DELETE FILE
// ---------------------------------------------------------
async function handleDeleteFile(user, dirPath, fileName, db) {
const relPath = normalizeRelPath(user, dirPath, fileName);
log(`🟥 [DEL] user=${user} file=${relPath}`);
const { id } = computeId(user, relPath);
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
await deleteThumbsById(id);
await deleteFromDB(id, user);
log(`🔴 [DEL] File eliminato id=${id}`);
return { deleted: id };
}
// ---------------------------------------------------------
// 3) ADD DIRECTORY
// ---------------------------------------------------------
async function handleAddDir(user, dir, db) {
log(`🟦 [ADD_DIR] user=${user} dir=${dir}`);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const absDir = path.join(photosRoot, user, 'original', dir);
log(`📁 [ADD_DIR] absDir=${absDir}`);
let results = [];
for await (const f of scanCartella(user, dir, absDir, db)) {
log(`📄 [ADD_DIR] File trovato: ${f.relPath}`);
const meta = await processFile(
user,
dir,
f.relPath,
f.absPath,
f.ext,
f.stat
);
await db('photos').insert(meta).onConflict('id').merge();
log(`🟢 [ADD_DIR] Inserito/aggiornato id=${meta.id} name=${meta.name}`);
results.push(meta);
}
log(`🟣 [ADD_DIR] Completato. Files=${results.length}`);
return results;
}
// ---------------------------------------------------------
// 4) DELETE DIRECTORY
// ---------------------------------------------------------
async function handleDeleteDir(user, dir, db) {
log(`🟥 [DEL_DIR] user=${user} dir=${dir}`);
const rows = await db('photos')
.where({ user, cartella: dir })
.select('id');
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
log(`📁 [DEL_DIR] Trovati ${rows.length} file da eliminare`);
for (const r of rows) {
log(`🗑️ [DEL_DIR] Eliminazione id=${r.id}`);
await deleteThumbsById(r.id);
await deleteFromDB(r.id, user);
}
log(`🔴 [DEL_DIR] Cartella eliminata. Totale file=${rows.length}`);
return { deleted: rows.length };
}
module.exports = {
handleAddFile,
handleDeleteFile,
handleAddDir,
handleDeleteDir
};

View file

@ -0,0 +1,67 @@
// api_v1/scanner/scanCartella.js
const path = require('path');
const fsp = require('fs/promises');
const fs = require('fs');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* Scansiona ricorsivamente la cartella:
* /photos/<user>/original/<cartella>/
*
* Restituisce (yield) i metadati dei file trovati.
*/
async function* scanCartella(userName, cartella, absCartella, db) {
async function* walk(currentAbs, relPath = '') {
let entries = [];
try {
entries = await fsp.readdir(currentAbs, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const absPath = path.join(currentAbs, e.name);
if (e.isDirectory()) {
yield* walk(absPath, path.join(relPath, e.name));
continue;
}
const ext = path.extname(e.name).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) continue;
const fileRelPath = relPath ? `${relPath}/${e.name}` : e.name;
// ID deterministico basato sul percorso
const id = sha256(`${userName}/${cartella}/${fileRelPath}`);
let st;
try {
st = await fsp.stat(absPath);
} catch {
continue;
}
// Log per-file disabilitato: rallenta significativamente lo scan.
yield {
id,
user: userName,
cartella,
name: e.name,
relPath: fileRelPath,
absPath,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${fileRelPath}`
};
}
}
yield* walk(absCartella);
}
module.exports = scanCartella;

235
api_v1/scanner/scanFile.js Normal file
View file

@ -0,0 +1,235 @@
// api_v1/scanner/scanFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFileEntry(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
let st;
try {
st = await fsp.stat(absFile);
} catch {
log(`⛔ Impossibile leggere stat per ${absFile}`);
return null;
}
const id = sha256(`${userName}/${cartella}/${relPath}`);
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
async function scanFile(userName, cartella, absFile, db) {
const f = await scanFile(user, cart, absFile);
const fileName = f.name;
const id = f.id;
const st = f.stat;
let prev = await db("photos")
.select("id", "size_bytes", "mtimeMs", "_indexHash", "fast_hash", "path")
.where({ id })
.first();
if (prev && LOG_VERBOSE) {
log(`${prefix} ⚪ Invariato: ${fileName}`);
}
const fastHash = sha256(`${st.size}-${st.mtimeMs}`);
// ---------------------------------------------------------
// PATH CAMBIATO = NUOVO FILE
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
prev = null;
}
// ---------------------------------------------------------
// NUOVO FILE
// ---------------------------------------------------------
if (!prev) {
log(`${prefix} 🟢 Nuovo/Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
// INSERT CORRETTO (senza colonna gps)
await db("photos").insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
cast: meta.cast,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
_indexHash: meta._indexHash,
fast_hash: fastHash
});
await db('photo_changes').insert({
photo_id: meta.id,
user,
change_type: 'added',
timestamp: new Date().toISOString()
});
newFiles.push(meta);
//log(`🟢 [PUSH newFiles] id=${meta.id} path=${meta.path}`);
return;
}
// ---------------------------------------------------------
// FAST-SIZE-SKIP
// ---------------------------------------------------------
if (prev.size_bytes === st.size) {
//log(`🔵 [FAST-SIZE-SKIP] id=${id}`);
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs,
fast_hash: fastHash
});
continue;
}
// ---------------------------------------------------------
// FAST-HASH-SKIP
// ---------------------------------------------------------
if (prev.fast_hash === fastHash) {
//log(`🔵 [FAST-HASH-SKIP] id=${id}`);
await db("photos")
.where({ id })
.update({
path: f.path,
size_bytes: st.size,
mtimeMs: st.mtimeMs
});
continue;
}
// ---------------------------------------------------------
// MODIFICATO
// ---------------------------------------------------------
//log(`🟠 [FULL-SCAN] id=${id}`);
log(`${prefix} 🟠 Nuovo/Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
await db("photos")
.insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
cast: meta.cast,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location ? JSON.stringify(meta.location) : null,
_indexHash: meta._indexHash,
fast_hash: fastHash
})
.onConflict("id")
.merge();
await db('photo_changes').insert({
photo_id: meta.id,
user,
change_type: 'updated',
timestamp: new Date().toISOString()
});
newFiles.push(meta);
log(`${prefix} 🟠 Nuovo/Modificato al server ${fileName}`);
}
}
}
module.exports = scanFile;

View file

@ -0,0 +1,53 @@
// api_v1/scanner/scanFile.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFile(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
let st;
try {
st = await fsp.stat(absFile);
} catch {
log(`⛔ Impossibile leggere stat per ${absFile}`);
return null;
}
const id = sha256(`${userName}/${cartella}/${relPath}`);
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st,
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
module.exports = scanFile;

View file

@ -0,0 +1,55 @@
// api_v1/scanner/scanFileEntry.js
const path = require('path');
const fsp = require('fs/promises');
const { sha256 } = require('./utils');
const { SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
/**
* scanFile: genera informazioni su UN singolo file
* Restituisce lo stesso formato di scanCartella, ma senza recursion.
*
* Parametri:
* - userName
* - cartella
* - absFile (percorso assoluto del file)
* - db (opzionale)
*/
async function scanFile(userName, cartella, absFile, db) {
const ext = path.extname(absFile).toLowerCase();
if (!SUPPORTED_EXTS.has(ext)) {
log(`⛔ Estensione non supportata: ${ext}`);
return null;
}
const name = path.basename(absFile);
const relPath = name; // singolo file → niente struttura ricorsiva
// ID deterministico (funziona anche se il file non esiste)
const id = sha256(`${userName}/${cartella}/${relPath}`);
let st = null;
try {
st = await fsp.stat(absFile);
} catch {
// File NON esiste → caso DEL
log(`⛔ Impossibile leggere stat per ${absFile} (file mancante)`);
// Continuiamo comunque: l'ID è valido e serve per DEL
}
log(`📄 scanFile → user=${userName} cartella=${cartella} file=${name} id=${id}`);
return {
id,
user: userName,
cartella,
name,
relPath,
absPath: absFile,
ext,
stat: st, // può essere null
path: `/photos/${userName}/original/${cartella}/${relPath}`
};
}
module.exports = scanFile;

View file

@ -0,0 +1,76 @@
// api_v1/scanner/scanNewCartella.js
const path = require('path');
const fsp = require('fs/promises');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
const scanPhoto = require('./scanPhoto');
// ---------------------------------------------------------
// Conta ricorsivamente i file in /photos/<user>/original/<folder>/
// ---------------------------------------------------------
async function countFilesInUserFolder(userName, folderName) {
const rootDir = path.join(WEB_ROOT, userName, "original", folderName);
let count = 0;
async function walk(dir) {
let entries = [];
try {
entries = await fsp.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const abs = path.join(dir, e.name);
if (e.isDirectory()) {
await walk(abs);
} else {
const ext = path.extname(e.name).toLowerCase();
if (SUPPORTED_EXTS.has(ext)) {
count++;
}
}
}
}
await walk(rootDir);
return count;
}
// ---------------------------------------------------------
// Crea thumbs/<folder> e scansiona la cartella come fa /scan
// ---------------------------------------------------------
async function scanNewCartella(userName, folderName, db) {
// 1) crea la cartella root in thumbs
const thumbsDir = path.join(WEB_ROOT, userName, "thumbs", folderName);
await fsp.mkdir(thumbsDir, { recursive: true });
// 2) conta i file ricorsivamente
const TOTAL_FILES = await countFilesInUserFolder(userName, folderName);
// 3) esegui lo scan della cartella
const CURRENT = { value: 0 };
const start = Date.now();
// ⭐ newFiles conterrà i meta delle foto nuove
const newFiles = await scanPhoto(
folderName,
userName,
db,
CURRENT,
TOTAL_FILES,
start
);
return {
ok: true,
folder: folderName,
totalFiles: TOTAL_FILES,
newFiles
};
}
module.exports = {
scanNewCartella,
countFilesInUserFolder
};

111
api_v1/scanner/scanPhoto.js Normal file
View file

@ -0,0 +1,111 @@
// api_v1/scanner/scanPhoto.js
const path = require('path');
const { log } = require('./logger');
const scanPhotoCartella = require('./scanPhotoCartella');
const postWithAuth = require('./postWithAuth');
const createCleanupFunctions = require('./orphanCleanup');
const writeJsonAtomic = require('./writeJsonAtomic');
const {
WEB_ROOT,
SEND_PHOTOS,
BASE_URL
} = require('../config');
// ---------------------------------------------------------
// FORMAT TIME
// ---------------------------------------------------------
function formatTime(ms) {
const sec = Math.floor(ms / 1000);
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
const s = String(sec % 60).padStart(2, '0');
return `${h}:${m}:${s}`;
}
// ---------------------------------------------------------
// UPDATE STATUS FILE
// ---------------------------------------------------------
async function updateStatusFile(CURRENT, TOTAL_FILES, start) {
const now = Date.now();
const elapsedMs = now - start;
const avg = elapsedMs / Math.max(CURRENT.value, 1);
const remainingMs = (TOTAL_FILES - CURRENT.value) * avg;
const percent = TOTAL_FILES > 0
? Number(((CURRENT.value / TOTAL_FILES) * 100).toFixed(2))
: 100;
const status = {
current: CURRENT.value,
total: TOTAL_FILES,
percent,
eta: formatTime(remainingMs),
elapsed: formatTime(elapsedMs)
};
const statusPath = path.resolve(
__dirname,
'..',
'..',
'public/photos/scan_status.json'
);
await writeJsonAtomic(statusPath, status);
}
// ---------------------------------------------------------
// SCAN UNA SOLA CARTELLA
// ---------------------------------------------------------
async function scanPhoto(dir, userName, db, CURRENT, TOTAL_FILES, start, onProgress) {
const newFiles = [];
const { deleteThumbsById, deleteFromDB } = createCleanupFunctions(db);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const userDir = path.join(photosRoot, userName, 'original');
const cartella = dir;
const absCartella = path.join(userDir, cartella);
log(`📁 [SCAN CARTELLA] ${cartella}`);
await scanPhotoCartella(
db,
userName,
cartella,
absCartella,
newFiles,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress
);
// ---------------------------------------------------------
// INVIO AL SERVER REMOTO
// ---------------------------------------------------------
if (SEND_PHOTOS && BASE_URL && newFiles.length > 0) {
log(`📤 [SEND START] newFiles=${newFiles.length}`);
for (const p of newFiles) {
try {
p.user = userName;
await postWithAuth(`${BASE_URL}/photos`, p);
log(`📥 [SENT TO SERVER] ${p.name}`);
} catch (err) {
log(`❌ [SERVER SENT ERROR] ${p.name} → ${err.message}`);
}
}
} else {
log(`⚠️ [NO SEND] newFiles=${newFiles.length}`);
}
return newFiles;
}
module.exports = scanPhoto;

View file

@ -0,0 +1,141 @@
// api_v1/scanner/scanPhotoCartella.js
const scanCartella = require('./scanCartella');
const scanPhotoSingle = require('./scanPhotoSingle');
const { log } = require('./logger');
async function scanPhotoCartella(
db,
user,
cartella,
absCartella,
newFiles,
updateStatusFile,
CURRENT,
TOTAL_FILES,
start,
deleteThumbsById,
deleteFromDB,
onProgress
) {
log(`📁 Scan cartella: ${cartella}`);
let lastProgress = 0;
// ---------------------------------------------------------
// CARICA TUTTI I RECORD DELLA CARTELLA UNA SOLA VOLTA
// ---------------------------------------------------------
const rows = await db('photos')
.where({ user, cartella })
.select(
'id',
'size_bytes',
'mtimeMs',
'fast_hash',
'path'
);
// Map: id -> record DB
const photosMap = new Map(
rows.map(row => [row.id, row])
);
// Manteniamo il Set per individuare gli orfani
const idsSet = new Set(
rows.map(row => row.id)
);
log(
`📊 DB cartella=${cartella}: ${rows.length} record`
);
// ---------------------------------------------------------
// SCANSIONE FILESYSTEM
// ---------------------------------------------------------
for await (
const f of scanCartella(
user,
cartella,
absCartella,
db
)
) {
// Aggiorna contatore
CURRENT.value++;
// ---------------------------------------------------------
// PROGRESS THROTTLED
//
// Evita una scrittura su disco per ogni singolo file.
// Aggiorniamo al massimo ogni 500 ms.
// ---------------------------------------------------------
const now = Date.now();
if (now - lastProgress >= 500) {
lastProgress = now;
if (updateStatusFile) {
await updateStatusFile(
CURRENT,
TOTAL_FILES,
start
);
}
if (onProgress) {
await onProgress();
}
}
// Recupera il record DB dalla Map
const prev = photosMap.get(f.id) || null;
// Processa il file
await scanPhotoSingle(
db,
user,
cartella,
f,
newFiles,
'',
prev
);
// Il file esiste realmente nel filesystem:
// quindi non è un orfano.
idsSet.delete(f.id);
}
// ---------------------------------------------------------
// ULTIMO AGGIORNAMENTO PROGRESS
// ---------------------------------------------------------
if (updateStatusFile) {
await updateStatusFile(
CURRENT,
TOTAL_FILES,
start
);
}
if (onProgress) {
await onProgress();
}
// ---------------------------------------------------------
// GESTIONE ORFANI
// ---------------------------------------------------------
for (const orphanId of idsSet) {
log(`🔴 Orfano: ${orphanId}`);
await deleteThumbsById(orphanId);
await deleteFromDB(orphanId, user);
}
}
module.exports = scanPhotoCartella;

View file

@ -0,0 +1,423 @@
// api_v1/scanner/scanPhotoSingle.js
const path = require('path');
const fsp = require('fs/promises');
const processFile = require('./processFile');
const {
enqueueGeocodeJob,
} = require('./geocodeQueue');
const { sha256 } = require('./utils');
const { log } = require('./logger');
const { WEB_ROOT } = require('../config');
function normCountryCode(value) {
if (!value) return undefined;
const s = String(value).trim().toUpperCase();
return s.length ? s : undefined;
}
function normalizeLocation(location) {
if (!location) return null;
const countryCode = normCountryCode(
location.country_code ||
location.countryCode ||
location.isoCountryCode
);
const countyCode =
location.county_code ||
location.countyCode ||
undefined;
return {
...location,
// Paese
country: location.country || undefined,
country_code: countryCode,
// County / contea
county: location.county || undefined,
county_code: countyCode,
// Campi già esistenti
region: location.region || undefined,
city: location.city || undefined,
postcode: location.postcode || undefined,
address: location.address || undefined,
timezone: location.timezone || undefined,
time: location.time || undefined,
};
}
// ---------------------------------------------------------
// Controlla se un file esiste fisicamente
// ---------------------------------------------------------
async function fileExists(filePath) {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------
// Controlla i thumbnail fisici
//
// FOTO -> min + avg + cast
// VIDEO -> min + avg
// ---------------------------------------------------------
async function thumbnailsExist(user, cartella, f) {
const thumbBase = path.join(
WEB_ROOT,
'photos',
user,
'thumbs',
cartella,
path.dirname(f.relPath)
);
const parsed = path.parse(f.name);
const baseName = parsed.name;
const thumbMin = path.join(
thumbBase,
`${baseName}_min.jpg`
);
const thumbAvg = path.join(
thumbBase,
`${baseName}_avg.jpg`
);
const isVideo = [
'.mp4',
'.mov',
'.m4v'
].includes(f.ext.toLowerCase());
// I video NON hanno cast
if (isVideo) {
return (
await fileExists(thumbMin) &&
await fileExists(thumbAvg)
);
}
// Le foto richiedono soltanto MIN e AVG.
// La variante CAST viene generata esclusivamente on demand
// da POST /share/prepare-cast.
return (
await fileExists(thumbMin) &&
await fileExists(thumbAvg)
);
}
async function scanPhotoSingle(
db,
user,
cartella,
f,
newFiles,
prefix = '',
prev = null
) {
const fileName = f.name;
const id = f.id;
const st = f.stat;
// Hash veloce basato su size + mtime
const fastHash = sha256(
`${st.size}-${st.mtimeMs}`
);
// ---------------------------------------------------------
// PATH CAMBIATO = NUOVO FILE
// ---------------------------------------------------------
if (prev && prev.path !== f.path) {
log(
`${prefix} 🟢 Nuovo/Modificato (path changed): ${fileName}`
);
prev = null;
}
// ---------------------------------------------------------
// NUOVO FILE
// ---------------------------------------------------------
if (!prev) {
const profileStart = process.hrtime.bigint();
log(`${prefix} 🟢 Nuovo: ${fileName}`);
const processStart = process.hrtime.bigint();
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
const processMs =
Number(process.hrtime.bigint() - processStart) / 1e6;
log(
`⏱️ [PROFILE] ${fileName} processFile=${processMs.toFixed(2)} ms`
);
meta.id = id;
meta.path = f.path;
// Normalizza location prima di salvarla e inviarla
meta.location = normalizeLocation(
meta.location
);
log(
`🌍 [LOCATION NEW] ${fileName} ` +
`location=${JSON.stringify(meta.location)}`
);
const dbStart = process.hrtime.bigint();
await db('photos').insert({
id: meta.id,
user,
cartella,
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
cast: meta.cast,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
// Qui ora il JSON può contenere:
// country, country_code, county, county_code,
// region, city, ecc.
location: meta.location
? JSON.stringify(meta.location)
: null,
fast_hash: fastHash,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
});
const dbMs =
Number(process.hrtime.bigint() - dbStart) / 1e6;
log(
`⏱️ [PROFILE] ${fileName} DB insert=${dbMs.toFixed(2)} ms`
);
const geocodeStart = process.hrtime.bigint();
await enqueueGeocodeJob({
entityType: 'photo',
entityId: meta.id,
lat: meta.gps?.lat,
lon: meta.gps?.lng,
});
const geocodeMs =
Number(process.hrtime.bigint() - geocodeStart) / 1e6;
const totalMs =
Number(process.hrtime.bigint() - profileStart) / 1e6;
log(
`⏱️ [PROFILE] ${fileName} ` +
`geocode=${geocodeMs.toFixed(2)} ms ` +
`TOTAL=${totalMs.toFixed(2)} ms`
);
newFiles.push(meta);
return meta;
}
// ---------------------------------------------------------
// IDENTICO (fastHash uguale)
//
// Se il file è identico:
// - se tutti i thumbnail esistono -> non fare niente
// - se manca anche un solo thumbnail -> riprocessa
//
// FOTO -> min + avg + cast
// VIDEO -> min + avg
// ---------------------------------------------------------
if (prev.fast_hash === fastHash) {
const thumbsOk = await thumbnailsExist(
user,
cartella,
f
);
if (thumbsOk) {
return null;
}
// -------------------------------------------------------
// File identico ma thumbnail mancanti
// -------------------------------------------------------
log(
`${prefix} 🟡 File identico ma thumbnail mancanti: ${fileName}`
);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
// Normalizza location
meta.location = normalizeLocation(
meta.location
);
log(
`🌍 [LOCATION THUMBS REBUILD] ${fileName} ` +
`location=${JSON.stringify(meta.location)}`
);
await db('photos')
.where({ id })
.update({
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
cast: meta.cast,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
location: meta.location
? JSON.stringify(meta.location)
: null,
fast_hash: fastHash,
updated_at: new Date().toISOString(),
});
await enqueueGeocodeJob({
entityType: 'photo',
entityId: meta.id,
lat: meta.gps?.lat,
lon: meta.gps?.lng,
});
newFiles.push(meta);
return meta;
}
// ---------------------------------------------------------
// MODIFICATO (fastHash diverso)
// ---------------------------------------------------------
log(`${prefix} 🟠 Modificato: ${fileName}`);
const meta = await processFile(
user,
cartella,
f.relPath,
f.absPath,
f.ext,
st
);
meta.id = id;
meta.path = f.path;
// Normalizza location prima di salvarla e inviarla
meta.location = normalizeLocation(
meta.location
);
log(
`🌍 [LOCATION UPDATED] ${fileName} ` +
`location=${JSON.stringify(meta.location)}`
);
await db('photos')
.where({ id })
.update({
name: meta.name,
path: meta.path,
thub1: meta.thub1,
thub2: meta.thub2,
mime_type: meta.mime_type,
width: meta.width,
height: meta.height,
rotation: meta.rotation,
size_bytes: meta.size_bytes,
mtimeMs: meta.mtimeMs,
duration_ms: meta.duration_ms,
taken_at: meta.taken_at,
data: meta.data,
lat: meta.gps?.lat ?? null,
lon: meta.gps?.lng ?? null,
alt: meta.gps?.alt ?? null,
// Qui ora il JSON può contenere:
// country, country_code, county, county_code,
// region, city, ecc.
location: meta.location
? JSON.stringify(meta.location)
: null,
fast_hash: fastHash,
updated_at: new Date().toISOString(),
});
await enqueueGeocodeJob({
entityType: 'photo',
entityId: meta.id,
lat: meta.gps?.lat,
lon: meta.gps?.lng,
});
newFiles.push(meta);
return meta;
}
module.exports = scanPhotoSingle;

View file

@ -0,0 +1,143 @@
'use strict';
// Scansione completa: individua i file e li accoda.
// L'elaborazione effettiva è responsabilità di photoIngestWorker.
const path = require('path');
const fsp = require('fs/promises');
const crypto = require('crypto');
const { WEB_ROOT, SUPPORTED_EXTS } = require('../config');
const { log } = require('./logger');
const scanCartella = require('./scanCartella');
const {
ensurePhotoIngestQueue,
enqueuePhotoIngestJob,
} = require('./photoIngestQueue');
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
async function thumbnailsExist(userName, cartella, relPath) {
const parsed = path.parse(relPath);
const thumbDir = path.resolve(
__dirname,
'..',
'..',
WEB_ROOT,
'photos',
userName,
'thumbs',
cartella,
parsed.dir
);
const base = path.join(thumbDir, parsed.name);
try {
await Promise.all([
fsp.access(`${base}_min.jpg`),
fsp.access(`${base}_avg.jpg`),
]);
return true;
} catch {
return false;
}
}
async function scanPhotosUser(userName, db) {
log(`🔵 Inizio scansione e accodamento per user=${userName}`);
const photosRoot = path.resolve(__dirname, '..', '..', WEB_ROOT, 'photos');
const userDir = path.join(photosRoot, userName, 'original');
let entries;
try {
entries = await fsp.readdir(userDir, { withFileTypes: true });
} catch {
log(`❌ Nessuna directory per utente ${userName}`);
return [];
}
const folders = entries
.filter(entry => entry.isDirectory())
.map(entry => entry.name);
// Mantiene la pulizia delle cartelle eliminate dal filesystem.
const { deleteFolderForUser } = require('./deleteFolder')(db);
const dbFolders = await db('photos')
.where({ user: userName })
.distinct('cartella')
.pluck('cartella');
for (const dbFolder of dbFolders) {
if (!folders.includes(dbFolder)) {
log(`🗑️ Cartella rimossa dal filesystem: ${dbFolder}`);
await deleteFolderForUser(userName, dbFolder);
}
}
await ensurePhotoIngestQueue(db);
let queued = 0;
let skipped = 0;
for (const cartella of folders) {
const absCartella = path.join(userDir, cartella);
log(`📁 Controllo cartella: ${cartella}`);
for await (const file of scanCartella(userName, cartella, absCartella, db)) {
const previous = await db('photos')
.select('id', 'size_bytes', 'mtimeMs', 'fast_hash', 'path')
.where({ id: file.id })
.first();
const expectedHash = sha256(`${file.stat.size}-${file.stat.mtimeMs}`);
const expectedPath = file.path;
let needsProcessing =
!previous ||
previous.path !== expectedPath ||
previous.fast_hash !== expectedHash;
if (!needsProcessing) {
needsProcessing = !(await thumbnailsExist(
userName,
cartella,
file.relPath
));
}
if (needsProcessing) {
await enqueuePhotoIngestJob(db, {
user: userName,
cartella,
relPath: file.relPath,
mtimeMs: file.stat.mtimeMs,
sizeBytes: file.stat.size,
});
queued++;
} else {
skipped++;
}
}
}
log(
`🟣 Scansione/accodamento terminato per ${userName}: ` +
`${queued} accodati, ${skipped} invariati`
);
// Restituisce un array per compatibilità con chi già chiama questa funzione.
// Non contiene risultati elaborati: quelli arriveranno dal worker.
return [];
}
module.exports = scanPhotosUser;

View file

@ -0,0 +1,25 @@
// api_v1/scanner/scanUser.js
const scanPhotosUser = require('./scanPhotosUser');
const scanActivitiesUser = require('./scanActivitiesUser');
const { log } = require('./logger');
async function scanUser(userName, db) {
log(`🔵 Inizio scan COMPLETO per user=${userName}`);
// 🔥 SCAN FOTO
const newPhotos = await scanPhotosUser(userName, db);
// 🔥 SCAN ATTIVITÀ
const newActivities = await scanActivitiesUser(userName, db);
log(`🟣 Scan COMPLETATO per user=${userName}`);
log(` 📸 Nuove foto: ${newPhotos.length}`);
log(` 🏃‍♂️ Nuove attività: ${newActivities.length}`);
return {
photos: newPhotos,
activities: newActivities
};
}
module.exports = scanUser;

288
api_v1/scanner/thumbs.js Normal file
View file

@ -0,0 +1,288 @@
// api_v1/scanner/thumbs.js
const fs = require('fs');
const sharp = require('sharp');
const { exec } = require('child_process');
const path = require('path');
/*
* ============================================================
* CONFIGURAZIONE IMMAGINE CAST
* ============================================================
*/
/*
* Se il file originale è JPEG e pesa meno di questa soglia,
* il file _cast.jpg sarà una copia identica dell'originale.
*
* 500 * 1024 = 512000 byte.
*/
const CAST_JPEG_COPY_LIMIT_BYTES = 500 * 1024;
/*
* Dimensioni massime dell'immagine Cast.
*
* L'immagine viene sempre contenuta dentro questo rettangolo,
* senza crop e senza ingrandimento.
*/
const CAST_MAX_WIDTH = 1920;
const CAST_MAX_HEIGHT = 1080;
/*
* Qualità JPEG usata quando è necessaria la conversione.
*/
const CAST_JPEG_QUALITY = 85;
/*
* Video: crea soltanto AVG e MIN.
*/
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, error => {
if (error) {
console.error(
'[THUMB VIDEO] errore creazione thumbnail',
{
videoPath,
thumbMinPath,
thumbAvgPath,
error: error.message,
}
);
} else {
console.log(
'[THUMB VIDEO] thumbnails create',
{
videoPath,
thumbMinPath,
thumbAvgPath,
}
);
}
/*
* Mantiene il comportamento originale:
* la Promise viene risolta anche in caso di errore.
*/
resolve();
});
});
}
/*
* Crea la versione CAST dell'immagine.
*
* Regole:
*
* 1. Se l'originale è JPEG e pesa meno di 500 KB:
* copia byte per byte l'originale nel file _cast.jpg.
*
* 2. In tutti gli altri casi:
* converte l'immagine in JPEG qualità 85.
*
* 3. Se l'immagine supera 1920x1080:
* viene ridimensionata per stare dentro 1920x1080.
*
* 4. Se l'immagine è già più piccola:
* mantiene le sue dimensioni, senza ingrandirla.
*
* 5. Non viene mai effettuato crop.
*/
async function createCastImage(
filePath,
finalCastPath
) {
const sourceStat =
await fs.promises.stat(filePath);
const metadata =
await sharp(filePath).metadata();
/*
* metadata.format === "jpeg" riconosce il contenuto reale
* dell'immagine, indipendentemente dall'estensione .jpg o .jpeg.
*/
const sourceIsJpeg =
metadata.format === 'jpeg';
const sourceIsUnderCopyLimit =
sourceStat.size <
CAST_JPEG_COPY_LIMIT_BYTES;
/*
* JPEG sotto 500 KB:
* copia identica dell'originale.
*
* Non viene effettuata:
*
* - ricompressione;
* - rotazione;
* - modifica dei metadati;
* - modifica delle dimensioni.
*/
if (
sourceIsJpeg &&
sourceIsUnderCopyLimit
) {
await fs.promises.copyFile(
filePath,
finalCastPath
);
const castStat =
await fs.promises.stat(finalCastPath);
console.log(
'[THUMB CAST] originale JPEG copiato',
{
source: filePath,
destination: finalCastPath,
sourceBytes: sourceStat.size,
castBytes: castStat.size,
copyLimitBytes:
CAST_JPEG_COPY_LIMIT_BYTES,
sourceFormat: metadata.format,
sourceWidth: metadata.width || null,
sourceHeight: metadata.height || null,
}
);
return;
}
/*
* Tutti gli altri casi:
*
* - JPEG da almeno 500 KB;
* - PNG;
* - WebP;
* - HEIC/HEIF, se supportato da Sharp;
* - TIFF;
* - altri formati decodificabili da Sharp.
*
* L'immagine viene convertita in JPEG qualità 85.
*
* withoutEnlargement: true garantisce che un'immagine più
* piccola di 1920x1080 mantenga le proprie dimensioni.
*/
await sharp(filePath)
.rotate()
.resize({
width: CAST_MAX_WIDTH,
height: CAST_MAX_HEIGHT,
fit: 'inside',
withoutEnlargement: true,
fastShrinkOnLoad: true,
})
.jpeg({
quality: CAST_JPEG_QUALITY,
progressive: false,
optimizeCoding: true,
})
.toFile(finalCastPath);
const castStat =
await fs.promises.stat(finalCastPath);
const castMetadata =
await sharp(finalCastPath).metadata();
console.log(
'[THUMB CAST] immagine convertita',
{
source: filePath,
destination: finalCastPath,
sourceBytes: sourceStat.size,
castBytes: castStat.size,
sourceFormat:
metadata.format || null,
sourceWidth:
metadata.width || null,
sourceHeight:
metadata.height || null,
castFormat:
castMetadata.format || null,
castWidth:
castMetadata.width || null,
castHeight:
castMetadata.height || null,
quality: CAST_JPEG_QUALITY,
maxWidth: CAST_MAX_WIDTH,
maxHeight: CAST_MAX_HEIGHT,
reason: sourceIsJpeg
? 'JPEG sopra o uguale alla soglia'
: 'formato originale non JPEG',
}
);
}
/*
* Foto: crea MIN, AVG e CAST.
*/
async function createThumbnails(
filePath,
thumbMinPath,
thumbAvgPath
) {
try {
const avgBuffer = await sharp(filePath)
.rotate()
.resize({
width: 400,
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({
quality: 78,
progressive: false,
optimizeCoding: true,
})
.toBuffer();
await Promise.all([
fs.promises.writeFile(thumbAvgPath, avgBuffer),
sharp(avgBuffer)
.resize({
width: 100,
height: 100,
fit: 'inside',
withoutEnlargement: true,
})
.jpeg({
quality: 75,
progressive: false,
optimizeCoding: true,
})
.toFile(thumbMinPath),
]);
} catch (err) {
console.error(
'[THUMB] errore creazione thumbnails',
{
filePath,
thumbMinPath,
thumbAvgPath,
error: err.message,
stack: err.stack,
}
);
}
}
module.exports = {
createVideoThumbnail,
createThumbnails,
};

41
api_v1/scanner/utils.js Normal file
View file

@ -0,0 +1,41 @@
const path = require('path');
const crypto = require('crypto');
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();
}
module.exports = {
toPosix,
sha256,
inferMimeFromExt,
parseExifDateUtc
};

17
api_v1/scanner/video.js Normal file
View file

@ -0,0 +1,17 @@
const { exec } = require('child_process');
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({});
}
});
});
}
module.exports = { probeVideo };

View file

@ -0,0 +1,17 @@
// api_v1/scanner/writeJsonAtomic.js
const path = require('path');
const fsp = require('fs/promises');
async function writeJsonAtomic(filePath, data) {
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random()
.toString(16)
.slice(2)}.tmp`;
const json = JSON.stringify(data, null, 2);
await fsp.mkdir(path.dirname(filePath), { recursive: true });
await fsp.writeFile(tmpPath, json, 'utf8');
await fsp.rename(tmpPath, filePath);
}
module.exports = writeJsonAtomic;

51
api_v1/tools.js Normal file
View file

@ -0,0 +1,51 @@
/**
* Required libraries
*/
const bcrypt = require('bcrypt')
const readLine = require('readline')
const async = require('async')
// Password hash method
const hashPassword = plain => bcrypt.hashSync(plain, 8)
// Ask user password method
function askPassword(question) {
return new Promise((resolve, reject) => {
const rl = readLine.createInterface({
input: process.stdin,
output: process.stdout
})
rl.question(question, answer => {
rl.close()
resolve(answer)
})
})
}
// Generate hash password method
async function generateHash() {
try {
console.log('**********************************')
console.log('** Password hash script **')
console.log('**********************************')
const passwordAnswer = await askPassword(
'Please give me a password to hash: '
)
if (passwordAnswer != '') {
const hashedPassword = hashPassword(passwordAnswer)
const compare = bcrypt.compareSync(passwordAnswer, hashedPassword)
await console.log('Hashed password:', hashedPassword)
await console.log('Valdiation:', compare)
} else {
console.log('You need write something. Script aborted!')
}
} catch (err) {
console.log(err)
return process.exit(1)
}
}
generateHash()

159
api_v1/userAccess.js Normal file
View file

@ -0,0 +1,159 @@
const fs = require('fs');
const path = require('path');
const usersPath = path.resolve(__dirname, 'users.json');
function normalize(value) {
return String(value ?? '').trim();
}
function unique(values) {
return [
...new Set(
values
.map(normalize)
.filter(Boolean)
)
];
}
function loadUsers() {
const raw = fs.readFileSync(usersPath, 'utf8');
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.users)) {
throw new Error('Formato users.json non valido');
}
return data.users;
}
function findUserByName(userName) {
const requestedName = normalize(userName).toLowerCase();
if (!requestedName) {
return null;
}
return (
loadUsers().find((user) => {
return normalize(user.name).toLowerCase() === requestedName;
}) || null
);
}
function getUserAccess(userName) {
const user = findUserByName(userName);
if (!user) {
return null;
}
const devices = Array.isArray(user.devices)
? unique(user.devices)
: [];
const usersR = Array.isArray(user.usersR)
? unique(user.usersR)
: [];
const usersW = Array.isArray(user.usersW)
? unique(user.usersW)
: [];
return {
id: user.id,
name: normalize(user.name),
devices,
usersR,
usersW,
// Contenuti leggibili nella galleria
readableSources: unique([
...usersR,
...devices
]),
// Contenuti modificabili
writableSources: unique([
...usersW,
...devices
])
};
}
function getReadableSources(userName) {
const access = getUserAccess(userName);
return access
? access.readableSources
: [];
}
function getWritableSources(userName) {
const access = getUserAccess(userName);
return access
? access.writableSources
: [];
}
function filterReadableSources(userName, requestedSources) {
const readableSources = getReadableSources(userName);
if (!requestedSources) {
return readableSources;
}
const requested = Array.isArray(requestedSources)
? requestedSources
: [requestedSources];
const readableMap = new Map(
readableSources.map((source) => [
source.toLowerCase(),
source
])
);
return unique(requested)
.map((source) => {
return readableMap.get(source.toLowerCase());
})
.filter(Boolean);
}
function canRead(userName, sourceName) {
const source = normalize(sourceName).toLowerCase();
if (!source) {
return false;
}
return getReadableSources(userName).some((allowedSource) => {
return allowedSource.toLowerCase() === source;
});
}
function canWrite(userName, sourceName) {
const source = normalize(sourceName).toLowerCase();
if (!source) {
return false;
}
return getWritableSources(userName).some((allowedSource) => {
return allowedSource.toLowerCase() === source;
});
}
module.exports = {
loadUsers,
findUserByName,
getUserAccess,
getReadableSources,
getWritableSources,
filterReadableSources,
canRead,
canWrite
};

31
api_v1/users.json Normal file
View file

@ -0,0 +1,31 @@
{
"users": [
{
"id": 1,
"name": "Admin",
"email": "admin@gmail.com",
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.",
"devices": [],
"usersR": ["Common", "Fabio", "Jessica", "Fabio13TPro", "Fabio11TPro"],
"usersW": ["Common", "Fabio", "Jessica", "Fabio13TPro", "Fabio11TPro"]
},
{
"id": 2,
"name": "Fabio",
"email": "fabio@gmail.com",
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.",
"devices": ["Fabio13TPro", "Fabio11TPro"],
"usersR": ["Common", "Fabio"],
"usersW": ["Fabio"]
},
{
"id": 3,
"name": "Jessica",
"email": "jessie@libero.it",
"password": "$2b$08$g0UWN6RnN7e.8rX3fuXSSOSJDTvucu./0FAU.yXp0wx4SJXyeaU3.",
"devices": [],
"usersR": ["Common", "Jessica"],
"usersW": ["Jessica"]
}
]
}

BIN
bin/face_scan Executable file

Binary file not shown.

51
clear.sh Executable file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "=== RESET s175js ==="
echo "Directory: $(pwd)"
# Verifica i PID registrati nei lock prima di rimuoverli.
for file in face-scan-daemon.lock prototype-worker-daemon.lock geocode-daemon.lock; do
[ -f "$file" ] || continue
pid=""
if [[ "$file" == "prototype-worker-daemon.lock" ]]; then
pid=$(tr -cd '0-9' < "$file")
else
pid=$(node -e '
try {
const x = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
console.log(x.pid || "");
} catch {}
' "$file")
fi
if [[ -n "$pid" && -d "/proc/$pid" ]]; then
echo "ERRORE: $file segnala il PID attivo $pid. Reset annullato."
exit 1
fi
done
# Backup del database prima della cancellazione.
if [ -f api_v1/database.sqlite ]; then
backup="api_v1/database.sqlite.pre-clear-$(date +%Y%m%d-%H%M%S)"
cp api_v1/database.sqlite "$backup"
echo "Backup creato: $backup"
fi
rm -f \
face-scan-daemon.lock \
prototype-worker-daemon.lock \
geocode-daemon.lock \
api_v1/database.sqlite \
api_v1/database.sqlite-wal \
api_v1/database.sqlite-shm
echo "=== INIZIALIZZAZIONE DATABASE ==="
node db/init.js
echo "=== CONTROLLO INTEGRITÀ ==="
sqlite3 api_v1/database.sqlite "PRAGMA integrity_check;"
echo "=== RESET COMPLETATO ==="

209
db/activities.js Normal file
View file

@ -0,0 +1,209 @@
// db/activities.js
const knex = require('./knex');
/* ============================================================
INSERT OR UPDATE ACTIVITY
============================================================ */
async function insertOrUpdateActivity(row) {
const exists = await knex('activities').where({ id: row.id }).first();
if (!exists) {
await knex('activities').insert({
id: row.id,
user: row.user,
cartella: row.cartella,
file_name: row.file_name,
file_extension: row.file_extension,
format: row.format,
display_name: row.display_name,
route_name: row.route_name,
title: row.title,
description: row.description,
notes: row.notes,
sport: row.sport,
activity_type: row.activity_type,
device_manufacturer: row.device_manufacturer,
device_model: row.device_model,
source_app: row.source_app,
start_time_millis: row.start_time_millis,
end_time_millis: row.end_time_millis,
duration_millis: row.duration_millis,
distance_meters: row.distance_meters,
elevation_gain_meters: row.elevation_gain_meters,
elevation_loss_meters: row.elevation_loss_meters,
min_elevation_meters: row.min_elevation_meters,
max_elevation_meters: row.max_elevation_meters,
file_distance_meters: row.file_distance_meters,
file_elevation_gain_meters: row.file_elevation_gain_meters,
computed_distance_meters: row.computed_distance_meters,
computed_elevation_gain_meters: row.computed_elevation_gain_meters,
average_speed_mps: row.average_speed_mps,
max_speed_mps: row.max_speed_mps,
average_pace_sec_per_km: row.average_pace_sec_per_km,
average_heart_rate: row.average_heart_rate,
max_heart_rate: row.max_heart_rate,
average_cadence: row.average_cadence,
max_cadence: row.max_cadence,
calories: row.calories,
power_avg: row.power_avg,
power_max: row.power_max,
point_count: row.point_count,
segment_count: row.segment_count,
lap_count: row.lap_count,
min_latitude: row.min_latitude,
max_latitude: row.max_latitude,
min_longitude: row.min_longitude,
max_longitude: row.max_longitude,
center_latitude: row.center_latitude,
center_longitude: row.center_longitude,
size_bytes: row.size_bytes,
last_modified_millis: row.last_modified_millis,
date_added_millis: row.date_added_millis,
date_modified_millis: row.date_modified_millis,
last_scan_millis: row.last_scan_millis,
is_favorite: row.is_favorite,
is_hidden: row.is_hidden,
rating: row.rating,
tags: row.tags ? JSON.stringify(row.tags) : null,
scan_status: row.scan_status,
scan_error: row.scan_error,
diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : null,
location: row.location ? JSON.stringify(row.location) : null,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
} else {
await knex('activities').where({ id: row.id }).update({
// aggiorna solo i campi che possono cambiare
file_name: row.file_name || exists.file_name,
file_extension: row.file_extension || exists.file_extension,
format: row.format || exists.format,
display_name: row.display_name || exists.display_name,
route_name: row.route_name || exists.route_name,
title: row.title || exists.title,
description: row.description || exists.description,
notes: row.notes || exists.notes,
sport: row.sport || exists.sport,
activity_type: row.activity_type || exists.activity_type,
device_manufacturer: row.device_manufacturer || exists.device_manufacturer,
device_model: row.device_model || exists.device_model,
source_app: row.source_app || exists.source_app,
start_time_millis: row.start_time_millis || exists.start_time_millis,
end_time_millis: row.end_time_millis || exists.end_time_millis,
duration_millis: row.duration_millis || exists.duration_millis,
distance_meters: row.distance_meters || exists.distance_meters,
elevation_gain_meters: row.elevation_gain_meters || exists.elevation_gain_meters,
elevation_loss_meters: row.elevation_loss_meters || exists.elevation_loss_meters,
min_elevation_meters: row.min_elevation_meters || exists.min_elevation_meters,
max_elevation_meters: row.max_elevation_meters || exists.max_elevation_meters,
file_distance_meters: row.file_distance_meters || exists.file_distance_meters,
file_elevation_gain_meters: row.file_elevation_gain_meters || exists.file_elevation_gain_meters,
computed_distance_meters: row.computed_distance_meters || exists.computed_distance_meters,
computed_elevation_gain_meters: row.computed_elevation_gain_meters || exists.computed_elevation_gain_meters,
average_speed_mps: row.average_speed_mps || exists.average_speed_mps,
max_speed_mps: row.max_speed_mps || exists.max_speed_mps,
average_pace_sec_per_km: row.average_pace_sec_per_km || exists.average_pace_sec_per_km,
average_heart_rate: row.average_heart_rate || exists.average_heart_rate,
max_heart_rate: row.max_heart_rate || exists.max_heart_rate,
average_cadence: row.average_cadence || exists.average_cadence,
max_cadence: row.max_cadence || exists.max_cadence,
calories: row.calories || exists.calories,
power_avg: row.power_avg || exists.power_avg,
power_max: row.power_max || exists.power_max,
point_count: row.point_count || exists.point_count,
segment_count: row.segment_count || exists.segment_count,
lap_count: row.lap_count || exists.lap_count,
min_latitude: row.min_latitude || exists.min_latitude,
max_latitude: row.max_latitude || exists.max_latitude,
min_longitude: row.min_longitude || exists.min_longitude,
max_longitude: row.max_longitude || exists.max_longitude,
center_latitude: row.center_latitude || exists.center_latitude,
center_longitude: row.center_longitude || exists.center_longitude,
size_bytes: row.size_bytes || exists.size_bytes,
last_modified_millis: row.last_modified_millis || exists.last_modified_millis,
date_added_millis: row.date_added_millis || exists.date_added_millis,
date_modified_millis: row.date_modified_millis || exists.date_modified_millis,
last_scan_millis: row.last_scan_millis || exists.last_scan_millis,
is_favorite: row.is_favorite ?? exists.is_favorite,
is_hidden: row.is_hidden ?? exists.is_hidden,
rating: row.rating ?? exists.rating,
tags: row.tags ? JSON.stringify(row.tags) : exists.tags,
scan_status: row.scan_status || exists.scan_status,
scan_error: row.scan_error || exists.scan_error,
diagnostics: row.diagnostics ? JSON.stringify(row.diagnostics) : exists.diagnostics,
location: row.location ? JSON.stringify(row.location) : exists.location,
updated_at: knex.fn.now()
});
}
}
/* ============================================================
HARD DELETE
============================================================ */
async function markActivityDeletedHard(id, user) {
await knex('activities').where({ id, user }).del();
await knex('deleted_hard_activities')
.insert({ id, user, deleted_at: knex.fn.now() })
.catch(() => {});
}
/* ============================================================
CHANGES SINCE
============================================================ */
async function getActivitiesSince(user, sinceIso, limit = 1000) {
return knex('activities')
.where({ user })
.andWhere('updated_at', '>', sinceIso)
.orderBy('updated_at', 'asc')
.limit(limit)
.select('*');
}
/* ============================================================
DELETED HARD SINCE
============================================================ */
async function getDeletedHardActivitiesSince(user, sinceIso) {
return knex('deleted_hard_activities')
.where({ user })
.andWhere('deleted_at', '>', sinceIso)
.select('id', 'deleted_at');
}
/* ============================================================
EXPORT
============================================================ */
module.exports = {
insertOrUpdateActivity,
markActivityDeletedHard,
getActivitiesSince,
getDeletedHardActivitiesSince
};

142
db/dbWs.js Normal file
View file

@ -0,0 +1,142 @@
// db/dbWs.js
console.log(">>> dbWs.js CARICATO:", __filename);
const knex = require("./knex");
// ===============================
// SESSIONI
// ===============================
async function upsertSession(session_id, user, device_id) {
const now = Date.now();
const existing = await knex("ws_sessions")
.where({ session_id, device_id })
.first();
if (!existing) {
await knex("ws_sessions").insert({
session_id,
user,
device_id,
connected_at: now,
last_ack: now,
last_sync: null,
need_full_sync: false
});
} else {
await knex("ws_sessions")
.where({ session_id, device_id })
.update({
user,
connected_at: now,
last_ack: now
});
}
}
async function getDevicesForSession(session_id) {
const rows = await knex('ws_sessions')
.where({ session_id })
.select('device_id');
return rows.map(r => r.device_id);
}
async function getSession(session_id, device_id) {
return knex("ws_sessions")
.where({ session_id, device_id })
.first();
}
async function updateSessionAck(session_id, device_id) {
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ last_ack: Date.now() });
}
async function setSessionNeedFullSync(session_id, device_id, value) {
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ need_full_sync: value });
}
async function clearNeedFullSync(session_id, device_id) {
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ need_full_sync: false });
}
async function getSessionsByUser(user) {
return knex("ws_sessions").where({ user });
}
async function deleteSession(session_id, device_id) {
await knex("ws_sessions").where({ session_id, device_id }).del();
await knex("pending_events").where({ session_id, device_id }).del();
}
// ===============================
// PENDING EVENTS (nuovo modello)
// ===============================
async function insertPendingEvent({ user_id, session_id, device_id, event_id, payload, created_at }) {
return knex("pending_events").insert({
user_id,
session_id,
device_id,
event_id,
payload,
created_at
});
}
async function deletePendingEvent({ user_id, session_id, device_id, event_id }) {
return knex("pending_events")
.where({ user_id, session_id, device_id, event_id })
.delete();
}
async function getPendingEvents(user_id, session_id, device_id) {
return knex("pending_events")
.where({ user_id, session_id, device_id })
.orderBy("created_at", "asc");
}
async function deleteAllSessions() {
console.log(">>> deleteAllSessions() – elimino tutte le sessioni WS");
return knex("ws_sessions").del();
}
async function deleteAllPendingEvents() {
console.log(">>> deleteAllPendingEvents() – elimino tutti i pending events");
return knex("pending_events").del();
}
async function setLastSync(session_id, device_id, timestamp = null) {
const ts = timestamp || Date.now();
return knex("ws_sessions")
.where({ session_id, device_id })
.update({ last_sync: ts });
}
// ===============================
// EXPORT
// ===============================
module.exports = {
upsertSession,
getSession,
updateSessionAck,
setSessionNeedFullSync,
clearNeedFullSync,
deleteSession,
getSessionsByUser,
insertPendingEvent,
deletePendingEvent,
getPendingEvents,
getDevicesForSession,
setLastSync,
deleteAllSessions,
deleteAllPendingEvents
};

219
db/faceScanAutomation.js Normal file
View file

@ -0,0 +1,219 @@
async function ensureFaceScanAutomation(db) {
const triggerNames = [
'photos_face_enqueue_insert',
'photos_face_enqueue_update',
'photos_face_cleanup_update',
'photos_face_cleanup_delete',
];
for (const triggerName of triggerNames) {
await db.raw(
`DROP TRIGGER IF EXISTS ${triggerName}`,
);
}
await db.raw(`
CREATE TRIGGER photos_face_enqueue_insert
AFTER INSERT ON photos
WHEN
NEW.deleted_at IS NULL
AND NEW.path IS NOT NULL
AND NEW.path != ''
AND (
NEW.mime_type IS NULL
OR NEW.mime_type LIKE 'image/%'
)
BEGIN
INSERT INTO face_scan_jobs (
photo_id,
status,
attempts,
rescan_requested,
created_at,
updated_at
)
VALUES (
NEW.id,
'pending',
0,
0,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT(photo_id) DO UPDATE SET
status = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.status
ELSE 'pending'
END,
rescan_requested = CASE
WHEN face_scan_jobs.status = 'processing'
THEN 1
ELSE 0
END,
faces_detected = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.faces_detected
ELSE NULL
END,
execution_ms = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.execution_ms
ELSE NULL
END,
applied_rotation = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.applied_rotation
ELSE NULL
END,
last_error = NULL,
started_at = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.started_at
ELSE NULL
END,
completed_at = NULL,
updated_at = CURRENT_TIMESTAMP;
END
`);
await db.raw(`
CREATE TRIGGER photos_face_enqueue_update
AFTER UPDATE OF
path,
fast_hash,
mtimeMs,
size_bytes,
rotation,
mime_type,
deleted_at
ON photos
WHEN
NEW.deleted_at IS NULL
AND NEW.path IS NOT NULL
AND NEW.path != ''
AND (
NEW.mime_type IS NULL
OR NEW.mime_type LIKE 'image/%'
)
AND (
OLD.path IS NOT NEW.path
OR OLD.fast_hash IS NOT NEW.fast_hash
OR OLD.mtimeMs IS NOT NEW.mtimeMs
OR OLD.size_bytes IS NOT NEW.size_bytes
OR OLD.rotation IS NOT NEW.rotation
OR OLD.mime_type IS NOT NEW.mime_type
OR OLD.deleted_at IS NOT NEW.deleted_at
)
BEGIN
INSERT INTO face_scan_jobs (
photo_id,
status,
attempts,
rescan_requested,
created_at,
updated_at
)
VALUES (
NEW.id,
'pending',
0,
0,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
)
ON CONFLICT(photo_id) DO UPDATE SET
status = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.status
ELSE 'pending'
END,
rescan_requested = CASE
WHEN face_scan_jobs.status = 'processing'
THEN 1
ELSE 0
END,
faces_detected = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.faces_detected
ELSE NULL
END,
execution_ms = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.execution_ms
ELSE NULL
END,
applied_rotation = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.applied_rotation
ELSE NULL
END,
last_error = NULL,
started_at = CASE
WHEN face_scan_jobs.status = 'processing'
THEN face_scan_jobs.started_at
ELSE NULL
END,
completed_at = NULL,
updated_at = CURRENT_TIMESTAMP;
END
`);
await db.raw(`
CREATE TRIGGER photos_face_cleanup_update
AFTER UPDATE OF
path,
mime_type,
deleted_at
ON photos
WHEN
NEW.deleted_at IS NOT NULL
OR NEW.path IS NULL
OR NEW.path = ''
OR (
NEW.mime_type IS NOT NULL
AND NEW.mime_type NOT LIKE 'image/%'
)
BEGIN
DELETE FROM faces
WHERE photo_id = NEW.id;
DELETE FROM face_scan_jobs
WHERE photo_id = NEW.id;
END
`);
await db.raw(`
CREATE TRIGGER photos_face_cleanup_delete
AFTER DELETE ON photos
BEGIN
DELETE FROM faces
WHERE photo_id = OLD.id;
DELETE FROM face_scan_jobs
WHERE photo_id = OLD.id;
END
`);
console.log(
'✔ trigger automatici scansione volti creati',
);
}
module.exports = {
ensureFaceScanAutomation,
};

547
db/init.j Normal file
View file

@ -0,0 +1,547 @@
// db/init.js
const db = require('./knex');
/* ============================================================
FOTO
============================================================ */
async function ensurePhotos() {
const exists = await db.schema.hasTable('photos');
if (!exists) {
await db.schema.createTable('photos', (t) => {
t.string('id').primary();
t.string('user');
t.string('cartella');
t.string('name');
t.string('path');
t.text('extPath').notNullable().defaultTo('');
t.text('extMedia').notNullable().defaultTo('');
t.integer('hybrid').notNullable().defaultTo(0);
t.string('thub1');
t.string('thub2');
t.string('cast');
t.string('mime_type');
t.integer('width');
t.integer('height');
t.integer('rotation');
t.integer('size_bytes');
t.integer('mtimeMs');
t.integer('duration_ms');
t.string('taken_at');
t.string('data');
t.float('lat');
t.float('lon');
t.float('alt');
t.text('location');
t.string('_indexHash');
t.string('fast_hash');
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
t.datetime('deleted_at').nullable();
});
console.log("✔ photos creata");
} else {
const cols = [
'thub1','thub2','fast_hash','created_at','updated_at','deleted_at'
];
for (const col of cols) {
const existsCol = await db.schema.hasColumn('photos', col);
if (!existsCol) {
await db.schema.alterTable('photos', (t) => {
if (col === 'created_at' || col === 'updated_at')
t.datetime(col).defaultTo(db.fn.now());
else if (col === 'deleted_at')
t.datetime(col).nullable();
else
t.string(col);
});
console.log(`✔ aggiunta colonna photos.${col}`);
}
}
}
}
/* ============================================================
FOTO — deleted_hard
============================================================ */
async function ensureDeletedHard() {
const exists = await db.schema.hasTable('deleted_hard');
if (!exists) {
await db.schema.createTable('deleted_hard', (t) => {
t.string('id').primary();
t.string('user');
t.datetime('deleted_at').defaultTo(db.fn.now());
});
console.log("✔ deleted_hard creata");
}
}
/* ============================================================
WS
============================================================ */
async function ensureWsSessions() {
const exists = await db.schema.hasTable('ws_sessions');
if (!exists) {
await db.schema.createTable('ws_sessions', (t) => {
t.string('session_id').notNullable();
t.string('device_id').notNullable();
t.string('user').notNullable();
t.integer('connected_at').notNullable();
t.integer('last_ack').notNullable();
t.integer('last_sync').nullable();
t.boolean('need_full_sync').notNullable().defaultTo(false);
t.primary(['session_id', 'device_id']);
});
console.log("✔ ws_sessions creata");
} else {
const cols = [
{ name: 'device_id', type: 'string' },
{ name: 'last_sync', type: 'integer' }
];
for (const c of cols) {
const existsCol = await db.schema.hasColumn('ws_sessions', c.name);
if (!existsCol) {
await db.schema.alterTable('ws_sessions', (t) => {
if (c.type === 'string') t.string(c.name).notNullable().defaultTo('default');
if (c.type === 'integer') t.integer(c.name).nullable();
});
console.log(`✔ aggiunta colonna ws_sessions.${c.name}`);
}
}
}
}
async function ensureWsPending() {
const exists = await db.schema.hasTable('ws_pending_events');
if (!exists) {
await db.schema.createTable('ws_pending_events', (t) => {
t.string('event_id').primary();
t.string('session_id').notNullable();
t.text('payload').notNullable();
t.integer('sent_at').notNullable();
t.integer('retries').notNullable().defaultTo(0);
});
console.log("✔ ws_pending_events creata");
}
}
async function ensurePendingEvents() {
const exists = await db.schema.hasTable('pending_events');
if (!exists) {
await db.schema.createTable('pending_events', (t) => {
t.increments('id').primary();
t.string('user_id').notNullable();
t.string('session_id').notNullable();
t.string('device_id').notNullable();
t.string('event_id').notNullable();
t.text('payload').notNullable();
t.integer('created_at').notNullable();
t.unique(['user_id', 'session_id', 'device_id', 'event_id']);
});
console.log("✔ pending_events creata");
}
}
/* ============================================================
🏃 ATTIVITÀ — nuova tabella
============================================================ */
async function ensureActivities() {
const exists = await db.schema.hasTable('activities');
if (!exists) {
await db.schema.createTable('activities', (t) => {
t.string('id').primary();
t.string('user');
t.string('cartella');
t.string('file_name');
t.string('file_extension');
t.string('format');
t.string('display_name');
t.string('route_name');
t.string('title');
t.string('description');
t.string('notes');
t.string('sport');
t.string('activity_type');
t.string('device_manufacturer');
t.string('device_model');
t.string('source_app');
t.bigInteger('start_time_millis');
t.bigInteger('end_time_millis');
t.bigInteger('duration_millis');
t.float('distance_meters');
t.float('elevation_gain_meters');
t.float('elevation_loss_meters');
t.float('min_elevation_meters');
t.float('max_elevation_meters');
t.float('file_distance_meters');
t.float('file_elevation_gain_meters');
t.float('computed_distance_meters');
t.float('computed_elevation_gain_meters');
t.float('average_speed_mps');
t.float('max_speed_mps');
t.float('average_pace_sec_per_km');
t.integer('average_heart_rate');
t.integer('max_heart_rate');
t.integer('average_cadence');
t.integer('max_cadence');
t.integer('calories');
t.integer('power_avg');
t.integer('power_max');
t.integer('point_count');
t.integer('segment_count');
t.integer('lap_count');
t.float('min_latitude');
t.float('max_latitude');
t.float('min_longitude');
t.float('max_longitude');
t.float('center_latitude');
t.float('center_longitude');
t.bigInteger('size_bytes');
t.bigInteger('last_modified_millis');
t.bigInteger('date_added_millis');
t.bigInteger('date_modified_millis');
t.bigInteger('last_scan_millis');
t.boolean('is_favorite');
t.boolean('is_hidden');
t.integer('rating');
t.text('tags');
t.text('track_preview');
t.string('scan_status');
t.text('scan_error');
t.string('path');
t.text('extPath').notNullable().defaultTo('');
t.integer('hybrid').notNullable().defaultTo(0);
t.text('diagnostics');
t.text('location');
t.string('fast_hash');
t.datetime('deleted_at').nullable();
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
});
console.log("✔ activities creata");
}
}
/* ============================================================
ATTIVITÀ — deleted_hard
============================================================ */
async function ensureDeletedHardActivities() {
const exists = await db.schema.hasTable('deleted_hard_activities');
if (!exists) {
await db.schema.createTable('deleted_hard_activities', (t) => {
t.string('id').primary();
t.string('user');
t.datetime('deleted_at').defaultTo(db.fn.now());
});
console.log("✔ deleted_hard_activities creata");
}
}
/* ============================================================
ATTIVITÀ — sync_done
============================================================ */
async function ensureSyncDoneActivities() {
const exists = await db.schema.hasTable('sync_done_activities');
if (!exists) {
await db.schema.createTable('sync_done_activities', (t) => {
t.string('session_id');
t.string('device_id');
t.bigInteger('last_sync');
});
console.log("✔ sync_done_activities creata");
}
}
/* ============================================================
VOLTI
============================================================ */
async function ensureFaces() {
const exists = await db.schema.hasTable('faces');
if (!exists) {
await db.schema.createTable('faces', (t) => {
// ID univoco del volto
t.string('id').primary();
// Foto da cui è stato estratto
t.string('photo_id').notNullable();
t.integer('face_index').notNullable();
// Bounding box nel frame originale
t.float('x').notNullable();
t.float('y').notNullable();
t.float('width').notNullable();
t.float('height').notNullable();
// Confidence del detector SCRFD
t.float('score').notNullable();
// 5 landmark × 2 coordinate
t.float('landmark_0_x');
t.float('landmark_0_y');
t.float('landmark_1_x');
t.float('landmark_1_y');
t.float('landmark_2_x');
t.float('landmark_2_y');
t.float('landmark_3_x');
t.float('landmark_3_y');
t.float('landmark_4_x');
t.float('landmark_4_y');
// 128 × float32 = 512 byte
t.binary('embedding').notNullable();
// Risultato HDBSCAN.
// NULL = non ancora clusterizzato.
t.string('cluster_id').nullable();
// Persona assegnata dall'utente.
// NULL = identità non ancora assegnata.
t.string('person_id').nullable();
// Stato applicativo del volto.
// detected / clustered / assigned
t.string('status').notNullable().defaultTo('detected');
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
// Indici principali
t.index(['photo_id']);
t.index(['cluster_id']);
t.index(['person_id']);
t.index(['status']);
t.unique(['photo_id', 'face_index']);
// Evita due record per lo stesso volto nella stessa foto
// solo se il nostro scanner assegnerà un face_index.
});
console.log("✔ faces creata");
}
}
/* ============================================================
CODA SCANSIONE VOLTI
============================================================ */
async function ensureFaceScanJobs() {
const exists =
await db.schema.hasTable('face_scan_jobs');
if (!exists) {
await db.schema.createTable(
'face_scan_jobs',
(t) => {
t.string('photo_id').primary();
// pending / processing / done / error
t.string('status')
.notNullable()
.defaultTo('pending');
t.integer('attempts')
.notNullable()
.defaultTo(0);
t.integer('rescan_requested')
.notNullable()
.defaultTo(0);
t.integer('faces_detected').nullable();
t.integer('execution_ms').nullable();
t.integer('applied_rotation').nullable();
t.text('last_error').nullable();
t.datetime('started_at').nullable();
t.datetime('completed_at').nullable();
t.datetime('created_at')
.defaultTo(db.fn.now());
t.datetime('updated_at')
.defaultTo(db.fn.now());
t.index(['status']);
t.index(['updated_at']);
},
);
console.log("✔ face_scan_jobs creata");
} else {
const hasRescanRequested =
await db.schema.hasColumn(
'face_scan_jobs',
'rescan_requested',
);
if (!hasRescanRequested) {
await db.schema.alterTable(
'face_scan_jobs',
(t) => {
t.integer('rescan_requested')
.notNullable()
.defaultTo(0);
},
);
console.log(
"✔ aggiunta face_scan_jobs.rescan_requested",
);
}
}
}
/* ============================================================
CLUSTER HDBSCAN
============================================================ */
async function ensureFaceClusters() {
const exists = await db.schema.hasTable('face_clusters');
if (!exists) {
await db.schema.createTable('face_clusters', (t) => {
// ID persistente del cluster
t.string('id').primary();
// Identifica una specifica esecuzione di HDBSCAN.
// Esempio: "initial-2026-09-23T04:20:00"
t.string('run_id').notNullable();
// Label numerica prodotta da HDBSCAN.
// Può essere -1 per noise.
t.integer('label').notNullable();
// Numero di facce nel cluster
t.integer('size').notNullable().defaultTo(0);
// Parametri principali usati per generare il cluster
t.integer('min_samples');
t.integer('min_cluster_size');
// NULL se il cluster non è ancora stato
// associato ad una persona.
t.string('person_id').nullable();
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
t.index(['run_id']);
t.index(['person_id']);
// Un label può comparire una sola volta
// all'interno dello stesso run.
t.unique(['run_id', 'label']);
});
console.log("✔ face_clusters creata");
}
}
/* ============================================================
PERSONE
============================================================ */
async function ensurePersons() {
const exists = await db.schema.hasTable('persons');
if (!exists) {
await db.schema.createTable('persons', (t) => {
// ID stabile dell'identità applicativa
t.string('id').primary();
// Utente proprietario della libreria
t.string('user').nullable();
// Nome assegnato dall'utente
t.string('name').notNullable();
t.datetime('created_at').defaultTo(db.fn.now());
t.datetime('updated_at').defaultTo(db.fn.now());
t.index(['user']);
t.index(['name']);
});
console.log("✔ persons creata");
}
}
/* ============================================================
INIT
============================================================ */
/* async function init() {
try {
console.log("🔧 init DB...");
await ensurePhotos();
await ensureDeletedHard();
await ensureActivities();
await ensureDeletedHardActivities();
await ensureSyncDoneActivities();
await ensureWsSessions();
await ensureWsPending();
await ensurePendingEvents();
console.log("✅ init completato");
process.exit(0);
} catch (err) {
console.error("❌ errore init:", err);
process.exit(1);
}
}
*/
async function init() {
try {
console.log("🔧 init DB...");
await ensurePhotos();
await ensureDeletedHard();
await ensureActivities();
await ensureDeletedHardActivities();
await ensureSyncDoneActivities();
await ensureWsSessions();
await ensureWsPending();
await ensurePendingEvents();
// Face recognition
await ensureFaces();
await ensureFaceScanJobs();
await ensureFaceClusters();
await ensurePersons();
console.log("✅ init completato");
process.exit(0);
} catch (err) {
console.error("❌ errore init:", err);
process.exit(1);
}
}
init();

1325
db/init.js Normal file

File diff suppressed because it is too large Load diff

36
db/knex.js Normal file
View file

@ -0,0 +1,36 @@
// db/knex.js
const knex = require('knex');
const path = require('path');
const db = knex({
client: 'better-sqlite3',
connection: {
filename: './api_v1/database.sqlite',
},
useNullAsDefault: true,
pool: {
afterCreate(conn, done) {
try {
conn.pragma('journal_mode = WAL');
conn.pragma('busy_timeout = 30000');
console.log('[DB] journal_mode=WAL');
console.log('[DB] busy_timeout=30000');
} catch (err) {
console.error('[DB] pragma error', err);
}
done(null, conn);
},
},
});
console.log(
'📌 DB PATH:',
path.resolve('./api_v1/database.sqlite')
);
module.exports = db;

73
db/photos.js Normal file
View file

@ -0,0 +1,73 @@
// db/photos.js
const knex = require('./knex');
async function insertOrUpdatePhoto(row) {
// row deve contenere almeno id, user, cartella, name, path, thumb1, thumb2, fast_hash, mtimeMs, etc.
const exists = await knex('photos').where({ id: row.id }).first();
if (!exists) {
await knex('photos').insert({
id: row.id,
user: row.user,
cartella: row.cartella,
name: row.name,
path: row.path,
thumb1: row.thumb1 || null,
thumb2: row.thumb2 || null,
mime_type: row.mime_type || null,
width: row.width || null,
height: row.height || null,
rotation: row.rotation || null,
size_bytes: row.size_bytes || null,
mtimeMs: row.mtimeMs || null,
taken_at: row.taken_at || null,
fast_hash: row.fast_hash || null,
created_at: knex.fn.now(),
updated_at: knex.fn.now()
});
} else {
await knex('photos').where({ id: row.id }).update({
path: row.path,
thumb1: row.thumb1 || exists.thumb1,
thumb2: row.thumb2 || exists.thumb2,
mime_type: row.mime_type || exists.mime_type,
width: row.width || exists.width,
height: row.height || exists.height,
rotation: row.rotation || exists.rotation,
size_bytes: row.size_bytes || exists.size_bytes,
mtimeMs: row.mtimeMs || exists.mtimeMs,
taken_at: row.taken_at || exists.taken_at,
fast_hash: row.fast_hash || exists.fast_hash,
updated_at: knex.fn.now(),
deleted_at: null
});
}
}
async function markPhotoDeletedHard(id, user) {
await knex('photos').where({ id, user }).del();
await knex('deleted_hard').insert({ id, user, deleted_at: knex.fn.now() }).catch(() => {});
}
async function getPhotosSince(user, sinceIso, limit = 1000) {
// ritorna foto aggiornate dopo sinceIso (ISO string)
return knex('photos')
.where({ user })
.andWhere('updated_at', '>', sinceIso)
.orderBy('updated_at', 'asc')
.limit(limit)
.select('*');
}
async function getDeletedHardSince(user, sinceIso) {
return knex('deleted_hard')
.where({ user })
.andWhere('deleted_at', '>', sinceIso)
.select('id', 'deleted_at');
}
module.exports = {
insertOrUpdatePhoto,
markPhotoDeletedHard,
getPhotosSince,
getDeletedHardSince
};

147
db/serverEventOutbox.js Normal file
View file

@ -0,0 +1,147 @@
'use strict';
/*
* Outbox inter-processo.
*
* I daemon possono produrre eventi senza importare ws-server.
* Solo il processo server consuma questi eventi e li inoltra
* al WebSocket reale.
*/
let ensurePromise = null;
async function ensureServerEventOutbox(db) {
if (!ensurePromise) {
ensurePromise = (async () => {
const exists = await db.schema.hasTable('server_event_outbox');
if (!exists) {
await db.schema.createTable('server_event_outbox', (t) => {
t.increments('id').primary();
t.string('channel').notNullable();
t.string('user').notNullable();
t.text('payload').notNullable();
t.string('status')
.notNullable()
.defaultTo('pending');
t.integer('attempts')
.notNullable()
.defaultTo(0);
t.text('last_error').nullable();
t.datetime('created_at')
.notNullable()
.defaultTo(db.fn.now());
t.datetime('updated_at')
.notNullable()
.defaultTo(db.fn.now());
t.datetime('processed_at').nullable();
t.index(
['status', 'id'],
'server_event_outbox_status_id_idx'
);
});
console.log(
'[ServerEventOutbox] tabella server_event_outbox creata'
);
}
})().catch((err) => {
ensurePromise = null;
throw err;
});
}
return ensurePromise;
}
async function enqueueServerEvent(
db,
{
channel,
user,
payload,
}
) {
if (!channel || !user || !payload) {
throw new Error(
'enqueueServerEvent richiede channel, user e payload'
);
}
await ensureServerEventOutbox(db);
const now = new Date().toISOString();
await db('server_event_outbox').insert({
channel,
user,
payload: JSON.stringify(payload),
status: 'pending',
attempts: 0,
last_error: null,
created_at: now,
updated_at: now,
processed_at: null,
});
}
/*
* C'e' un solo consumer nel processo server.
* Non serve tenere il job in processing durante il broadcast:
* viene selezionato pending e marcato done soltanto dopo
* un broadcast completato senza eccezioni.
*/
async function getNextPendingServerEvent(db) {
await ensureServerEventOutbox(db);
return db('server_event_outbox')
.where({ status: 'pending' })
.orderBy('id', 'asc')
.first();
}
async function completeServerEvent(db, id) {
const now = new Date().toISOString();
return db('server_event_outbox')
.where({
id,
status: 'pending',
})
.update({
status: 'done',
processed_at: now,
updated_at: now,
last_error: null,
});
}
async function failServerEvent(db, id, error) {
const now = new Date().toISOString();
return db('server_event_outbox')
.where({
id,
status: 'pending',
})
.update({
attempts: db.raw('attempts + 1'),
last_error: String(error || 'Errore sconosciuto'),
updated_at: now,
});
}
module.exports = {
ensureServerEventOutbox,
enqueueServerEvent,
getNextPendingServerEvent,
completeServerEvent,
failServerEvent,
};

68
db/sqliteRetry.js Normal file
View file

@ -0,0 +1,68 @@
'use strict';
const SQLITE_BUSY_RETRY_DELAYS_MS = [
25,
50,
100,
200,
400,
800,
1600,
];
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function isSqliteBusy(error) {
if (!error) return false;
if (
error.code === 'SQLITE_BUSY' ||
error.code === 'SQLITE_LOCKED'
) {
return true;
}
const message = String(
error.message || error
).toLowerCase();
return (
message.includes('database is locked') ||
message.includes('database table is locked')
);
}
async function withSqliteBusyRetry(label, operation) {
let retry = 0;
while (true) {
try {
return await operation();
} catch (error) {
if (
!isSqliteBusy(error) ||
retry >= SQLITE_BUSY_RETRY_DELAYS_MS.length
) {
throw error;
}
const delay = SQLITE_BUSY_RETRY_DELAYS_MS[retry];
retry += 1;
console.warn(
`[SQLiteRetry] SQLITE_BUSY ${label}; ` +
`retry=${retry}/${SQLITE_BUSY_RETRY_DELAYS_MS.length} ` +
`delay=${delay}ms`
);
await sleep(delay);
}
}
}
module.exports = {
isSqliteBusy,
withSqliteBusyRetry,
};

86
diff_js_css_html.py Normal file
View file

@ -0,0 +1,86 @@
#!/usr/bin/env python3
from pathlib import Path
import hashlib
import argparse
import sys
parser = argparse.ArgumentParser(
description="Confronta i file JS, CSS e HTML della cartella corrente "
"con un'altra cartella specificata come parametro."
)
parser.add_argument(
"altra_cartella",
help="Percorso della cartella da confrontare con quella corrente"
)
args = parser.parse_args()
old = Path(".").resolve()
new = Path(args.altra_cartella).expanduser().resolve()
if not old.is_dir():
sys.exit(f"ERRORE: cartella corrente non valida: {old}")
if not new.is_dir():
sys.exit(f"ERRORE: cartella inesistente: {new}")
exts = {".js", ".mjs", ".cjs", ".jsx", ".css", ".html", ".htm"}
excluded = {
"node_modules", ".git", ".venv", ".venv-clustering",
"var", "native", "coverage", "dist", "build"
}
def collect(root):
result = {}
for p in root.rglob("*"):
if not p.is_file() or p.suffix.lower() not in exts:
continue
rel = p.relative_to(root)
if any(part in excluded or part.startswith("backup-")
for part in rel.parts):
continue
result[str(rel)] = p
return result
a = collect(old)
b = collect(new)
added = sorted(b.keys() - a.keys())
removed = sorted(a.keys() - b.keys())
common = sorted(a.keys() & b.keys())
modified = [
f for f in common
if hashlib.sha256(a[f].read_bytes()).digest()
!= hashlib.sha256(b[f].read_bytes()).digest()
]
print(f"\nCARTELLA CORRENTE: {old}")
print(f"ALTRA CARTELLA: {new}")
print(f"\nAGGIUNTI nella cartella corrente: {len(removed)}")
for f in removed:
print(" +", f)
print(f"\nASSENTI dalla cartella corrente: {len(added)}")
for f in added:
print(" -", f)
print(f"\nMODIFICATI: {len(modified)}")
for f in modified:
print(" *", f)
Path("/tmp/s184js-modificati.txt").write_text(
"\n".join(modified) + ("\n" if modified else ""),
encoding="utf-8"
)
print("\nElenco dei modificati salvato in /tmp/s184js-modificati.txt")

278
docs/README_WS.md Normal file
View file

@ -0,0 +1,278 @@
Perfetto Fabio — ora ti preparo il diagramma del flusso completo, chiaro, leggibile e fedele al comportamento reale del tuo sistema.
È un diagramma architetturale, non un semplice schema: mostra tutto il ciclo di vita di una sessione WebSocket affidabile, con SQLite, retry, timeout, needfullsync e sync del client.
---
🟦 DIAGRAMMA DEL FLUSSO COMPLETO — WebSocket Affidabile + Sync
Lo divido in 6 sezioni:
1. Connessione iniziale
2. Autenticazione + session_id
3. Invio evento dal server
4. Ricezione evento dal client
5. Retry + Timeout + needfullsync
6. Riconnessione del client
---
🟩 1) CONNESSIONE INIZIALE
`
CLIENT (browser) SERVER (ws-server.js)
──────────────────────────────────────────────────────────────────────
startWebSocket()
↓
Apre WebSocket ------------------------→ ws.on("connection")
crea ws object
ws.authenticated = false
`
---
🟩 2) AUTENTICAZIONE + SESSION_ID
`
CLIENT SERVER
──────────────────────────────────────────────────────────────────────
Invia:
{
type: "auth",
token: JWT,
session_id: <persistente>
}
↓ ws.on("message")
verifica JWT
cerca sessione in SQLite
se non esiste → createSession()
aggiorna last_ack
salva wsBySession[session_id] = ws
Riceve:
{
type: "auth_ok",
user: "...",
session_id: "...",
needfullsync: true/false
}
↓
Se needfullsync = true → incrementalSync()
`
---
🟩 3) INVIO EVENTO DAL SERVER (broadcast affidabile)
`
SERVER
──────────────────────────────────────────────────────────────────────
broadcastToUserReliable(user, payload)
↓
Genera event_id (UUID)
↓
Per ogni sessione dell’utente:
- Se ws attivo:
ws.send(payload + event_id)
addPendingEvent(eventid, sessionid)
- Se ws NON attivo:
setSessionNeedFullSync(session_id, true)
`
---
🟩 4) RICEZIONE EVENTO DAL CLIENT
`
CLIENT
──────────────────────────────────────────────────────────────────────
Riceve evento:
{
type: "added" | "removed" | ...,
id: "...",
event_id: "..."
}
↓
Se event_id già in processedEvents:
- invia solo ACK
- ignora evento
- FINE
Se nuovo:
- applica evento (add/remove foto)
- aggiorna UI
- aggiunge event_id a processedEvents
- invia ACK:
{ type: "ack", event_id }
`
---
🟩 5) RETRY + TIMEOUT + NEEDFULLSYNC
`
SERVER (ogni 3 secondi)
──────────────────────────────────────────────────────────────────────
Legge da SQLite tutti gli eventi pendenti
con sent_at < now - 3000 ms
Per ogni evento:
Se retries < 40 (2 minuti):
- se ws attivo:
ws.send(payload)
updatePendingEventRetry()
- se ws NON attivo:
setSessionNeedFullSync(session_id, true)
deletePendingEvent(event_id)
Se retries >= 40:
- ws.terminate()
- wsBySession.delete(session_id)
- deleteSession(session_id) ← cancella queue
- (sessione verrà ricreata al prossimo auth)
`
---
🟩 6) RICONNESSIONE DEL CLIENT
`
CLIENT riapre la tab / si risveglia
──────────────────────────────────────────────────────────────────────
startWebSocket()
↓
Invia auth con lo stesso session_id
↓
SERVER:
- trova sessione in SQLite?
NO → la ricrea con needfullsync = true
SÌ → legge needfullsync
- invia authok con needfull_sync
CLIENT:
Se needfullsync = true:
incrementalSync()
Altrimenti:
continua con WS realtime
`
---
🟦 RISULTATO DEL FLUSSO
✔ Nessun evento perso
✔ Nessun evento duplicato
✔ Chrome Android non può più “morire in silenzio”
✔ Multi‑dispositivo perfetto
✔ Queue pulita dopo 2 minuti
✔ Sync completo automatico quando serve
✔ WS leggero (ping solo on‑demand)
✔ Stato persistente in SQLite
---
┌──────────────────────────┐ ┌──────────────────────────┐
│ CLIENT │ │ SERVER │
└─────────────┬────────────┘ └─────────────┬────────────┘
│ │
│ 1) Connessione WS │
├──────────────────────────────────────→│
│ │
│ 2) AUTH + session_id │
│ {type:auth, token, session_id} │
├──────────────────────────────────────→│
│ │
│ │ verifica JWT
│ │ cerca sessione in DB
│ │ crea/aggiorna sessione
│ │
│ 3) AUTH_OK │
│ ←──────────────────────────────────────┤
│ {need_full_sync:true/false} │
│ │
│ se need_full_sync → incrementalSync() │
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ 4) Ricezione evento WS │
│ {type:added/removed, event_id} │
│ ←──────────────────────────────────────┤
│ │
│ se event_id già visto → ACK │
│ se nuovo → applica evento │
│ │
│ ACK │
├──────────────────────────────────────→│
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ │ 5) Retry loop (ogni 3s)
│ │ cerca eventi pendenti
│ │
│ │ se retries < 40:
│ │ reinvia evento
│ │
│ │ se retries >= 40:
│ │ ws.terminate()
│ │ deleteSession()
│ │ (queue eliminata)
│ │
──────────────┼────────────────────────────────────────┼──────────────
│ │
│ 6) Riconnessione client │
├──────────────────────────────────────→│
│ {auth, token, session_id} │
│ │
│ │ server vede sessione mancante
│ │ → need_full_sync = true
│ │
│ AUTH_OK │
│ ←──────────────────────────────────────┤
│ {need_full_sync:true} │
│ │
│ incrementalSync() │
│ │
──────────────┴────────────────────────────────────────┴──────────────
CLIENT → WS CONNECT
CLIENT → AUTH(token, session_id)
SERVER → AUTH_OK(need_full_sync)
EVENTO:
SERVER → EVENT(event_id)
CLIENT:
se nuovo → applica + ACK
se già visto → solo ACK
RETRY LOOP:
se retries < 40 → reinvia
se retries >= 40 → chiudi WS + cancella sessione
RICONNESSIONE:
CLIENT → AUTH(session_id)
SERVER → AUTH_OK(need_full_sync=true)
CLIENT → incrementalSync()
______
AUTH → EVENTI → ACK → (retry finché vivo)
se client sparisce → timeout → cancella sessione
se torna → need_full_sync → incrementalSync

View file

@ -0,0 +1,761 @@
# Riconoscimento facciale nel server s141js
## Scopo del documento
Questo documento descrive il funzionamento attuale del riconoscimento facciale nel server `s141js`.
La soluzione usa due percorsi paralleli:
- Python produce il risultato ufficiale e lo salva nel database;
- OpenCL sulla GPU Mali-G610 ricalcola lo stesso risultato in modalità shadow e verifica la coerenza.
La modalità shadow non sostituisce ancora il risultato Python e non modifica le valutazioni ufficiali.
## Legenda dei segnaposto
Il file usa segnaposto al posto dei caratteri Markdown che possono essere alterati durante la copia in chat o nel terminale.
```text
# titolo livello 1, da convertire in #
## titolo livello 2, da convertire in ##
### titolo livello 3, da convertire in ###
#### titolo livello 4, da convertire in ####
__FENCE__ apertura o chiusura di un blocco di codice
__FENCE_TEXT__ apertura di un blocco text
__FENCE_BASH__ apertura di un blocco bash
__FENCE_JSON__ apertura di un blocco json
__FENCE_JS__ apertura di un blocco javascript
` apice rovescio singolo
```
## Architettura generale
```text
Fotografia
|
v
SCRFD tramite RKNN e NPU
|
v
Bounding box e cinque landmark
|
v
Allineamento del volto a 112 x 112
|
v
SFace tramite RKNN e NPU
|
v
Embedding float32[128] normalizzato L2
|
+-------------------------------+
| |
v v
Pipeline Python ufficiale kNN OpenCL shadow
| |
v v
Valutazioni salvate Confronto di coerenza
nel database Python contro OpenCL
| |
+---------------+---------------+
|
v
Report e stato del job
```
## Componenti principali
### Face engine RKNN
Il face engine usa la NPU del dispositivo RK3588 per:
- rilevare i volti con SCRFD;
- calcolare confidence, bounding box e landmark;
- allineare il volto;
- generare un embedding SFace;
- normalizzare l'embedding con norma L2.
File principali:
```text
include/face_config.h
include/face_engine.h
src/face_engine.cc
src/face_recognition.cc
src/face_scan.cc
```
Ogni embedding ha il seguente formato:
```text
Dimensione: 128 componenti
Tipo: float32
Dimensione binaria: 512 byte
Ordine byte: little-endian
Normalizzazione: L2
```
Poiché gli embedding sono normalizzati, la cosine similarity coincide con il prodotto scalare.
### Motore kNN nativo
Il motore nativo è installato in:
```text
native/face-knn/
```
File principali:
```text
native/face-knn/bin/face_knn
native/face-knn/kernels/knn_float4_topk.cl
native/face-knn/run-face-knn.sh
native/face-knn/config.json
native/face-knn/SHA256SUMS
```
Il launcher `run-face-knn.sh` imposta la directory di lavoro corretta prima di avviare il binario. Questo permette a `face_knn` di trovare il kernel tramite il percorso relativo:
```text
kernels/knn_float4_topk.cl
```
Il motore supporta due backend:
```text
cpu
opencl
```
Il backend CPU è il riferimento numerico e il fallback. Il backend OpenCL usa la GPU Mali-G610.
### Runtime Node.js
Il modulo:
```text
tools/faceKnnRuntime.js
```
ha queste responsabilità:
1. legge la configurazione da `native/face-knn/config.json`;
2. recupera gli embedding dal database SQLite;
3. preserva l'ordine delle query prodotto dall'esperimento Python;
4. rilegge i prototype attivi ordinati per `selection_order`;
5. verifica che ogni embedding sia un Buffer da 512 byte;
6. crea file binari temporanei per query e prototype;
7. genera la self-map per il leave-one-out;
8. avvia il binario nativo;
9. analizza il JSON prodotto;
10. elimina i file biometrici temporanei.
I file temporanei vengono creati sotto:
```text
var/face-knn/run-*
```
Durante l'esecuzione normale vengono rimossi automaticamente.
### Shadow runner
Il comando indipendente:
```text
tools/runFaceKnnShadow.js
```
confronta il risultato Python con quello del backend nativo.
Confronta per ogni query:
- prototype migliore;
- best similarity;
- media dei primi tre score;
- decisione recognized;
- esclusione leave-one-out.
Il report contiene solo metadati e risultati numerici. Non contiene gli embedding.
## Database e tabelle utilizzate
Il database attivo è:
```text
api_v1/database.sqlite
```
Le tabelle principali sono:
### faces
Contiene i volti e gli embedding da 512 byte.
### person_face_prototypes
Contiene i prototype attivi di ogni persona.
L'ordine è determinato da:
```text
selection_order
```
La corrispondenza con gli indici OpenCL è:
```text
selection_order 1 -> indice OpenCL 0
selection_order 2 -> indice OpenCL 1
selection_order 3 -> indice OpenCL 2
selection_order 4 -> indice OpenCL 3
selection_order 5 -> indice OpenCL 4
```
### face_prototype_jobs
Contiene i job di ricalcolo dei prototype.
Stati principali:
```text
pending
processing
done
error
superseded
```
### person_face_recognition_evaluations
Contiene le valutazioni ufficiali prodotte dalla pipeline Python.
Campi principali:
```text
face_id
prototype_job_id
prototype_count
best_similarity
top3_mean
best_prototype_face_id
recognition_threshold
recognized
evaluated_at
```
### face_recognition_index_state
Tiene traccia dello stato dell'indice persistente.
Stato attuale validato:
```text
user: Fabio
status: dirty
dirty: 1
version: 0
prototype_count: 5
index_path: null
metadata_path: null
built_at: null
last_error: null
```
Lo stato `dirty` indica che i prototype sono stati ricalcolati, ma l'indice persistente non è ancora stato costruito.
## Come nasce un job di ricalcolo
Il router è montato con il prefisso:
```text
/face-review
```
L'endpoint manuale è:
```text
POST /face-review/persons/:personId/prototypes/recalculate
```
La richiesta richiede:
```text
Authorization: Bearer TOKEN
Content-Type: application/json
```
Esempio di body:
```json
{
"reason": "opencl_shadow_validation"
}
```
La funzione `queuePrototypeRefresh()`:
- crea un job pending se non ne esiste uno;
- aggiorna il job pending esistente per evitare duplicati;
- salva reason, requested_by e timestamp.
Il ricalcolo viene accodato anche automaticamente quando:
- viene assegnato un cluster a una persona;
- un volto viene rimosso da una persona;
- un volto noise viene assegnato;
- un volto viene riassegnato;
- più volti noise vengono assegnati insieme.
## Elaborazione del job
Il worker è:
```text
tools/runPrototypeWorker.js
```
Esecuzione manuale:
```bash
node tools/runPrototypeWorker.js \
--persist \
--job-id JOB_ID
```
Il flusso è il seguente.
### 1. Acquisizione del job
Il worker cerca un job con stato `pending`, lo porta a `processing` e incrementa `attempts`.
### 2. Firma degli input
Il worker calcola una firma SHA-256 basata sui volti e sulle esclusioni di riferimento.
Se la firma cambia durante il lavoro, il job viene marcato `superseded` e viene creato un job sostitutivo.
### 3. Esperimento Python
Il worker avvia:
```text
tools/prototype_experiment.py
```
Parametri attuali:
```text
noise_prototypes: 2
greedy_additions: 2
min_detector_score: 0.60
```
L'esperimento produce:
```text
prototype_sets.greedy_gain
evaluations.greedy_gain.queries
evaluations.greedy_gain.summary
```
Ogni query Python contiene:
```text
face_id
photo_name
is_prototype
best_similarity
best_prototype_face_id
top3_mean
```
### 4. Selezione dei prototype
Il worker:
1. applica le esclusioni manuali;
2. mantiene i prototype bloccati;
3. disattiva i prototype automatici precedenti;
4. salva i nuovi prototype;
5. assegna `selection_order` a partire da 1.
Per Jessica l'ordine validato è:
```text
0 IMG_0145.JPG cluster_medoid
1 IMG_0141.JPG cluster_medoid
2 IMG_0193.JPG cluster_medoid
3 IMG_0143.JPG greedy_leave_one_out_gain
4 IMG_0146.JPG greedy_leave_one_out_gain
```
Il numero a sinistra è l'indice kNN, cioè `selection_order - 1`.
### 5. Valutazioni Python ufficiali
La soglia attuale è:
```text
0.50
```
Le valutazioni precedenti vengono sostituite atomicamente nella transazione SQLite.
Il risultato Python viene salvato in `person_face_recognition_evaluations` ed è ancora il risultato ufficiale.
### 6. Aggiornamento dello stato indice
Dopo il ricalcolo dei prototype:
```text
status = dirty
dirty = 1
prototype_count = numero dei prototype attivi
last_error = null
```
### 7. Chiusura della transazione
`persistResult()` conclude la transazione prima dell'avvio OpenCL.
Questo evita di mantenere un lock SQLite durante:
- creazione dei file binari;
- inizializzazione OpenCL;
- compilazione del kernel;
- esecuzione del kNN;
- scrittura del report shadow.
### 8. Shadow OpenCL
Dopo il commit della transazione, il worker chiama `runFaceKnnShadow()`.
La sequenza è:
```text
Risultato Python già salvato
|
v
Recupero embedding query nello stesso ordine Python
|
v
Recupero prototype ordinati per selection_order
|
v
Generazione queries.bin
Generazione prototypes.bin
Generazione self-map.txt
|
v
face_knn --backend opencl
|
v
Confronto Python contro OpenCL
|
v
Report shadow JSON
```
## Leave-one-out
Se una query è anche un prototype, il confronto con sé stessa deve essere escluso.
Il runtime costruisce una mappa tra `face_id` e indice del prototype.
La self-map contiene:
```text
-1 -> nessuna esclusione
0 -> escludi prototype indice 0
1 -> escludi prototype indice 1
2 -> escludi prototype indice 2
...
```
Il kernel calcola il TOPK completo. L'esclusione viene applicata sul risultato host.
## Output del motore nativo
Il binario mantiene separati i flussi:
```text
stdout -> JSON valido
stderr -> diagnostica OpenCL e messaggi del driver Mali
```
Il runtime Mali può scrivere direttamente:
```text
arm_release_ver: g24p0-00eac0, rk_so_ver: 10
```
La CLI devia temporaneamente stdout verso stderr durante OpenCL e lo ripristina prima di scrivere il JSON.
## Controlli shadow
Per ogni query vengono confrontati:
- prototype migliore Python e OpenCL;
- best similarity;
- media dei primi tre score;
- decisione recognized;
- leave-one-out.
Tolleranza configurata:
```text
0.00001
```
Campi aggregati del report:
```text
query_count
prototype_count
top1_matches
decision_matches
best_score_matches
top3_mean_matches
maximum_score_error
maximum_top3_mean_error
mismatch_count
```
I report automatici vengono scritti in:
```text
var/face-knn/shadow-JOB_ID.json
```
Il report non contiene embedding.
## Gestione degli errori
### Errore Python o persistenza
```text
Il job viene marcato error.
last_error viene valorizzato.
Il worker restituisce exit code non zero.
```
### Errore OpenCL shadow
```text
Il job Python resta done.
Le valutazioni ufficiali restano salvate.
L'errore viene registrato nel log shadow.
Non viene effettuato rollback.
```
### Mismatch Python/OpenCL
```text
Il job Python resta done.
Il report contiene mismatch_count maggiore di zero.
OpenCL non viene promosso a motore ufficiale.
```
## Configurazione attuale
File:
```text
native/face-knn/config.json
```
Configurazione validata:
```json
{
"enabled": true,
"mode": "shadow",
"backend": "opencl",
"fallbackBackend": "cpu",
"dimension": 128,
"topK": 5,
"recognitionThreshold": 0.5,
"scoreTolerance": 0.00001,
"autoBackendMinimumComparisons": 100000,
"launcher": "native/face-knn/run-face-knn.sh",
"indexDirectory": "api_v1/face-recognition-index",
"runtimeDirectory": "var/face-knn"
}
```
## Validazione completata
Persona validata:
```text
User: Fabio
Persona: Jessica
Person ID: 77456100-d7b2-4c36-b3d6-09f8c1c9d7e7
Query: 21
Prototype: 5
```
Risultato funzionale:
```text
Riconosciuti: 20
Sotto soglia: 1
Copertura: 95.2%
Caso sotto soglia: IMG_0183.JPG
Score: 0.4664280414581299
Miglior prototype: IMG_0143.JPG
Indice prototype: 3
```
Risultato shadow automatico:
```text
Top-1 identici: 21 / 21
Decisioni identiche: 21 / 21
Best score entro tolleranza: 21 / 21
Top-3 mean entro tolleranza: 21 / 21
Mismatch: 0
Errore massimo best score: 1.7869088742017425e-7
Errore massimo top-3 mean: 1.3911677043676463e-7
```
Job validato:
```text
Job ID: 7741c033-dae9-4b6f-b00f-bc082f08b8a1
Stato: done
Tentativi: 1
Reason: opencl_shadow_validation
Last error: null
```
## Stato attuale della produzione
```text
Python:
- seleziona i prototype;
- calcola le valutazioni;
- salva il risultato ufficiale.
OpenCL:
- ricalcola il kNN dopo il commit;
- confronta il risultato;
- genera il report shadow;
- non modifica il risultato ufficiale.
```
## Prossimi passi
1. creare il builder dell'indice persistente;
2. generare atomicamente embeddings e metadati;
3. portare `face_recognition_index_state` da dirty a ready;
4. mantenere la modalità shadow per più job reali;
5. definire criteri di promozione OpenCL;
6. mantenere il backend CPU come fallback;
7. passare a OpenCL ufficiale solo dopo validazioni sufficienti.
## Comandi operativi utili
### Controllare i job pending
```bash
sqlite3 -header -column \
api_v1/database.sqlite \
"
SELECT
id,
user,
person_id,
status,
reason,
requested_at
FROM face_prototype_jobs
WHERE status = 'pending'
ORDER BY requested_at;
"
```
### Eseguire un job specifico
```bash
node tools/runPrototypeWorker.js \
--persist \
--job-id "JOB_ID"
```
### Validare un report shadow
```bash
python3 -m json.tool \
"var/face-knn/shadow-JOB_ID.json" \
> /dev/null \
&& echo "OK: report shadow valido"
```
### Controllare le valutazioni ufficiali
```bash
sqlite3 -header -column \
api_v1/database.sqlite \
"
SELECT
COUNT(*) AS evaluations,
SUM(CASE WHEN recognized = 1 THEN 1 ELSE 0 END)
AS recognized_faces,
SUM(CASE WHEN recognized = 0 THEN 1 ELSE 0 END)
AS below_threshold
FROM person_face_recognition_evaluations;
"
```
### Controllare lo stato dell'indice
```bash
sqlite3 -header -column \
api_v1/database.sqlite \
"
SELECT
user,
status,
dirty,
version,
prototype_count,
index_path,
metadata_path,
built_at,
last_error
FROM face_recognition_index_state
WHERE user = 'Fabio';
"
```
## Conversione in Markdown standard
Per generare un file Markdown normale:
```bash
sed \
-e 's/^__H4__[[:space:]]*/#### /' \
-e 's/^__H3__[[:space:]]*/### /' \
-e 's/^__H2__[[:space:]]*/## /' \
-e 's/^__H1__[[:space:]]*/# /' \
-e 's/^__FENCE_TEXT__$/```text/' \
-e 's/^__FENCE_BASH__$/```bash/' \
-e 's/^__FENCE_JSON__$/```json/' \
-e 's/^__FENCE_JS__$/```javascript/' \
-e 's/^__FENCE__$/```/' \
-e 's/`/`/g' \
RICONOSCIMENTO_SERVER.src.md \
> RICONOSCIMENTO_SERVER.md
```
Verifica che non siano rimasti segnaposto:
```bash
grep -nE \
'__H[1-4]__|__FENCE|`' \
RICONOSCIMENTO_SERVER.md \
|| echo "OK: segnaposto convertiti"
```

341
docs/activity_scan.md Normal file
View file

@ -0,0 +1,341 @@
Certo. Dopo le modifiche in s179js, il flusso Activity è ormai molto simile a quello Photo, ma la notifica alla gallery segue ancora il percorso tramite POST /activities.
1. Flusso attuale di scansione Activity
Quando premi Scan Activities:
UTENTE
│
▼
Scan Activities
│
▼
routes/scanActivities.js
│
▼
scanActivitiesUser()
│
trova cartelle Activity
│
▼
scanActivity()
│
▼
scanActivityCartella()
│
trova FIT/GPX/TCX
│
▼
enqueueActivityIngestJob()
│
▼
activity_ingest_jobs
│
┌─────────────┼──────────────┐
│ │ │
pending processing done
│
▼
│ ACTIVITY DAEMON [A]
└────────────►│
▼
runOneJob()
│
▼
scanActivitySingle()
│
▼
processActivityFile()
│
parsing FIT/GPX/TCX
│
▼
activities DB
│
├───────────────┐
│ │
▼ ▼
geocode_jobs ritorno meta
│
▼
GEOCODE DAEMON [G]
│
▼
geocoding
Lo Scan adesso è solo il produttore
Questa è la differenza fondamentale rispetto a prima.
Prima:
Scan
↓
trova file
↓
parsa FIT
↓
salva Activity
↓
file successivo
Adesso:
Scan
↓
trova file
↓
mette job pending
↓
file successivo
Per questo nel log vedi velocemente:
[S] 📥 [ACTIVITY QUEUE] xxx.fit status=pending inserted
[S] 📥 [ACTIVITY QUEUE] yyy.fit status=pending inserted
[S] 📥 [ACTIVITY QUEUE] zzz.fit status=pending inserted
e poi:
[S] Scan ATTIVITÀ COMPLETATO
Questo però non significa che tutte le Activity siano elaborate. Significa solamente che la fase di discovery ha finito di riempire la coda.
2. Il daemon svuota la coda
Parallelamente [A] fa:
activity_ingest_jobs
│
▼
prende primo pending
│
▼
processing
│
▼
parse FIT
│
▼
salva Activity
│
▼
accoda Geocode
│
▼
sincronizza server
│
▼
done
│
▼
prende IMMEDIATAMENTE
il pending successivo
Infatti nel tuo log:
[A] Avvio job ...
[A] Nuova activity ...
[A] Activity ... inviata al server remoto
[A] Job ... completato; stato: done
[A] Avvio job successivo ...
Non ci sono 2 secondi tra un'attività e l'altra.
I 2 secondi servono solo quando:
pending = 0
3. Geocode è completamente indipendente
Quando scanActivitySingle() salva l'Activity esegue anche:
enqueueGeocodeJob
quindi:
ACTIVITY DAEMON
│
▼
activities DB
│
└────────────► geocode_jobs
│
▼
GEOCODE DAEMON [G]
│
▼
geocoding
Activity daemon non aspetta che il Geocode finisca.
Per questo nel log puoi vedere:
[A] job Activity successivo
mentre:
[G] geocodeWorker processing
sta ancora lavorando sull'attività precedente.
4. Flusso attuale di notifica alla gallery Activity
Qui il percorso è leggermente diverso da Photo.
Quando scanActivitySingle() restituisce la nuova Activity:
ACTIVITY DAEMON [A]
│
▼
scannedActivity/meta
│
▼
postWithAuth()
│
▼
POST BASE_URL/activities
│
▼
routes/activities.js
│
▼
INSERT activity
ON CONFLICT MERGE
│
▼
invalidateActivitySpatialIndex()
│
▼
broadcastToUser()
│
payload:
type: "added"
id: activityId
entity: "activity"
│
▼
WebSocket
│
▼
CLIENT
│
▼
Activity Gallery
Quindi la notifica alla gallery non viene inviata direttamente dall'Activity daemon.
Il daemon fa:
Activity daemon
↓
HTTP POST
↓
server
↓
WebSocket
↓
Gallery
Questo è importante perché il processo [A] non importa ws-server e quindi non rischia di aprire un secondo WebSocket sulla porta 4002.
5. Pipeline completa
Mettendo insieme tutto:
SCAN
│
▼
trova FIT/GPX/TCX
│
▼
activity_ingest_jobs
│
│ pending
▼
ACTIVITY DAEMON [A]
│
▼
parse FIT/GPX/TCX
│
▼
activities DB
│
┌────────────┴─────────────┐
│ │
▼ ▼
geocode_jobs activity meta
│ │
▼ ▼
GEOCODE DAEMON [G] POST /activities
│
▼
SERVER
│
INSERT / UPDATE
│
▼
WebSocket
│
▼
ACTIVITY GALLERY
Activity daemon
│
▼
job status = done
│
▼
prossimo pending
6. Cosa significa per il futuro banner
Ora abbiamo finalmente una coda affidabile:
activity_ingest_jobs
quindi il banner Activity non deve più basarsi sul vecchio:
scan_status.json
Può mostrare direttamente:
remaining = pending + processing
Per esempio:
Activities processing: 27
poi:
Activities processing: 26
Activities processing: 25
...
Activities processing: 1
e quando:
pending = 0
processing = 0
il banner scompare.
Questo sarebbe molto più corretto del vecchio progress dello Scan, perché lo Scan può aver finito di accodare i file mentre l'Activity daemon ha ancora 30 FIT da elaborare.

15
docs/cast.md Normal file
View file

@ -0,0 +1,15 @@
./transcode_rk_vod.sh ../IMG_1425.MOV ../fabiocast
python3 hls_cors_server.py ../fabiocast -p 9999
catt -d 192.168.1.15 cast http://192.168.1.4:9999/master.m3u8
./transcode_rk_vod.sh \
../IMG_1425.MOV \
../fabiocast
python3 hls_cors_server.py \
../fabiocast \
-p 9999
catt -d 192.168.1.15 cast \
http://192.168.1.4:9999/master.m3u8

202
docs/come funziona.md Normal file
View file

@ -0,0 +1,202 @@
Perfetto Fabio — ti preparo **la guida completa, chiara e pratica** per integrare il tuo **viewer Flutter Android** con il **WebSocket del tuo server Node.js**, così come funziona oggi nel tuo backend.
Ti spiego:
1. **Come si autentica il client Flutter**
2. **Come si connette al WebSocket**
3. **Quali messaggi riceve** (formato esatto)
4. **Come deve rispondere**
5. **Come integrare tutto nel viewer**
Tutto basato sul tuo backend reale.
---
# ⭐ 1. COME SI AUTENTICA IL CLIENT FLUTTER
Il tuo server usa JWT.
### Passi:
1. Flutter fa login via HTTP:
```
POST /auth/login
{
"email": "...",
"password": "..."
}
```
2. Il server risponde:
```json
{
"token": "<JWT>",
"name": "Fabio"
}
```
3. Flutter salva il token.
4. **Ogni chiamata HTTP successiva** deve avere:
```
Authorization: Bearer <JWT>
```
---
# ⭐ 2. COME SI CONNETTE AL WEBSOCKET
Il tuo server WebSocket accetta connessioni così:
```
ws://<server>:<port>/?token=<JWT>
```
Quindi in Flutter:
```dart
import 'package:web_socket_channel/web_socket_channel.dart';
final channel = WebSocketChannel.connect(
Uri.parse("ws://192.168.1.10:4000/?token=$jwt"),
);
```
### Importante:
- Devi passare **lo stesso JWT** usato per le API HTTP.
- Il server autentica il token e associa la connessione all’utente.
---
# ⭐ 3. QUALI MESSAGGI RICEVE FLUTTER (FORMATO ESATTO)
Il tuo backend invia questi eventi:
### 📌 Quando una foto viene aggiunta
```json
{
"type": "added",
"id": "12345"
}
```
### 📌 Quando una foto viene rimossa (DEL)
```json
{
"type": "removed",
"id": "12345"
}
```
### 📌 Quando una cartella viene aggiunta (ADD_DIR)
```json
{
"type": "add_dir",
"folder": "Vacanze/2024"
}
```
### 📌 Quando una cartella viene rimossa (DEL_DIR)
```json
{
"type": "del_dir",
"folder": "Vacanze/2024"
}
```
### 📌 Quando una foto viene soft-deleted / restored
```json
{
"type": "updated",
"id": "12345",
"deleted_at": "2026-03-31T10:00:00Z" // oppure null
}
```
---
# ⭐ 4. COME FLUTTER DEVE GESTIRE I MESSAGGI
Esempio completo:
```dart
channel.stream.listen((message) {
final data = jsonDecode(message);
switch (data["type"]) {
case "added":
// Scarica la foto via /photos/:id
fetchPhoto(data["id"]);
break;
case "removed":
// Rimuovi la foto dalla UI
removePhotoFromUI(data["id"]);
break;
case "updated":
// Aggiorna soft delete
updatePhotoState(data["id"], data["deleted_at"]);
break;
case "add_dir":
// Aggiorna lista cartelle
addFolderToUI(data["folder"]);
break;
case "del_dir":
// Rimuovi cartella dalla UI
removeFolderFromUI(data["folder"]);
break;
}
});
```
---
# ⭐ 5. COME DEVE RISPONDERE FLUTTER AL SERVER
Il tuo server **non richiede risposte** dal client WebSocket.
È **one-way**:
- Il server invia eventi
- Il client li riceve
- Il client aggiorna la UI
Flutter **non deve inviare nulla** al WebSocket.
---
# ⭐ 6. COME INTEGRARE TUTTO NEL VIEWER
### Al login:
- fai login HTTP
- salva il JWT
- apri il WebSocket
### All’avvio del viewer:
1. Carica tutte le foto via:
```
GET /photos
```
2. Avvia il WebSocket
3. Applica in tempo reale:
- added → aggiungi foto
- removed → rimuovi foto
- updated → aggiorna stato
- add_dir → aggiungi cartella
- del_dir → rimuovi cartella
### Dopo 2 minuti (quando WS non è più affidabile):
- usa progressive sync:
```
GET /photos/changes?since=<timestamp>
GET /photos/deleted_hard?since=<timestamp>
```
---

View file

@ -0,0 +1,207 @@
# Face KNN OpenCL - Note di compilazione
## Repository
Directory sorgente:
/home/nvme/dockerdata/prove/face-clustering-rk3588-knn-opencl-validated
Questo repository contiene i sorgenti del motore KNN utilizzato dai progetti s1xxjs/s190js.
Il binario distribuito è:
bin/face_knn
SHA256 del binario originale:
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033
---
## File principali
### Motore KNN
src/face_knn.cc
Gestisce:
- parsing input
- caricamento embeddings
- scelta CPU/OpenCL
- output risultati
### Backend OpenCL
src/knn_opencl.cc
Implementazione KNN GPU.
### Integrazione Mali G610
src/knn_opencl_backend.cc
Gestisce:
- ricerca device OpenCL
- compilazione kernel
- allocazione buffer
- esecuzione GPU
### Kernel GPU
kernels/knn_float4_topk.cl
Kernel OpenCL eseguito sulla Mali G610.
---
## Verifica binario
Verifica hash:
sha256sum bin/face_knn
Hash atteso:
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033
---
## Librerie richieste
Il binario utilizza:
- libOpenCL.so.1
- libstdc++.so.6
- libgcc_s.so.1
- libc.so.6
- libm.so.6
Verifica:
ldd bin/face_knn
---
## Simboli principali
Verifica presenza delle funzioni:
nm -C bin/face_knn | grep search_opencl
nm -C bin/face_knn | grep search_cpu
Devono comparire:
face_knn::search_opencl()
face_knn::search_opencl_impl()
face_knn::search_cpu()
---
## Compilazione (da verificare)
La build originale non è presente nel Makefile attuale.
Comando ricostruito:
g++ \
-std=c++17 \
-O2 \
-Wall \
-Wextra \
-Wpedantic \
-Iinclude \
src/face_knn.cc \
src/knn_opencl.cc \
src/knn_opencl_backend.cc \
-lOpenCL \
-ldl \
-lpthread \
-o bin/face_knn
NOTA:
Prima di utilizzarlo verificare eventuali dipendenze mancanti.
---
## Evidenze raccolte
Il binario contiene riferimenti diretti a:
- face_knn.cc
- knn_opencl.cc
- knn_opencl_backend.cc
Verifica:
strings bin/face_knn | grep -i "face_knn.cc\\|knn_opencl"
Il binario esporta:
- face_knn::search_opencl()
- face_knn::search_opencl_impl()
- face_knn::search_cpu()
Verifica:
nm -C bin/face_knn | grep search_
---
## Utilizzo nei progetti NodeJS
Il binario è utilizzato da:
native/face-knn/bin/face_knn
richiamato tramite:
tools/faceKnnRuntime.js
nei progetti s141js → s190js.
## Provenienza verificata
Il binario utilizzato nei progetti s141js → s190js:
native/face-knn/bin/face_knn
ha SHA256:
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033
Lo stesso hash è stato trovato nei repository:
- face-recognition-rk3588-opencl
- face-clustering-rk3588-knn-opencl
- face-clustering-rk3588-knn-opencl-validated
Verifiche effettuate:
- strings bin/face_knn
- nm -C bin/face_knn
- ldd bin/face_knn
- sha256sum bin/face_knn
Risultato:
Il binario deriva dai sorgenti:
- src/face_knn.cc
- src/knn_opencl.cc
- src/knn_opencl_backend.cc
e utilizza il kernel:
- kernels/knn_float4_topk.cl
Target GPU:
- ARM Mali-G610 (RK3588 / Orange Pi 5 Plus)
Data documentazione:
2026-10-26

View file

@ -0,0 +1 @@
e74bd672eebaa0f10028e25c0f7051e981cd16c2eeb9c529f513a6c412c11033 bin/face_knn

View file

BIN
docs/face-knn-source/bin/face_knn Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,5 @@
#pragma once
#ifndef FACE_CLUSTERING_ROOT
#define FACE_CLUSTERING_ROOT "."
#endif

View file

@ -0,0 +1,47 @@
#pragma once
#include <string>
#include <vector>
struct Point {
float x = 0.0f;
float y = 0.0f;
};
struct Face {
float score = 0.0f;
float x1 = 0.0f;
float y1 = 0.0f;
float x2 = 0.0f;
float y2 = 0.0f;
Point kps[5]{};
// SFace: embedding L2-normalizzato da 128 float32.
std::vector<float> embedding;
};
class FaceEngine {
public:
FaceEngine();
~FaceEngine();
FaceEngine(const FaceEngine&) = delete;
FaceEngine& operator=(const FaceEngine&) = delete;
bool init(
const std::string& scrfd_model,
const std::string& sface_model);
bool process_image(
const std::string& image_path,
std::vector<Face>& faces,
int& image_width,
int& image_height,
int rotation_degrees = 0);
private:
struct Impl;
Impl* impl_;
};

View file

@ -0,0 +1,121 @@
#ifndef FACE_CLUSTERING_KNN_OPENCL_H
#define FACE_CLUSTERING_KNN_OPENCL_H
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace face_knn {
constexpr std::uint32_t invalid_index =
0xffffffffu;
struct Neighbor {
float score = 0.0f;
std::uint32_t index =
invalid_index;
};
struct SearchOptions {
std::uint32_t dimension = 128;
std::uint32_t top_k = 1;
std::uint32_t lanes_per_query = 8;
std::size_t chunk_size = 50000;
std::string kernel_path;
};
struct SearchOutput {
std::size_t query_count = 0;
std::size_t database_count = 0;
std::uint32_t dimension = 0;
std::uint32_t top_k = 0;
std::string backend;
std::string device_name;
double elapsed_ms = 0.0;
/*
* Layout:
*
* neighbors[
* query_index * top_k
* + neighbor_position
* ]
*/
std::vector<Neighbor> neighbors;
};
struct ValidationResult {
std::size_t positions = 0;
std::size_t exact_index_matches = 0;
std::size_t decision_matches = 0;
float maximum_score_error = 0.0f;
bool exact_indices = false;
bool decisions_equal = false;
};
void validate_input(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options);
SearchOutput search_cpu(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options);
SearchOutput search_opencl(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options);
ValidationResult compare_outputs(
const SearchOutput& expected,
const SearchOutput& actual,
float score_tolerance,
float recognition_threshold);
std::vector<Neighbor> query_neighbors(
const SearchOutput& output,
std::size_t query_index);
std::vector<Neighbor> exclude_neighbor(
const std::vector<Neighbor>& neighbors,
std::uint32_t excluded_index);
} // namespace face_knn
#endif

View file

@ -0,0 +1,804 @@
/****************************************************************************
*
* Copyright (c) 2017 - 2022 by Rockchip Corp. All rights reserved.
*
* The material in this file is confidential and contains trade secrets
* of Rockchip Corporation. This is proprietary information owned by
* Rockchip Corporation. No part of this work may be disclosed,
* reproduced, copied, transmitted, or used in any way for any purpose,
* without the express written permission of Rockchip Corporation.
*
*****************************************************************************/
#ifndef _RKNN_API_H
#define _RKNN_API_H
#ifdef __cplusplus
extern "C" {
#endif
#include <stdint.h>
/*
Definition of extended flag for rknn_init.
*/
/* set high priority context. */
#define RKNN_FLAG_PRIOR_HIGH 0x00000000
/* set medium priority context */
#define RKNN_FLAG_PRIOR_MEDIUM 0x00000001
/* set low priority context. */
#define RKNN_FLAG_PRIOR_LOW 0x00000002
/* asynchronous mode.
when enable, rknn_outputs_get will not block for too long because it directly retrieves the result of
the previous frame which can increase the frame rate on single-threaded mode, but at the cost of
rknn_outputs_get not retrieves the result of the current frame.
in multi-threaded mode you do not need to turn this mode on. */
#define RKNN_FLAG_ASYNC_MASK 0x00000004
/* collect performance mode.
when enable, you can get detailed performance reports via rknn_query(ctx, RKNN_QUERY_PERF_DETAIL, ...),
but it will reduce the frame rate. */
#define RKNN_FLAG_COLLECT_PERF_MASK 0x00000008
/* allocate all memory in outside, includes weight/internal/inputs/outputs */
#define RKNN_FLAG_MEM_ALLOC_OUTSIDE 0x00000010
/* weight sharing with the same network structure */
#define RKNN_FLAG_SHARE_WEIGHT_MEM 0x00000020
/* send fence fd from outside */
#define RKNN_FLAG_FENCE_IN_OUTSIDE 0x00000040
/* get fence fd from inside */
#define RKNN_FLAG_FENCE_OUT_OUTSIDE 0x00000080
/* dummy init flag: could only get total_weight_size and total_internal_size by rknn_query*/
#define RKNN_FLAG_COLLECT_MODEL_INFO_ONLY 0x00000100
/* allocate internal memory in outside */
#define RKNN_FLAG_INTERNAL_ALLOC_OUTSIDE 0x00000200
/* set GPU as the preferred execution backend When the operator is not supported by the NPU */
#define RKNN_FLAG_EXECUTE_FALLBACK_PRIOR_DEVICE_GPU 0x00000400
/* enable allocate sram type buffers */
#define RKNN_FLAG_ENABLE_SRAM 0x00000800
/* sram type buffers are shared among different contexts */
#define RKNN_FLAG_SHARE_SRAM 0x00001000
/* default nice -19, this flag can disable default priority */
#define RKNN_FLAG_DISABLE_PROC_HIGH_PRIORITY 0x00002000
/* don't flush input buffer cache, the user must ensure that the input tensor has flushed the cache before calling rknn_run.
!!! Don't use this flags when you call rknn_inputs_set() to set input data. */
#define RKNN_FLAG_DISABLE_FLUSH_INPUT_MEM_CACHE 0x00004000
/* Don't invalid output buffer cache.
Users cannot directly access output_mem->virt_addr,
which will cause cache consistency problems.
If you want to use output_mem->virt_addr,
you must use rknn_mem_sync (ctx, mem, RKNN_MEMORY_SYNC_FROM_DEVICE) to flush the cache.
This flags is generally used when the output data of the NPU is not accessed by the CPU,
but is accessed by the GPU or RGA to reduce the time required to flush the cache.
!!! Don't use this flags when you call rknn_outputs_get() to get output data.*/
#define RKNN_FLAG_DISABLE_FLUSH_OUTPUT_MEM_CACHE 0x00008000
/* This flag is used when the model data buffer is allocated by NPU, and can be accessed by NPU directly. */
#define RKNN_FLAG_MODEL_BUFFER_ZERO_COPY 0x00010000
/* This flag is a memory allocation flag, which is used in rknn_create_mem2() when no context is available. */
#define RKNN_MEM_FLAG_ALLOC_NO_CONTEXT 0x00020000
/*
Error code returned by the RKNN API.
*/
#define RKNN_SUCC 0 /* execute succeed. */
#define RKNN_ERR_FAIL -1 /* execute failed. */
#define RKNN_ERR_TIMEOUT -2 /* execute timeout. */
#define RKNN_ERR_DEVICE_UNAVAILABLE -3 /* device is unavailable. */
#define RKNN_ERR_MALLOC_FAIL -4 /* memory malloc fail. */
#define RKNN_ERR_PARAM_INVALID -5 /* parameter is invalid. */
#define RKNN_ERR_MODEL_INVALID -6 /* model is invalid. */
#define RKNN_ERR_CTX_INVALID -7 /* context is invalid. */
#define RKNN_ERR_INPUT_INVALID -8 /* input is invalid. */
#define RKNN_ERR_OUTPUT_INVALID -9 /* output is invalid. */
#define RKNN_ERR_DEVICE_UNMATCH -10 /* the device is unmatch, please update rknn sdk
and npu driver/firmware. */
#define RKNN_ERR_INCOMPATILE_PRE_COMPILE_MODEL -11 /* This RKNN model use pre_compile mode, but not compatible with current driver. */
#define RKNN_ERR_INCOMPATILE_OPTIMIZATION_LEVEL_VERSION -12 /* This RKNN model set optimization level, but not compatible with current driver. */
#define RKNN_ERR_TARGET_PLATFORM_UNMATCH -13 /* This RKNN model set target platform, but not compatible with current platform. */
/*
Definition for tensor
*/
#define RKNN_MAX_DIMS 16 /* maximum dimension of tensor. */
#define RKNN_MAX_NUM_CHANNEL 15 /* maximum channel number of input tensor. */
#define RKNN_MAX_NAME_LEN 256 /* maximum name lenth of tensor. */
#define RKNN_MAX_DYNAMIC_SHAPE_NUM 512 /* maximum number of dynamic shape for each input. */
#ifdef __arm__
typedef uint32_t rknn_context;
#else
typedef uint64_t rknn_context;
#endif
/*
The query command for rknn_query
*/
typedef enum _rknn_query_cmd {
RKNN_QUERY_IN_OUT_NUM = 0, /* query the number of input & output tensor. */
RKNN_QUERY_INPUT_ATTR = 1, /* query the attribute of input tensor. */
RKNN_QUERY_OUTPUT_ATTR = 2, /* query the attribute of output tensor. */
RKNN_QUERY_PERF_DETAIL = 3, /* query the detail performance, need set
RKNN_FLAG_COLLECT_PERF_MASK when call rknn_init,
this query needs to be valid after rknn_outputs_get. */
RKNN_QUERY_PERF_RUN = 4, /* query the time of run,
this query needs to be valid after rknn_outputs_get. */
RKNN_QUERY_SDK_VERSION = 5, /* query the sdk & driver version */
RKNN_QUERY_MEM_SIZE = 6, /* query the weight & internal memory size */
RKNN_QUERY_CUSTOM_STRING = 7, /* query the custom string */
RKNN_QUERY_NATIVE_INPUT_ATTR = 8, /* query the attribute of native input tensor. */
RKNN_QUERY_NATIVE_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */
RKNN_QUERY_NATIVE_NC1HWC2_INPUT_ATTR = 8, /* query the attribute of native input tensor. */
RKNN_QUERY_NATIVE_NC1HWC2_OUTPUT_ATTR = 9, /* query the attribute of native output tensor. */
RKNN_QUERY_NATIVE_NHWC_INPUT_ATTR = 10, /* query the attribute of native input tensor. */
RKNN_QUERY_NATIVE_NHWC_OUTPUT_ATTR = 11, /* query the attribute of native output tensor. */
RKNN_QUERY_DEVICE_MEM_INFO = 12, /* query the attribute of rknn memory information. */
RKNN_QUERY_INPUT_DYNAMIC_RANGE = 13, /* query the dynamic shape range of rknn input tensor. */
RKNN_QUERY_CURRENT_INPUT_ATTR = 14, /* query the current shape of rknn input tensor, only valid for dynamic rknn model*/
RKNN_QUERY_CURRENT_OUTPUT_ATTR = 15, /* query the current shape of rknn output tensor, only valid for dynamic rknn model*/
RKNN_QUERY_CURRENT_NATIVE_INPUT_ATTR = 16, /* query the current native shape of rknn input tensor, only valid for dynamic rknn model*/
RKNN_QUERY_CURRENT_NATIVE_OUTPUT_ATTR = 17, /* query the current native shape of rknn output tensor, only valid for dynamic rknn model*/
RKNN_QUERY_CMD_MAX
} rknn_query_cmd;
/*
the tensor data type.
*/
typedef enum _rknn_tensor_type {
RKNN_TENSOR_FLOAT32 = 0, /* data type is float32. */
RKNN_TENSOR_FLOAT16, /* data type is float16. */
RKNN_TENSOR_INT8, /* data type is int8. */
RKNN_TENSOR_UINT8, /* data type is uint8. */
RKNN_TENSOR_INT16, /* data type is int16. */
RKNN_TENSOR_UINT16, /* data type is uint16. */
RKNN_TENSOR_INT32, /* data type is int32. */
RKNN_TENSOR_UINT32, /* data type is uint32. */
RKNN_TENSOR_INT64, /* data type is int64. */
RKNN_TENSOR_BOOL,
RKNN_TENSOR_INT4,
RKNN_TENSOR_BFLOAT16,
RKNN_TENSOR_TYPE_MAX
} rknn_tensor_type;
inline static const char* get_type_string(rknn_tensor_type type)
{
switch(type) {
case RKNN_TENSOR_FLOAT32: return "FP32";
case RKNN_TENSOR_FLOAT16: return "FP16";
case RKNN_TENSOR_INT8: return "INT8";
case RKNN_TENSOR_UINT8: return "UINT8";
case RKNN_TENSOR_INT16: return "INT16";
case RKNN_TENSOR_UINT16: return "UINT16";
case RKNN_TENSOR_INT32: return "INT32";
case RKNN_TENSOR_UINT32: return "UINT32";
case RKNN_TENSOR_INT64: return "INT64";
case RKNN_TENSOR_BOOL: return "BOOL";
case RKNN_TENSOR_INT4: return "INT4";
case RKNN_TENSOR_BFLOAT16: return "BF16";
default: return "UNKNOW";
}
}
/*
the quantitative type.
*/
typedef enum _rknn_tensor_qnt_type {
RKNN_TENSOR_QNT_NONE = 0, /* none. */
RKNN_TENSOR_QNT_DFP, /* dynamic fixed point. */
RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC, /* asymmetric affine. */
RKNN_TENSOR_QNT_MAX
} rknn_tensor_qnt_type;
inline static const char* get_qnt_type_string(rknn_tensor_qnt_type type)
{
switch(type) {
case RKNN_TENSOR_QNT_NONE: return "NONE";
case RKNN_TENSOR_QNT_DFP: return "DFP";
case RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC: return "AFFINE";
default: return "UNKNOW";
}
}
/*
the tensor data format.
*/
typedef enum _rknn_tensor_format {
RKNN_TENSOR_NCHW = 0, /* data format is NCHW. */
RKNN_TENSOR_NHWC, /* data format is NHWC. */
RKNN_TENSOR_NC1HWC2, /* data format is NC1HWC2. */
RKNN_TENSOR_UNDEFINED,
RKNN_TENSOR_FORMAT_MAX
} rknn_tensor_format;
/*
the mode of running on target NPU core.
*/
typedef enum _rknn_core_mask {
RKNN_NPU_CORE_AUTO = 0, /* default, run on NPU core randomly. */
RKNN_NPU_CORE_0 = 1, /* run on NPU core 0. */
RKNN_NPU_CORE_1 = 2, /* run on NPU core 1. */
RKNN_NPU_CORE_2 = 4, /* run on NPU core 2. */
RKNN_NPU_CORE_0_1 = RKNN_NPU_CORE_0 | RKNN_NPU_CORE_1, /* run on NPU core 0 and core 1. */
RKNN_NPU_CORE_0_1_2 = RKNN_NPU_CORE_0_1 | RKNN_NPU_CORE_2, /* run on NPU core 0 and core 1 and core 2. */
RKNN_NPU_CORE_ALL = 0xffff, /* auto choice, run on NPU cores depending on platform */
RKNN_NPU_CORE_UNDEFINED,
} rknn_core_mask;
inline static const char* get_format_string(rknn_tensor_format fmt)
{
switch(fmt) {
case RKNN_TENSOR_NCHW: return "NCHW";
case RKNN_TENSOR_NHWC: return "NHWC";
case RKNN_TENSOR_NC1HWC2: return "NC1HWC2";
case RKNN_TENSOR_UNDEFINED: return "UNDEFINED";
default: return "UNKNOW";
}
}
/*
the information for RKNN_QUERY_IN_OUT_NUM.
*/
typedef struct _rknn_input_output_num {
uint32_t n_input; /* the number of input. */
uint32_t n_output; /* the number of output. */
} rknn_input_output_num;
/*
the information for RKNN_QUERY_INPUT_ATTR / RKNN_QUERY_OUTPUT_ATTR.
*/
typedef struct _rknn_tensor_attr {
uint32_t index; /* input parameter, the index of input/output tensor,
need set before call rknn_query. */
uint32_t n_dims; /* the number of dimensions. */
uint32_t dims[RKNN_MAX_DIMS]; /* the dimensions array. */
char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */
uint32_t n_elems; /* the number of elements. */
uint32_t size; /* the bytes size of tensor. */
rknn_tensor_format fmt; /* the data format of tensor. */
rknn_tensor_type type; /* the data type of tensor. */
rknn_tensor_qnt_type qnt_type; /* the quantitative type of tensor. */
int8_t fl; /* fractional length for RKNN_TENSOR_QNT_DFP. */
int32_t zp; /* zero point for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */
float scale; /* scale for RKNN_TENSOR_QNT_AFFINE_ASYMMETRIC. */
uint32_t w_stride; /* the stride of tensor along the width dimention of input,
Note: it is read-only, 0 means equal to width. */
uint32_t size_with_stride; /* the bytes size of tensor with stride. */
uint8_t pass_through; /* pass through mode, for rknn_set_io_mem interface.
if TRUE, the buf data is passed directly to the input node of the rknn model
without any conversion. the following variables do not need to be set.
if FALSE, the buf data is converted into an input consistent with the model
according to the following type and fmt. so the following variables
need to be set.*/
uint32_t h_stride; /* the stride along the height dimention of input,
Note: it is write-only, if it was set to 0, h_stride = height. */
} rknn_tensor_attr;
typedef struct _rknn_input_range {
uint32_t index; /* input parameter, the index of input/output tensor,
need set before call rknn_query. */
uint32_t shape_number; /* the number of shape. */
rknn_tensor_format fmt; /* the data format of tensor. */
char name[RKNN_MAX_NAME_LEN]; /* the name of tensor. */
uint32_t dyn_range[RKNN_MAX_DYNAMIC_SHAPE_NUM][RKNN_MAX_DIMS]; /* the dynamic input dimensions range. */
uint32_t n_dims; /* the number of dimensions. */
} rknn_input_range;
/*
the information for RKNN_QUERY_PERF_DETAIL.
*/
typedef struct _rknn_perf_detail {
char* perf_data; /* the string pointer of perf detail. don't need free it by user. */
uint64_t data_len; /* the string length. */
} rknn_perf_detail;
/*
the information for RKNN_QUERY_PERF_RUN.
*/
typedef struct _rknn_perf_run {
int64_t run_duration; /* real inference time (us) */
} rknn_perf_run;
/*
the information for RKNN_QUERY_SDK_VERSION.
*/
typedef struct _rknn_sdk_version {
char api_version[256]; /* the version of rknn api. */
char drv_version[256]; /* the version of rknn driver. */
} rknn_sdk_version;
/*
the information for RKNN_QUERY_MEM_SIZE.
*/
typedef struct _rknn_mem_size {
uint32_t total_weight_size; /* the weight memory size */
uint32_t total_internal_size; /* the internal memory size, exclude inputs/outputs */
uint64_t total_dma_allocated_size; /* total dma memory allocated size */
uint32_t total_sram_size; /* total system sram size reserved for rknn */
uint32_t free_sram_size; /* free system sram size reserved for rknn */
uint32_t reserved[10]; /* reserved */
} rknn_mem_size;
/*
the information for RKNN_QUERY_CUSTOM_STRING.
*/
typedef struct _rknn_custom_string {
char string[1024]; /* the string of custom, lengths max to 1024 bytes */
} rknn_custom_string;
/*
The flags of rknn_tensor_mem.
*/
typedef enum _rknn_tensor_mem_flags {
RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE = 1, /*Used to mark in rknn_destroy_mem() whether it is necessary to release the "mem" pointer itself.
If the flag RKNN_TENSOR_MEMORY_FLAGS_ALLOC_INSIDE is set, rknn_destroy_mem() will call free(mem).*/
RKNN_TENSOR_MEMORY_FLAGS_FROM_FD = 2, /*Used to mark in rknn_create_mem_from_fd() whether it is necessary to release the "mem" pointer itself.
If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_FD is set, rknn_destroy_mem() will call free(mem).*/
RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS = 3, /*Used to mark in rknn_create_mem_from_phys() whether it is necessary to release the "mem" pointer itself.
If the flag RKNN_TENSOR_MEMORY_FLAGS_FROM_PHYS is set, rknn_destroy_mem() will call free(mem).*/
RKNN_TENSOR_MEMORY_FLAGS_UNKNOWN
} rknn_tensor_mem_flags;
/*
The mode to allocate rknn memory.
*/
typedef enum _rknn_mem_alloc_flags {
RKNN_FLAG_MEMORY_FLAGS_DEFAULT = 0 << 0, /* Same with RKNN_FLAG_MEMORY_CACHEABLE */
RKNN_FLAG_MEMORY_CACHEABLE = 1 << 0, /* Create Cacheable memory. */
RKNN_FLAG_MEMORY_NON_CACHEABLE = 1 << 1, /* Create NON-Cacheable memory. */
RKNN_FLAG_MEMORY_TRY_ALLOC_SRAM = 1 << 2, /* Try to allocate memory in SRAM if possible. if SRAM is not enough, allocate rest memory in DRAM. */
} rknn_mem_alloc_flags;
/*
The mode to sync cacheable rknn memory.
*/
typedef enum _rknn_mem_sync_mode {
RKNN_MEMORY_SYNC_TO_DEVICE = 0x1, /* the mode used for consistency of device access after CPU accesses data. */
RKNN_MEMORY_SYNC_FROM_DEVICE = 0x2, /* the mode used for consistency of CPU access after device accesses data. */
RKNN_MEMORY_SYNC_BIDIRECTIONAL = RKNN_MEMORY_SYNC_TO_DEVICE | RKNN_MEMORY_SYNC_FROM_DEVICE, /* the mode used for consistency of data access
between device and CPU in both directions. */
} rknn_mem_sync_mode;
/*
the memory information of tensor.
*/
typedef struct _rknn_tensor_memory {
void* virt_addr; /* the virtual address of tensor buffer. */
uint64_t phys_addr; /* the physical address of tensor buffer. */
int32_t fd; /* the fd of tensor buffer. */
int32_t offset; /* indicates the offset of the memory. */
uint32_t size; /* the size of tensor buffer. */
uint32_t flags; /* the flags of tensor buffer, reserved */
void * priv_data; /* the private data of tensor buffer. */
} rknn_tensor_mem;
/*
the input information for rknn_input_set.
*/
typedef struct _rknn_input {
uint32_t index; /* the input index. */
void* buf; /* the input buf for index. */
uint32_t size; /* the size of input buf. */
uint8_t pass_through; /* pass through mode.
if TRUE, the buf data is passed directly to the input node of the rknn model
without any conversion. the following variables do not need to be set.
if FALSE, the buf data is converted into an input consistent with the model
according to the following type and fmt. so the following variables
need to be set.*/
rknn_tensor_type type; /* the data type of input buf. */
rknn_tensor_format fmt; /* the data format of input buf.
currently the internal input format of NPU is NCHW by default.
so entering NCHW data can avoid the format conversion in the driver. */
} rknn_input;
/*
the output information for rknn_outputs_get.
*/
typedef struct _rknn_output {
uint8_t want_float; /* want transfer output data to float */
uint8_t is_prealloc; /* whether buf is pre-allocated.
if TRUE, the following variables need to be set.
if FALSE, the following variables do not need to be set. */
uint32_t index; /* the output index. */
void* buf; /* the output buf for index.
when is_prealloc = FALSE and rknn_outputs_release called,
this buf pointer will be free and don't use it anymore. */
uint32_t size; /* the size of output buf. */
} rknn_output;
/*
the extend information for rknn_init.
*/
typedef struct _rknn_init_extend {
rknn_context ctx; /* rknn context */
int32_t real_model_offset; /* real rknn model file offset, only valid when init context with rknn file path and zero-copy model model */
uint32_t real_model_size; /* real rknn model file size, only valid when init context with rknn file path and zero-copy model model */
int32_t model_buffer_fd; /* the fd of model buffer. */
uint32_t model_buffer_flags; /* the flags of model_buffer */
uint8_t reserved[112]; /* reserved */
} rknn_init_extend;
/*
the extend information for rknn_run.
*/
typedef struct _rknn_run_extend {
uint64_t frame_id; /* output parameter, indicate current frame id of run. */
int32_t non_block; /* block flag of run, 0 is block else 1 is non block */
int32_t timeout_ms; /* timeout for block mode, in milliseconds */
int32_t fence_fd; /* fence fd from other unit */
} rknn_run_extend;
/*
the extend information for rknn_outputs_get.
*/
typedef struct _rknn_output_extend {
uint64_t frame_id; /* output parameter, indicate the frame id of outputs, corresponds to
struct rknn_run_extend.frame_id.*/
} rknn_output_extend;
/* rknn_init
initial the context and load the rknn model.
input:
rknn_context* context the pointer of context handle.
void* model if size > 0, pointer to the rknn model, if size = 0, filepath to the rknn model.
uint32_t size the size of rknn model.
uint32_t flag extend flag, see the define of RKNN_FLAG_XXX_XXX.
rknn_init_extend* extend the extend information of init.
return:
int error code.
*/
int rknn_init(rknn_context* context, void* model, uint32_t size, uint32_t flag, rknn_init_extend* extend);
/* rknn_dup_context
initial the context and load the rknn model.
input:
rknn_context* context_in the pointer of context in handle.
rknn_context* context_out the pointer of context out handle.
return:
int error code.
*/
int rknn_dup_context(rknn_context* context_in, rknn_context* context_out);
/* rknn_destroy
unload the rknn model and destroy the context.
input:
rknn_context context the handle of context.
return:
int error code.
*/
int rknn_destroy(rknn_context context);
/* rknn_query
query the information about model or others. see rknn_query_cmd.
input:
rknn_context context the handle of context.
rknn_query_cmd cmd the command of query.
void* info the buffer point of information.
uint32_t size the size of information.
return:
int error code.
*/
int rknn_query(rknn_context context, rknn_query_cmd cmd, void* info, uint32_t size);
/* rknn_inputs_set
set inputs information by input index of rknn model.
inputs information see rknn_input.
input:
rknn_context context the handle of context.
uint32_t n_inputs the number of inputs.
rknn_input inputs[] the arrays of inputs information, see rknn_input.
return:
int error code
*/
int rknn_inputs_set(rknn_context context, uint32_t n_inputs, rknn_input inputs[]);
/*
rknn_set_batch_core_num
set rknn batch core_num.
input:
rknn_context context the handle of context.
int core_num the core number.
return:
int error code.
*/
int rknn_set_batch_core_num(rknn_context context, int core_num);
/* rknn_set_core_mask
set the core mask for the model.(only supported on multi-core NPU platform)
RKNN_NPU_CORE_AUTO: auto mode, default value
RKNN_NPU_CORE_0: core 0 mode
RKNN_NPU_CORE_1: core 1 mode
RKNN_NPU_CORE_2: core 2 mode
RKNN_NPU_CORE_0_1: combine core 0/1 mode
RKNN_NPU_CORE_0_1_2: combine core 0/1/2 mode
RKNN_NPU_CORE_ALL: auto mode, select multiple npu cores to run depending on platform
input:
rknn_context context the handle of context.
rknn_core_mask core_mask the core mask.
return:
int error code.
*/
int rknn_set_core_mask(rknn_context context, rknn_core_mask core_mask);
/* rknn_run
run the model to execute inference.
input:
rknn_context context the handle of context.
rknn_run_extend* extend the extend information of run.
return:
int error code.
*/
int rknn_run(rknn_context context, rknn_run_extend* extend);
/* rknn_wait
wait the model after execute inference.
input:
rknn_context context the handle of context.
rknn_run_extend* extend the extend information of run.
return:
int error code.
*/
int rknn_wait(rknn_context context, rknn_run_extend* extend);
/* rknn_outputs_get
wait the inference to finish and get the outputs.
this function will block until inference finish.
the results will set to outputs[].
input:
rknn_context context the handle of context.
uint32_t n_outputs the number of outputs.
rknn_output outputs[] the arrays of output, see rknn_output.
rknn_output_extend* the extend information of output.
return:
int error code.
*/
int rknn_outputs_get(rknn_context context, uint32_t n_outputs, rknn_output outputs[], rknn_output_extend* extend);
/* rknn_outputs_release
release the outputs that get by rknn_outputs_get.
after called, the rknn_output[x].buf get from rknn_outputs_get will
also be free when rknn_output[x].is_prealloc = FALSE.
input:
rknn_context context the handle of context.
uint32_t n_ouputs the number of outputs.
rknn_output outputs[] the arrays of output.
return:
int error code
*/
int rknn_outputs_release(rknn_context context, uint32_t n_ouputs, rknn_output outputs[]);
/* new api for zero copy */
/* rknn_create_mem_from_phys (memory allocated outside)
initialize tensor memory from physical address.
input:
rknn_context ctx the handle of context.
uint64_t phys_addr physical address.
void *virt_addr virtual address.
uint32_t size the size of tensor buffer.
return:
rknn_tensor_mem the pointer of tensor memory information.
*/
rknn_tensor_mem* rknn_create_mem_from_phys(rknn_context ctx, uint64_t phys_addr, void *virt_addr, uint32_t size);
/* rknn_create_mem_from_fd (memory allocated outside)
initialize tensor memory from file description.
input:
rknn_context ctx the handle of context.
int32_t fd file description.
void *virt_addr virtual address.
uint32_t size the size of tensor buffer.
int32_t offset indicates the offset of the memory (virt_addr without offset).
return:
rknn_tensor_mem the pointer of tensor memory information.
*/
rknn_tensor_mem* rknn_create_mem_from_fd(rknn_context ctx, int32_t fd, void *virt_addr, uint32_t size, int32_t offset);
/* rknn_create_mem_from_mb_blk (memory allocated outside)
create tensor memory from mb_blk.
input:
rknn_context ctx the handle of context.
void *mb_blk mb_blk allocate from system api.
int32_t offset indicates the offset of the memory.
return:
rknn_tensor_mem the pointer of tensor memory information.
*/
rknn_tensor_mem* rknn_create_mem_from_mb_blk(rknn_context ctx, void *mb_blk, int32_t offset);
/* rknn_create_mem (memory allocated inside)
create tensor memory.
input:
rknn_context ctx the handle of context.
uint32_t size the size of tensor buffer.
return:
rknn_tensor_mem the pointer of tensor memory information.
*/
rknn_tensor_mem* rknn_create_mem(rknn_context ctx, uint32_t size);
/* rknn_create_mem2 (memory allocated inside)
create tensor memory.
input:
rknn_context ctx the handle of context.
uint64_t size the size of tensor buffer.
uint64_t alloc_flags memory allocation flags.
return:
rknn_tensor_mem the pointer of tensor memory information.
*/
rknn_tensor_mem* rknn_create_mem2(rknn_context ctx, uint64_t size, uint64_t alloc_flags);
/* rknn_destroy_mem (support allocate inside and outside)
destroy tensor memory.
input:
rknn_context ctx the handle of context.
rknn_tensor_mem *mem the pointer of tensor memory information.
return:
int error code
*/
int rknn_destroy_mem(rknn_context ctx, rknn_tensor_mem *mem);
/* rknn_set_weight_mem
set the weight memory.
input:
rknn_context ctx the handle of context.
rknn_tensor_mem *mem the array of tensor memory information
return:
int error code.
*/
int rknn_set_weight_mem(rknn_context ctx, rknn_tensor_mem *mem);
/* rknn_set_internal_mem
set the internal memory.
input:
rknn_context ctx the handle of context.
rknn_tensor_mem *mem the array of tensor memory information
return:
int error code.
*/
int rknn_set_internal_mem(rknn_context ctx, rknn_tensor_mem *mem);
/* rknn_set_io_mem
set the input and output tensors buffer.
input:
rknn_context ctx the handle of context.
rknn_tensor_mem *mem the array of tensor memory information.
rknn_tensor_attr *attr the attribute of input or output tensor buffer.
return:
int error code.
*/
int rknn_set_io_mem(rknn_context ctx, rknn_tensor_mem *mem, rknn_tensor_attr *attr);
/* rknn_set_input_shape(deprecated)
set the input tensor shape (only valid for dynamic shape rknn model).
input:
rknn_context ctx the handle of context.
rknn_tensor_attr *attr the attribute of input or output tensor buffer.
return:
int error code.
*/
int rknn_set_input_shape(rknn_context ctx, rknn_tensor_attr* attr);
/* rknn_set_input_shapes
set all the input tensor shapes. graph will run under current set of input shapes after rknn_set_input_shapes.(only valid for dynamic shape rknn model).
input:
rknn_context ctx the handle of context.
uint32_t n_inputs the number of inputs.
rknn_tensor_attr attr[] the attribute array of all input tensors.
return:
int error code.
*/
int rknn_set_input_shapes(rknn_context ctx, uint32_t n_inputs, rknn_tensor_attr attr[]);
/* rknn_mem_sync
sync cacheable rknn memory when both cpu and device access data.
input:
rknn_context context the handle of context.
rknn_tensor_mem *mem the pointer of tensor memory information.
rknn_mem_sync_mode mode the mode of sync cache.
return:
int error code.
*/
int rknn_mem_sync(rknn_context context, rknn_tensor_mem* mem, rknn_mem_sync_mode mode);
#ifdef __cplusplus
} //extern "C"
#endif
#endif //_RKNN_API_H

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,192 @@
#ifndef DIM
#define DIM 128
#endif
#ifndef TOPK
#define TOPK 32
#endif
#ifndef QUERY_COUNT
#define QUERY_COUNT 1
#endif
/*
* Numero di work-item che collaborano per ogni query.
*/
#ifndef LANES_PER_QUERY
#define LANES_PER_QUERY 8
#endif
/*
* Ogni work-group contiene 8 work-item.
*
* Ogni work-item calcola un TOPK locale.
* Poi il work-item 0 fonde gli 8 TOPK locali.
*/
__kernel void knn(
__global const float *db,
__global const float *queries,
__global uint *indices,
__global float *values,
uint n)
{
uint lane = get_local_id(0);
uint q = get_group_id(0);
if (q >= QUERY_COUNT)
return;
/*
* TOPK locale per ogni work-item.
*/
float best_score[TOPK];
uint best_index[TOPK];
for (uint k = 0; k < TOPK; ++k) {
best_score[k] = -3.402823466e+38f;
best_index[k] = 0xffffffff;
}
__global const float *query =
&queries[q * DIM];
/*
* Ogni lane analizza una parte del database.
*
* Esempio con 8 lane:
*
* lane 0 -> 0, 8, 16, 24, ...
* lane 1 -> 1, 9, 17, 25, ...
* ...
* lane 7 -> 7, 15, 23, 31, ...
*/
for (uint i = lane; i < n; i += LANES_PER_QUERY) {
__global const float *v =
&db[i * DIM];
float s = 0.0f;
for (uint j = 0; j < DIM; ++j)
s += v[j] * query[j];
if (s <= best_score[TOPK - 1])
continue;
int pos = TOPK - 1;
while (pos > 0 &&
s > best_score[pos - 1]) {
--pos;
}
for (int k = TOPK - 1; k > pos; --k) {
best_score[k] =
best_score[k - 1];
best_index[k] =
best_index[k - 1];
}
best_score[pos] = s;
best_index[pos] = i;
}
/*
* TOPK locali nella local memory.
*
* 8 lane × 32 elementi.
*/
__local float local_scores[LANES_PER_QUERY][TOPK];
__local uint local_indices[LANES_PER_QUERY][TOPK];
for (uint k = 0; k < TOPK; ++k) {
local_scores[lane][k] = best_score[k];
local_indices[lane][k] = best_index[k];
}
/*
* Tutti i lane devono aver scritto
* la local memory prima della riduzione.
*/
barrier(CLK_LOCAL_MEM_FENCE);
/*
* Solo lane 0 esegue il merge finale.
*/
if (lane == 0) {
float final_score[TOPK];
uint final_index[TOPK];
for (uint k = 0; k < TOPK; ++k) {
final_score[k] =
-3.402823466e+38f;
final_index[k] =
0xffffffff;
}
/*
* Fonde gli 8 TOPK locali.
*
* Al massimo 8 × 32 = 256 candidati.
*/
for (uint l = 0;
l < LANES_PER_QUERY;
++l) {
for (uint k = 0;
k < TOPK;
++k) {
float s =
local_scores[l][k];
uint idx =
local_indices[l][k];
if (idx == 0xffffffff)
continue;
if (s <= final_score[TOPK - 1])
continue;
int pos = TOPK - 1;
while (pos > 0 &&
s > final_score[pos - 1]) {
--pos;
}
for (int p = TOPK - 1;
p > pos;
--p) {
final_score[p] =
final_score[p - 1];
final_index[p] =
final_index[p - 1];
}
final_score[pos] = s;
final_index[pos] = idx;
}
}
/*
* Risultato finale della query.
*/
for (uint k = 0; k < TOPK; ++k) {
indices[q * TOPK + k] =
final_index[k];
values[q * TOPK + k] =
final_score[k];
}
}
}

View file

@ -0,0 +1 @@
f1979b6aa6d24633c6ac261fa66204e7f714a398091c87552712c43f8bb29467 knn_float4_topk.cl

View file

@ -0,0 +1,181 @@
#define DIM 128
#define TOPK 32
/*
* Numero di work-item che collaborano per ogni query.
*/
#define LANES_PER_QUERY 8
/*
* Ogni work-group contiene 8 work-item.
*
* Ogni work-item calcola un TOPK locale.
* Poi il work-item 0 fonde gli 8 TOPK locali.
*/
__kernel void knn(
__global const float *db,
__global const float *queries,
__global uint *indices,
__global float *values,
uint n)
{
uint lane = get_local_id(0);
uint q = get_group_id(0);
if (q >= 256)
return;
/*
* TOPK locale per ogni work-item.
*/
float best_score[TOPK];
uint best_index[TOPK];
for (uint k = 0; k < TOPK; ++k) {
best_score[k] = -3.402823466e+38f;
best_index[k] = 0xffffffff;
}
__global const float *query =
&queries[q * DIM];
/*
* Ogni lane analizza una parte del database.
*
* Esempio con 8 lane:
*
* lane 0 -> 0, 8, 16, 24, ...
* lane 1 -> 1, 9, 17, 25, ...
* ...
* lane 7 -> 7, 15, 23, 31, ...
*/
for (uint i = lane; i < n; i += LANES_PER_QUERY) {
__global const float *v =
&db[i * DIM];
float s = 0.0f;
for (uint j = 0; j < DIM; ++j)
s += v[j] * query[j];
if (s <= best_score[TOPK - 1])
continue;
int pos = TOPK - 1;
while (pos > 0 &&
s > best_score[pos - 1]) {
--pos;
}
for (int k = TOPK - 1; k > pos; --k) {
best_score[k] =
best_score[k - 1];
best_index[k] =
best_index[k - 1];
}
best_score[pos] = s;
best_index[pos] = i;
}
/*
* TOPK locali nella local memory.
*
* 8 lane × 32 elementi.
*/
__local float local_scores[LANES_PER_QUERY][TOPK];
__local uint local_indices[LANES_PER_QUERY][TOPK];
for (uint k = 0; k < TOPK; ++k) {
local_scores[lane][k] = best_score[k];
local_indices[lane][k] = best_index[k];
}
/*
* Tutti i lane devono aver scritto
* la local memory prima della riduzione.
*/
barrier(CLK_LOCAL_MEM_FENCE);
/*
* Solo lane 0 esegue il merge finale.
*/
if (lane == 0) {
float final_score[TOPK];
uint final_index[TOPK];
for (uint k = 0; k < TOPK; ++k) {
final_score[k] =
-3.402823466e+38f;
final_index[k] =
0xffffffff;
}
/*
* Fonde gli 8 TOPK locali.
*
* Al massimo 8 × 32 = 256 candidati.
*/
for (uint l = 0;
l < LANES_PER_QUERY;
++l) {
for (uint k = 0;
k < TOPK;
++k) {
float s =
local_scores[l][k];
uint idx =
local_indices[l][k];
if (idx == 0xffffffff)
continue;
if (s <= final_score[TOPK - 1])
continue;
int pos = TOPK - 1;
while (pos > 0 &&
s > final_score[pos - 1]) {
--pos;
}
for (int p = TOPK - 1;
p > pos;
--p) {
final_score[p] =
final_score[p - 1];
final_index[p] =
final_index[p - 1];
}
final_score[pos] = s;
final_index[pos] = idx;
}
}
/*
* Risultato finale della query.
*/
for (uint k = 0; k < TOPK; ++k) {
indices[q * TOPK + k] =
final_index[k];
values[q * TOPK + k] =
final_score[k];
}
}
}

View file

@ -0,0 +1,856 @@
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <string>
#include <vector>
#define STB_IMAGE_IMPLEMENTATION
#include "stb_image.h"
#include "rknn_api.h"
#include "face_engine.h"
static constexpr int SCRFD_SIZE = 640;
static constexpr int SFACE_SIZE = 112;
static constexpr int EMBED_DIM = 128;
static uint16_t float_to_fp16(float value)
{
uint32_t bits;
std::memcpy(&bits, &value, sizeof(bits));
uint32_t sign = (bits >> 31) & 1;
int exp = ((bits >> 23) & 0xff) - 127;
uint32_t mant = bits & 0x7fffff;
if (exp == 128) {
return (sign << 15) | 0x7c00;
}
if (exp > 15) {
return (sign << 15) | 0x7c00;
}
if (exp < -14) {
if (exp < -24)
return sign << 15;
mant |= 0x800000;
int shift = -exp - 14;
uint16_t m = mant >> (shift + 13);
return (sign << 15) | m;
}
uint16_t h_exp = (uint16_t)(exp + 15);
uint16_t h_mant = (uint16_t)(mant >> 13);
return (sign << 15) | (h_exp << 10) | h_mant;
}
static float fp16_to_float(uint16_t h)
{
uint32_t sign = (h >> 15) & 1;
uint32_t exp = (h >> 10) & 0x1f;
uint32_t mant = h & 0x3ff;
uint32_t bits;
if (exp == 0) {
if (mant == 0) {
bits = sign << 31;
} else {
float v = mant / 1024.0f;
v = std::ldexp(v, -14);
return sign ? -v : v;
}
} else if (exp == 31) {
bits = (sign << 31) | 0x7f800000 | (mant << 13);
} else {
uint32_t fexp = exp - 15 + 127;
bits = (sign << 31) | (fexp << 23) | (mant << 13);
}
float v;
std::memcpy(&v, &bits, sizeof(v));
return v;
}
static float iou(const Face& a, const Face& b)
{
float xx1 = std::max(a.x1, b.x1);
float yy1 = std::max(a.y1, b.y1);
float xx2 = std::min(a.x2, b.x2);
float yy2 = std::min(a.y2, b.y2);
float w = std::max(0.0f, xx2 - xx1);
float h = std::max(0.0f, yy2 - yy1);
float inter = w * h;
float area_a = std::max(0.0f, a.x2-a.x1) *
std::max(0.0f, a.y2-a.y1);
float area_b = std::max(0.0f, b.x2-b.x1) *
std::max(0.0f, b.y2-b.y1);
return inter / (area_a + area_b - inter + 1e-6f);
}
static void nms(std::vector<Face>& faces, float threshold)
{
std::sort(faces.begin(), faces.end(),
[](const Face& a, const Face& b) {
return a.score > b.score;
});
std::vector<Face> result;
for (const auto& f : faces) {
bool keep = true;
for (const auto& r : result) {
if (iou(f, r) > threshold) {
keep = false;
break;
}
}
if (keep)
result.push_back(f);
}
faces.swap(result);
}
struct Image {
int w = 0;
int h = 0;
std::vector<uint8_t> rgb;
};
static bool normalize_rotation(
int rotation_degrees,
int& normalized_rotation)
{
int value = rotation_degrees % 360;
if (value < 0)
value += 360;
if (
value != 0
&& value != 90
&& value != 180
&& value != 270
) {
return false;
}
normalized_rotation = value;
return true;
}
static Image rotate_image(
const Image& source,
int rotation_degrees)
{
if (rotation_degrees == 0)
return source;
Image destination;
if (
rotation_degrees == 90
|| rotation_degrees == 270
) {
destination.w = source.h;
destination.h = source.w;
} else {
destination.w = source.w;
destination.h = source.h;
}
destination.rgb.resize(
static_cast<std::size_t>(destination.w)
* static_cast<std::size_t>(destination.h)
* 3);
for (int source_y = 0; source_y < source.h; ++source_y) {
for (int source_x = 0; source_x < source.w; ++source_x) {
int destination_x = 0;
int destination_y = 0;
switch (rotation_degrees) {
case 90:
destination_x = source.h - 1 - source_y;
destination_y = source_x;
break;
case 180:
destination_x = source.w - 1 - source_x;
destination_y = source.h - 1 - source_y;
break;
case 270:
destination_x = source_y;
destination_y = source.w - 1 - source_x;
break;
default:
destination_x = source_x;
destination_y = source_y;
break;
}
const std::size_t source_offset =
(
static_cast<std::size_t>(source_y)
* static_cast<std::size_t>(source.w)
+ static_cast<std::size_t>(source_x)
) * 3;
const std::size_t destination_offset =
(
static_cast<std::size_t>(destination_y)
* static_cast<std::size_t>(destination.w)
+ static_cast<std::size_t>(destination_x)
) * 3;
destination.rgb[destination_offset + 0] =
source.rgb[source_offset + 0];
destination.rgb[destination_offset + 1] =
source.rgb[source_offset + 1];
destination.rgb[destination_offset + 2] =
source.rgb[source_offset + 2];
}
}
return destination;
}
static bool load_image(const std::string& path, Image& img)
{
int c = 0;
unsigned char* p =
stbi_load(path.c_str(), &img.w, &img.h, &c, 3);
if (!p) {
std::cerr << "Errore caricamento: " << path << "\n";
return false;
}
img.rgb.assign(p, p + img.w * img.h * 3);
stbi_image_free(p);
return true;
}
static void resize_bilinear_rgb(
const Image& src,
std::vector<uint8_t>& dst,
int dw,
int dh)
{
dst.resize(dw * dh * 3);
float sx = (float)src.w / dw;
float sy = (float)src.h / dh;
for (int y = 0; y < dh; ++y) {
float fy = (y + 0.5f) * sy - 0.5f;
int y0 = (int)std::floor(fy);
float wy = fy - y0;
if (y0 < 0) {
y0 = 0;
wy = 0;
}
int y1 = std::min(y0 + 1, src.h - 1);
for (int x = 0; x < dw; ++x) {
float fx = (x + 0.5f) * sx - 0.5f;
int x0 = (int)std::floor(fx);
float wx = fx - x0;
if (x0 < 0) {
x0 = 0;
wx = 0;
}
int x1 = std::min(x0 + 1, src.w - 1);
for (int c = 0; c < 3; ++c) {
float p00 = src.rgb[(y0*src.w+x0)*3+c];
float p01 = src.rgb[(y0*src.w+x1)*3+c];
float p10 = src.rgb[(y1*src.w+x0)*3+c];
float p11 = src.rgb[(y1*src.w+x1)*3+c];
float v =
p00 * (1-wx) * (1-wy) +
p01 * wx * (1-wy) +
p10 * (1-wx) * wy +
p11 * wx * wy;
dst[(y*dw+x)*3+c] =
(uint8_t)std::clamp((int)std::round(v), 0, 255);
}
}
}
}
class Scrfd {
public:
rknn_context ctx = 0;
bool init(const char* model)
{
FILE* fp = fopen(model, "rb");
if (!fp) {
perror(model);
return false;
}
fseek(fp, 0, SEEK_END);
size_t size = ftell(fp);
fseek(fp, 0, SEEK_SET);
std::vector<uint8_t> data(size);
fread(data.data(), 1, size, fp);
fclose(fp);
int ret = rknn_init(&ctx, data.data(), size, 0, nullptr);
if (ret != 0) {
std::cerr << "rknn_init SCRFD failed: " << ret << "\n";
return false;
}
return true;
}
std::vector<Face> detect(const Image& image)
{
float scale =
std::min(640.0f / image.w, 640.0f / image.h);
int rw = (int)std::round(image.w * scale);
int rh = (int)std::round(image.h * scale);
std::vector<uint8_t> resized;
resize_bilinear_rgb(image, resized, rw, rh);
std::vector<uint16_t> input(
SCRFD_SIZE * SCRFD_SIZE * 3);
for (int y = 0; y < rh; ++y) {
for (int x = 0; x < rw; ++x) {
int dst = (y * SCRFD_SIZE + x) * 3;
int src = (y * rw + x) * 3;
for (int c = 0; c < 3; ++c) {
float v =
((float)resized[src+c] - 127.5f) / 128.0f;
input[dst+c] = float_to_fp16(v);
}
}
}
rknn_input in{};
in.index = 0;
in.type = RKNN_TENSOR_FLOAT16;
in.fmt = RKNN_TENSOR_NHWC;
in.size = input.size() * sizeof(uint16_t);
in.buf = input.data();
in.pass_through = 1;
int ret = rknn_inputs_set(ctx, 1, &in);
if (ret != 0) {
std::cerr << "SCRFD inputs_set failed\n";
return {};
}
ret = rknn_run(ctx, nullptr);
if (ret != 0) {
std::cerr << "SCRFD run failed\n";
return {};
}
rknn_output outputs[9]{};
for (int i = 0; i < 9; ++i) {
outputs[i].want_float = 0;
}
ret = rknn_outputs_get(ctx, 9, outputs, nullptr);
if (ret != 0) {
std::cerr << "SCRFD outputs_get failed\n";
return {};
}
const int strides[3] = {8, 16, 32};
const int counts[3] = {12800, 3200, 800};
std::vector<Face> faces;
const float score_threshold = 0.50f;
for (int level = 0; level < 3; ++level) {
const int stride = strides[level];
const int count = counts[level];
const uint16_t* scores =
(const uint16_t*)outputs[level].buf;
const uint16_t* bbox =
(const uint16_t*)outputs[3 + level].buf;
const uint16_t* kps =
(const uint16_t*)outputs[6 + level].buf;
int feat_w = SCRFD_SIZE / stride;
for (int i = 0; i < count; ++i) {
float score = fp16_to_float(scores[i]);
if (score < score_threshold)
continue;
int anchor = i % 2;
int p = i / 2;
int gx = p % feat_w;
int gy = p / feat_w;
float cx = gx * stride;
float cy = gy * stride;
/*
* AnchorGenerator con due anchor:
* centers duplicati sullo stesso punto.
*/
(void)anchor;
float l = fp16_to_float(bbox[i*4+0]) * stride;
float t = fp16_to_float(bbox[i*4+1]) * stride;
float r = fp16_to_float(bbox[i*4+2]) * stride;
float b = fp16_to_float(bbox[i*4+3]) * stride;
Face f;
f.score = score;
f.x1 = (cx - l) / scale;
f.y1 = (cy - t) / scale;
f.x2 = (cx + r) / scale;
f.y2 = (cy + b) / scale;
for (int j = 0; j < 5; ++j) {
float x =
cx + fp16_to_float(kps[i*10+j*2])
* stride;
float y =
cy + fp16_to_float(kps[i*10+j*2+1])
* stride;
f.kps[j].x = x / scale;
f.kps[j].y = y / scale;
}
f.x1 = std::clamp(f.x1, 0.0f, (float)image.w);
f.y1 = std::clamp(f.y1, 0.0f, (float)image.h);
f.x2 = std::clamp(f.x2, 0.0f, (float)image.w);
f.y2 = std::clamp(f.y2, 0.0f, (float)image.h);
faces.push_back(f);
}
}
rknn_outputs_release(ctx, 9, outputs);
nms(faces, 0.45f);
return faces;
}
~Scrfd()
{
if (ctx)
rknn_destroy(ctx);
}
};
static const Point sface_template[5] = {
{38.2946f, 51.6963f},
{73.5318f, 51.5014f},
{56.0252f, 71.7366f},
{41.5493f, 92.3655f},
{70.7299f, 92.2041f}
};
/*
* Trova la trasformazione similarity:
*
* x' = a*x - b*y + tx
* y' = b*x + a*y + ty
*/
static void similarity_transform(
const Point src[5],
float& a,
float& b,
float& tx,
float& ty)
{
double sx = 0, sy = 0;
double dx = 0, dy = 0;
for (int i = 0; i < 5; ++i) {
sx += src[i].x;
sy += src[i].y;
dx += sface_template[i].x;
dy += sface_template[i].y;
}
sx /= 5;
sy /= 5;
dx /= 5;
dy /= 5;
double num_a = 0;
double num_b = 0;
double den = 0;
for (int i = 0; i < 5; ++i) {
double x = src[i].x - sx;
double y = src[i].y - sy;
double X = sface_template[i].x - dx;
double Y = sface_template[i].y - dy;
num_a += x*X + y*Y;
num_b += x*Y - y*X;
den += x*x + y*y;
}
a = (float)(num_a / den);
b = (float)(num_b / den);
tx = (float)(dx - a*sx + b*sy);
ty = (float)(dy - b*sx - a*sy);
}
static std::vector<uint16_t> align_face(
const Image& image,
const Face& face)
{
float a, b, tx, ty;
similarity_transform(face.kps, a, b, tx, ty);
/*
* Inversa della similarity transform.
*
* src = A^-1 * (dst - t)
*/
float denom = a*a + b*b;
std::vector<uint16_t> output(
SFACE_SIZE * SFACE_SIZE * 3);
for (int y = 0; y < SFACE_SIZE; ++y) {
for (int x = 0; x < SFACE_SIZE; ++x) {
float X = x - tx;
float Y = y - ty;
float sx =
(a*X + b*Y) / denom;
float sy =
(-b*X + a*Y) / denom;
int x0 = (int)std::floor(sx);
int y0 = (int)std::floor(sy);
float wx = sx - x0;
float wy = sy - y0;
uint8_t rgb[3] = {0,0,0};
if (x0 >= 0 &&
y0 >= 0 &&
x0 + 1 < image.w &&
y0 + 1 < image.h) {
int x1 = x0 + 1;
int y1 = y0 + 1;
for (int c = 0; c < 3; ++c) {
float p00 =
image.rgb[(y0*image.w+x0)*3+c];
float p01 =
image.rgb[(y0*image.w+x1)*3+c];
float p10 =
image.rgb[(y1*image.w+x0)*3+c];
float p11 =
image.rgb[(y1*image.w+x1)*3+c];
float v =
p00*(1-wx)*(1-wy) +
p01*wx*(1-wy) +
p10*(1-wx)*wy +
p11*wx*wy;
rgb[c] =
(uint8_t)std::clamp(
(int)std::round(v), 0, 255);
}
}
int idx = (y*SFACE_SIZE+x)*3;
/*
* SFace OpenCV:
* blobFromImage(..., swapRB=true, scalefactor=1)
*
* Noi partiamo già da RGB, quindi non facciamo
* nessun ulteriore swap e nessuna normalizzazione.
*/
output[idx+0] = float_to_fp16(rgb[0]);
output[idx+1] = float_to_fp16(rgb[1]);
output[idx+2] = float_to_fp16(rgb[2]);
}
}
return output;
}
class SFace {
public:
rknn_context ctx = 0;
bool init(const char* model)
{
FILE* fp = fopen(model, "rb");
if (!fp) {
perror(model);
return false;
}
fseek(fp, 0, SEEK_END);
size_t size = ftell(fp);
fseek(fp, 0, SEEK_SET);
std::vector<uint8_t> data(size);
fread(data.data(), 1, size, fp);
fclose(fp);
int ret = rknn_init(&ctx, data.data(), size, 0, nullptr);
if (ret != 0) {
std::cerr << "rknn_init SFace failed: "
<< ret << "\n";
return false;
}
return true;
}
std::vector<float> feature(
const std::vector<uint16_t>& input)
{
rknn_input in{};
in.index = 0;
in.type = RKNN_TENSOR_FLOAT16;
in.fmt = RKNN_TENSOR_NHWC;
in.size = input.size() * sizeof(uint16_t);
in.buf = (void*)input.data();
in.pass_through = 1;
int ret = rknn_inputs_set(ctx, 1, &in);
if (ret != 0) {
std::cerr << "SFace inputs_set failed\n";
return {};
}
ret = rknn_run(ctx, nullptr);
if (ret != 0) {
std::cerr << "SFace run failed\n";
return {};
}
rknn_output output{};
output.want_float = 0;
ret = rknn_outputs_get(
ctx, 1, &output, nullptr);
if (ret != 0) {
std::cerr << "SFace outputs_get failed\n";
return {};
}
const uint16_t* p =
(const uint16_t*)output.buf;
std::vector<float> emb(EMBED_DIM);
for (int i = 0; i < EMBED_DIM; ++i)
emb[i] = fp16_to_float(p[i]);
rknn_outputs_release(ctx, 1, &output);
/*
* L2 normalization.
*/
double norm = 0;
for (float v : emb)
norm += (double)v * v;
norm = std::sqrt(norm);
if (norm > 0) {
for (float& v : emb)
v /= (float)norm;
}
return emb;
}
~SFace()
{
if (ctx)
rknn_destroy(ctx);
}
};
static std::vector<Face> process_image(
const Image& image,
Scrfd& detector,
SFace& recognizer)
{
auto faces = detector.detect(image);
for (auto& face : faces) {
auto aligned = align_face(image, face);
face.embedding =
recognizer.feature(aligned);
}
return faces;
}
struct FaceEngine::Impl {
Scrfd detector;
SFace recognizer;
bool initialized = false;
};
FaceEngine::FaceEngine()
: impl_(new Impl())
{
}
FaceEngine::~FaceEngine()
{
delete impl_;
}
bool FaceEngine::init(
const std::string& scrfd_model_path,
const std::string& sface_model_path)
{
if (impl_->initialized)
return true;
if (!impl_->detector.init(scrfd_model_path.c_str()))
return false;
if (!impl_->recognizer.init(sface_model_path.c_str()))
return false;
impl_->initialized = true;
return true;
}
bool FaceEngine::process_image(
const std::string& image_path,
std::vector<Face>& faces,
int& image_width,
int& image_height,
int rotation_degrees)
{
faces.clear();
image_width = 0;
image_height = 0;
if (!impl_->initialized) {
std::cerr << "FaceEngine non inizializzato\n";
return false;
}
int normalized_rotation = 0;
if (!normalize_rotation(
rotation_degrees,
normalized_rotation)) {
std::cerr
<< "Rotazione non valida: "
<< rotation_degrees
<< ". Valori ammessi: 0, 90, 180, 270\n";
return false;
}
Image source_image;
if (!load_image(image_path, source_image))
return false;
Image oriented_image = rotate_image(
source_image,
normalized_rotation);
image_width = oriented_image.w;
image_height = oriented_image.h;
faces = ::process_image(
oriented_image,
impl_->detector,
impl_->recognizer);
for (const Face& face : faces) {
if (face.embedding.size() != EMBED_DIM) {
std::cerr
<< "Embedding non valido per "
<< image_path
<< ": attesi "
<< EMBED_DIM
<< " valori, trovati "
<< face.embedding.size()
<< "\n";
faces.clear();
return false;
}
}
return true;
}

View file

@ -0,0 +1,781 @@
#include "knn_opencl.h"
#include <cstddef>
#include <cstdint>
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <iostream>
#include <limits>
#include <stdexcept>
#include <string>
#include <vector>
#include <cstdio>
#include <unistd.h>
namespace {
class ScopedStdoutToStderr {
public:
ScopedStdoutToStderr()
{
std::cout.flush();
std::fflush(stdout);
saved_stdout_ =
::dup(STDOUT_FILENO);
if (saved_stdout_ < 0) {
throw std::runtime_error(
"Impossibile salvare stdout"
);
}
if (
::dup2(
STDERR_FILENO,
STDOUT_FILENO
) < 0
) {
::close(saved_stdout_);
saved_stdout_ = -1;
throw std::runtime_error(
"Impossibile deviare stdout"
);
}
}
~ScopedStdoutToStderr()
{
if (saved_stdout_ < 0) {
return;
}
std::cout.flush();
std::fflush(stdout);
::dup2(
saved_stdout_,
STDOUT_FILENO
);
::close(saved_stdout_);
}
ScopedStdoutToStderr(
const ScopedStdoutToStderr&
) = delete;
ScopedStdoutToStderr& operator=(
const ScopedStdoutToStderr&
) = delete;
private:
int saved_stdout_ = -1;
};
struct Arguments {
std::string queries_path;
std::string database_path;
std::string self_map_path;
std::string backend = "cpu";
std::size_t query_count = 0;
std::size_t database_count = 0;
std::uint32_t dimension = 128;
std::uint32_t top_k = 1;
float threshold = 0.50f;
};
void print_usage(
const char* program)
{
std::cerr
<< "Uso:\n"
<< " "
<< program
<< " --queries FILE"
<< " --database FILE"
<< " --query-count N"
<< " --database-count N"
<< " --dimension N"
<< " --top-k N"
<< " --backend cpu"
<< " [--threshold SCORE]"
<< " [--self-map FILE]"
<< "\n";
}
std::string require_value(
int argc,
char** argv,
int& index)
{
if (index + 1 >= argc) {
throw std::invalid_argument(
std::string(
"Valore mancante per "
)
+ argv[index]
);
}
index += 1;
return argv[index];
}
std::size_t parse_size(
const std::string& text,
const char* name)
{
std::size_t consumed = 0;
const auto value =
std::stoull(
text,
&consumed,
10
);
if (
consumed != text.size()
|| value == 0
) {
throw std::invalid_argument(
std::string(name)
+ " non valido: "
+ text
);
}
return static_cast<std::size_t>(
value
);
}
std::uint32_t parse_u32(
const std::string& text,
const char* name)
{
const std::size_t value =
parse_size(
text,
name
);
if (
value
> std::numeric_limits<
std::uint32_t
>::max()
) {
throw std::invalid_argument(
std::string(name)
+ " supera uint32"
);
}
return static_cast<std::uint32_t>(
value
);
}
float parse_float(
const std::string& text,
const char* name)
{
std::size_t consumed = 0;
const float value =
std::stof(
text,
&consumed
);
if (consumed != text.size()) {
throw std::invalid_argument(
std::string(name)
+ " non valido: "
+ text
);
}
return value;
}
Arguments parse_arguments(
int argc,
char** argv)
{
Arguments arguments;
for (
int index = 1;
index < argc;
++index
) {
const std::string option =
argv[index];
if (
option == "--help"
|| option == "-h"
) {
print_usage(argv[0]);
std::exit(0);
}
if (option == "--queries") {
arguments.queries_path =
require_value(
argc,
argv,
index
);
} else if (
option == "--database"
) {
arguments.database_path =
require_value(
argc,
argv,
index
);
} else if (
option == "--self-map"
) {
arguments.self_map_path =
require_value(
argc,
argv,
index
);
} else if (
option == "--backend"
) {
arguments.backend =
require_value(
argc,
argv,
index
);
} else if (
option == "--query-count"
) {
arguments.query_count =
parse_size(
require_value(
argc,
argv,
index
),
"query-count"
);
} else if (
option == "--database-count"
) {
arguments.database_count =
parse_size(
require_value(
argc,
argv,
index
),
"database-count"
);
} else if (
option == "--dimension"
) {
arguments.dimension =
parse_u32(
require_value(
argc,
argv,
index
),
"dimension"
);
} else if (
option == "--top-k"
) {
arguments.top_k =
parse_u32(
require_value(
argc,
argv,
index
),
"top-k"
);
} else if (
option == "--threshold"
) {
arguments.threshold =
parse_float(
require_value(
argc,
argv,
index
),
"threshold"
);
} else {
throw std::invalid_argument(
"Opzione sconosciuta: "
+ option
);
}
}
if (
arguments.queries_path.empty()
|| arguments.database_path.empty()
|| arguments.query_count == 0
|| arguments.database_count == 0
) {
throw std::invalid_argument(
"Argomenti obbligatori mancanti"
);
}
if (
arguments.backend != "cpu"
&& arguments.backend != "opencl"
) {
throw std::invalid_argument(
"Backend non supportato: "
+ arguments.backend
);
}
return arguments;
}
std::vector<float> read_float_file(
const std::string& path,
std::size_t expected_values)
{
std::ifstream input(
path,
std::ios::binary
);
if (!input) {
throw std::runtime_error(
"Impossibile aprire: "
+ path
);
}
input.seekg(
0,
std::ios::end
);
const std::streamoff byte_count =
input.tellg();
input.seekg(
0,
std::ios::beg
);
const auto expected_bytes =
static_cast<std::streamoff>(
expected_values
* sizeof(float)
);
if (byte_count != expected_bytes) {
throw std::runtime_error(
"Dimensione file incoerente: "
+ path
+ ", byte="
+ std::to_string(byte_count)
+ ", attesi="
+ std::to_string(
expected_bytes
)
);
}
std::vector<float> values(
expected_values
);
input.read(
reinterpret_cast<char*>(
values.data()
),
byte_count
);
if (!input) {
throw std::runtime_error(
"Lettura incompleta: "
+ path
);
}
return values;
}
std::vector<std::int64_t> read_self_map(
const std::string& path,
std::size_t query_count)
{
std::vector<std::int64_t> values(
query_count,
-1
);
if (path.empty()) {
return values;
}
std::ifstream input(path);
if (!input) {
throw std::runtime_error(
"Impossibile aprire self-map: "
+ path
);
}
std::size_t index = 0;
std::int64_t value = -1;
while (input >> value) {
if (index >= query_count) {
throw std::runtime_error(
"Self-map contiene "
"troppe righe"
);
}
values[index] = value;
index += 1;
}
if (index != query_count) {
throw std::runtime_error(
"Self-map contiene "
+ std::to_string(index)
+ " righe, attese "
+ std::to_string(
query_count
)
);
}
return values;
}
void write_json(
const face_knn::SearchOutput& output,
const std::vector<std::int64_t>& self_map,
float threshold)
{
std::size_t recognized_count = 0;
std::cout
<< std::setprecision(9)
<< "{\n"
<< " \"backend\": \""
<< output.backend
<< "\",\n"
<< " \"device_name\": \""
<< output.device_name
<< "\",\n"
<< " \"query_count\": "
<< output.query_count
<< ",\n"
<< " \"database_count\": "
<< output.database_count
<< ",\n"
<< " \"dimension\": "
<< output.dimension
<< ",\n"
<< " \"top_k\": "
<< output.top_k
<< ",\n"
<< " \"threshold\": "
<< threshold
<< ",\n"
<< " \"leave_one_out\": "
<< (
self_map.empty()
? "false"
: "true"
)
<< ",\n"
<< " \"elapsed_ms\": "
<< output.elapsed_ms
<< ",\n"
<< " \"results\": [\n";
for (
std::size_t query_index = 0;
query_index < output.query_count;
++query_index
) {
auto neighbors =
face_knn::query_neighbors(
output,
query_index
);
const std::int64_t excluded =
self_map[query_index];
if (excluded >= 0) {
neighbors =
face_knn::exclude_neighbor(
neighbors,
static_cast<
std::uint32_t
>(excluded)
);
}
if (neighbors.empty()) {
throw std::runtime_error(
"Nessun vicino disponibile "
"dopo leave-one-out"
);
}
const auto& best =
neighbors.front();
const bool recognized =
best.score >= threshold;
if (recognized) {
recognized_count += 1;
}
std::cout
<< " {\n"
<< " \"query_index\": "
<< query_index
<< ",\n"
<< " \"excluded_index\": ";
if (excluded < 0) {
std::cout << "null";
} else {
std::cout << excluded;
}
std::cout
<< ",\n"
<< " \"recognized\": "
<< (
recognized
? "true"
: "false"
)
<< ",\n"
<< " \"best_index\": "
<< best.index
<< ",\n"
<< " \"best_score\": "
<< best.score
<< ",\n"
<< " \"neighbors\": [\n";
for (
std::size_t position = 0;
position < neighbors.size();
++position
) {
const auto& neighbor =
neighbors[position];
std::cout
<< " {"
<< "\"index\": "
<< neighbor.index
<< ", \"score\": "
<< neighbor.score
<< "}";
if (
position + 1
< neighbors.size()
) {
std::cout << ",";
}
std::cout << "\n";
}
std::cout
<< " ]\n"
<< " }";
if (
query_index + 1
< output.query_count
) {
std::cout << ",";
}
std::cout << "\n";
}
const std::size_t below_count =
output.query_count
- recognized_count;
const double coverage =
output.query_count > 0
? (
static_cast<double>(
recognized_count
)
/ static_cast<double>(
output.query_count
)
* 100.0
)
: 0.0;
std::cout
<< " ],\n"
<< " \"recognized_count\": "
<< recognized_count
<< ",\n"
<< " \"below_threshold_count\": "
<< below_count
<< ",\n"
<< " \"coverage_percent\": "
<< coverage
<< "\n"
<< "}\n";
}
} // namespace
int main(
int argc,
char** argv)
{
try {
const Arguments arguments =
parse_arguments(
argc,
argv
);
const std::size_t query_values =
arguments.query_count
* arguments.dimension;
const std::size_t database_values =
arguments.database_count
* arguments.dimension;
const auto queries =
read_float_file(
arguments.queries_path,
query_values
);
const auto database =
read_float_file(
arguments.database_path,
database_values
);
const auto self_map =
read_self_map(
arguments.self_map_path,
arguments.query_count
);
face_knn::SearchOptions options;
options.dimension =
arguments.dimension;
options.top_k =
arguments.top_k;
options.kernel_path =
"kernels/knn_float4_topk.cl";
face_knn::SearchOutput output;
if (
arguments.backend == "opencl"
) {
/*
* Il runtime Mali scrive la propria versione
* direttamente su stdout. Durante OpenCL
* deviamo stdout su stderr, affinché l'output
* JSON resti puro.
*/
ScopedStdoutToStderr redirect;
output =
face_knn::search_opencl(
database,
queries,
arguments.database_count,
arguments.query_count,
options
);
} else {
output =
face_knn::search_cpu(
database,
queries,
arguments.database_count,
arguments.query_count,
options
);
}
write_json(
output,
self_map,
arguments.threshold
);
return 0;
} catch (const std::exception& error) {
std::cerr
<< "[face_knn] errore: "
<< error.what()
<< "\n";
print_usage(argv[0]);
return 1;
}
}

View file

@ -0,0 +1,144 @@
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
#include "face_engine.h"
#include "face_config.h"
static constexpr float MATCH_THRESHOLD = 0.363f;
static float cosine(
const std::vector<float>& a,
const std::vector<float>& b)
{
if (a.size() != b.size())
return 0.0f;
float score = 0.0f;
for (std::size_t i = 0; i < a.size(); ++i)
score += a[i] * b[i];
return score;
}
static void print_faces(
const std::string& image_path,
const std::vector<Face>& faces)
{
std::cout << "\n" << image_path << "\n";
std::cout << "Faces: " << faces.size() << "\n";
for (std::size_t i = 0; i < faces.size(); ++i) {
const Face& face = faces[i];
std::cout
<< "\nFace " << i
<< " score=" << face.score
<< "\n bbox="
<< face.x1 << " "
<< face.y1 << " "
<< face.x2 << " "
<< face.y2
<< "\n";
std::cout << " landmarks:\n";
for (int j = 0; j < 5; ++j) {
std::cout
<< " "
<< j
<< ": "
<< face.kps[j].x
<< " "
<< face.kps[j].y
<< "\n";
}
}
}
int main(int argc, char** argv)
{
if (argc != 2 && argc != 3) {
std::cerr
<< "Uso:\n"
<< " " << argv[0]
<< " image.jpg\n"
<< " " << argv[0]
<< " reference.jpg query.jpg\n";
return 1;
}
const std::string root =
FACE_CLUSTERING_ROOT;
const std::string scrfd_model =
root + "/models/SCRFD_500M_KPS_640.rknn";
const std::string sface_model =
root + "/models/face_recognition_sface_2021dec.rknn";
FaceEngine engine;
if (!engine.init(scrfd_model, sface_model))
return 1;
std::vector<Face> faces1;
int image1_width = 0;
int image1_height = 0;
if (!engine.process_image(
argv[1],
faces1,
image1_width,
image1_height)) {
return 1;
}
print_faces(argv[1], faces1);
if (argc == 2)
return 0;
std::vector<Face> faces2;
int image2_width = 0;
int image2_height = 0;
if (!engine.process_image(
argv[2],
faces2,
image2_width,
image2_height)) {
return 1;
}
print_faces(argv[2], faces2);
std::cout
<< "\n========================================\n"
<< "COSINE SIMILARITY\n"
<< "========================================\n";
for (std::size_t i = 0; i < faces1.size(); ++i) {
for (std::size_t j = 0; j < faces2.size(); ++j) {
const float similarity = cosine(
faces1[i].embedding,
faces2[j].embedding);
std::cout
<< "ref[" << i
<< "] vs query[" << j
<< "] = "
<< similarity;
if (similarity >= MATCH_THRESHOLD)
std::cout << " MATCH";
std::cout << "\n";
}
}
return 0;
}

View file

@ -0,0 +1,368 @@
#include <cmath>
#include <cstddef>
#include <cstdlib>
#include <filesystem>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
#include <unistd.h>
#include "face_engine.h"
static constexpr std::size_t EMBEDDING_SIZE = 128;
static std::filesystem::path executable_path()
{
std::vector<char> buffer(4096);
while (true) {
const ssize_t length = readlink(
"/proc/self/exe",
buffer.data(),
buffer.size() - 1);
if (length < 0) {
throw std::runtime_error(
"Impossibile leggere /proc/self/exe");
}
const std::size_t path_length =
static_cast<std::size_t>(length);
if (path_length < buffer.size() - 1) {
buffer[path_length] = '\0';
return std::filesystem::path(
buffer.data());
}
buffer.resize(buffer.size() * 2);
}
}
static std::filesystem::path application_root()
{
/*
* Struttura prevista:
*
* root/
* bin/face_scan
* lib/librknnrt.so
* models/<model>.rknn
*
* executable_path():
* root/bin/face_scan
*
* parent_path():
* root/bin
*
* parent_path().parent_path():
* root
*/
return executable_path()
.parent_path()
.parent_path();
}
static void write_json_string(const std::string& value)
{
std::cout << '"';
for (unsigned char ch : value) {
switch (ch) {
case '"':
std::cout << "\\\"";
break;
case '\\':
std::cout << "\\\\";
break;
case '\b':
std::cout << "\\b";
break;
case '\f':
std::cout << "\\f";
break;
case '\n':
std::cout << "\\n";
break;
case '\r':
std::cout << "\\r";
break;
case '\t':
std::cout << "\\t";
break;
default:
if (ch < 0x20) {
const char* hex = "0123456789abcdef";
std::cout
<< "\\u00"
<< hex[(ch >> 4) & 0x0f]
<< hex[ch & 0x0f];
} else {
std::cout << static_cast<char>(ch);
}
}
}
std::cout << '"';
}
static bool finite_face(const Face& face)
{
if (!std::isfinite(face.score) ||
!std::isfinite(face.x1) ||
!std::isfinite(face.y1) ||
!std::isfinite(face.x2) ||
!std::isfinite(face.y2)) {
return false;
}
for (const Point& point : face.kps) {
if (!std::isfinite(point.x) ||
!std::isfinite(point.y)) {
return false;
}
}
if (face.embedding.size() != EMBEDDING_SIZE)
return false;
for (float value : face.embedding) {
if (!std::isfinite(value))
return false;
}
return true;
}
static void write_face_json(
const Face& face,
std::size_t face_index)
{
const float width = face.x2 - face.x1;
const float height = face.y2 - face.y1;
std::cout << "{";
std::cout
<< "\"face_index\":"
<< face_index
<< ",";
std::cout
<< "\"score\":"
<< face.score
<< ",";
std::cout << "\"bbox\":{";
std::cout
<< "\"x\":"
<< face.x1
<< ",";
std::cout
<< "\"y\":"
<< face.y1
<< ",";
std::cout
<< "\"width\":"
<< width
<< ",";
std::cout
<< "\"height\":"
<< height;
std::cout << "},";
std::cout << "\"landmarks\":[";
for (int i = 0; i < 5; ++i) {
if (i != 0)
std::cout << ",";
std::cout
<< "["
<< face.kps[i].x
<< ","
<< face.kps[i].y
<< "]";
}
std::cout << "],";
std::cout << "\"embedding\":[";
for (std::size_t i = 0;
i < face.embedding.size();
++i) {
if (i != 0)
std::cout << ",";
std::cout << face.embedding[i];
}
std::cout << "]";
std::cout << "}";
}
int main(int argc, char** argv)
{
int rotation_degrees = 0;
const char* image_path = nullptr;
if (argc == 2) {
image_path = argv[1];
} else if (
argc == 4
&& std::string(argv[1]) == "--rotation"
) {
char* parse_end = nullptr;
const long parsed_rotation = std::strtol(
argv[2],
&parse_end,
10);
if (
parse_end == argv[2]
|| *parse_end != '\0'
|| parsed_rotation < -360000
|| parsed_rotation > 360000
) {
std::cerr
<< "Rotazione non valida: "
<< argv[2]
<< "\n";
return 2;
}
rotation_degrees =
static_cast<int>(parsed_rotation);
image_path = argv[3];
} else {
std::cerr
<< "Uso:\n"
<< " " << argv[0]
<< " image.jpg\n"
<< " " << argv[0]
<< " --rotation 90 image.jpg\n";
return 2;
}
std::filesystem::path root;
try {
root = application_root();
} catch (const std::exception& error) {
std::cerr
<< "Impossibile determinare la root applicativa: "
<< error.what()
<< "\n";
return 3;
}
const std::string scrfd_model =
(
root
/ "models"
/ "SCRFD_500M_KPS_640.rknn"
).string();
const std::string sface_model =
(
root
/ "models"
/ "face_recognition_sface_2021dec.rknn"
).string();
FaceEngine engine;
if (!engine.init(scrfd_model, sface_model)) {
std::cerr
<< "Impossibile inizializzare FaceEngine\n";
return 3;
}
std::vector<Face> faces;
int image_width = 0;
int image_height = 0;
if (!engine.process_image(
image_path,
faces,
image_width,
image_height,
rotation_degrees)) {
std::cerr
<< "Scansione fallita: "
<< image_path
<< "\n";
return 4;
}
for (std::size_t i = 0; i < faces.size(); ++i) {
if (!finite_face(faces[i])) {
std::cerr
<< "Risultato non valido per face_index="
<< i
<< "\n";
return 5;
}
}
std::cout << std::setprecision(9);
std::cout << "{";
std::cout << "\"image\":{";
std::cout << "\"path\":";
write_json_string(image_path);
std::cout
<< ",\"width\":"
<< image_width
<< ",\"height\":"
<< image_height
<< ",\"rotation\":"
<< rotation_degrees;
std::cout << "},";
std::cout << "\"faces\":[";
for (std::size_t i = 0; i < faces.size(); ++i) {
if (i != 0)
std::cout << ",";
write_face_json(faces[i], i);
}
std::cout << "]";
std::cout << "}\n";
return 0;
}

View file

@ -0,0 +1,484 @@
#include "knn_opencl.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <limits>
#include <stdexcept>
#include <string>
#include <vector>
namespace face_knn {
SearchOutput search_opencl_impl(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options);
namespace {
bool better_neighbor(
const Neighbor& left,
const Neighbor& right)
{
if (left.score != right.score) {
return left.score > right.score;
}
return left.index < right.index;
}
void validate_finite_values(
const std::vector<float>& values,
const char* name)
{
for (
std::size_t index = 0;
index < values.size();
++index
) {
if (!std::isfinite(values[index])) {
throw std::invalid_argument(
std::string(name)
+ " contiene un valore non finito"
);
}
}
}
std::uint32_t effective_top_k(
std::size_t database_count,
std::uint32_t requested_top_k)
{
return std::min(
requested_top_k,
static_cast<std::uint32_t>(
database_count
)
);
}
} // namespace
void validate_input(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options)
{
if (options.dimension == 0) {
throw std::invalid_argument(
"dimension deve essere maggiore di zero"
);
}
if (options.top_k == 0) {
throw std::invalid_argument(
"top_k deve essere maggiore di zero"
);
}
if (options.lanes_per_query == 0) {
throw std::invalid_argument(
"lanes_per_query deve essere maggiore di zero"
);
}
if (options.chunk_size == 0) {
throw std::invalid_argument(
"chunk_size deve essere maggiore di zero"
);
}
if (database_count == 0) {
throw std::invalid_argument(
"database_count deve essere maggiore di zero"
);
}
if (query_count == 0) {
throw std::invalid_argument(
"query_count deve essere maggiore di zero"
);
}
if (
database_count
> std::numeric_limits<
std::uint32_t
>::max()
) {
throw std::invalid_argument(
"database_count supera uint32"
);
}
const std::size_t expected_database =
database_count
* static_cast<std::size_t>(
options.dimension
);
const std::size_t expected_queries =
query_count
* static_cast<std::size_t>(
options.dimension
);
if (database.size() != expected_database) {
throw std::invalid_argument(
"dimensione database incoerente"
);
}
if (queries.size() != expected_queries) {
throw std::invalid_argument(
"dimensione query incoerente"
);
}
validate_finite_values(
database,
"database"
);
validate_finite_values(
queries,
"queries"
);
}
SearchOutput search_cpu(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options)
{
validate_input(
database,
queries,
database_count,
query_count,
options
);
const std::uint32_t result_top_k =
effective_top_k(
database_count,
options.top_k
);
SearchOutput output;
output.query_count = query_count;
output.database_count = database_count;
output.dimension = options.dimension;
output.top_k = result_top_k;
output.backend = "cpu";
output.device_name = "CPU";
output.neighbors.resize(
query_count
* static_cast<std::size_t>(
result_top_k
)
);
const auto started_at =
std::chrono::steady_clock::now();
for (
std::size_t query_index = 0;
query_index < query_count;
++query_index
) {
const float* query =
&queries[
query_index
* options.dimension
];
std::vector<Neighbor> candidates;
candidates.reserve(database_count);
for (
std::size_t database_index = 0;
database_index < database_count;
++database_index
) {
const float* database_vector =
&database[
database_index
* options.dimension
];
float score = 0.0f;
for (
std::uint32_t component = 0;
component < options.dimension;
++component
) {
score +=
database_vector[component]
* query[component];
}
candidates.push_back({
score,
static_cast<std::uint32_t>(
database_index
),
});
}
if (
result_top_k
< candidates.size()
) {
std::nth_element(
candidates.begin(),
candidates.begin()
+ result_top_k,
candidates.end(),
better_neighbor
);
}
std::sort(
candidates.begin(),
candidates.begin()
+ result_top_k,
better_neighbor
);
const std::size_t output_offset =
query_index
* result_top_k;
for (
std::uint32_t position = 0;
position < result_top_k;
++position
) {
output.neighbors[
output_offset + position
] = candidates[position];
}
}
const auto completed_at =
std::chrono::steady_clock::now();
output.elapsed_ms =
std::chrono::duration<
double,
std::milli
>(
completed_at - started_at
).count();
return output;
}
SearchOutput search_opencl(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options)
{
return search_opencl_impl(
database,
queries,
database_count,
query_count,
options
);
}
std::vector<Neighbor> query_neighbors(
const SearchOutput& output,
std::size_t query_index)
{
if (query_index >= output.query_count) {
throw std::out_of_range(
"query_index fuori intervallo"
);
}
const std::size_t begin =
query_index
* output.top_k;
const std::size_t end =
begin
+ output.top_k;
if (end > output.neighbors.size()) {
throw std::runtime_error(
"layout SearchOutput incoerente"
);
}
return {
output.neighbors.begin() + begin,
output.neighbors.begin() + end,
};
}
std::vector<Neighbor> exclude_neighbor(
const std::vector<Neighbor>& neighbors,
std::uint32_t excluded_index)
{
std::vector<Neighbor> filtered;
filtered.reserve(neighbors.size());
for (const Neighbor& neighbor : neighbors) {
if (neighbor.index == invalid_index) {
continue;
}
if (neighbor.index == excluded_index) {
continue;
}
filtered.push_back(neighbor);
}
return filtered;
}
ValidationResult compare_outputs(
const SearchOutput& expected,
const SearchOutput& actual,
float score_tolerance,
float recognition_threshold)
{
if (score_tolerance < 0.0f) {
throw std::invalid_argument(
"score_tolerance non valida"
);
}
if (
expected.query_count
!= actual.query_count
|| expected.database_count
!= actual.database_count
|| expected.dimension
!= actual.dimension
|| expected.top_k
!= actual.top_k
|| expected.neighbors.size()
!= actual.neighbors.size()
) {
throw std::invalid_argument(
"Output kNN non confrontabili"
);
}
ValidationResult result;
result.positions =
expected.neighbors.size();
for (
std::size_t index = 0;
index < result.positions;
++index
) {
const Neighbor& expected_neighbor =
expected.neighbors[index];
const Neighbor& actual_neighbor =
actual.neighbors[index];
if (
expected_neighbor.index
== actual_neighbor.index
) {
result.exact_index_matches += 1;
}
const float error = std::fabs(
expected_neighbor.score
- actual_neighbor.score
);
result.maximum_score_error =
std::max(
result.maximum_score_error,
error
);
}
for (
std::size_t query_index = 0;
query_index < expected.query_count;
++query_index
) {
const auto expected_neighbors =
query_neighbors(
expected,
query_index
);
const auto actual_neighbors =
query_neighbors(
actual,
query_index
);
const bool expected_recognized =
!expected_neighbors.empty()
&& expected_neighbors.front().score
>= recognition_threshold;
const bool actual_recognized =
!actual_neighbors.empty()
&& actual_neighbors.front().score
>= recognition_threshold;
if (
expected_recognized
== actual_recognized
) {
result.decision_matches += 1;
}
}
result.exact_indices =
result.exact_index_matches
== result.positions;
result.decisions_equal =
result.decision_matches
== expected.query_count;
if (
result.maximum_score_error
> score_tolerance
) {
result.decisions_equal = false;
}
return result;
}
} // namespace face_knn

View file

@ -0,0 +1,944 @@
#define CL_TARGET_OPENCL_VERSION 120
#include <CL/cl.h>
#include "knn_opencl.h"
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <fstream>
#include <iostream>
#include <iterator>
#include <limits>
#include <stdexcept>
#include <string>
#include <vector>
namespace face_knn {
namespace {
void check_cl(
cl_int status,
const char* operation)
{
if (status != CL_SUCCESS) {
throw std::runtime_error(
std::string(operation)
+ ": OpenCL status "
+ std::to_string(status)
);
}
}
std::string device_string(
cl_device_id device,
cl_device_info property)
{
size_t size = 0;
check_cl(
clGetDeviceInfo(
device,
property,
0,
nullptr,
&size
),
"clGetDeviceInfo size"
);
std::string value(
size,
'\0'
);
check_cl(
clGetDeviceInfo(
device,
property,
size,
value.data(),
nullptr
),
"clGetDeviceInfo value"
);
while (
!value.empty()
&& value.back() == '\0'
) {
value.pop_back();
}
return value;
}
cl_device_id find_mali_device(
std::string& device_name)
{
cl_uint platform_count = 0;
check_cl(
clGetPlatformIDs(
0,
nullptr,
&platform_count
),
"clGetPlatformIDs count"
);
if (platform_count == 0) {
throw std::runtime_error(
"Nessuna piattaforma OpenCL"
);
}
std::vector<cl_platform_id> platforms(
platform_count
);
check_cl(
clGetPlatformIDs(
platform_count,
platforms.data(),
nullptr
),
"clGetPlatformIDs list"
);
cl_device_id fallback = nullptr;
std::string fallback_name;
for (
cl_platform_id platform
: platforms
) {
cl_uint device_count = 0;
const cl_int status =
clGetDeviceIDs(
platform,
CL_DEVICE_TYPE_GPU,
0,
nullptr,
&device_count
);
if (
status != CL_SUCCESS
|| device_count == 0
) {
continue;
}
std::vector<cl_device_id> devices(
device_count
);
check_cl(
clGetDeviceIDs(
platform,
CL_DEVICE_TYPE_GPU,
device_count,
devices.data(),
nullptr
),
"clGetDeviceIDs list"
);
for (
cl_device_id device
: devices
) {
const std::string name =
device_string(
device,
CL_DEVICE_NAME
);
const std::string vendor =
device_string(
device,
CL_DEVICE_VENDOR
);
const std::string full_name =
vendor + " / " + name;
if (fallback == nullptr) {
fallback = device;
fallback_name = full_name;
}
if (
name.find("Mali-G610")
!= std::string::npos
) {
device_name = full_name;
return device;
}
}
}
if (fallback != nullptr) {
device_name = fallback_name;
std::cerr
<< "[face_knn] Mali-G610 non trovata; "
<< "uso GPU OpenCL: "
<< fallback_name
<< "\n";
return fallback;
}
throw std::runtime_error(
"Nessuna GPU OpenCL disponibile"
);
}
std::string read_text_file(
const std::string& path)
{
std::ifstream input(path);
if (!input) {
throw std::runtime_error(
"Kernel OpenCL non leggibile: "
+ path
);
}
const std::string content{
std::istreambuf_iterator<char>(
input
),
std::istreambuf_iterator<char>()
};
if (content.empty()) {
throw std::runtime_error(
"Kernel OpenCL vuoto: "
+ path
);
}
return content;
}
bool better_neighbor(
const Neighbor& left,
const Neighbor& right)
{
if (left.score != right.score) {
return left.score > right.score;
}
return left.index < right.index;
}
Neighbor invalid_neighbor()
{
return {
-std::numeric_limits<float>::infinity(),
invalid_index,
};
}
void merge_top_k(
const std::vector<std::uint32_t>& chunk_indices,
const std::vector<float>& chunk_scores,
std::size_t chunk_offset,
std::size_t query_count,
std::uint32_t top_k,
std::vector<Neighbor>& global_result)
{
for (
std::size_t query_index = 0;
query_index < query_count;
++query_index
) {
const std::size_t result_offset =
query_index
* top_k;
std::vector<Neighbor> candidates;
candidates.reserve(
static_cast<std::size_t>(
top_k
) * 2
);
for (
std::uint32_t position = 0;
position < top_k;
++position
) {
const Neighbor& current =
global_result[
result_offset
+ position
];
if (
current.index
!= invalid_index
) {
candidates.push_back(
current
);
}
}
for (
std::uint32_t position = 0;
position < top_k;
++position
) {
const std::size_t chunk_position =
result_offset
+ position;
const std::uint32_t local_index =
chunk_indices[
chunk_position
];
if (
local_index
== invalid_index
) {
continue;
}
candidates.push_back({
chunk_scores[
chunk_position
],
static_cast<std::uint32_t>(
chunk_offset
+ local_index
),
});
}
std::sort(
candidates.begin(),
candidates.end(),
better_neighbor
);
for (
std::uint32_t position = 0;
position < top_k;
++position
) {
Neighbor value =
invalid_neighbor();
if (
position
< candidates.size()
) {
value =
candidates[position];
}
global_result[
result_offset
+ position
] = value;
}
}
}
struct OpenCLResources {
cl_context context = nullptr;
cl_command_queue queue = nullptr;
cl_program program = nullptr;
cl_kernel kernel = nullptr;
cl_mem database_buffer = nullptr;
cl_mem query_buffer = nullptr;
cl_mem index_buffer = nullptr;
cl_mem score_buffer = nullptr;
~OpenCLResources()
{
if (score_buffer != nullptr) {
clReleaseMemObject(
score_buffer
);
}
if (index_buffer != nullptr) {
clReleaseMemObject(
index_buffer
);
}
if (query_buffer != nullptr) {
clReleaseMemObject(
query_buffer
);
}
if (database_buffer != nullptr) {
clReleaseMemObject(
database_buffer
);
}
if (kernel != nullptr) {
clReleaseKernel(kernel);
}
if (program != nullptr) {
clReleaseProgram(program);
}
if (queue != nullptr) {
clReleaseCommandQueue(queue);
}
if (context != nullptr) {
clReleaseContext(context);
}
}
};
std::string build_log(
cl_program program,
cl_device_id device)
{
size_t size = 0;
clGetProgramBuildInfo(
program,
device,
CL_PROGRAM_BUILD_LOG,
0,
nullptr,
&size
);
std::string value(
size,
'\0'
);
if (size > 0) {
clGetProgramBuildInfo(
program,
device,
CL_PROGRAM_BUILD_LOG,
size,
value.data(),
nullptr
);
}
while (
!value.empty()
&& value.back() == '\0'
) {
value.pop_back();
}
return value;
}
} // namespace
SearchOutput search_opencl_impl(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options)
{
validate_input(
database,
queries,
database_count,
query_count,
options
);
if (
query_count
> std::numeric_limits<
std::uint32_t
>::max()
) {
throw std::invalid_argument(
"query_count supera uint32"
);
}
const std::uint32_t top_k =
std::min(
options.top_k,
static_cast<std::uint32_t>(
database_count
)
);
const std::string kernel_path =
options.kernel_path.empty()
? "kernels/knn_float4_topk.cl"
: options.kernel_path;
const std::string kernel_source =
read_text_file(
kernel_path
);
std::string device_name;
const cl_device_id device =
find_mali_device(
device_name
);
std::cerr
<< "[face_knn] OpenCL GPU: "
<< device_name
<< "\n";
OpenCLResources resources;
cl_int status = CL_SUCCESS;
resources.context =
clCreateContext(
nullptr,
1,
&device,
nullptr,
nullptr,
&status
);
check_cl(
status,
"clCreateContext"
);
resources.queue =
clCreateCommandQueue(
resources.context,
device,
0,
&status
);
check_cl(
status,
"clCreateCommandQueue"
);
const char* source_pointer =
kernel_source.c_str();
const size_t source_size =
kernel_source.size();
resources.program =
clCreateProgramWithSource(
resources.context,
1,
&source_pointer,
&source_size,
&status
);
check_cl(
status,
"clCreateProgramWithSource"
);
const std::string build_options =
"-cl-fast-relaxed-math"
" -D DIM="
+ std::to_string(
options.dimension
)
+ " -D TOPK="
+ std::to_string(top_k)
+ " -D QUERY_COUNT="
+ std::to_string(query_count)
+ " -D LANES_PER_QUERY="
+ std::to_string(
options.lanes_per_query
);
status = clBuildProgram(
resources.program,
1,
&device,
build_options.c_str(),
nullptr,
nullptr
);
if (status != CL_SUCCESS) {
throw std::runtime_error(
"clBuildProgram fallito: "
+ build_log(
resources.program,
device
)
);
}
resources.kernel =
clCreateKernel(
resources.program,
"knn",
&status
);
check_cl(
status,
"clCreateKernel"
);
const std::size_t chunk_capacity =
std::min(
options.chunk_size,
database_count
);
resources.database_buffer =
clCreateBuffer(
resources.context,
CL_MEM_READ_ONLY,
chunk_capacity
* options.dimension
* sizeof(float),
nullptr,
&status
);
check_cl(
status,
"clCreateBuffer database"
);
resources.query_buffer =
clCreateBuffer(
resources.context,
CL_MEM_READ_ONLY
| CL_MEM_COPY_HOST_PTR,
queries.size()
* sizeof(float),
const_cast<float*>(
queries.data()
),
&status
);
check_cl(
status,
"clCreateBuffer queries"
);
const std::size_t result_count =
query_count
* static_cast<std::size_t>(
top_k
);
std::vector<std::uint32_t> chunk_indices(
result_count,
invalid_index
);
std::vector<float> chunk_scores(
result_count,
-std::numeric_limits<float>::infinity()
);
resources.index_buffer =
clCreateBuffer(
resources.context,
CL_MEM_WRITE_ONLY,
result_count
* sizeof(std::uint32_t),
nullptr,
&status
);
check_cl(
status,
"clCreateBuffer indices"
);
resources.score_buffer =
clCreateBuffer(
resources.context,
CL_MEM_WRITE_ONLY,
result_count
* sizeof(float),
nullptr,
&status
);
check_cl(
status,
"clCreateBuffer scores"
);
check_cl(
clSetKernelArg(
resources.kernel,
0,
sizeof(
resources.database_buffer
),
&resources.database_buffer
),
"clSetKernelArg database"
);
check_cl(
clSetKernelArg(
resources.kernel,
1,
sizeof(
resources.query_buffer
),
&resources.query_buffer
),
"clSetKernelArg queries"
);
check_cl(
clSetKernelArg(
resources.kernel,
2,
sizeof(
resources.index_buffer
),
&resources.index_buffer
),
"clSetKernelArg indices"
);
check_cl(
clSetKernelArg(
resources.kernel,
3,
sizeof(
resources.score_buffer
),
&resources.score_buffer
),
"clSetKernelArg scores"
);
SearchOutput output;
output.query_count =
query_count;
output.database_count =
database_count;
output.dimension =
options.dimension;
output.top_k =
top_k;
output.backend =
"opencl";
output.device_name =
device_name;
output.neighbors.assign(
result_count,
invalid_neighbor()
);
const std::size_t global_size =
query_count
* options.lanes_per_query;
const std::size_t local_size =
options.lanes_per_query;
const std::size_t chunk_count =
(
database_count
+ options.chunk_size
- 1
)
/ options.chunk_size;
std::cerr
<< "[face_knn] kernel_path="
<< kernel_path
<< "\n"
<< "[face_knn] build_options="
<< build_options
<< "\n"
<< "[face_knn] chunk_size="
<< chunk_capacity
<< "\n"
<< "[face_knn] chunks="
<< chunk_count
<< "\n";
const auto started_at =
std::chrono::steady_clock::now();
for (
std::size_t chunk_offset = 0;
chunk_offset < database_count;
chunk_offset += options.chunk_size
) {
const std::size_t chunk_size =
std::min(
options.chunk_size,
database_count
- chunk_offset
);
const float* chunk_pointer =
&database[
chunk_offset
* options.dimension
];
check_cl(
clEnqueueWriteBuffer(
resources.queue,
resources.database_buffer,
CL_TRUE,
0,
chunk_size
* options.dimension
* sizeof(float),
chunk_pointer,
0,
nullptr,
nullptr
),
"clEnqueueWriteBuffer database"
);
const std::uint32_t kernel_count =
static_cast<std::uint32_t>(
chunk_size
);
check_cl(
clSetKernelArg(
resources.kernel,
4,
sizeof(kernel_count),
&kernel_count
),
"clSetKernelArg database count"
);
check_cl(
clEnqueueNDRangeKernel(
resources.queue,
resources.kernel,
1,
nullptr,
&global_size,
&local_size,
0,
nullptr,
nullptr
),
"clEnqueueNDRangeKernel"
);
check_cl(
clFinish(
resources.queue
),
"clFinish"
);
check_cl(
clEnqueueReadBuffer(
resources.queue,
resources.index_buffer,
CL_TRUE,
0,
chunk_indices.size()
* sizeof(std::uint32_t),
chunk_indices.data(),
0,
nullptr,
nullptr
),
"clEnqueueReadBuffer indices"
);
check_cl(
clEnqueueReadBuffer(
resources.queue,
resources.score_buffer,
CL_TRUE,
0,
chunk_scores.size()
* sizeof(float),
chunk_scores.data(),
0,
nullptr,
nullptr
),
"clEnqueueReadBuffer scores"
);
merge_top_k(
chunk_indices,
chunk_scores,
chunk_offset,
query_count,
top_k,
output.neighbors
);
}
const auto completed_at =
std::chrono::steady_clock::now();
output.elapsed_ms =
std::chrono::duration<
double,
std::milli
>(
completed_at - started_at
).count();
return output;
}
} // namespace face_knn

View file

@ -0,0 +1,281 @@
# Face Scan RK3588 - Guida ai sorgenti e compilazione
## Repository sorgente
Repository originale identificato:
```text
/home/nvme/dockerdata/prove/face-clustering-rk3588-knn-opencl-validated
```
Questo repository contiene il motore utilizzato per il Face Scan dei progetti:
```text
s141js → s190js
```
---
## Scopo del programma
Il binario:
```text
bin/face_scan
```
esegue:
- rilevamento dei volti nelle fotografie;
- estrazione degli embedding facciali;
- generazione dell'output JSON utilizzato successivamente dal sistema di riconoscimento.
Il riconoscimento delle persone non viene eseguito da `face_scan`.
Il riconoscimento viene effettuato successivamente dal programma:
```text
bin/face_knn
```
che utilizza gli embedding generati da `face_scan`.
---
## Sorgenti principali
### Main Face Scan
```text
src/face_scan.cc
```
Si occupa di:
- caricamento immagine;
- avvio scansione;
- generazione output JSON.
### Motore RKNN
```text
src/face_engine.cc
```
Gestisce:
- caricamento modello RKNN;
- inferenza tramite NPU Rockchip;
- estrazione embedding.
### Header
```text
include/face_engine.h
include/face_config.h
```
---
## Binari generati
```text
bin/face_scan
bin/face_recognition
```
---
## Modelli utilizzati
I modelli sono contenuti nella directory:
```text
models/
```
Verificare i modelli disponibili con:
```bash
find models -type f
```
---
## Librerie richieste
Verifica:
```bash
ldd bin/face_scan
```
Dipendenze identificate:
```text
librknnrt.so
libpthread.so
libdl.so
libstdc++.so.6
libgcc_s.so.1
libc.so.6
libm.so.6
```
Dipendenza fondamentale:
```text
lib/librknnrt.so
```
Runtime NPU Rockchip.
---
## Compilazione
Pulizia:
```bash
make clean
```
Compilazione:
```bash
make
```
Il Makefile genera:
```text
bin/face_scan
bin/face_recognition
```
---
## Comandi di verifica
Verifica dipendenze:
```bash
ldd bin/face_scan
```
Verifica hash:
```bash
sha256sum bin/face_scan
```
Visualizza simboli:
```bash
nm -C bin/face_scan | head
```
---
## Hardware target
Scheda:
```text
Orange Pi 5 Plus
```
SoC:
```text
Rockchip RK3588
```
Acceleratore:
```text
Rockchip NPU
```
Runtime:
```text
RKNN Runtime
```
---
## Relazione con Face KNN
Pipeline completa:
```text
Foto
↓
face_scan
↓
embedding facciali
↓
face_knn
↓
riconoscimento persona
↓
database
↓
gallery
```
Face Scan:
```text
NPU Rockchip (RKNN)
```
Face KNN:
```text
CPU/OpenCL
Mali-G610
```
---
## Cartelle da conservare
Per poter ricompilare il Face Scan in futuro conservare:
```text
src/
include/
models/
lib/
Makefile
```
In particolare:
```text
src/face_scan.cc
src/face_engine.cc
include/face_engine.h
include/face_config.h
lib/librknnrt.so
```
---
## Note storiche
Il repository:
```text
face-clustering-rk3588-knn-opencl-validated
```
è stato identificato come repository sorgente originale del motore Face Scan utilizzato dai progetti s141js → s190js.
Data documentazione:
```text
2026-10-26
```

View file

@ -0,0 +1,99 @@
CXX ?= g++
ROOT := $(abspath .)
CXXFLAGS := \
-std=c++17 \
-O2 \
-Wall \
-Wextra \
-Wpedantic \
-Iinclude \
-DFACE_CLUSTERING_ROOT=\"$(ROOT)\"
LDFLAGS := \
-L$(ROOT)/lib \
-Wl,-rpath,'$$ORIGIN/../lib'
LDLIBS := \
-lrknnrt \
-ldl \
-lpthread
BIN_DIR := bin
BUILD_DIR := build
ENGINE_SOURCE := src/face_engine.cc
ENGINE_OBJECT := $(BUILD_DIR)/face_engine.o
FACE_RECOGNITION_SOURCE := src/face_recognition.cc
FACE_RECOGNITION_OBJECT := $(BUILD_DIR)/face_recognition.o
FACE_RECOGNITION_BINARY := $(BIN_DIR)/face_recognition
FACE_SCAN_SOURCE := src/face_scan.cc
FACE_SCAN_OBJECT := $(BUILD_DIR)/face_scan.o
FACE_SCAN_BINARY := $(BIN_DIR)/face_scan
.PHONY: all clean test info
all: \
$(FACE_RECOGNITION_BINARY) \
$(FACE_SCAN_BINARY)
$(BUILD_DIR):
mkdir -p $(BUILD_DIR)
$(BIN_DIR):
mkdir -p $(BIN_DIR)
$(ENGINE_OBJECT): $(ENGINE_SOURCE) include/face_engine.h | $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(FACE_RECOGNITION_OBJECT): \
$(FACE_RECOGNITION_SOURCE) \
include/face_engine.h \
include/face_config.h \
| $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(FACE_SCAN_OBJECT): \
$(FACE_SCAN_SOURCE) \
include/face_engine.h \
include/face_config.h \
| $(BUILD_DIR)
$(CXX) $(CXXFLAGS) -c $< -o $@
$(FACE_RECOGNITION_BINARY): \
$(ENGINE_OBJECT) \
$(FACE_RECOGNITION_OBJECT) \
| $(BIN_DIR)
$(CXX) $^ $(LDFLAGS) $(LDLIBS) -o $@
$(FACE_SCAN_BINARY): \
$(ENGINE_OBJECT) \
$(FACE_SCAN_OBJECT) \
| $(BIN_DIR)
$(CXX) $^ $(LDFLAGS) $(LDLIBS) -o $@
test: all
./bin/face_recognition \
test/test.jpg \
test/test3f.jpg
./bin/face_scan \
test/test3f.jpg \
> /tmp/face_scan_test.json
python3 -m json.tool \
/tmp/face_scan_test.json \
> /dev/null
@echo "Tests completed successfully"
info:
@echo "ROOT=$(ROOT)"
@echo "CXX=$(CXX)"
@echo "CXXFLAGS=$(CXXFLAGS)"
@echo "LDFLAGS=$(LDFLAGS)"
clean:
rm -rf $(BUILD_DIR)
rm -f $(FACE_RECOGNITION_BINARY)
rm -f $(FACE_SCAN_BINARY)

View file

@ -0,0 +1,5 @@
#pragma once
#ifndef FACE_CLUSTERING_ROOT
#define FACE_CLUSTERING_ROOT "."
#endif

View file

@ -0,0 +1,47 @@
#pragma once
#include <string>
#include <vector>
struct Point {
float x = 0.0f;
float y = 0.0f;
};
struct Face {
float score = 0.0f;
float x1 = 0.0f;
float y1 = 0.0f;
float x2 = 0.0f;
float y2 = 0.0f;
Point kps[5]{};
// SFace: embedding L2-normalizzato da 128 float32.
std::vector<float> embedding;
};
class FaceEngine {
public:
FaceEngine();
~FaceEngine();
FaceEngine(const FaceEngine&) = delete;
FaceEngine& operator=(const FaceEngine&) = delete;
bool init(
const std::string& scrfd_model,
const std::string& sface_model);
bool process_image(
const std::string& image_path,
std::vector<Face>& faces,
int& image_width,
int& image_height,
int rotation_degrees = 0);
private:
struct Impl;
Impl* impl_;
};

View file

@ -0,0 +1,121 @@
#ifndef FACE_CLUSTERING_KNN_OPENCL_H
#define FACE_CLUSTERING_KNN_OPENCL_H
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace face_knn {
constexpr std::uint32_t invalid_index =
0xffffffffu;
struct Neighbor {
float score = 0.0f;
std::uint32_t index =
invalid_index;
};
struct SearchOptions {
std::uint32_t dimension = 128;
std::uint32_t top_k = 1;
std::uint32_t lanes_per_query = 8;
std::size_t chunk_size = 50000;
std::string kernel_path;
};
struct SearchOutput {
std::size_t query_count = 0;
std::size_t database_count = 0;
std::uint32_t dimension = 0;
std::uint32_t top_k = 0;
std::string backend;
std::string device_name;
double elapsed_ms = 0.0;
/*
* Layout:
*
* neighbors[
* query_index * top_k
* + neighbor_position
* ]
*/
std::vector<Neighbor> neighbors;
};
struct ValidationResult {
std::size_t positions = 0;
std::size_t exact_index_matches = 0;
std::size_t decision_matches = 0;
float maximum_score_error = 0.0f;
bool exact_indices = false;
bool decisions_equal = false;
};
void validate_input(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options);
SearchOutput search_cpu(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options);
SearchOutput search_opencl(
const std::vector<float>& database,
const std::vector<float>& queries,
std::size_t database_count,
std::size_t query_count,
const SearchOptions& options);
ValidationResult compare_outputs(
const SearchOutput& expected,
const SearchOutput& actual,
float score_tolerance,
float recognition_threshold);
std::vector<Neighbor> query_neighbors(
const SearchOutput& output,
std::size_t query_index);
std::vector<Neighbor> exclude_neighbor(
const std::vector<Neighbor>& neighbors,
std::uint32_t excluded_index);
} // namespace face_knn
#endif

Some files were not shown because too many files have changed in this diff Show more