James Ketrenos a71fb177e9 Working fairly well
Signed-off-by: James Ketrenos <james_git@ketrenos.com>
2023-01-28 21:33:49 -08:00

1222 lines
38 KiB
JavaScript
Executable File

"use strict";
const express = require("express"),
fs = require("fs"),
config = require("config"),
moment = require("moment-holiday"),
crypto = require("crypto"),
util = require("util"),
Promise = require("bluebird");
require("../lib/pascha.js")(moment);
const execFile = util.promisify(require("child_process").execFile);
let photoDB;
require("../db/photos").then(function(db) {
photoDB = db;
});
const router = express.Router();
const picturesPath = config.get("picturesPath").replace(/\/$/, "") + "/";
const unlink = function (_path) {
if (_path.indexOf(picturesPath.replace(/\/$/, "")) == 0) {
_path = _path.substring(picturesPath.length);
}
let path = picturesPath + _path;
return new Promise(function (resolve, reject) {
fs.unlink(path, function (error, stats) {
if (error) {
return reject(error);
}
return resolve(stats);
});
});
}
const rename = function (_src, _dst) {
if (_src.indexOf(picturesPath.replace(/\/$/, "")) == 0) {
_src = _src.substring(picturesPath.length);
}
if (_dst.indexOf(picturesPath.replace(/\/$/, "")) == 0) {
_dst = _dst.substring(picturesPath.length);
}
let src = picturesPath + _src,
dst = picturesPath + _dst;
return new Promise(function (resolve, reject) {
fs.rename(src, dst, function (error, stats) {
if (error) {
return reject(error);
}
return resolve(stats);
});
});
}
const computeHash = function(_filepath) {
if (_filepath.indexOf(picturesPath.replace(/\/$/, "")) == 0) {
_filepath = _filepath.substring(picturesPath.length);
}
let filepath = picturesPath + _filepath;
return new Promise(function(resolve, reject) {
let input = fs.createReadStream(filepath),
hash = crypto.createHash("sha256");
if (!input) {
return reject();
}
input.on("readable", function() {
const data = input.read();
if (data) {
hash.update(data);
} else {
input.close();
resolve(hash.digest("hex"));
hash = null;
input = null;
}
});
});
};
const stat = function (_path) {
if (_path.indexOf(picturesPath.replace(/\/$/, "")) == 0) {
_path = _path.substring(picturesPath.length);
}
let path = picturesPath + _path;
return new Promise(function (resolve, reject) {
fs.stat(path, function (error, stats) {
if (error) {
return reject(error);
}
return resolve(stats);
});
});
}
const sharp = require("sharp");
const inProcess = [];
router.put("/:id", function(req, res/*, next*/) {
if (!req.user.maintainer) {
return res.status(401).send("Unauthorized to modify photos.");
}
const replacements = {
id: req.params.id
};
if (inProcess.indexOf(req.params.id) != -1) {
console.log("Request to modify asset currently in modification: " + req.params.id);
return res.status(409).send("Asset " + req.params.id + " is already being processed. Please try again.");
}
inProcess.push(req.params.id);
console.log("PUT /" + replacements.id, req.query);
switch (req.query.a) {
case "undelete":
console.log("Undeleting " + req.params.id);
return photoDB.sequelize.query("UPDATE photos SET deleted=0,updated=CURRENT_TIMESTAMP WHERE id=:id", {
replacements: replacements
}).then(function() {
return res.status(200).send(req.params.id + " updated.");
}).catch(function(error) {
console.log(error);
return res.status(500).send(error);
}).then(function() {
inProcess.splice(inProcess.indexOf(req.params.id), 1);
});
case "rename":
return getPhoto(req.params.id).then(function(asset) {
if (!asset) {
return res.status(404).send(req.params.id + " not found.");
}
let src = asset.filename;
asset.filename = asset.filename.replace(/(\.[^.]*)$/, "-" + asset.hash.substring(0, 8) + "$1");
return rename(picturesPath + asset.path + src, picturesPath + asset.path + asset.filename).then(function() {
return rename(picturesPath + asset.path + "thumbs/" + src, picturesPath + asset.path + "thumbs/" + asset.filename);
}).then(function() {
return rename(picturesPath + asset.path + "thumbs/scaled/" + src, picturesPath + asset.path + "thumbs/scaled/" + asset.filename);
}).then(function() {
return photoDB.sequelize.query("UPDATE photos SET filename=:filename WHERE id=:id", {
replacements: asset
}).then(function() {
return res.status(200).send(asset);
});
});
}).catch(function(error) {
console.log(error);
return res.status(500).send(error);
}).then(function() {
inProcess.splice(inProcess.indexOf(req.params.id), 1);
});
case "rotate":
let direction = req.query.direction || "right";
if (direction == "right") {
direction = 90;
} else {
direction = -90;
}
return getPhoto(req.params.id).then(function(asset) {
if (!asset) {
return res.status(404).send(req.params.id + " not found.");
}
let original = picturesPath + asset.path + asset.filename,
target = picturesPath + asset.path + ".tmp." + asset.filename,
thumb = picturesPath + asset.path + "thumbs/" + asset.filename,
scaled = picturesPath + asset.path + "thumbs/scaled/" + asset.filename;
let tmp = asset.width;
asset.width = asset.height;
asset.height = tmp;
asset.image = sharp(original);
return asset.image.rotate(direction).withMetadata().toFile(target).then(function() {
let stamp = moment(new Date(asset.modified)).format("YYYYMMDDhhmm.ss");
console.log("Restamping " + target + " to " + stamp);
/* Re-stamp the file's ctime with the original ctime */
return execFile("touch", [
"-t",
stamp,
target
]);
}).then(function() {
return asset.image.rotate(direction).resize(256, 256).withMetadata().toFile(thumb);
}).then(function() {
return asset.image.resize(Math.min(1024, asset.width)).withMetadata().toFile(scaled);
}).then(function() {
return stat(target).then(function(stats) {
if (!stats) {
throw "Unable to find original file after attempting to rotate!";
}
asset.size = stats.size;
asset.modified = stats.mtime;
return unlink(original).then(function() {
return rename(target, original);
}).then(function() {
return photoDB.sequelize.query("UPDATE photos SET " +
"modified=:modified,width=:width,height=:height,size=:size,updated=CURRENT_TIMESTAMP,scanned=CURRENT_TIMESTAMP " +
"WHERE id=:id", {
replacements: asset
});
});
});
}).then(function() {
sharp.cache(false);
sharp.cache(true);
res.status(200).send(asset);
return computeHash(asset.filepath).then(function(hash) {
asset.hash = hash;
return asset;
}).then(function(asset) {
return photoDB.sequelize.query("SELECT photos.id,photohashes.*,photos.filename,albums.path FROM photohashes " +
"LEFT JOIN photos ON (photos.id=photohashes.photoId) " +
"LEFT JOIN albums ON (albums.id=photos.albumId) " +
"WHERE hash=:hash OR photoId=:id", {
replacements: asset,
type: photoDB.sequelize.QueryTypes.SELECT
}).then(function(results) {
let query;
if (results.length == 0) {
query = "INSERT INTO photohashes (hash,photoId) VALUES(:hash,:id)";
console.warn("HASH being updated and photoId " + asset.id + " *should* already exist, but it doesn't.");
} else if (results.length > 1) {
/* This image is now a duplicate! */
for (var i = 0; i < results.length; i++) {
if (results[i].id != asset.id) {
console.log("Duplicate asset: " +
"'" + asset.filepath + "' is a copy of " +
"'" + results[i].path + results[i].filename + "'");
asset.duplicate = results[i].id;
break;
}
}
query = "UPDATE photos SET duplicate=:duplicate WHERE id=:id; " +
"DELETE FROM photohashes WHERE photoId=:id";
console.log("Updating photo " + asset.id + " as duplicate of " + asset.duplicate);
} else if (results[0].hash != asset.hash) {
query = "UPDATE photohashes SET hash=:hash WHERE photoId=:id; UPDATE photos SET duplicate=0 WHERE id=:id";
console.log("Updating photohash for " + asset.id + " to " + asset.hash + ", and clearing duplicate field.");
} else {
console.log("Unexpected!", asset, results[0]);
return;
}
return photoDB.sequelize.query(query, {
replacements: asset,
}).then(function() {
return asset;
});
});
});
});
}).catch(function(error) {
console.log(error);
return res.status(500).send(error);
}).then(function() {
inProcess.splice(inProcess.indexOf(req.params.id), 1);
});
}
return res.status(400).send("Invalid request");
});
const getPhoto = function(id) {
return photoDB.sequelize.query("SELECT " +
"photos.*,albums.path AS path,photohashes.hash,modified,(albums.path || photos.filename) AS filepath FROM photos " +
"LEFT JOIN albums ON albums.id=photos.albumId " +
"LEFT JOIN photohashes ON photohashes.photoId=photos.id " +
"WHERE photos.id=:id", {
replacements: {
id: id
},
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(photos) {
if (photos.length == 0) {
return null;
}
if (!photos[0].duplicate) {
return photos[0];
}
return photoDB.sequelize.query("SELECT hash FROM photohashes WHERE photoId=:duplicate", {
replacements: photos[0],
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(results) {
if (results.length != 0) {
photos[0].hash = results[0].hash;
}
return photos[0];
});
});
}
const tasks = [];
const Task = function() {
return new Promise(function(resolve, reject) {
crypto.randomBytes(16, function(error, buffer) {
if (error) {
return reject(error);
}
let now = Date.now(), task;
task = {
started: now,
lastUpdate: now,
eta: now,
processed: 0,
remaining: 0,
token: buffer.toString('hex'),
};
tasks.push(task);
return resolve(task);
});
});
};
const removeTask = function(task) {
let i = tasks.indexOf(task);
if (i != -1) {
tasks.splice(i, 1);
}
}
router.get("/status/:token", function(req, res) {
if (!req.params.token) {
return res.status(400).send("Usage /status/:token");
}
for (var i = 0; i < tasks.length; i++) {
if (tasks[i].token == req.params.token) {
return res.status(200).send(tasks[i]);
}
}
return res.status(404).send("Task " + req.params.token + " not found.");
});
/**
* 1. Look if there are duplicates.
* 2. Update all other duplicates to be duplicates of the first image that
* remains
* 3. If no duplicates, DELETE the entry from referencing tables:
* photohashes, faces, and any face{descriptor,distanceCosine} using that face
* 4. If there are duplicates, update the HASH (and any FACE) entry to point
* to the first image that remains
* 5. Delete the entry from photos
* 6. Delete the scaled, thumb, and original from disk
*/
const deletePhoto = function(photo) {
return photoDB.sequelize.transaction(function(transaction) {
return photoDB.sequelize.query(
"SELECT id FROM photos WHERE duplicate=:id", {
replacements: photo,
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(duplicates) {
if (!duplicates.length) {
return null;
}
let first = duplicates.shift();
let needsUpdate = [];
duplicates.forEach(function(duplicate) {
needsUpdate.push(duplicate.id);
});
if (!needsUpdate.length) {
return first;
}
// 2. Update all other duplicates to be duplicates of the first image
// that remains
console.log("Updating " + needsUpdate + " to point to " + first.id);
return photoDB.sequelize.query(
"UPDATE photos SET duplicate=:first WHERE id IN (:needsUpdate)", {
replacements: {
first: first.id,
needsUpdate: needsUpdate
},
transaction: transaction
}).then(function() {
return first;
});
}).then(function(first) {
if (!first) {
console.log("Deleting " + photo.id + " from photohash.");
// 3. If no duplicates, DELETE the entry from photohashes and faces
// and any face{descriptor,distanceCosine} using that face
return photoDB.sequelize.query(
"DELETE FROM photohashes WHERE photoId=:id", {
replacements: photo,
transaction: transaction
}).then(() => {
return photoDB.sequelize.query(
"SELECT id FROM faces WHERE photoId=:id", {
replacements: photo,
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true,
transaction: transaction
}).then((faces) => {
if (faces.length == 0) {
return;
}
const ids = faces.map(face => face.id);
console.log(`Deleting faces: ${JSON.stringify(ids, null, 2)}`);
return photoDB.sequelize.query(
"DELETE FROM facedistances " +
"WHERE descriptor1Id IN (:ids) " +
"OR descriptor2Id IN (:ids)", {
replacements: { ids },
transaction: transaction
}).then(() => {
return photoDB.sequelize.query(
"DELETE FROM facedescriptors WHERE faceId IN (:ids)", {
replacements: { ids },
transaction: transaction
}).then(() => {
return photoDB.sequelize.query(
"DELETE FROM faces WHERE id IN (:ids)", {
replacements: { ids },
transaction: transaction
});
});
});
}).then(() => {
console.log("Deleting " + photo.id + " from faces.");
// 3. If no duplicates, DELETE the entry from photohashes
return photoDB.sequelize.query(
"DELETE FROM faces WHERE photoId=:id", {
replacements: photo,
transaction: transaction
});
});
});
}
console.log("Updating photohash for " + photo.id + " to point to " + first.id);
// 4. If there are duplicates, update the HASH entry to point to the first image that remains
return photoDB.sequelize.query(
"UPDATE photohashes SET photoId=:first WHERE photoId=:photo", {
replacements: {
first: first.id,
photo: photo.id
},
transaction: transaction
}).then(() => {
console.log("Updating faces for " + photo.id + " to point to " + first.id);
return photoDB.sequelize.query(
"UPDATE faces SET photoId=:first WHERE photoId=:photo", {
replacements: {
first: first.id,
photo: photo.id
},
transaction: transaction
});
});
}).then(function() {
console.log("Deleting " + photo.path + photo.filename + " from DB.");
// 5. Delete the entry from photos
return photoDB.sequelize.query("DELETE FROM photos WHERE id=:id", {
replacements: photo,
transaction: transaction
});
}).then(function() {
// 6. Delete the scaled, thumb, and original from disk
console.log("Deleting " + photo.path + photo.filename + " from disk.");
return unlink(photo.path + "thumbs/scaled/" + photo.filename).catch(function() {}).then(function() {
return unlink(photo.path + "thumbs/" + photo.filename).catch(function() {}).then(function() {
return unlink(photo.path + photo.filename).catch(function(error) {
console.log("Error removing file: " + error);
});
});
});
});
}).then(() => {
console.log(`Completed deleting ${photo.id}`);
});
}
router.delete("/:id?", function(req, res/*, next*/) {
if (!req.user.maintainer) {
return res.status(401).send("Unauthorized to delete photos.");
}
const replacements = {
id: req.params.id || "*"
};
if (!req.params.id && !req.query.permanent) {
return res.status(400).send("Trash can only be emptied if permanent.");
}
let where = "";
if (req.params.id) {
where = "photos.id=:id";
if (req.query.permanent) {
where += " AND photos.deleted=1";
}
} else {
where = "photos.deleted=1";
}
console.log("DELETE /" + replacements.id, req.query);
let sent = false;
return photoDB.sequelize.query("SELECT " +
"photos.*,albums.path AS path,photohashes.hash,(albums.path || photos.filename) AS filepath FROM photos " +
"LEFT JOIN albums ON albums.id=photos.albumId " +
"LEFT JOIN photohashes ON photohashes.photoId=photos.id " +
"WHERE " + where, {
replacements: replacements,
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(photos) {
if (photos.length == 0) {
sent = true;
return res.status(404).send("Unable to find photo " + replacements.id);
}
return photoDB.sequelize.query(
"SELECT id FROM faces WHERE photoId IN (:photos)", {
replacements: photos.map(photo => photo.id),
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then((faces) => {
const faceIds = faces.map(face => face.id);
return photoDB.sequelize.query(
"DELETE FROM facedistances WHERE descriptor1Id IN (:ids) OR descriptor2Id IN (:ids)", {
replacements: {
ids: faceIds
}
}).then(() => {
return photoDB.sequelize.query(
"DELETE FROM faces WHERE id IN (:ids)", {
replacements: {
ids: faceIds
}
});
});
}).then(() => {
if (!req.query.permanent) {
return photoDB.sequelize.query("UPDATE photos SET deleted=1,updated=CURRENT_TIMESTAMP WHERE id=:id", {
replacements: replacements
}).then(function() {
sent = true;
return res.status(200).send({
id: req.params.id,
deleted: true,
permanent: (req.query && req.query.permanent) || false
});
});
}
/**
* Delete the asset from disk and the DB
*/
return Task().then(function(task) {
task.remaining = photos.length;
task.eta = -1;
sent = true;
res.status(200).send({
id: req.params.id,
task: task,
permanent: (req.query && req.query.permanent) || false
});
return Promise.mapSeries(photos, function(photo) {
let lastStamp = Date.now();
return deletePhoto(photo).then(function() {
let now = Date.now(), elapsed = now - lastStamp;
lastStamp= now;
task.processed++;
task.remaining--;
task.lastUpdate = Date.now();
task.eta = elapsed * task.remaining;
})
}).then(function() {
console.log("Processing task " + task.token + " finished: " + (task.lastUpdate - task.started));
removeTask(task);
}).catch(function(error) {
console.log("Processing task " + task.token + " failed: " + error);
removeTask(task);
});
});
});
}).catch(function(error) {
console.log(error);
if (!sent) {
sent = true;
res.status(500).send("Unable to delete photo " + req.params.id + ": " + error);
}
});
});
router.get("/holiday/:holiday", function(req, res/*, next*/) {
let startYear = 1990,
endYear = moment().year(),
dayIsHoliday = "",
holidayName,
date = undefined;
/* Find the holiday in the list of holidays */
let lookup = moment().holidays([req.params.holiday]);
if (!lookup) {
date = req.params.holiday.match(/^((\d{4})-)?(\d{2})-(\d{2})$/);
if (!date) {
return res.status(404).send(req.params.holiday + " holiday not found.");
}
date = {
year: date[1],
month: date[3],
day: date[4]
};
if (date.year) {
startYear = date.year;
endYear = date.year;
}
}
holidayName = date ? req.params.holiday : Object.getOwnPropertyNames(lookup)[0];
console.log("Searching for holiday: " + holidayName);
/* Lookup the date for the holiday on every year from 'startYear' (1990) to today */
for (let year = startYear; year <= endYear; year++) {
console.log("Getting year: " + year);
let holiday;
if (!date) {
holiday = moment(year + "-01-01", "YYYY-MM-DD").holiday(req.params.holiday);
if (!holiday) {
/* 'Leap Year' doesn't exist every year... */
continue;
}
/*
* NOTE: Memorial Day and Labor Day are two special cases -- the holiday is a Monday,
* however the entire weekend typically is holidy-esque. Account for that below.
*
* For those that have 'eve' celebrations, include those too.
*
* We (should) could expand this to account for Fri or Mon on the 4th of July or the
* entire weekend if it occurs on a Thu or Tues */
let extraDays = 0;
switch (req.params.holiday.toLowerCase()) {
case 'labor day':
case 'memorial day':
extraDays = -2; /* Include two days prior */
break;
case 'christmas day':
case 'new year\'s day':
extraDays = -1; /* Include 'Eve' */
break;
}
let direction = extraDays < 0 ? -1 : 1;
for (let i = 0; i <= Math.abs(extraDays); i++) {
let comparison = "strftime('%Y-%m-%d',taken)='" + holiday.format("YYYY-MM-DD") + "'";
/* If no holiday has been set yet, start the comparison function passed to WHERE
* otherwise append it with OR. */
if (!dayIsHoliday) {
dayIsHoliday = comparison;
} else {
dayIsHoliday += " OR " + comparison;
}
holiday.date(holiday.date() + direction);
}
} else {
let comparison = "strftime('%Y-%m-%d',taken)='"
+ moment(year + "-" + date.month + "-" + date.day, "YYYY-MM-DD").format("YYYY-MM-DD")
+ "'";
if (!dayIsHoliday) {
dayIsHoliday = comparison;
} else {
dayIsHoliday += " OR " + comparison;
}
}
}
let query = "SELECT photos.*,albums.path AS path FROM photos " +
"INNER JOIN albums ON (albums.id=photos.albumId) " +
"WHERE (photos.duplicate=0 AND photos.deleted=0 AND photos.scanned NOT NULL AND (" + dayIsHoliday + ")) " +
"ORDER BY strftime('%Y-%m-%d', taken) DESC,id DESC";
return photoDB.sequelize.query(query, {
type: photoDB.Sequelize.QueryTypes.SELECT
}).then(function(photos) {
photos.forEach(function(photo) {
for (var key in photo) {
if (photo[key] instanceof Date) {
photo[key] = moment(photo[key]);
}
}
});
return res.status(200).json({
holiday: holidayName,
items: photos
});
}).catch(function(error) {
console.error("Query failed: " + query);
return Promise.reject(error);
});
});
router.get("/folder/:folder", function(req, res/*, next*/) {
const folder = req.params.folder;
console.log("Searching for photos under folder: " + folder);
let query = "SELECT photos.*,albums.path AS path FROM photos " +
"INNER JOIN albums ON (photos.albumId=albums.id AND albums.path LIKE :folder) " +
"WHERE (photos.duplicate=0 AND photos.deleted=0 AND photos.scanned NOT NULL) " +
"ORDER BY strftime('%Y-%m-%d', taken) DESC,id DESC";
return photoDB.sequelize.query(query, {
replacements: {
folder: folder + '%'
},
type: photoDB.Sequelize.QueryTypes.SELECT
}).then(function(photos) {
console.log("Found " + photos.length + " photos.");
photos.forEach(function(photo) {
for (var key in photo) {
if (photo[key] instanceof Date) {
photo[key] = moment(photo[key]);
}
}
});
return res.status(200).json({
folder: folder,
items: photos
});
}).catch(function(error) {
console.error("Query failed: " + query);
return Promise.reject(error);
});
});
/* Each photos has:
* locations
* people
* date
* tags
* photo info
*/
router.get("/memories/:date?", function(req, res/*, next*/) {
let limit = parseInt(req.query.limit) || 50,
id, cursor, index;
if (req.query.next) {
let parts = req.query.next.split("_");
cursor = new Date(parts[0]);
id = parseInt(parts[1]);
} else {
cursor = "";
id = -1;
}
if (id == -1) {
index = "strftime('%m-%d',taken)=strftime('%m-%d',:date)";
} else {
index = "((strftime('%Y-%m-%d',taken)=strftime('%Y-%m-%d',:cursor) AND photos.id<"+id+ ") OR " +
"(strftime('%m-%d',taken)=strftime('%m-%d',:cursor) AND strftime('%Y',taken)<strftime('%Y',:cursor)))";
}
let date = new Date("2016-" + req.params.date);
let query = "SELECT photos.*,albums.path AS path FROM photos " +
"INNER JOIN albums ON (albums.id=photos.albumId) " +
"WHERE (photos.duplicate=0 AND photos.deleted=0 AND photos.scanned NOT NULL AND " + index + ") " +
"ORDER BY strftime('%Y-%m-%d',taken) DESC,id DESC LIMIT " + (limit + 1);
// console.log("Memories for " + date.toISOString().replace(/^2016-(.*)T.*$/, "$1"));
// if (cursor) {
// console.log("Cursor" + cursor.toISOString().replace(/T.*/, ""));
// }
return photoDB.sequelize.query(query, {
replacements: {
cursor: cursor,
date: date
},
type: photoDB.Sequelize.QueryTypes.SELECT
}).then(function(photos) {
photos.forEach(function(photo) {
for (var key in photo) {
if (photo[key] instanceof Date) {
photo[key] = moment(photo[key]);
}
}
});
let more = photos.length > limit; /* We queried one extra item to see if there are more than LIMIT available */
let last;
if (more) {
photos.splice(limit);
last = photos[photos.length - 1];
}
let results = {
items: photos.sort(function(a, b) {
return new Date(b.taken) - new Date(a.taken);
})
};
if (more) {
results.cursor = new Date(last.taken).toISOString().replace(/T.*/, "") + "_" + last.id;
results.more = true;
}
return res.status(200).json(results);
}).catch(function(error) {
console.error("Query failed: " + query);
return Promise.reject(error);
});
});
router.get("/duplicates", function(req, res/*, next*/) {
let replacements = {};
return photoDB.sequelize.query(
"SELECT filename,COUNT(*) AS count FROM photos WHERE photos.duplicate=0 AND photos.deleted!=1 " +
"GROUP BY filename HAVING count > 1", {
replacements: replacements,
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(duplicates) {
let filenames = [];
duplicates.forEach(function(duplicate) {
filenames.push(duplicate.filename);
});
replacements.filenames = filenames;
return photoDB.sequelize.query(
"SELECT photos.*,albums.path AS path,photohashes.hash,(albums.path || photos.filename) AS filepath FROM photos " +
"LEFT JOIN albums ON albums.id=photos.albumId " +
"LEFT JOIN photohashes ON photohashes.photoId=photos.id " +
"WHERE filename IN (:filenames) ORDER BY photos.filename,photos.width,photos.height,photos.size DESC", {
replacements: replacements,
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(photos) {
return res.status(200).json({
items: photos
});
});
}).catch(function(error) {
return Promise.reject(error);
});
});
router.get("/trash", function(req, res/*, next*/) {
return photoDB.sequelize.query(
"SELECT photos.*,albums.path AS path,(albums.path || photos.filename) AS filepath FROM photos " +
"LEFT JOIN albums ON albums.id=photos.albumId " +
"WHERE deleted=1 ORDER BY photos.updated DESC", {
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then(function(photos) {
return res.status(200).json({
items: photos
});
});
});
function getFacesForPhoto(id) {
/* Get the set of faces in this photo */
return photoDB.sequelize.query(
"SELECT * FROM faces WHERE photoId=:id AND faceConfidence>0.9", {
replacements: {
id: id,
},
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then((faces) => {
/* For each face in the photo, get the related faces */
return photoDB.sequelize.query(
"SELECT relatedFaces.photoId AS photoId,fd.descriptor1Id,fd.descriptor2Id,fd.distanceCosine,relatedFaces.faceConfidence " +
"FROM (SELECT id,photoId,faceConfidence FROM faces WHERE faces.faceConfidence>=0.9 AND faces.id IN (:ids)) AS faces " +
"INNER JOIN faces AS relatedFaces ON relatedFaces.faceConfidence>=0.9 AND relatedFaces.id IN (fd.descriptor1Id,fd.descriptor2Id) " +
"INNER JOIN facedistances AS fd ON fd.distanceCosine<=0.5 " +
" AND (fd.descriptor1Id=faces.id OR fd.descriptor2Id=faces.id) " +
"WHERE (faces.id=fd.descriptor1Id OR faces.id=fd.descriptor2Id) " +
"ORDER BY fd.distanceCosine ASC", {
replacements: {
ids: faces.map(face => face.id),
},
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
}).then((relatedFaces) => {
faces.forEach((face) => {
face.relatedFaces = relatedFaces.filter((related) => {
return (related.photoId != id && (related.descriptor1Id == face.id || related.descriptor2Id == face.id));
}).map((related) => {
return {
distanceCosine: related.distanceCosine,
faceConfidence: related.faceConfidence,
photoId: related.photoId,
faceId: related.descriptor1Id != face.id ? related.descriptor1Id : related.descriptor2Id
}
});
});
return faces;
});
});
}
router.get("/faces/:id", (req, res) => {
const id = parseInt(req.params.id);
if (id != req.params.id) {
return res.status(400).send({ message: "Usage faces/:id"});
}
return getFacesForPhoto(id).then((faces) => {
return res.status(200).json(faces);
});
});
router.get("/random/:id?", async (req, res) => {
let id = parseInt(req.params.id),
filter = "";
if (id == req.params.id) {
console.log("GET /random/" + id);
filter = "AND id=:id";
} else {
console.log("GET /random/");
filter = "";//AND faces>0";
id = undefined;
}
/* If the requested ID is a duplicate, we need to find the original
* photo ID */
const results = await photoDB.sequelize.query(
"SELECT id,duplicate FROM photos WHERE deleted=0 " + filter, {
replacements: { id },
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
});
if (!results.length) {
return res.status(404).send({ message: id + " not found." });
}
if (id) {
if (results[0].duplicate) {
id = results[0].duplicate;
}
} else {
id = results[Math.floor(Math.random() * results.length)].id;
}
const photos = await photoDB.sequelize.query(
"SELECT photos.*,albums.path AS path FROM photos " +
"INNER JOIN albums ON albums.id=photos.albumId " +
"WHERE photos.duplicate=0 AND photos.deleted=0 AND photos.scanned NOT NULL AND photos.id=:id", {
replacements: { id },
type: photoDB.Sequelize.QueryTypes.SELECT,
raw: true
});
if (photos.length === 0) {
return res.status(404).send({ message: `Error obtaining ${id}.`});
}
const photo = photos[0];
for (var key in photo) {
if (photo[key] instanceof Date) {
photo[key] = moment(photo[key]);
}
}
const faces = await getFacesForPhoto(photo.id);
photo.faces = faces;
return res.status(200).json(photo);
});
router.get("/mvimg/*", function(req, res/*, next*/) {
let limit = parseInt(req.query.limit) || 50,
id, cursor, index;
if (req.query.next) {
let parts = req.query.next.split("_");
cursor = parts[0];
id = parseInt(parts[1]);
} else {
cursor = "";
id = -1;
}
if (id == -1) {
index = "";
} else {
index = "AND ((strftime('%Y-%m-%d',taken)=strftime('%Y-%m-%d',:cursor) AND photos.id<:id) OR " +
"strftime('%Y-%m-%d',taken)<strftime('%Y-%m-%d',:cursor))";
}
console.log(req.url);
let path = decodeURI(req.url).replace(/\?.*$/, "").replace(/^\//, "").replace(/^mvimg\//, ""),
query = "SELECT photos.*,albums.path AS path FROM photos " +
"INNER JOIN albums ON (albums.id=photos.albumId AND albums.path LIKE :path) " +
"WHERE (photos.name LIKE 'MVIMG%' AND photos.duplicate=0 AND photos.deleted=0 AND photos.scanned NOT NULL) " + index + " " +
"ORDER BY strftime('%Y-%m-%d',taken) DESC,id DESC LIMIT " + (limit + 1),
replacements = {
id: id,
cursor: cursor,
path: path + "%"
};
console.log("Trying path as: " + path);
// console.log("Fetching from: " + JSON.stringify(replacements, null, 2) + "\nwith:\n" + query);
return photoDB.sequelize.query(query, {
replacements: replacements,
type: photoDB.Sequelize.QueryTypes.SELECT
}).then(function(photos) {
photos.forEach(function(photo) {
for (var key in photo) {
if (photo[key] instanceof Date) {
photo[key] = moment(photo[key]);
}
}
});
let more = photos.length > limit; /* We queried one extra item to see if there are more than LIMIT available */
// console.log("Requested " + limit + " and matched " + photos.length);
let last;
if (more) {
photos.splice(limit);
last = photos[photos.length - 1];
}
let results = {
items: photos.sort(function(a, b) {
return new Date(b.taken) - new Date(a.taken);
})
};
if (more) {
results.cursor = new Date(last.taken).toISOString().replace(/T.*/, "") + "_" + last.id;
results.more = true;
}
return res.status(200).json(results);
}).catch(function(error) {
console.error("Query failed: " + query);
return Promise.reject(error);
});
});
router.get("/:id", async (req, res) => {
console.log(`GET ${req.url}`);
const id = parseInt(req.params.id);
try {
let results;
results = await photoDB.sequelize.query(
`
SELECT photos.*,albums.path AS path
FROM photos
INNER JOIN albums ON albums.id=photos.albumId
WHERE photos.id=:id
`, {
replacements: { id },
type: photoDB.Sequelize.QueryTypes.SELECT
}
);
if (results.length === 0) {
return res.status(404);
}
const photo = results[0];
results = await photoDB.sequelize.query(
`
SELECT faces.* FROM faces
WHERE faces.photoId=:id
AND faces.classifiedBy NOT IN ('not-a-face', 'forget' )
`, {
replacements: { id },
type: photoDB.Sequelize.QueryTypes.SELECT
}
);
photo.faces = results;
/* For each face, look up the Identity and clean up any
* fields we don't want to return vai the rest API */
await Promise.map(photo.faces, async (face) => {
face.faceId = face.id;
delete face.id;
delete face.descriptorId;
delete face.lastComparedId;
delete face.photoId;
delete face.scanVersion;
if (face.identityId) {
const results = await photoDB.sequelize.query(
`
SELECT id AS identityId,displayName,firstName,lastName,middleName FROM identities
WHERE identityId=:id
`, {
replacements: { id: face.identityId },
type: photoDB.Sequelize.QueryTypes.SELECT
}
);
face.identity = results[0];
}
});
return res.status(200).json(photo);
} catch (error) {
console.error(error);
return res.status(404).json({message: `Error connecting to DB for ${id}.`})
}
});
router.get("/*", function(req, res/*, next*/) {
let limit = parseInt(req.query.limit) || 50,
id, cursor, index;
if (req.query.next) {
let parts = req.query.next.split("_");
cursor = parts[0];
id = parseInt(parts[1]);
} else {
cursor = "";
id = -1;
}
if (id == -1) {
index = "";
} else {
index = "AND ((strftime('%Y-%m-%d',taken)=strftime('%Y-%m-%d',:cursor) AND photos.id<:id) OR " +
"strftime('%Y-%m-%d',taken)<strftime('%Y-%m-%d',:cursor))";
}
let path = decodeURI(req.url).replace(/\?.*$/, "").replace(/^\//, ""),
query = "SELECT photos.*,albums.path AS path FROM photos " +
"INNER JOIN albums ON (albums.id=photos.albumId AND albums.path LIKE :path) " +
"WHERE (photos.duplicate=0 AND photos.deleted=0 AND photos.scanned NOT NULL) " + index + " " +
"ORDER BY strftime('%Y-%m-%d',taken) DESC,id DESC LIMIT " + (limit + 1),
replacements = {
id: id,
cursor: cursor,
path: path + "%"
};
// console.log("Fetching from: " + JSON.stringify(replacements, null, 2) + "\nwith:\n" + query);
return photoDB.sequelize.query(query, {
replacements: replacements,
type: photoDB.Sequelize.QueryTypes.SELECT
}).then(function(photos) {
photos.forEach(function(photo) {
for (var key in photo) {
if (photo[key] instanceof Date) {
photo[key] = moment(photo[key]);
}
}
});
let more = photos.length > limit; /* We queried one extra item to see if there are more than LIMIT available */
// console.log("Requested " + limit + " and matched " + photos.length);
let last;
if (more) {
photos.splice(limit);
last = photos[photos.length - 1];
}
let results = {
items: photos.sort(function(a, b) {
return new Date(b.taken) - new Date(a.taken);
})
};
if (more) {
results.cursor = new Date(last.taken).toISOString().replace(/T.*/, "") + "_" + last.id;
results.more = true;
}
return res.status(200).json(results);
}).catch(function(error) {
console.error("Query failed: " + query);
return Promise.reject(error);
});
});
module.exports = router;