import 'dotenv/config'; import { createLog } from './log'; import type { Logger } from 'winston'; import { readFile, writeFile, readdir, realpath, rename } from 'fs/promises'; import { join, basename } from 'path'; import { promisify } from 'util'; import { v4 as uuid } from 'uuid'; import { randomBytes } from 'crypto'; import { tmpdir } from 'os'; import moment from 'moment'; import { ArgumentParser } from 'argparse'; import { Shell } from './shell'; import { Hashes } from './hash'; import { Files3 } from './files3' import { envString } from './env'; import { DB } from './db'; import type { Photo, LatLng } from './db'; import { Geocode } from './geocode'; const sizeOf = promisify(require('image-size')); interface Metadata { year? : number; month? : number; day? : number; format?: string; filmstock?: string; location? : string; description? : string; original?: string; } class Generate { private log : Logger; private files : string[]; private inbox : string = envString('INBOX', '~/Photos/toprocess'); private photos : string = envString('PHOTOS', '~/Photos/processed'); private artist : string = envString('ARTIST', 'Unknown'); private s3 : Files3; private db : DB; private geocode : Geocode; private tmp : string = tmpdir(); private score : number; constructor () { this.log = createLog('generate'); const parser = new ArgumentParser({ description: 'Generate script' }); parser.add_argument('-s', '--score', { type : 'int', default : 0, help: 'Starting score' }); const args : any = parser.parse_args(); this.log.info(`Generating site: ${new Date()}`); this.db = new DB(); this.s3 = new Files3(envString('S3_BUCKET', 's3bucket'), true); this.geocode = new Geocode(this.db); this.generate(); this.score = args.score; } private async generate () { let inbox : string; let images : string[]; let filename : string; let meta : Metadata; let photo : Photo; let exif : string; try { inbox = await realpath(this.inbox); } catch (err) { this.log.error(err); return; } try { images = await readdir(inbox); } catch (err) { this.log.error(err); return; } images = images.filter((el : string) => { if (el.toLowerCase().indexOf('.jpg') !== -1 || el.toLowerCase().indexOf('.jpeg') !== -1 || el.toLowerCase().indexOf('.tif') !== -1 || el.toLowerCase().indexOf('.tiff') !== -1) { return true; } return false; }); if (images.length === 0) { this.log.info(`No new images found`); return; } images = await Promise.all(images.map(async (el : any) : Promise => { return await realpath(join(inbox, el)); }) ); for (let image of images) { this.log.info(image); filename = basename(image); meta = this.parseFilename(filename); try { photo = await this.createPhoto(image, meta); } catch (err) { this.log.error(`Error creating photo record metadata`, err); continue; } if (await this.db.existsName(filename)) { this.log.info(`Image ${filename} already exists`); if (await this.db.existsHash(photo.hash)) { this.log.warn(`Image ${name} already exists, moving...`); await this.move(image); continue; } } if (await this.db.existsHash(photo.hash)) { this.log.warn(`Image exists under different name, update?`); await this.move(image); continue; } try { await this.db.create(photo); this.log.info(JSON.stringify(photo, null, '\t')); } catch (err) { this.log.error(`Error inserting photo into database`, err); continue; } try { await this.upload(image); } catch (err) { this.log.error(`Error uploading image`, err); continue; } try { exif = await this.exif(photo); } catch (err) { this.log.error(`Error building EXIF data`, err); } try { await this.img(image, photo.id, exif); } catch (err) { this.log.error(`Error running img.sh script`, err); } await this.move(image); } } //Artist string //ImageTitle string //ImageUniqueID string //ISO int16u[n] //DateTimeOriginal string (YYYY:MM:DD HH:MM:SS) // //GPSLatitude rational64u[3] //GPSLongitude private async exif (photo : Photo) : Promise { const filePath : string = await this.mktemp('photosite_exif'); const created : string = moment.unix(photo.created / 1000).format('YYYY:MM:DD HH:mm:ss'); let exif : string = `-Artist=${this.artist} -Title=${photo.description} -ImageUniqueId=${photo.id} -DateTimeOriginal=${created}` const iso : number[] = photo.filmstock.split(' ').filter(el => this.isOnlyNumbers(el)).map(el => parseInt(el)).filter(el => el > 25); if (iso.length > 0) { exif += ` -ISO=${iso}` } if (photo.latitude !== null && photo.longitude !== null) { exif += ` -GPSLatitude=${photo.latitude} -GPSLongitude=${photo.longitude}` } try { await writeFile(filePath, exif, 'utf8'); } catch (err) { this.log.error(`Error writing EXIF data`, err); } return filePath; } private async img (file : string, id : string, exif : string) { const cmd : string[] = ['bash', 'scripts/img.sh', file, id, exif]; const shell : Shell = new Shell(cmd); try { await shell.execute(); } catch (err) { this.log.error(err); return; } this.log.info(`Processed image file for ${file}`); } async getImageDimensions (imagePath: string): Promise<{ width: number, height: number }> { let dimensions : any; try { dimensions = await sizeOf(imagePath); return dimensions; } catch (err) { this.log.error('Error getting image dimensions:', err); } } private capitalize (str : string) : string { return (str.substring(0, 1).toUpperCase()) + str.substring(1); } private formatProperNouns (str : string) : string { let parts : string[] = str.split('-'); parts = parts.map(el => this.capitalize(el)); return parts.join(' '); } //year //month //day //format //filmstock //location //description //original //2024_12_02_35mm_Kodak-Gold-200_Somerville-MA_Walk-towards-Harvard-Square#000061280009.tif private parseFilename (filename : string) : Metadata { const halves : string[] = filename.split('#') const parts : string[] = halves[0].split('_'); let meta : Metadata = {}; for (let i = 0; i < parts.length; i++) { switch (i) { case 0 : meta.year = parseInt(parts[i]); break; case 1 : meta.month = parseInt(parts[i]); break; case 2 : meta.day = parseInt(parts[i]); break; case 3 : meta.format = parts[i]; break; case 4: meta.filmstock = parts[i].split('-').join(' '); break; case 5 : meta.location = parts[i].split('-').join(' '); break; case 6 : meta.description = parts[i].split('-').join(' '); break; } } meta.original = halves[1]; return meta; } private async createPhoto (image : string, meta : Metadata) : Promise { const hash : string = await Hashes.fileHash(image); const dimensions : any = await this.getImageDimensions(image); const now : number = Date.now(); const latlng : LatLng = await this.geocode.query(meta.location); return { id : uuid(), name : basename(image), description : meta.description, original: meta.original, hash, width : dimensions.width, height : dimensions.height, format : meta.format, filmstock : meta.filmstock, location : meta.location, latitude : latlng === null ? null : latlng.latitude, longitude : latlng === null ? null : latlng.longitude, discovered : now, updated : now, created : + new Date(meta.year, meta.month, meta.day), score : this.score } } private async upload (image: string) { const name : string = basename(image); return this.s3.createFromPath(image, name); } private async move (image : string) { const name : string = basename(image); const dest : string = join(this.photos, name); try { await rename(image, dest); this.log.info(`Moved image ${name} to outbox`); } catch (err) { this.log.error(`Error moving image`, err); } } async mktemp (prefix : string = 'tmp') : Promise { const uniqueId = randomBytes(16).toString('hex'); const tempFilePath = join(this.tmp, `${prefix}-${uniqueId}`); try { await writeFile(tempFilePath, '', { flag: 'wx' }); return tempFilePath; } catch (err) { if (err.code === 'EEXIST') { return this.mktemp(prefix); } else { throw err; } } } private isOnlyNumbers(str : string) : boolean { return /^[0-9]+$/.test(str); } } new Generate();