gen-thumbs.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. #!/usr/bin/env node
  2. // Ok, so the d8te is 3 March 2021, and the music wiki was initially released
  3. // on 15 November 2019. That is 474 days or 11376 hours. In my opinion, and
  4. // pro8a8ly the opinions of at least one other person, that is WAY TOO LONG
  5. // to go without media thum8nails!!!! So that's what this file is here to do.
  6. //
  7. // This program takes a path to the media folder (via --media or the environ.
  8. // varia8le HSMUSIC_MEDIA), traverses su8directories to locate image files,
  9. // and gener8tes lower-resolution/file-size versions of all that are new or
  10. // have 8een modified since the last run. We use a JSON-format cache of MD5s
  11. // for each file to perform this comparision; we gener8te files (using ffmpeg)
  12. // in "medium" and "small" sizes adjacent to the existing PNG for easy and
  13. // versatile access in site gener8tion code.
  14. //
  15. // So for example, on the very first run, you might have a media folder which
  16. // looks something like this:
  17. //
  18. // media/
  19. // album-art/
  20. // one-year-older/
  21. // cover.jpg
  22. // firefly-cloud.jpg
  23. // october.jpg
  24. // ...
  25. // flash-art/
  26. // 413.jpg
  27. // ...
  28. // bg.jpg
  29. // ...
  30. //
  31. // After running gen-thumbs.js with the path to that folder passed, you'd end
  32. // up with something like this:
  33. //
  34. // media/
  35. // album-art/
  36. // one-year-older/
  37. // cover.jpg
  38. // cover.medium.jpg
  39. // cover.small.jpg
  40. // firefly-cloud.jpg
  41. // firefly-cloud.medium.jpg
  42. // firefly-cloud.small.jpg
  43. // october.jpg
  44. // october.medium.jpg
  45. // october.small.jpg
  46. // ...
  47. // flash-art/
  48. // 413.jpg
  49. // 413.medium.jpg
  50. // 413.small.jpg
  51. // ...
  52. // bg.jpg
  53. // bg.medium.jpg
  54. // bg.small.jpg
  55. // thumbs-cache.json
  56. // ...
  57. //
  58. // (Do note that while 8oth JPG and PNG are supported, gener8ted files will
  59. // always 8e in JPG format and file extension. GIFs are skipped since there
  60. // aren't any super gr8 ways to make those more efficient!)
  61. //
  62. // And then in gener8tion code, you'd reference the medium/small or original
  63. // version of each file, as decided is appropriate. Here are some guidelines:
  64. //
  65. // - Small: Grid tiles on the homepage and in galleries.
  66. // - Medium: Cover art on individual al8um and track pages, etc.
  67. // - Original: Only linked to, not embedded.
  68. //
  69. // The traversal code is indiscrimin8te: there are no special cases to, say,
  70. // not gener8te thum8nails for the bg.jpg file (since those would generally go
  71. // unused). This is just to make the code more porta8le and sta8le, long-term,
  72. // since it avoids a lot of otherwise implic8ted maintenance.
  73. 'use strict';
  74. const CACHE_FILE = 'thumbnail-cache.json';
  75. const WARNING_DELAY_TIME = 10000;
  76. const { spawn } = require('child_process');
  77. const crypto = require('crypto');
  78. const fsp = require('fs/promises'); // Whatcha know! Nice.
  79. const fs = require('fs'); // Still gotta include 8oth tho, for createReadStream.
  80. const path = require('path');
  81. const {
  82. delay,
  83. logError,
  84. logInfo,
  85. logWarn,
  86. parseOptions,
  87. progressPromiseAll,
  88. promisifyProcess,
  89. queue,
  90. } = require('./upd8-util');
  91. function traverse(startDirPath, {
  92. filterFile = () => true,
  93. filterDir = () => true
  94. } = {}) {
  95. const recursive = (names, subDirPath) => Promise
  96. .all(names.map(name => fsp.readdir(path.join(startDirPath, subDirPath, name)).then(
  97. names => filterDir(name) ? recursive(names, path.join(subDirPath, name)) : [],
  98. err => filterFile(name) ? [path.join(subDirPath, name)] : [])))
  99. .then(pathArrays => pathArrays.flatMap(x => x));
  100. return fsp.readdir(startDirPath)
  101. .then(names => recursive(names, ''));
  102. }
  103. function readFileMD5(filePath) {
  104. return new Promise((resolve, reject) => {
  105. const md5 = crypto.createHash('md5');
  106. const stream = fs.createReadStream(filePath);
  107. stream.on('data', data => md5.update(data));
  108. stream.on('end', data => resolve(md5.digest('hex')));
  109. stream.on('error', err => reject(err));
  110. });
  111. }
  112. function generateImageThumbnails(filePath) {
  113. const dirname = path.dirname(filePath);
  114. const extname = path.extname(filePath);
  115. const basename = path.basename(filePath, extname);
  116. const output = name => path.join(dirname, basename + name + '.jpg');
  117. const convert = (name, {size, quality}) => spawn('convert', [
  118. '-strip',
  119. '-resize', `${size}x${size}>`,
  120. '-interlace', 'Plane',
  121. '-quality', `${quality}%`,
  122. filePath,
  123. output(name)
  124. ]);
  125. return Promise.all([
  126. promisifyProcess(convert('.medium', {size: 400, quality: 95}), false),
  127. promisifyProcess(convert('.small', {size: 250, quality: 85}), false)
  128. ]);
  129. return new Promise((resolve, reject) => {
  130. if (Math.random() < 0.2) {
  131. reject(new Error(`Them's the 8r8ks, kiddo!`));
  132. } else {
  133. resolve();
  134. }
  135. });
  136. }
  137. async function genThumbs(mediaPath, {
  138. queueSize = 0,
  139. quiet = false
  140. } = {}) {
  141. if (!mediaPath) {
  142. throw new Error('Expected mediaPath to be passed');
  143. }
  144. const quietInfo = (quiet
  145. ? () => null
  146. : logInfo);
  147. const filterFile = name => {
  148. // TODO: Why is this not working????????
  149. // thumbnail-cache.json is 8eing passed through, for some reason.
  150. const ext = path.extname(name);
  151. if (ext !== '.jpg' && ext !== '.png') return false;
  152. const rest = path.basename(name, ext);
  153. if (rest.endsWith('.medium') || rest.endsWith('.small')) return false;
  154. return true;
  155. };
  156. const filterDir = name => {
  157. if (name === '.git') return false;
  158. return true;
  159. };
  160. let cache, firstRun = false, failedReadingCache = false;
  161. try {
  162. cache = JSON.parse(await fsp.readFile(path.join(mediaPath, CACHE_FILE)));
  163. quietInfo`Cache file successfully read.`;
  164. } catch (error) {
  165. cache = {};
  166. if (error.code === 'ENOENT') {
  167. firstRun = true;
  168. } else {
  169. failedReadingCache = true;
  170. logWarn`Malformed or unreadable cache file: ${error}`;
  171. logWarn`You may want to cancel and investigate this!`;
  172. logWarn`All-new thumbnails and cache will be generated for this run.`;
  173. await delay(WARNING_DELAY_TIME);
  174. }
  175. }
  176. try {
  177. await fsp.writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(cache));
  178. quietInfo`Writing to cache file appears to be working.`;
  179. } catch (error) {
  180. logWarn`Test of cache file writing failed: ${error}`;
  181. if (cache) {
  182. logWarn`Cache read succeeded: Any newly written thumbs will be unnecessarily regenerated on the next run.`;
  183. } else if (firstRun) {
  184. logWarn`No cache found: All thumbs will be generated now, and will be unnecessarily regenerated next run.`;
  185. } else {
  186. logWarn`Cache read failed: All thumbs will be regenerated now, and will be unnecessarily regenerated again next run.`;
  187. }
  188. logWarn`You may want to cancel and investigate this!`;
  189. await delay(WARNING_DELAY_TIME);
  190. }
  191. const imagePaths = await traverse(mediaPath, {filterFile, filterDir});
  192. const imageToMD5Entries = await progressPromiseAll(`Generating MD5s of image files`, queue(
  193. imagePaths.map(imagePath => () => readFileMD5(path.join(mediaPath, imagePath)).then(
  194. md5 => [imagePath, md5],
  195. error => [imagePath, {error}]
  196. )),
  197. queueSize
  198. ));
  199. {
  200. let error = false;
  201. for (const entry of imageToMD5Entries) {
  202. if (entry[1].error) {
  203. logError`Failed to read ${entry[0]}: ${entry[1].error}`;
  204. error = true;
  205. }
  206. }
  207. if (error) {
  208. logError`Failed to read at least one image file!`;
  209. logError`This implies a thumbnail probably won't be generatable.`;
  210. logError`So, exiting early.`;
  211. return false;
  212. } else {
  213. quietInfo`All image files successfully read.`;
  214. }
  215. }
  216. // Technically we could pro8a8ly mut8te the cache varia8le in-place?
  217. // 8ut that seems kinda iffy.
  218. const updatedCache = Object.assign({}, cache);
  219. const entriesToGenerate = imageToMD5Entries
  220. .filter(([filePath, md5]) => md5 !== cache[filePath]);
  221. if (entriesToGenerate.length === 0) {
  222. logInfo`All image thumbnails are already up-to-date - nice!`;
  223. return true;
  224. }
  225. const failed = [];
  226. const succeeded = [];
  227. const writeMessageFn = () => `Writing image thumbnails. [failed: ${failed.length}]`;
  228. // This is actually sort of a lie, 8ecause we aren't doing synchronicity.
  229. // (We pass queueSize = 1 to queue().) 8ut we still use progressPromiseAll,
  230. // 'cuz the progress indic8tor is very cool and good.
  231. await progressPromiseAll(writeMessageFn, queue(entriesToGenerate.map(([filePath, md5]) =>
  232. () => generateImageThumbnails(path.join(mediaPath, filePath)).then(
  233. () => {
  234. updatedCache[filePath] = md5;
  235. succeeded.push(filePath);
  236. },
  237. error => {
  238. failed.push([filePath, error]);
  239. }
  240. )
  241. )));
  242. if (failed.length > 0) {
  243. for (const [path, error] of failed) {
  244. logError`Thumbnails failed to generate for ${path} - ${error}`;
  245. }
  246. logWarn`Result is incomplete - the above ${failed.length} thumbnails should be checked for errors.`;
  247. logWarn`${succeeded.length} successfully generated images won't be regenerated next run, though!`;
  248. } else {
  249. logInfo`Generated all (updated) thumbnails successfully!`;
  250. }
  251. try {
  252. await fsp.writeFile(path.join(mediaPath, CACHE_FILE), JSON.stringify(updatedCache));
  253. quietInfo`Updated cache file successfully written!`;
  254. } catch (error) {
  255. logWarn`Failed to write updated cache file: ${error}`;
  256. logWarn`Any newly (re)generated thumbnails will be regenerated next run.`;
  257. logWarn`Sorry about that!`;
  258. }
  259. return true;
  260. };
  261. module.exports = genThumbs;
  262. if (require.main === module) {
  263. (async () => {
  264. const miscOptions = await parseOptions(process.argv.slice(2), {
  265. 'media': {
  266. type: 'value'
  267. },
  268. 'queue-size': {
  269. type: 'value',
  270. validate(size) {
  271. if (parseInt(size) !== parseFloat(size)) return 'an integer';
  272. if (parseInt(size) < 0) return 'a counting number or zero';
  273. return true;
  274. }
  275. },
  276. queue: {alias: 'queue-size'}
  277. });
  278. const mediaPath = miscOptions.media || process.env.HSMUSIC_MEDIA;
  279. if (!mediaPath) {
  280. logError`Expected --media option or HSMUSIC_MEDIA to be set`;
  281. }
  282. const queueSize = +(miscOptions['queue-size'] ?? 0);
  283. await genThumbs(mediaPath, {queueSize});
  284. })().catch(err => console.error(err));
  285. }