"use strict"; const Promise = require("bluebird"), fs = require("fs"), config = require("config"), moment = require("moment"); let scanning = 0; let photoDB = null; const picturesPath = config.get("picturesPath"); const processQueue = [], triedClean = []; function removeNewerFile(path, fileA, fileB) { fs.stat(path + "/" + fileA, function(err, statsA) { if (err) { return; } fs.stat(path + "/" + fileB, function(err, statsB) { if (err) { return; } if (statsA.mtime > statsB.mtime) { console.log("Removing file by moving to 'corrupt':" + fileA); moveCorrupt(path, fileA); } else { console.log("Removing file by moving to 'corrupt':" + fileB); moveCorrupt(path, fileB); } }); }); } function scanDir(parent, path) { let extensions = [ "jpg", "jpeg", "png", "gif", "nef" ], re = new RegExp("\.((" + extensions.join(")|(") + "))$", "i"), replacements = { path: path.slice(picturesPath.length), name: path.replace(/.*\//, "").replace(/_/g, " "), parent: parent || null }; let query = "SELECT id FROM albums WHERE path=:path AND "; if (!parent) { query += "parentId IS NULL"; } else { query += "parentId=:parent"; } return photoDB.sequelize.query(query, { replacements: replacements, type: photoDB.sequelize.QueryTypes.SELECT }).then(function(results) { if (results.length == 0) { // console.log("Adding " + path + " under " + parent, replacements); return photoDB.sequelize.query("INSERT INTO albums (path,parentId,name) VALUES(:path,:parent,:name)", { replacements: replacements }).then(function(results) { return results[1].lastID; }); } else { return results[0].id; } }).then(function(parent) { return new Promise(function(resolve, reject) { console.log("Scanning " + replacements.path); fs.readdir(path, function(err, files) { if (err) { console.warn(" Could not readdir " + path); return resolve(null); } scanning++; let hasThumbs = false; for (let i = 0; i < files.length; i++) { if (files[i] == "thumbs") { hasThumbs = true; break; } } let tmp; if (!hasThumbs) { tmp = mkdirPromise(path + "/thumbs"); } else { tmp = Promise.resolve(); } /* Remove 'thumbs' and 'raw' directories from being processed */ files = files.filter(function(file) { for (var i = 0; i < files.length; i++) { /* If this file has an original NEF on the system, don't add the JPG to the DB */ if (/\.nef$/i.exec(files[i]) && file == files[i].replace(/\.nef$/i, ".jpg")) { return false; } /* If there is a different CASE (eg. JPG vs jpg) don't add it, and remove the 'lower case' * version from disk. */ if (file != files[i] && file.toUpperCase() == files[i]) { removeNewerFile(path, file, files[i]); console.log("Duplicate file in " + path + ": ", file, files[i]); return false; } } return file != "raw" && file != "thumbs" && file != ".git" && file != "corrupt"; }); return tmp.then(function() { return Promise.map(files, function(file) { let filepath = path + "/" + file; return new Promise(function(resolve, reject) { fs.stat(filepath, function(err, stats) { if (err) { console.warn("Could not stat " + filepath); return resolve(false); } if (stats.isDirectory()) { return scanDir(parent, filepath, stats).then(function(entry) { return resolve(true); }); } /* Check file extensions */ if (!re.exec(file)) { return resolve(true); } const replacements = { path: path.slice(picturesPath.length), filename: file.replace(/\.nef$/i, ".jpg") /* We will be converting from NEF => JPG */ }; return photoDB.sequelize.query("SELECT id FROM photos WHERE path=:path AND filename=:filename", { replacements: replacements, type: photoDB.sequelize.QueryTypes.SELECT }).then(function(photo) { if (photo.length == 0) { processQueue.push([ replacements.path, file, stats.mtime, parent ]); } return resolve(true); }); }); }); }, { concurrency: 10 }).then(function() { scanning--; if (scanning == 0) { const endStamp = Date.now(); console.log("Scanning completed in " + Math.round(((endStamp - startStamp))) + "ms. " + processQueue.length + " items to process."); } }).then(function() { return resolve(); }); }); }); }); }); } const startStamp = Date.now(); let processRunning = false; const { spawn } = require('child_process'); const sharp = require("sharp"), exif = require("exif-reader"); function mkdirPromise(path) { return new Promise(function(resolve, reject) { fs.stat(path, function(err, stats) { if (err && err.code != 'ENOENT') { console.warn("Could not stat " + path + "/raw"); return reject(); } if (!err) { return resolve(); } fs.mkdir(path, function(err) { if (err) { return reject("Unable to create " + path, err); } return resolve(); }); }); }); } function existsPromise(path) { return new Promise(function(resolve, reject) { fs.stat(path, function(err, stats) { if (!err) { return resolve(true); } if (err.code == 'ENOENT') { return resolve(false); } return reject(err); }); }) } function convertNefToJpg(path, file) { console.log("Converting " + path + "/" + file); path = picturesPath + path; return new Promise(function(resolve, reject) { fs.stat(path + "/" + file.replace(/\.nef$/i, ".jpg"), function(err, stats) { if (!err) { console.log("Skipping already converted file: " + file); return resolve(); } const ufraw = spawn("ufraw-batch", [ "--silent", "--wb=camera", "--rotate=camera", "--out-type=jpg", "--compression=90", "--exif", "--overwrite", "--output", path + "/" + file.replace(/\.nef$/i, ".jpg"), path + "/" + file ]); ufraw.on('close', function(code) { if (code != 0) { return reject("UFRAW for " + path + "/" + file + " returned an error: " + code); } fs.rename(path + "/" + file, path + "/raw/" + path, function(err) { if (err) { console.error("Unable to move RAW file: " + path + "/" + file); return reject(err); } else { return resolve(); } }); }); }); }); } function moveCorrupt(path, file) { if (path.indexOf(picturesPath) != 0) { path = picturesPath + path; } console.warn("Moving corrupt file '" + file + "' to " + path + "/corrupt"); return mkdirPromise(path + "/corrupt").then(function() { return new Promise(function(resolve, reject) { fs.rename(path + "/" + file, path + "/corrupt/" + file, function(err) { if (err) { console.error("Unable to move corrupt file: " + path + "/" + file); return reject(err); } else { return resolve(); } }); }); }); } function triggerWatcher() { setTimeout(triggerWatcher, 1000); if (!processRunning && processQueue.length) { let lastMessage = moment(), toProcess = processQueue.length, processing = processQueue.splice(0); processRunning = true; return Promise.map(processing, function(entry) { var path = entry[0], file = entry[1], created = entry[2], albumId = entry[3]; // console.log("Processing " + src); let tmp = Promise.resolve(file); /* If this is a Nikon RAW file, convert it to JPG and move to /raw dir */ if (/\.nef$/i.exec(file)) { tmp = existsPromise(picturesPath + path + "/" + file.replace(/\.nef$/i, ".jpg")).then(function(exists) { if (exists) { return file.replace(/\.nef$/i, ".jpg"); /* We converted from NEF => JPG */ } return mkdirPromise(picturesPath + path + "/raw").then(function() { return convertNefToJpg(path, file); }).then(function() { return file.replace(/\.nef$/i, ".jpg"); /* We converted from NEF => JPG */ }); }); } return tmp.then(function(file) { var src = picturesPath + path + "/" + file, dst = picturesPath + path + "/thumbs/" + file, image = sharp(src); return image.metadata().then(function(metadata) { if (metadata.exif) { metadata.exif = exif(metadata.exif); delete metadata.exif.thumbnail; delete metadata.exif.image; for (var key in metadata.exif.exif) { if (Buffer.isBuffer(metadata.exif.exif[key])) { metadata.exif.exif[key] = "Buffer[" + metadata.exif.exif[key].length + "]"; } } } let replacements = { albumId: albumId, name: file.replace(/.[^.]*$/, ""), path: path, filename: file, width: metadata.width, height: metadata.height, added: moment().format() }; if (metadata.exif && metadata.exif.exif && metadata.exif.exif.DateTimeOriginal && !isNaN(metadata.exif.exif.DateTimeOriginal.valueOf())) { replacements.taken = moment(metadata.exif.exif.DateTimeOriginal).format(); replacements.modified = moment(metadata.exif.exif.DateTimeOriginal).format(); if (replacements.taken == "Invalid date" || replacements.taken.replace(/T.*/, "") == "1899-11-30") { console.log("Invalid EXIF date information: ", JSON.stringify(metadata.exif.exif)); replacements.taken = replacements.modified = moment(created).format(); } } else { let patterns = /(20[0-9][0-9]-?[0-9][0-9]-?[0-9][0-9])[_\-]?([0-9]*)/, date = moment(created).format(); let match = file.match(patterns); if (match) { date = moment(match[1].replace(/-/g, ""), "YYYYMMDD").format(); if (date == "Invalid date") { date = moment(created).format(); } } else { date = moment(created).format(); } replacements.taken = replacements.modified = date; } return existsPromise(dst).then(function(exists) { let resize; if (!exists) { resize = image.resize(256, 256).toFile(dst); } else { resize = Promise.resolve(); } return resize.then(function() { return photoDB.sequelize.query("INSERT INTO photos " + "(albumId,path,filename,added,modified,taken,width,height,name)" + "VALUES(:albumId,:path,:filename,DATE(:added),DATE(:modified),DATE(:taken),:width,:height,:name)", { replacements: replacements }); }).catch(function(error) { console.error("Error resizing image, writing to disc, or updating DB: " + src, error); throw error; }); }).then(function() { toProcess--; if (moment().add(-5, 'seconds') > lastMessage) { console.log("Items to be processed: " + toProcess); lastMessage = moment(); } }); }).catch(function(error) { console.error("Error reading image " + src + ": ", error); return moveCorrupt(path, file).then(function() { /* If the original file was not a NEF, we are done... */ if (!/\.nef$/i.exec(entry[1])) { return; } /* ... otherwise, attempt to re-convert the NEF->JPG and then resize again */ for (var i = 0; i < triedClean.length; i++) { if (triedClean[i] == path + "/" + file) { /* Move the NEF to /corrupt as well so it isn't re-checked again and again... */ // return moveCorrupt(path, entry[1]); console.error("Already attempted to convert NEF to JPG: " + path + "/" + file); return; } } console.warn("Adding " + path + "/" + file + " back onto processing queue."); triedClean.push(path + "/" + file); processQueue.push([ path, file, created, albumId ]); }); }); }); }, { concurrency: 1 }).then(function() { console.log("Completed processing queue."); }); } } module.exports = { scan: function (db) { photoDB = db; return scanDir(null, picturesPath).then(function() { triggerWatcher(); }); } };