Use [exiftool-json-db] to maintain the JSON database of media files

pull/58/head
Romain 7 years ago
parent dcb06c5242
commit 8dccb88f25

@ -22,6 +22,7 @@ Simply point `thumbsup` to a folder with photos & videos. All nested folders
*Requirements*
- [Node.js](http://nodejs.org/): `brew install Node`
- [exiftool](http://www.sno.phy.queensu.ca/~phil/exiftool/): `brew install exiftool`
- [GraphicsMagick](http://www.graphicsmagick.org/): `brew install graphicsmagick`
- [FFmpeg](http://www.ffmpeg.org/): `brew install ffmpeg`

@ -26,9 +26,9 @@
},
"dependencies": {
"async": "^2.1.2",
"exif-parser": "~0.1.9",
"debug": "^2.6.1",
"exiftool-json-db": "~1.0.0",
"fs-extra": "^1.0.0",
"glob": "^7.1.1",
"gm": "^1.23.0",
"handlebars": "~4.0.5",
"less": "^2.7.1",

@ -1,13 +1,14 @@
var fs = require('fs-extra');
var pad = require('pad');
var path = require('path');
var async = require('async');
var make = require('./utils/make');
var metadata = require('./input/metadata');
var collection = require('./model/collection');
var hierarchy = require('./model/hierarchy.js')
var thumbs = require('./output-media/thumbs');
var website = require('./output-website/website');
const async = require('async')
const fs = require('fs-extra')
const make = require('./utils/make')
const pad = require('pad')
const path = require('path')
const database = require('./input/database')
const File = require('./input/file')
const MediaFile = require('./model/file')
const hierarchy = require('./model/hierarchy.js')
const thumbs = require('./output-media/thumbs')
const website = require('./output-website/website')
exports.build = function(opts) {
@ -16,18 +17,18 @@ exports.build = function(opts) {
fs.mkdirpSync(opts.output);
var media = path.join(opts.output, 'media');
var databaseFile = path.join(opts.output, 'metadata.json');
// ---------------------
// These variables are set later during the async phase
// ---------------------
var meta = null; // metadata file to be read later
var album = null; // root album with nested albums
var allFiles = collection.fromMetadata({});
var album = null // root album with nested albums
var collection = null // all files in the database
function buildStep(options) {
return function(callback) {
if (options.condition !== false) {
make.exec(opts.input, media, meta, options, callback);
make.exec(opts.input, media, collection, options, callback);
} else {
callback();
}
@ -55,12 +56,11 @@ exports.build = function(opts) {
async.series([
function updateMetadata(callback) {
metadata.update(opts, function(err, data) {
meta = data;
allFiles = collection.fromMetadata(data);
callback(err);
});
function updateDatabase(callback) {
database.update(opts.input, databaseFile, (err, dbFiles) => {
collection = dbFiles.map(f => new File(f))
callback(err)
})
},
buildStep({
@ -115,7 +115,8 @@ exports.build = function(opts) {
}),
callbackStep('Album hierarchy', function(next) {
albums = hierarchy.createAlbums(allFiles, opts);
const mediaCollection = collection.map(f => new MediaFile(f.path, f))
albums = hierarchy.createAlbums(mediaCollection, opts);
next();
}),

@ -0,0 +1,39 @@
const debug = require('debug')('thumbsup')
const exifdb = require('exiftool-json-db')
const pad = require('pad')
const progress = require('../utils/progress')
exports.update = function(media, databasePath, callback) {
var updateBar = null
var emitter = null
try {
emitter = exifdb.create({media: media, database: databasePath})
} catch (ex) {
const message = 'Loading database\n' + ex.toString() + '\n' +
'If migrating from thumbsup v1, delete <metadata.json> to rebuild the database from scratch'
callback(new Error(message))
}
emitter.on('stats', (stats) => {
debug(`Database stats: total=${stats.total}`)
const totalBar = progress.create('List media files', stats.total)
totalBar.tick(stats.total)
updateBar = progress.create('Updating database', stats.added + stats.modified)
if (stats.added + stats.modified === 0) {
updateBar.tick(1)
}
})
emitter.on('file', (file) => {
updateBar.tick()
})
emitter.on('done', (files) => {
callback(null, files)
})
emitter.on('error', callback)
}

@ -1,54 +0,0 @@
var fs = require('fs');
var async = require('async');
var exif = require('exif-parser');
var exec = require('child_process').exec;
// convert video rotation in degrees
// to the standard EXIF rotation number
var ROTATION_TABLE = {
'0': 1,
'90': 6,
'180': 3,
'270': 8
};
var FFPROBE_DATE = /creation_time\s*:\s*(.*)\n/;
var FFPROBE_ROTATE = /rotate\s*:\s*(.*)\n/;
exports.read = function(filePath, callback) {
if (filePath.match(/\.(jpg|jpeg)$/i)) {
photo(filePath, callback);
} else if (filePath.match(/\.(mp4|mov|mts)$/i)) {
video(filePath, callback);
} else {
callback(new Error('Unknown format'));
}
};
function photo(filePath, callback) {
fs.readFile(filePath, function(err, contents) {
if (err) return callback(new Error('Failed to read file ' + filePath));
try {
var result = exif.create(contents).parse();
} catch (ex) {
return callback(new Error('Failed to read EXIF from ' + filePath));
}
callback(null, {
date: result.tags.DateTimeOriginal ? (result.tags.DateTimeOriginal * 1000) : null,
orientation: result.tags.Orientation || null,
caption: result.tags.ImageDescription
});
});
}
function video(filePath, callback) {
var ffprobe = 'ffprobe "' + filePath + '"';
exec(ffprobe, function(err, stdout, stderr) {
var dateMatch = FFPROBE_DATE.exec(stderr);
var rotateMatch = FFPROBE_ROTATE.exec(stderr);
callback(null, {
date: dateMatch ? Date.parse(dateMatch[1]) : null,
orientation: rotateMatch ? ROTATION_TABLE[rotateMatch[1]] : null
});
});
}

@ -0,0 +1,35 @@
const moment = require('moment')
const MIME_REGEX = /([^/])\/(.*)/
const EXIF_DATE_FORMAT = 'YYYY:MM:DD HH:mm:ssZ'
function File (dbFile) {
this.path = dbFile.SourceFile
this.fileDate = fileDate(dbFile)
this.mediaType = mediaType(dbFile)
this.exif = {
date: exifDate(dbFile),
caption: caption(dbFile)
}
}
function mediaType (dbFile) {
const match = MIME_REGEX.exec(dbFile.File.MIMEType)
if (match) return match[1]
return 'unknown'
}
function fileDate (dbFile) {
return moment(dbFile.File.FileModifyDate, EXIF_DATE_FORMAT).valueOf()
}
function exifDate (dbFile) {
if (!dbFile.EXIF) return null
return moment(dbFile.EXIF.DateTimeOriginal, EXIF_DATE_FORMAT).valueOf()
}
function caption (dbFile) {
return dbFile.EXIF ? dbFile.EXIF.ImageDescription : null
}
module.exports = File

@ -1,111 +0,0 @@
var _ = require('lodash');
var fs = require('fs');
var path = require('path');
var glob = require('glob');
var async = require('async');
var pad = require('pad');
var progress = require('../utils/progress');
var exif = require('./exif');
exports.update = function(opts, callback) {
var metadataPath = path.join(opts.output, 'metadata.json');
var existing = null;
var existingDate = null;
try {
existing = require(metadataPath);
existingDate = fs.statSync(metadataPath).mtime;
} catch (ex) {
existing = {};
existingDate = 0;
}
function findFiles(callback) {
var globOptions = {
cwd: opts.input,
nonull: false,
nocase: true
};
glob('**/*.{jpg,jpeg,png,gif,mp4,mov,mts,m2ts}', globOptions, callback);
}
function pathAndDate(filePath, next) {
var absolute = path.join(opts.input, filePath);
fs.stat(absolute, function(err, stats) {
next(null, {
absolute: absolute,
relative: filePath,
fileDate: stats.mtime.getTime()
});
});
}
function newer(fileInfo) {
var found = existing[fileInfo.relative];
if (!found) return true;
return fileInfo.fileDate > existingDate;
}
function removeDeletedFiles(allFiles) {
var existingPaths = _.keys(existing);
var actualPaths = _.map(allFiles, 'relative');
var deleted = _.difference(existingPaths, actualPaths);
deleted.forEach(function(key) {
delete existing[key];
});
return deleted.length > 0;
}
function metadata(fileInfo, callback) {
exif.read(fileInfo.absolute, function(err, exifData) {
callback(null, {
path: fileInfo.relative,
fileDate: fileInfo.fileDate,
mediaType: mediaType(fileInfo),
exif: {
date: exifData ? exifData.date : null,
orientation: exifData ? exifData.orientation : null,
caption: exifData ? exifData.caption: null
}
});
});
}
function mediaType(fileInfo) {
return fileInfo.relative.match(/\.(mp4|mov|mts|m2ts)$/i) ? 'video' : 'photo';
}
function writeToDisk() {
fs.writeFileSync(metadataPath, JSON.stringify(existing, null, ' '));
}
findFiles(function(err, files) {
var bar = progress.create('List all files', files.length);
bar.tick(files.length);
async.map(files, pathAndDate, function (err, allFiles) {
var deleted = removeDeletedFiles(allFiles);
var toProcess = allFiles.filter(newer);
var count = toProcess.length;
var bar = progress.create('Update metadata', count);
if (count > 0) {
bar.tick(0);
async.mapLimit(toProcess, 100, function(fileInfo, next) {
bar.tick();
metadata(fileInfo, next);
}, function(err, update) {
update.forEach(function(fileInfo) {
existing[fileInfo.path] = _.omit(fileInfo, 'path');
});
writeToDisk();
callback(null, existing);
});
} else {
bar.tick(1);
if (deleted) writeToDisk();
callback(null, existing);
}
});
});
};

@ -12,7 +12,7 @@ exports.albums = function(collection, opts) {
});
var groups = {};
// put all files in the right albums
collection.files.forEach(function(file) {
collection.forEach(function(file) {
var groupName = moment(file.date).format(opts.grouping);
createAlbumHierarchy(groups, groupName);
groups[groupName].files.push(file);

@ -8,7 +8,7 @@ var Album = require('./album');
exports.albums = function(collection, opts) {
var albumsByFullPath = {};
// put all files in the right album
collection.files.forEach(function(file) {
collection.forEach(function(file) {
var fullDir = path.dirname(file.filepath);
createAlbumHierarchy(albumsByFullPath, fullDir);
albumsByFullPath[fullDir].files.push(file);

@ -1,9 +0,0 @@
var File = require('./file');
exports.fromMetadata = function(metadata) {
return {
files: Object.keys(metadata).map(function(filepath) {
return new File(filepath, metadata[filepath]);
})
}
};

@ -4,16 +4,17 @@ var pad = require('pad');
var async = require('async');
var progress = require('./progress');
exports.exec = function(input, output, metadata, options, callback) {
exports.exec = function(input, output, collection, options, callback) {
var message = pad(options.message, 20)
var paths = Object.keys(metadata).filter(extension(options.ext));
var tasks = paths.map(function(relativePath) {
// create {src, dest} tasks
var tasks = collection.filter(extension(options.ext)).map(file => {
return {
src: path.join(input, relativePath),
dest: path.join(output, transform(relativePath, options.dest)),
metadata: metadata[relativePath]
src: path.join(input, file.path),
dest: path.join(output, transform(file.path, options.dest)),
metadata: file
};
});
// only keep the ones where dest is out of date
var process = tasks.filter(function(task) {
try {
var destDate = fs.statSync(task.dest).mtime.getTime();
@ -22,6 +23,7 @@ exports.exec = function(input, output, metadata, options, callback) {
return true;
}
});
// run all in sequence
var bar = progress.create(options.message, process.length);
if (process.length > 0) {
var ops = process.map(function(task) {
@ -42,8 +44,8 @@ exports.exec = function(input, output, metadata, options, callback) {
}
function extension(regex) {
return function(p) {
return p.match(new RegExp('\.(' + regex + ')$', 'i'));
return function(file) {
return file.path.match(new RegExp('\.(' + regex + ')$', 'i'));
}
}

@ -1,12 +1,12 @@
var pad = require('pad');
var ProgressBar = require('progress');
var pad = require('pad')
var ProgressBar = require('progress')
exports.create = function(message, count) {
if (count > 0) {
var format = pad(message, 20) + '[:bar] :current/:total files';
return new ProgressBar(format, { total: count, width: 20 });
var format = pad(message, 20) + '[:bar] :current/:total files (:etas)'
return new ProgressBar(format, { total: count, width: 20 })
} else {
var format = pad(message, 20) + '[:bar] up to date';
return new ProgressBar(format, { total: 1, width: 20 });
var format = pad(message, 20) + '[:bar] up to date'
return new ProgressBar(format, { total: 1, width: 20, incomplete: '=' })
}
};
}

@ -16,7 +16,7 @@ describe('ByDate', function() {
var c_2016_07 = fixtures.photo({date: fixtures.date('2016-07-23')});
var d_2016_07 = fixtures.video({date: fixtures.date('2016-07-18')});
// group them per month
var collection = { files: [a_2016_06, b_2016_06, c_2016_07, d_2016_07] };
var collection = [a_2016_06, b_2016_06, c_2016_07, d_2016_07]
var albums = bydate.albums(collection, {
grouping: 'YYYY-MM'
});
@ -42,7 +42,7 @@ describe('ByDate', function() {
var c_2016_07 = fixtures.photo({date: fixtures.date('2016-07-23')});
var d_2016_08 = fixtures.video({date: fixtures.date('2016-08-18')});
// group them per year, and nested month
var collection = { files: [a_2015_06, b_2015_06, c_2016_07, d_2016_08] };
var collection = [a_2015_06, b_2015_06, c_2016_07, d_2016_08]
var albums = bydate.albums(collection, {
grouping: 'YYYY/MM'
});

@ -16,7 +16,7 @@ describe('ByFolder', function() {
var newyork1 = fixtures.photo({path: 'newyork/IMG_000003.jpg'});
var newyork2 = fixtures.video({path: 'newyork/IMG_000004.mp4'});
// group them per folder
var collection = {files: [london1, london2, newyork1, newyork2]};
var collection = [london1, london2, newyork1, newyork2]
var albums = byfolder.albums(collection, {});
// assert on the result
should(albums).eql([
@ -38,7 +38,7 @@ describe('ByFolder', function() {
var photo1 = fixtures.photo({path: 'a/b/c/IMG_000001.jpg'});
var photo2 = fixtures.photo({path: 'a/d/IMG_000002.jpg'});
// group them per folder
var collection = {files: [photo1, photo2]};
var collection = [photo1, photo2]
var albums = byfolder.albums(collection, {});
// assert on the result
should(albums).eql([

Loading…
Cancel
Save