diff --git a/.gitignore b/.gitignore index 2f9ed1b76..6efad7e38 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ test.* *.sublime-workspace .DS_Store /coverage/ -.nyc_output/ \ No newline at end of file +.nyc_output/ +.vscode* \ No newline at end of file diff --git a/demo/images/dupl/big_ben_only_time.jpg b/demo/images/dupl/big_ben_only_time.jpg new file mode 100644 index 000000000..88d3bc35c Binary files /dev/null and b/demo/images/dupl/big_ben_only_time.jpg differ diff --git a/demo/images/dupl/sydney_opera_house.jpg b/demo/images/dupl/sydney_opera_house.jpg new file mode 100644 index 000000000..b595487ed Binary files /dev/null and b/demo/images/dupl/sydney_opera_house.jpg differ diff --git a/demo/images/timestamps/big_ben.jpg b/demo/images/timestamps/big_ben.jpg new file mode 100644 index 000000000..0649be533 Binary files /dev/null and b/demo/images/timestamps/big_ben.jpg differ diff --git a/demo/images/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg b/demo/images/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 000000000..48897cf14 Binary files /dev/null and b/demo/images/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg differ diff --git a/demo/images/timestamps/big_ben_only_time.jpg b/demo/images/timestamps/big_ben_only_time.jpg new file mode 100644 index 000000000..88d3bc35c Binary files /dev/null and b/demo/images/timestamps/big_ben_only_time.jpg differ diff --git a/demo/images/timestamps/newyear_london.jpg b/demo/images/timestamps/newyear_london.jpg new file mode 100644 index 000000000..5a2118537 Binary files /dev/null and b/demo/images/timestamps/newyear_london.jpg differ diff --git a/demo/images/timestamps/newyear_sydney.jpg b/demo/images/timestamps/newyear_sydney.jpg new file mode 100644 index 000000000..8576e37f5 Binary files /dev/null and b/demo/images/timestamps/newyear_sydney.jpg differ diff --git a/demo/images/timestamps/sydney_opera_house.jpg b/demo/images/timestamps/sydney_opera_house.jpg new file mode 100644 index 000000000..b595487ed Binary files /dev/null and b/demo/images/timestamps/sydney_opera_house.jpg differ diff --git a/demo/images/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg b/demo/images/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 000000000..184331398 Binary files /dev/null and b/demo/images/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg differ diff --git a/package-lock.json b/package-lock.json index db09d1724..e71ebb12d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,6 @@ "nodemailer": "6.9.4", "reflect-metadata": "0.1.13", "sharp": "0.31.3", - "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", "typeconfig": "2.1.2", "typeorm": "0.3.12", @@ -20052,12 +20051,6 @@ "node": ">=6.10" } }, - "node_modules/ts-exif-parser": { - "version": "0.2.2", - "dependencies": { - "sax": "1.2.4" - } - }, "node_modules/ts-helpers": { "version": "1.1.2", "dev": true, @@ -35085,12 +35078,6 @@ "dev": true, "optional": true }, - "ts-exif-parser": { - "version": "0.2.2", - "requires": { - "sax": "1.2.4" - } - }, "ts-helpers": { "version": "1.1.2", "dev": true, diff --git a/package.json b/package.json index f3dac2e6b..7219c2c0d 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "nodemailer": "6.9.4", "reflect-metadata": "0.1.13", "sharp": "0.31.3", - "ts-exif-parser": "0.2.2", "ts-node-iptc": "1.0.11", "typeconfig": "2.1.2", "typeorm": "0.3.12", diff --git a/src/backend/model/database/SearchManager.ts b/src/backend/model/database/SearchManager.ts index 609dc776c..3037ddfe2 100644 --- a/src/backend/model/database/SearchManager.ts +++ b/src/backend/model/database/SearchManager.ts @@ -364,7 +364,7 @@ export class SearchManager { for (const sort of sortings) { switch (sort.method) { case SortByTypes.Date: - query.addOrderBy('media.metadata.creationDate', sort.ascending ? 'ASC' : 'DESC'); + query.addOrderBy('media.metadata.creationDate', sort.ascending ? 'ASC' : 'DESC'); //If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). If taken into account, it will alter the sort order. Probably should not be done. break; case SortByTypes.Rating: query.addOrderBy('media.metadata.rating', sort.ascending ? 'ASC' : 'DESC'); @@ -563,7 +563,12 @@ export class SearchManager { const textParam: { [key: string]: unknown } = {}; textParam['from' + queryId] = (query as FromDateSearch).value; q.where( - `media.metadata.creationDate ${relation} :from${queryId}`, + `media.metadata.creationDate ${relation} :from${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). + //Example: -600 means in the database UTC-10:00. The time 20:00 in the evening in the UTC-10 timezone, is actually 06:00 the next morning + //in UTC+00:00. To make search take that into account, one can subtract the offset from the creationDate to "pretend" the photo is taken + //in UTC time. Subtracting -600 minutes (because it's the -10:00 timezone), corresponds to adding 10 hours to the photo's timestamp, thus + //bringing it into the next day as if it was taken at UTC+00:00. Similarly subtracting a positive timezone from a timestamp will "pretend" + //the photo is taken earlier in time (e.g. subtracting 300 from the UTC+05:00 timezone). textParam ); @@ -585,8 +590,8 @@ export class SearchManager { const textParam: { [key: string]: unknown } = {}; textParam['to' + queryId] = (query as ToDateSearch).value; q.where( - `media.metadata.creationDate ${relation} :to${queryId}`, - textParam + `media.metadata.creationDate ${relation} :to${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. + textParam ); return q; @@ -790,15 +795,15 @@ export class SearchManager { if (tq.negate) { q.where( - `media.metadata.creationDate >= :to${queryId}`, + `media.metadata.creationDate >= :to${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam - ).orWhere(`media.metadata.creationDate < :from${queryId}`, + ).orWhere(`media.metadata.creationDate < :from${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam); } else { q.where( - `media.metadata.creationDate < :to${queryId}`, + `media.metadata.creationDate < :to${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam - ).andWhere(`media.metadata.creationDate >= :from${queryId}`, + ).andWhere(`media.metadata.creationDate >= :from${queryId}`, //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. textParam); } @@ -821,10 +826,12 @@ export class SearchManager { if (Config.Database.type === DatabaseType.sqlite) { if (tq.daysLength == 0) { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationEql} CAST(strftime('${duration}','now') AS INTEGER)` ); } else { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationTop} CAST(strftime('${duration}','now') AS INTEGER)` )[whereFN](`CAST(strftime('${duration}',media.metadataCreationDate/1000, 'unixepoch') AS INTEGER) ${relationBottom} CAST(strftime('${duration}','now','-:diff${queryId} day') AS INTEGER)`, textParam); @@ -832,10 +839,12 @@ export class SearchManager { } else { if (tq.daysLength == 0) { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationEql} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)` ); } else { q.where( + //TODO: If media.metadata.creationDateOffset is defined, it is an offset of minutes (+/-). See explanation above. `CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationTop} CAST(DATE_FORMAT(CURDATE(),'${duration}') AS SIGNED)` )[whereFN](`CAST(FROM_UNIXTIME(media.metadataCreationDate/1000, '${duration}') AS SIGNED) ${relationBottom} CAST(DATE_FORMAT((DATE_ADD(curdate(), INTERVAL -:diff${queryId} DAY)),'${duration}') AS SIGNED)`, textParam); diff --git a/src/backend/model/database/enitites/MediaEntity.ts b/src/backend/model/database/enitites/MediaEntity.ts index 52bbf6006..5eb172242 100644 --- a/src/backend/model/database/enitites/MediaEntity.ts +++ b/src/backend/model/database/enitites/MediaEntity.ts @@ -4,6 +4,7 @@ import {MediaDimension, MediaDTO, MediaMetadata,} from '../../../../common/entit import {PersonJunctionTable} from './PersonJunctionTable'; import {columnCharsetCS} from './EntityUtils'; import {CameraMetadata, FaceRegion, GPSMetadata, PositionMetaData,} from '../../../../common/entities/PhotoDTO'; +import { Utils } from '../../../../common/Utils'; export class MediaDimensionEntity implements MediaDimension { @Column('int') @@ -105,6 +106,15 @@ export class MediaMetadataEntity implements MediaMetadata { }) @Index() creationDate: number; + + @Column('smallint', { + transformer: { + from: (v) => Utils.getOffsetString(v), //from database repr. as smallint (minutes) to string (+/-HH:MM) + to: (v) => Utils.getOffsetMinutes(v), //from entiry repr. as string (+/-HH:MM) to smallint (minutes) + }, + }) + creationDateOffset?: string; + @Column('int', {unsigned: true}) fileSize: number; diff --git a/src/backend/model/fileaccess/MetadataLoader.ts b/src/backend/model/fileaccess/MetadataLoader.ts index 8ded9e1d7..908e01163 100644 --- a/src/backend/model/fileaccess/MetadataLoader.ts +++ b/src/backend/model/fileaccess/MetadataLoader.ts @@ -1,570 +1,658 @@ -import * as fs from 'fs'; -import { imageSize } from 'image-size'; -import { Config } from '../../../common/config/private/Config'; -import { SideCar } from '../../../common/entities/MediaDTO'; -import { FaceRegion, PhotoMetadata } from '../../../common/entities/PhotoDTO'; -import { VideoMetadata } from '../../../common/entities/VideoDTO'; -import { Logger } from '../../Logger'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import * as exifr from 'exifr'; -import { FfprobeData } from 'fluent-ffmpeg'; -import { FileHandle } from 'fs/promises'; -import * as util from 'node:util'; -import * as path from 'path'; -import { ExifParserFactory, OrientationTypes } from 'ts-exif-parser'; -import { IptcParser } from 'ts-node-iptc'; -import { Utils } from '../../../common/Utils'; -import { FFmpegFactory } from '../FFmpegFactory'; -import { ExtensionDecorator } from '../extension/ExtensionDecorator'; - -const LOG_TAG = '[MetadataLoader]'; -const ffmpeg = FFmpegFactory.get(); - -export class MetadataLoader { - - @ExtensionDecorator(e => e.gallery.MetadataLoader.loadVideoMetadata) - public static async loadVideoMetadata(fullPath: string): Promise { - const metadata: VideoMetadata = { - size: { - width: 1, - height: 1, - }, - bitRate: 0, - duration: 0, - creationDate: 0, - fileSize: 0, - fps: 0, - }; - - try { - const stat = fs.statSync(fullPath); - metadata.fileSize = stat.size; - metadata.creationDate = stat.mtime.getTime(); - } catch (err) { - console.log(err); - // ignoring errors - } - try { - - - const data: FfprobeData = await util.promisify( - // wrap to arrow function otherwise 'this' is lost for ffprobe - (cb) => ffmpeg(fullPath).ffprobe(cb) - )(); - - try { - for (const stream of data.streams) { - if (stream.width) { - metadata.size.width = stream.width; - metadata.size.height = stream.height; - - if ( - Utils.isInt32(parseInt('' + stream.rotation, 10)) && - (Math.abs(parseInt('' + stream.rotation, 10)) / 90) % 2 === 1 - ) { - // noinspection JSSuspiciousNameCombination - metadata.size.width = stream.height; - // noinspection JSSuspiciousNameCombination - metadata.size.height = stream.width; - } - - if ( - Utils.isInt32(Math.floor(parseFloat(stream.duration) * 1000)) - ) { - metadata.duration = Math.floor( - parseFloat(stream.duration) * 1000 - ); - } - - if (Utils.isInt32(parseInt(stream.bit_rate, 10))) { - metadata.bitRate = parseInt(stream.bit_rate, 10) || null; - } - if (Utils.isInt32(parseInt(stream.avg_frame_rate, 10))) { - metadata.fps = parseInt(stream.avg_frame_rate, 10) || null; - } - metadata.creationDate = - Date.parse(stream.tags.creation_time) || - metadata.creationDate; - break; - } - } - - // For some filetypes (for instance Matroska), bitrate and duration are stored in - // the format section, not in the stream section. - - // Only use duration from container header if necessary (stream duration is usually more accurate) - if ( - metadata.duration === 0 && - data.format.duration !== undefined && - Utils.isInt32(Math.floor(data.format.duration * 1000)) - ) { - metadata.duration = Math.floor(data.format.duration * 1000); - } - - // Prefer bitrate from container header (includes video and audio) - if ( - data.format.bit_rate !== undefined && - Utils.isInt32(data.format.bit_rate) - ) { - metadata.bitRate = data.format.bit_rate; - } - - if ( - data.format.tags !== undefined && - typeof data.format.tags.creation_time === 'string' - ) { - metadata.creationDate = - Date.parse(data.format.tags.creation_time) || - metadata.creationDate; - } - - // eslint-disable-next-line no-empty - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); - Logger.silly(err); - } - metadata.creationDate = metadata.creationDate || 0; - - try { - // search for sidecar and merge metadata - const fullPathWithoutExt = path.parse(fullPath).name; - const sidecarPaths = [ - fullPath + '.xmp', - fullPath + '.XMP', - fullPathWithoutExt + '.xmp', - fullPathWithoutExt + '.XMP', - ]; - - for (const sidecarPath of sidecarPaths) { - if (fs.existsSync(sidecarPath)) { - const sidecarData = await exifr.sidecar(sidecarPath); - if (sidecarData !== undefined) { - if ((sidecarData as SideCar).dc.subject !== undefined) { - if (metadata.keywords === undefined) { - metadata.keywords = []; - } - for (const kw of (sidecarData as SideCar).dc.subject) { - if (metadata.keywords.indexOf(kw) === -1) { - metadata.keywords.push(kw); - } - } } - if ((sidecarData as SideCar).xmp.Rating !== undefined) { - metadata.rating = (sidecarData as SideCar).xmp.Rating; - } - } - } - } - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); - Logger.silly(err); - } - - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); - Logger.silly(err); - } - return metadata; - } - - private static readonly EMPTY_METADATA: PhotoMetadata = { - size: {width: 1, height: 1}, - creationDate: 0, - fileSize: 0, - }; - - @ExtensionDecorator(e => e.gallery.MetadataLoader.loadPhotoMetadata) - public static async loadPhotoMetadata(fullPath: string): Promise { - let fileHandle: FileHandle; - const metadata: PhotoMetadata = { - size: {width: 1, height: 1}, - creationDate: 0, - fileSize: 0, - }; - try { - const data = Buffer.allocUnsafe(Config.Media.photoMetadataSize); - fileHandle = await fs.promises.open(fullPath, 'r'); - try { - await fileHandle.read(data, 0, Config.Media.photoMetadataSize, 0); - } catch (err) { - Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); - console.error(err); - return MetadataLoader.EMPTY_METADATA; - } finally { - await fileHandle.close(); - } - - try { - try { - const stat = fs.statSync(fullPath); - metadata.fileSize = stat.size; - metadata.creationDate = stat.mtime.getTime(); - } catch (err) { - // ignoring errors - } - - try { - const exif = ExifParserFactory.create(data).parse(); - if ( - exif.tags.ISO || - exif.tags.Model || - exif.tags.Make || - exif.tags.FNumber || - exif.tags.ExposureTime || - exif.tags.FocalLength || - exif.tags.LensModel - ) { - if (exif.tags.Model && exif.tags.Model !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.model = '' + exif.tags.Model; - } - if (exif.tags.Make && exif.tags.Make !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.make = '' + exif.tags.Make; - } - if (exif.tags.LensModel && exif.tags.LensModel !== '') { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.lens = '' + exif.tags.LensModel; - } - if (Utils.isUInt32(exif.tags.ISO)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.ISO = parseInt('' + exif.tags.ISO, 10); - } - if (Utils.isFloat32(exif.tags.FocalLength)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.focalLength = parseFloat( - '' + exif.tags.FocalLength - ); - } - if (Utils.isFloat32(exif.tags.ExposureTime)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.exposure = parseFloat( - parseFloat('' + exif.tags.ExposureTime).toFixed(6) - ); - } - if (Utils.isFloat32(exif.tags.FNumber)) { - metadata.cameraData = metadata.cameraData || {}; - metadata.cameraData.fStop = parseFloat( - parseFloat('' + exif.tags.FNumber).toFixed(2) - ); - } - } - if ( - !isNaN(exif.tags.GPSLatitude) || - exif.tags.GPSLongitude || - exif.tags.GPSAltitude - ) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.GPSData = {}; - - if (Utils.isFloat32(exif.tags.GPSLongitude)) { - metadata.positionData.GPSData.longitude = parseFloat( - exif.tags.GPSLongitude.toFixed(6) - ); - } - if (Utils.isFloat32(exif.tags.GPSLatitude)) { - metadata.positionData.GPSData.latitude = parseFloat( - exif.tags.GPSLatitude.toFixed(6) - ); - } - } - if ( - exif.tags.CreateDate || - exif.tags.DateTimeOriginal || - exif.tags.ModifyDate - ) { - metadata.creationDate = - (exif.tags.DateTimeOriginal || - exif.tags.CreateDate || - exif.tags.ModifyDate) * 1000; - } - if (exif.imageSize) { - metadata.size = { - width: exif.imageSize.width, - height: exif.imageSize.height, - }; - } else if ( - exif.tags.RelatedImageWidth && - exif.tags.RelatedImageHeight - ) { - metadata.size = { - width: exif.tags.RelatedImageWidth, - height: exif.tags.RelatedImageHeight, - }; - } else if ( - exif.tags.ImageWidth && - exif.tags.ImageHeight - ) { - metadata.size = { - width: exif.tags.ImageWidth, - height: exif.tags.ImageHeight, - }; - } else { - const info = imageSize(fullPath); - metadata.size = {width: info.width, height: info.height}; - } - } catch (err) { - Logger.debug(LOG_TAG, 'Error parsing exif', fullPath, err); - try { - const info = imageSize(fullPath); - metadata.size = {width: info.width, height: info.height}; - } catch (e) { - metadata.size = {width: 1, height: 1}; - } - } - - try { - const iptcData = IptcParser.parse(data); - if (iptcData.country_or_primary_location_name) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.country = - iptcData.country_or_primary_location_name - .replace(/\0/g, '') - .trim(); - } - if (iptcData.province_or_state) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.state = iptcData.province_or_state - .replace(/\0/g, '') - .trim(); - } - if (iptcData.city) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.city = iptcData.city - .replace(/\0/g, '') - .trim(); - } - if (iptcData.object_name) { - metadata.title = iptcData.object_name.replace(/\0/g, '').trim(); - } - if (iptcData.caption) { - metadata.caption = iptcData.caption.replace(/\0/g, '').trim(); - } - if (Array.isArray(iptcData.keywords)) { - metadata.keywords = iptcData.keywords; - } - - if (iptcData.date_time) { - metadata.creationDate = iptcData.date_time.getTime(); - } - } catch (err) { - // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); - } - - if (!metadata.creationDate) { - // creationDate can be negative, when it was created before epoch (1970) - metadata.creationDate = 0; - } - - try { - const exifrOptions = { - tiff: true, - xmp: true, - icc: false, - jfif: false, //not needed and not supported for png - ihdr: true, - iptc: false, //exifr reads UTF8-encoded data wrongly - exif: true, - gps: true, - translateValues: false, //don't translate orientation from numbers to strings etc. - mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged - }; - - const exif = await exifr.parse(data, exifrOptions); - if (exif.xmp && exif.xmp.Rating) { - metadata.rating = exif.xmp.Rating; - if (metadata.rating < 0) { - metadata.rating = 0; - } - } - if (exif.dc && - exif.dc.subject && - exif.dc.subject.length > 0) { - const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject]; - if (metadata.keywords === undefined) { - metadata.keywords = []; - } - for (const kw of subj) { - if (metadata.keywords.indexOf(kw) === -1) { - metadata.keywords.push(kw); - } - } - } - let orientation = OrientationTypes.TOP_LEFT; - if (exif.ifd0 && - exif.ifd0.Orientation) { - orientation = parseInt( - exif.ifd0.Orientation as any, - 10 - ) as number; - } - if (OrientationTypes.BOTTOM_LEFT < orientation) { - // noinspection JSSuspiciousNameCombination - const height = metadata.size.width; - // noinspection JSSuspiciousNameCombination - metadata.size.width = metadata.size.height; - metadata.size.height = height; - } - - if (Config.Faces.enabled && - exif["mwg-rs"] && - exif["mwg-rs"].Regions) { - const faces: FaceRegion[] = []; - const regionListVal = Array.isArray(exif["mwg-rs"].Regions.RegionList) ? exif["mwg-rs"].Regions.RegionList : [exif["mwg-rs"].Regions.RegionList]; - if (regionListVal) { - for (const regionRoot of regionListVal) { - let type; - let name; - let box; - const createFaceBox = ( - w: string, - h: string, - x: string, - y: string - ) => { - if (OrientationTypes.BOTTOM_LEFT < orientation) { - [x, y] = [y, x]; - [w, h] = [h, w]; - } - let swapX = 0; - let swapY = 0; - switch (orientation) { - case OrientationTypes.TOP_RIGHT: - case OrientationTypes.RIGHT_TOP: - swapX = 1; - break; - case OrientationTypes.BOTTOM_RIGHT: - case OrientationTypes.RIGHT_BOTTOM: - swapX = 1; - swapY = 1; - break; - case OrientationTypes.BOTTOM_LEFT: - case OrientationTypes.LEFT_BOTTOM: - swapY = 1; - break; - } - // converting ratio to px - return { - width: Math.round(parseFloat(w) * metadata.size.width), - height: Math.round(parseFloat(h) * metadata.size.height), - left: Math.round(Math.abs(parseFloat(x) - swapX) * metadata.size.width), - top: Math.round(Math.abs(parseFloat(y) - swapY) * metadata.size.height), - }; - }; - - /* Adobe Lightroom based face region structure */ - if ( - regionRoot && - regionRoot['rdf:Description'] && - regionRoot['rdf:Description'] && - regionRoot['rdf:Description']['mwg-rs:Area'] - ) { - const region = regionRoot['rdf:Description']; - const regionBox = region['mwg-rs:Area'].attributes; - - name = region['mwg-rs:Name']; - type = region['mwg-rs:Type']; - box = createFaceBox( - regionBox['stArea:w'], - regionBox['stArea:h'], - regionBox['stArea:x'], - regionBox['stArea:y'] - ); - /* Load exiftool edited face region structure, see github issue #191 */ - } else if ( - regionRoot && - regionRoot.Name && - regionRoot.Type && - regionRoot.Area - ) { - const regionBox = regionRoot.Area; - name = regionRoot.Name; - type = regionRoot.Type; - box = createFaceBox( - regionBox.w, - regionBox.h, - regionBox.x, - regionBox.y - ); - } - - if (type !== 'Face' || !name) { - continue; - } - - // convert center base box to corner based box - box.left = Math.round(Math.max(0, box.left - box.width / 2)); - box.top = Math.round(Math.max(0, box.top - box.height / 2)); - - - faces.push({name, box}); - } - } - if (faces.length > 0) { - metadata.faces = faces; // save faces - if (Config.Faces.keywordsToPersons) { - // remove faces from keywords - metadata.faces.forEach((f) => { - const index = metadata.keywords.indexOf(f.name); - if (index !== -1) { - metadata.keywords.splice(index, 1); - } - }); - } - } - } - } catch (err) { - // ignoring errors - } - - try { - // search for sidecar and merge metadata - const fullPathWithoutExt = path.parse(fullPath).name; - const sidecarPaths = [ - fullPath + '.xmp', - fullPath + '.XMP', - fullPathWithoutExt + '.xmp', - fullPathWithoutExt + '.XMP', - ]; - - for (const sidecarPath of sidecarPaths) { - if (fs.existsSync(sidecarPath)) { - const sidecarData = await exifr.sidecar(sidecarPath); - - if (sidecarData !== undefined) { - if ((sidecarData as SideCar).dc.subject !== undefined) { - if (metadata.keywords === undefined) { - metadata.keywords = []; - } - for (const kw of (sidecarData as SideCar).dc.subject) { - if (metadata.keywords.indexOf(kw) === -1) { - metadata.keywords.push(kw); - } - } - } - if ((sidecarData as SideCar).xmp.Rating !== undefined) { - metadata.rating = (sidecarData as SideCar).xmp.Rating; - } - } - } - } - } catch (err) { - Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); - Logger.silly(err); - } - - } catch (err) { - Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); - console.error(err); - return MetadataLoader.EMPTY_METADATA; - } - } catch (err) { - Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); - console.error(err); - return MetadataLoader.EMPTY_METADATA; - } - return metadata; - - - } -} +import * as fs from 'fs'; +import { imageSize } from 'image-size'; +import { Config } from '../../../common/config/private/Config'; +import { SideCar } from '../../../common/entities/MediaDTO'; +import { FaceRegion, PhotoMetadata } from '../../../common/entities/PhotoDTO'; +import { VideoMetadata } from '../../../common/entities/VideoDTO'; +import { Logger } from '../../Logger'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import * as exifr from 'exifr'; +import { FfprobeData } from 'fluent-ffmpeg'; +import { FileHandle } from 'fs/promises'; +import * as util from 'node:util'; +import * as path from 'path'; +import { IptcParser } from 'ts-node-iptc'; +import { Utils } from '../../../common/Utils'; +import { FFmpegFactory } from '../FFmpegFactory'; +import { ExtensionDecorator } from '../extension/ExtensionDecorator'; + +const LOG_TAG = '[MetadataLoader]'; +const ffmpeg = FFmpegFactory.get(); + +export class MetadataLoader { + + @ExtensionDecorator(e => e.gallery.MetadataLoader.loadVideoMetadata) + public static async loadVideoMetadata(fullPath: string): Promise { + const metadata: VideoMetadata = { + size: { + width: 1, + height: 1, + }, + bitRate: 0, + duration: 0, + creationDate: 0, + fileSize: 0, + fps: 0, + }; + + try { + const stat = fs.statSync(fullPath); + metadata.fileSize = stat.size; + metadata.creationDate = stat.mtime.getTime(); //Default date is file system time of last modification + } catch (err) { + console.log(err); + // ignoring errors + } + try { + + + const data: FfprobeData = await util.promisify( + // wrap to arrow function otherwise 'this' is lost for ffprobe + (cb) => ffmpeg(fullPath).ffprobe(cb) + )(); + + try { + for (const stream of data.streams) { + if (stream.width) { + metadata.size.width = stream.width; + metadata.size.height = stream.height; + + if ( + Utils.isInt32(parseInt('' + stream.rotation, 10)) && + (Math.abs(parseInt('' + stream.rotation, 10)) / 90) % 2 === 1 + ) { + // noinspection JSSuspiciousNameCombination + metadata.size.width = stream.height; + // noinspection JSSuspiciousNameCombination + metadata.size.height = stream.width; + } + + if ( + Utils.isInt32(Math.floor(parseFloat(stream.duration) * 1000)) + ) { + metadata.duration = Math.floor( + parseFloat(stream.duration) * 1000 + ); + } + + if (Utils.isInt32(parseInt(stream.bit_rate, 10))) { + metadata.bitRate = parseInt(stream.bit_rate, 10) || null; + } + if (Utils.isInt32(parseInt(stream.avg_frame_rate, 10))) { + metadata.fps = parseInt(stream.avg_frame_rate, 10) || null; + } + metadata.creationDate = + Date.parse(stream.tags.creation_time) || + metadata.creationDate; + break; + } + } + + // For some filetypes (for instance Matroska), bitrate and duration are stored in + // the format section, not in the stream section. + + // Only use duration from container header if necessary (stream duration is usually more accurate) + if ( + metadata.duration === 0 && + data.format.duration !== undefined && + Utils.isInt32(Math.floor(data.format.duration * 1000)) + ) { + metadata.duration = Math.floor(data.format.duration * 1000); + } + + // Prefer bitrate from container header (includes video and audio) + if ( + data.format.bit_rate !== undefined && + Utils.isInt32(data.format.bit_rate) + ) { + metadata.bitRate = data.format.bit_rate; + } + + if ( + data.format.tags !== undefined && + typeof data.format.tags.creation_time === 'string' + ) { + metadata.creationDate = + Date.parse(data.format.tags.creation_time) || + metadata.creationDate; + } + + // eslint-disable-next-line no-empty + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); + Logger.silly(err); + } + metadata.creationDate = metadata.creationDate || 0; + + try { + // search for sidecar and merge metadata + const fullPathWithoutExt = path.parse(fullPath).name; + const sidecarPaths = [ + fullPath + '.xmp', + fullPath + '.XMP', + fullPathWithoutExt + '.xmp', + fullPathWithoutExt + '.XMP', + ]; + + for (const sidecarPath of sidecarPaths) { + if (fs.existsSync(sidecarPath)) { + const sidecarData = await exifr.sidecar(sidecarPath); + if (sidecarData !== undefined) { + if ((sidecarData as SideCar).dc.subject !== undefined) { + if (metadata.keywords === undefined) { + metadata.keywords = []; + } + for (const kw of (sidecarData as SideCar).dc.subject) { + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } + } + } + if ((sidecarData as SideCar).xmp.Rating !== undefined) { + metadata.rating = (sidecarData as SideCar).xmp.Rating; + } + } + } + } + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); + Logger.silly(err); + } + + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading metadata for : ' + fullPath); + Logger.silly(err); + } + return metadata; + } + + private static readonly EMPTY_METADATA: PhotoMetadata = { + size: { width: 0, height: 0 }, + creationDate: 0, + fileSize: 0, + }; + + @ExtensionDecorator(e => e.gallery.MetadataLoader.loadPhotoMetadata) + public static async loadPhotoMetadata(fullPath: string): Promise { + let fileHandle: FileHandle; + const metadata: PhotoMetadata = { + size: { width: 0, height: 0 }, + creationDate: 0, + fileSize: 0, + }; + const exifrOptions = { + tiff: true, + xmp: true, + icc: false, + jfif: false, //not needed and not supported for png + ihdr: true, + iptc: false, //exifr reads UTF8-encoded data wrongly, using IptcParser instead + exif: true, + gps: true, + reviveValues: false, //don't convert timestamps + translateValues: false, //don't translate orientation from numbers to strings etc. + mergeOutput: false //don't merge output, because things like Microsoft Rating (percent) and xmp.rating will be merged + }; + + //function to convert timestamp into milliseconds taking offset into account + const timestampToMS = (timestamp: string, offset: string) => { + if (!timestamp) { + return undefined; + } + //replace : with - in the yyyy-mm-dd part of the timestamp. + let formattedTimestamp = timestamp.substring(0,9).replaceAll(':', '-') + timestamp.substring(9,timestamp.length); + if (formattedTimestamp.indexOf("Z") > 0) { //replace Z (and what comes after the Z) with offset + formattedTimestamp.substring(0, formattedTimestamp.indexOf("Z")) + (offset ? offset : '+00:00'); + } else if (formattedTimestamp.indexOf("+") > 0) { //don't do anything + } else { //add offset + formattedTimestamp = formattedTimestamp + (offset ? offset : '+00:00'); + } + //parse into MS and return + return Date.parse(formattedTimestamp); + } + + //function to calculate offset from exif.exif.gpsTimeStamp or exif.gps.GPSDateStamp + exif.gps.GPSTimestamp + const getTimeOffsetByGPSStamp = (timestamp: string, gpsTimeStamp: string, gps: any) => { + let UTCTimestamp = gpsTimeStamp; + if (!UTCTimestamp && + gps && + gps.GPSDateStamp && + gps.GPSTimeStamp) { //else use exif.gps.GPS*Stamp if available + //GPS timestamp is always UTC (+00:00) + UTCTimestamp = gps.GPSDateStamp.replaceAll(':', '-') + gps.GPSTimeStamp.join(':'); + } + if (UTCTimestamp && timestamp) { + //offset in minutes is the difference between gps timestamp and given timestamp + //to calculate this correctly, we have to work with the same offset + const offsetMinutes = (timestampToMS(timestamp, '+00:00')- timestampToMS(UTCTimestamp, '+00:00')) / 1000 / 60; + return Utils.getOffsetString(offsetMinutes); + } else { + return undefined; + } + } + + //Function to convert html code for special characters into their corresponding character (used in exif.photoshop-section) + const unescape = (tag: string) => { + return tag.replace(/&#([0-9]{1,3});/gi, function (match, numStr) { + return String.fromCharCode(parseInt(numStr, 10)); + }); + } + + try { + const data = Buffer.allocUnsafe(Config.Media.photoMetadataSize); + fileHandle = await fs.promises.open(fullPath, 'r'); + try { + await fileHandle.read(data, 0, Config.Media.photoMetadataSize, 0); + } catch (err) { + Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); + console.error(err); + return MetadataLoader.EMPTY_METADATA; + } finally { + await fileHandle.close(); + } + try { + try { + const stat = fs.statSync(fullPath); + metadata.fileSize = stat.size; + metadata.creationDate = stat.mtime.getTime(); + } catch (err) { + // ignoring errors + } + try { + //read the actual image size, don't rely on tags for this + const info = imageSize(fullPath); + metadata.size = { width: info.width, height: info.height }; + } catch (e) { + //in case of failure, set dimensions to 0 so they may be read via tags + metadata.size = { width: 0, height: 0 }; + } + + + try { //Parse iptc data using the IptcParser, which works correctly for both UTF-8 and ASCII + const iptcData = IptcParser.parse(data); + if (iptcData.country_or_primary_location_name) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.country = + iptcData.country_or_primary_location_name + .replace(/\0/g, '') + .trim(); + } + if (iptcData.province_or_state) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.state = iptcData.province_or_state + .replace(/\0/g, '') + .trim(); + } + if (iptcData.city) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.city = iptcData.city + .replace(/\0/g, '') + .trim(); + } + if (iptcData.object_name) { + metadata.title = iptcData.object_name.replace(/\0/g, '').trim(); + } + if (iptcData.caption) { + metadata.caption = iptcData.caption.replace(/\0/g, '').trim(); + } + if (Array.isArray(iptcData.keywords)) { + metadata.keywords = iptcData.keywords; + } + + if (iptcData.date_time) { + metadata.creationDate = iptcData.date_time.getTime(); + } + } catch (err) { + // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); + } + + try { + let orientation = 1; //Orientation 1 is normal + const exif = await exifr.parse(data, exifrOptions); + //exif is structured in sections, we read the data by section + + //dc-section (subject is the only tag we want from dc) + if (exif.dc && + exif.dc.subject && + exif.dc.subject.length > 0) { + const subj = Array.isArray(exif.dc.subject) ? exif.dc.subject : [exif.dc.subject]; + if (metadata.keywords === undefined) { + metadata.keywords = []; + } + for (const kw of subj) { + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } + } + } + + //ifd0 section + if (exif.ifd0) { + if (exif.ifd0.ImageWidth && metadata.size.width <= 0) { + metadata.size.width = exif.ifd0.ImageWidth; + } + if (exif.ifd0.ImageHeight && metadata.size.height <= 0) { + metadata.size.height = exif.ifd0.ImageHeight; + } + if (exif.ifd0.Orientation) { + orientation = parseInt( + exif.ifd0.Orientation as any, + 10 + ) as number; + } + if (exif.ifd0.Make && exif.ifd0.Make !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.make = '' + exif.ifd0.Make; + } + if (exif.ifd0.Model && exif.ifd0.Model !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.model = '' + exif.ifd0.Model; + } + //if (exif.ifd0.ModifyDate) {} //Deferred to the exif-section where the other timestamps are + } + + //exif section starting with the date sectino + if (exif.exif) { + //Preceedence of dates: exif.DateTimeOriginal, exif.CreateDate, ifd0.ModifyDate, ihdr["Creation Time"], xmp.MetadataDate, file system date + //Filesystem is the absolute last resort, and it's hard to write tests for, since file system dates are changed on e.g. git clone. + if (exif.exif.DateTimeOriginal) { + //DateTimeOriginal is when the camera shutter closed + if (exif.exif.OffsetTimeOriginal) { //OffsetTimeOriginal is the corresponding offset + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, exif.exif.OffsetTimeOriginal); + metadata.creationDateOffset = exif.exif.OffsetTimeOriginal; + } else { + const alt_offset = exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.exif.CreateDate) { //using else if here, because DateTimeOriginal has preceedence + //Create is when the camera wrote the file (typically within the same ms as shutter close) + if (exif.exif.OffsetTimeDigitized) { //OffsetTimeDigitized is the corresponding offset + metadata.creationDate = timestampToMS(exif.exif.CreateDate, exif.exif.OffsetTimeDigitized); + metadata.creationDateOffset = exif.exif.OffsetTimeDigitized; + } else { + const alt_offset = exif.exif.OffsetTimeOriginal || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.exif.DateTimeOriginal, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.exif.DateTimeOriginal, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.ifd0?.ModifyDate) { //using else if here, because DateTimeOriginal and CreatDate have preceedence + if (exif.exif.OffsetTime) { + //exif.Offsettime is the offset corresponding to ifd0.ModifyDate + metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, exif.exif?.OffsetTime); + metadata.creationDateOffset = exif.exif?.OffsetTime + } else { + const alt_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.ifd0.ModifyDate, alt_offset); + metadata.creationDateOffset = alt_offset; + } + } else if (exif.ihdr && exif.ihdr["Creation Time"]) {// again else if (another fallback date if the good ones aren't there) { + const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.ihdr["Creation Time"], any_offset); + metadata.creationDateOffset = any_offset; + } else if (exif.xmp?.MetadataDate) {// again else if (another fallback date if the good ones aren't there - metadata date is probably later than actual creation date, but much better than file time) { + const any_offset = exif.exif.DateTimeOriginal || exif.exif.OffsetTimeDigitized || exif.exif.OffsetTime || getTimeOffsetByGPSStamp(exif.ifd0.ModifyDate, exif.exif.GPSTimeStamp, exif.gps); + metadata.creationDate = timestampToMS(exif.xmp.MetadataDate, any_offset); + metadata.creationDateOffset = any_offset; + } + if (exif.exif.LensModel && exif.exif.LensModel !== '') { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.lens = '' + exif.exif.LensModel; + } + if (Utils.isUInt32(exif.exif.ISO)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.ISO = parseInt('' + exif.exif.ISO, 10); + } + if (Utils.isFloat32(exif.exif.FocalLength)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.focalLength = parseFloat( + '' + exif.exif.FocalLength + ); + } + if (Utils.isFloat32(exif.exif.ExposureTime)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.exposure = parseFloat( + parseFloat('' + exif.exif.ExposureTime).toFixed(6) + ); + } + if (Utils.isFloat32(exif.exif.FNumber)) { + metadata.cameraData = metadata.cameraData || {}; + metadata.cameraData.fStop = parseFloat( + parseFloat('' + exif.exif.FNumber).toFixed(2) + ); + } + if (exif.exif.ExifImageWidth && metadata.size.width <= 0) { + metadata.size.width = exif.exif.ExifImageWidth; + } + if (exif.exif.ExifImageHeight && metadata.size.height <= 0) { + metadata.size.height = exif.exif.ExifImageHeight; + } + } + + //gps section + if (exif.gps) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.GPSData = metadata.positionData.GPSData || {}; + + if (Utils.isFloat32(exif.gps.longitude)) { + metadata.positionData.GPSData.longitude = parseFloat( + exif.gps.longitude.toFixed(6) + ); + } + if (Utils.isFloat32(exif.gps.latitude)) { + metadata.positionData.GPSData.latitude = parseFloat( + exif.gps.latitude.toFixed(6) + ); + } + + if (metadata.positionData) { + if (!metadata.positionData.GPSData || + Object.keys(metadata.positionData.GPSData).length === 0) { + metadata.positionData.GPSData = undefined; + metadata.positionData = undefined; + } + } + } + //photoshop section (sometimes has City, Country and State) + if (exif.photoshop) { + if (!metadata.positionData?.country && exif.photoshop.Country) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.country = unescape(exif.photoshop.Country); + } + if (!metadata.positionData?.state && exif.photoshop.State) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.state = unescape(exif.photoshop.State); + } + if (!metadata.positionData?.city && exif.photoshop.City) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.city = unescape(exif.photoshop.City); + } + } + + /////////////////////////////////////// + metadata.size.height = Math.max(metadata.size.height, 1); //ensure height dimension is positive + metadata.size.width = Math.max(metadata.size.width, 1); //ensure width dimension is positive + + //Before moving on to the XMP section (particularly the regions (mwg-rs)) + //we need to switch width and height for images that are rotated sideways + if (4 < orientation) { //Orientation is sideways (rotated 90% or 270%) + // noinspection JSSuspiciousNameCombination + const height = metadata.size.width; + // noinspection JSSuspiciousNameCombination + metadata.size.width = metadata.size.height; + metadata.size.height = height; + } + /////////////////////////////////////// + + //xmp section + if (exif.xmp && exif.xmp.Rating) { + metadata.rating = exif.xmp.Rating; + if (metadata.rating < 0) { + metadata.rating = 0; + } + } + //xmp."mwg-rs" section + if (Config.Faces.enabled && + exif["mwg-rs"] && + exif["mwg-rs"].Regions) { + const faces: FaceRegion[] = []; + const regionListVal = Array.isArray(exif["mwg-rs"].Regions.RegionList) ? exif["mwg-rs"].Regions.RegionList : [exif["mwg-rs"].Regions.RegionList]; + if (regionListVal) { + for (const regionRoot of regionListVal) { + let type; + let name; + let box; + const createFaceBox = ( + w: string, + h: string, + x: string, + y: string + ) => { + if (4 < orientation) { //roation is sidewards (90 or 270 degrees) + [x, y] = [y, x]; + [w, h] = [h, w]; + } + let swapX = 0; + let swapY = 0; + switch (orientation) { + case 2: //TOP RIGHT (Mirror horizontal): + case 6: //RIGHT TOP (Rotate 90 CW) + swapX = 1; + break; + case 3: // BOTTOM RIGHT (Rotate 180) + case 7: // RIGHT BOTTOM (Mirror horizontal and rotate 90 CW) + swapX = 1; + swapY = 1; + break; + case 4: //BOTTOM_LEFT (Mirror vertical) + case 8: //LEFT_BOTTOM (Rotate 270 CW) + swapY = 1; + break; + } + // converting ratio to px + return { + width: Math.round(parseFloat(w) * metadata.size.width), + height: Math.round(parseFloat(h) * metadata.size.height), + left: Math.round(Math.abs(parseFloat(x) - swapX) * metadata.size.width), + top: Math.round(Math.abs(parseFloat(y) - swapY) * metadata.size.height), + }; + }; + /* Adobe Lightroom based face region structure */ + if ( + regionRoot && + regionRoot['rdf:Description'] && + regionRoot['rdf:Description'] && + regionRoot['rdf:Description']['mwg-rs:Area'] + ) { + const region = regionRoot['rdf:Description']; + const regionBox = region['mwg-rs:Area'].attributes; + + name = region['mwg-rs:Name']; + type = region['mwg-rs:Type']; + box = createFaceBox( + regionBox['stArea:w'], + regionBox['stArea:h'], + regionBox['stArea:x'], + regionBox['stArea:y'] + ); + /* Load exiftool edited face region structure, see github issue #191 */ + } else if ( + regionRoot && + regionRoot.Name && + regionRoot.Type && + regionRoot.Area + ) { + const regionBox = regionRoot.Area; + name = regionRoot.Name; + type = regionRoot.Type; + box = createFaceBox( + regionBox.w, + regionBox.h, + regionBox.x, + regionBox.y + ); + } + + if (type !== 'Face' || !name) { + continue; + } + + // convert center base box to corner based box + box.left = Math.round(Math.max(0, box.left - box.width / 2)); + box.top = Math.round(Math.max(0, box.top - box.height / 2)); + + + faces.push({ name, box }); + } + } + if (faces.length > 0) { + metadata.faces = faces; // save faces + if (Config.Faces.keywordsToPersons) { + // remove faces from keywords + metadata.faces.forEach((f) => { + const index = metadata.keywords.indexOf(f.name); + if (index !== -1) { + metadata.keywords.splice(index, 1); + } + }); + } + } + } + } catch (err) { + // ignoring errors + } + + if (!metadata.creationDate) { + // creationDate can be negative, when it was created before epoch (1970) + metadata.creationDate = 0; + } + + try { + // search for sidecar and merge metadata + const fullPathWithoutExt = path.parse(fullPath).name; + const sidecarPaths = [ + fullPath + '.xmp', + fullPath + '.XMP', + fullPathWithoutExt + '.xmp', + fullPathWithoutExt + '.XMP', + ]; + + for (const sidecarPath of sidecarPaths) { + if (fs.existsSync(sidecarPath)) { + const sidecarData = await exifr.sidecar(sidecarPath); + + if (sidecarData !== undefined) { + if ((sidecarData as SideCar).dc.subject !== undefined) { + if (metadata.keywords === undefined) { + metadata.keywords = []; + } + for (const kw of (sidecarData as SideCar).dc.subject) { + if (metadata.keywords.indexOf(kw) === -1) { + metadata.keywords.push(kw); + } + } + } + if ((sidecarData as SideCar).xmp.Rating !== undefined) { + metadata.rating = (sidecarData as SideCar).xmp.Rating; + } + } + } + } + } catch (err) { + Logger.silly(LOG_TAG, 'Error loading sidecar metadata for : ' + fullPath); + Logger.silly(err); + } + + } catch (err) { + Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); + console.error(err); + return MetadataLoader.EMPTY_METADATA; + } + } catch (err) { + Logger.error(LOG_TAG, 'Error during reading photo: ' + fullPath); + console.error(err); + return MetadataLoader.EMPTY_METADATA; + } + return metadata; + } +} diff --git a/src/backend/model/messenger/EmailMessenger.ts b/src/backend/model/messenger/EmailMessenger.ts index c412c55f8..c9afd8a98 100644 --- a/src/backend/model/messenger/EmailMessenger.ts +++ b/src/backend/model/messenger/EmailMessenger.ts @@ -5,6 +5,7 @@ import {MediaDTOWithThPath, Messenger} from './Messenger'; import {backendTexts} from '../../../common/BackendTexts'; import {DynamicConfig} from '../../../common/entities/DynamicConfig'; import {DefaultMessengers} from '../../../common/entities/job/JobDTO'; +import {Utils} from '../../../common/Utils'; export class EmailMessenger extends Messenger<{ emailTo: string, @@ -69,7 +70,7 @@ export class EmailMessenger extends Messenger<{ (media[i].metadata as PhotoMetadata).positionData?.country : ((media[i].metadata as PhotoMetadata).positionData?.city ? (media[i].metadata as PhotoMetadata).positionData?.city : ''); - const caption = (new Date(media[i].metadata.creationDate)).getFullYear() + (location ? ', ' + location : ''); + const caption = Utils.getFullYear(media[i].metadata.creationDate, media[i].metadata.creationDateOffset) + (location ? ', ' + location : ''); attachments.push({ filename: media[i].name, path: media[i].thumbnailPath, diff --git a/src/common/Utils.ts b/src/common/Utils.ts index ceef88112..e0a475d6e 100644 --- a/src/common/Utils.ts +++ b/src/common/Utils.ts @@ -110,6 +110,43 @@ export class Utils { return d; } + static getUTCFullYear(d: number | Date, offset: string) { + if (!(d instanceof Date)) { + d = new Date(d); + } + return new Date(new Date(d).toISOString().substring(0,19) + (offset ? offset : '')).getUTCFullYear(); + } + + static getFullYear(d: number | Date, offset: string) { + if (!(d instanceof Date)) { + d = new Date(d); + } + return new Date(new Date(d).toISOString().substring(0,19) + (offset ? offset : '')).getFullYear(); + } + + static getOffsetString(offsetMinutes: number) { + if (-720 <= offsetMinutes && offsetMinutes <= 840) { + //valid offset is within -12 and +14 hrs (https://en.wikipedia.org/wiki/List_of_UTC_offsets) + return (offsetMinutes < 0 ? "-" : "+") + //leading +/- + ("0" + Math.trunc(Math.abs(offsetMinutes) / 60)).slice(-2) + ":" + //zeropadded hours and ':' + ("0" + Math.abs(offsetMinutes) % 60).slice(-2); //zeropadded minutes + } else { + return undefined; + } + } + + static getOffsetMinutes(offsetString: string) { //Convert offset string (+HH:MM or -HH:MM) into a minute value + const regex = /^([+-](0[0-9]|1[0-4]):[0-5][0-9])$/; //checks if offset is between -14:00 and +14:00. + //-12:00 is the lowest valid UTC-offset, but we allow down to -14 for efficiency + if (regex.test(offsetString)) { + const hhmm = offsetString.split(":"); + const hours = parseInt(hhmm[0]); + return hours < 0 ? ((hours*60) - parseInt(hhmm[1])) : ((hours*60) + parseInt(hhmm[1])); + } else { + return undefined; + } + } + static renderDataSize(size: number): string { const postFixes = ['B', 'KB', 'MB', 'GB', 'TB']; let index = 0; diff --git a/src/common/entities/ConentWrapper.ts b/src/common/entities/ConentWrapper.ts index f03215756..aedc17940 100644 --- a/src/common/entities/ConentWrapper.ts +++ b/src/common/entities/ConentWrapper.ts @@ -79,6 +79,11 @@ export class ContentWrapper { (media as MediaDTO).metadata['t'] = (media as MediaDTO).metadata.creationDate / 1000; // skip millies delete (media as MediaDTO).metadata.creationDate; + if ((media as MediaDTO).metadata.creationDateOffset) { + // @ts-ignore + (media as MediaDTO).metadata['o'] = Utils.getOffsetMinutes((media as MediaDTO).metadata.creationDateOffset); // offset in minutes + delete (media as MediaDTO).metadata.creationDateOffset; + } if ((media as PhotoDTO).metadata.rating) { // @ts-ignore @@ -338,6 +343,14 @@ export class ContentWrapper { delete (media as PhotoDTO).metadata['t']; } + // @ts-ignore + if (typeof (media as PhotoDTO).metadata['o'] !== 'undefined') { + // @ts-ignore + (media as PhotoDTO).metadata.creationDateOffset = Utils.getOffsetString((media as PhotoDTO).metadata['o']) ;//convert offset from minutes to String + // @ts-ignore + delete (media as PhotoDTO).metadata['o']; + } + // @ts-ignore if (typeof (media as PhotoDTO).metadata['r'] !== 'undefined') { // @ts-ignore diff --git a/src/common/entities/MediaDTO.ts b/src/common/entities/MediaDTO.ts index 9c282abec..7bcdcd992 100644 --- a/src/common/entities/MediaDTO.ts +++ b/src/common/entities/MediaDTO.ts @@ -17,6 +17,7 @@ export interface MediaMetadata { size: MediaDimension; creationDate: number; fileSize: number; + creationDateOffset?: string; keywords?: string[]; rating?: RatingTypes; title?: string; diff --git a/src/common/entities/PhotoDTO.ts b/src/common/entities/PhotoDTO.ts index a4af1c24a..6184bbd20 100644 --- a/src/common/entities/PhotoDTO.ts +++ b/src/common/entities/PhotoDTO.ts @@ -33,6 +33,7 @@ export interface PhotoMetadata extends MediaMetadata { positionData?: PositionMetaData; size: MediaDimension; creationDate: number; + creationDateOffset?: string; fileSize: number; faces?: FaceRegion[]; } diff --git a/src/common/entities/VideoDTO.ts b/src/common/entities/VideoDTO.ts index d9cefefcc..ef0e39fdb 100644 --- a/src/common/entities/VideoDTO.ts +++ b/src/common/entities/VideoDTO.ts @@ -11,6 +11,7 @@ export interface VideoDTO extends MediaDTO { export interface VideoMetadata extends MediaMetadata { size: MediaDimension; creationDate: number; + creationDateOffset?: string; bitRate: number; duration: number; // in milliseconds fileSize: number; diff --git a/src/frontend/app/ui/duplicates/duplicates.component.html b/src/frontend/app/ui/duplicates/duplicates.component.html index e1044534e..f1168a734 100644 --- a/src/frontend/app/ui/duplicates/duplicates.component.html +++ b/src/frontend/app/ui/duplicates/duplicates.component.html @@ -24,7 +24,8 @@ {{media.metadata.fileSize | fileSize}}
- {{media.metadata.creationDate | date}}, {{media.metadata.creationDate | date:'mediumTime'}} + {{ media.metadata.creationDate | date : 'longDate' : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}, + {{ media.metadata.creationDate | date : (media.metadata.creationDateOffset ? 'HH:mm:ss ZZZZZ' : 'HH:mm:ss') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}
diff --git a/src/frontend/app/ui/gallery/filter/filter.service.ts b/src/frontend/app/ui/gallery/filter/filter.service.ts index 220483e17..e6bbf2345 100644 --- a/src/frontend/app/ui/gallery/filter/filter.service.ts +++ b/src/frontend/app/ui/gallery/filter/filter.service.ts @@ -205,7 +205,7 @@ export class FilterService { const startMediaDate = new Date(floorDate(minDate)); prefiltered.media.forEach(m => { - const key = Math.floor((floorDate(m.metadata.creationDate) - startMediaDate.getTime()) / 1000 / usedDiv); + const key = Math.floor((floorDate(m.metadata.creationDate) - startMediaDate.getTime()) / 1000 / usedDiv); //TODO const getDate = (index: number) => { let d: Date; diff --git a/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts b/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts index 1fe5309e5..b96576e73 100644 --- a/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts +++ b/src/frontend/app/ui/gallery/lightbox/controls/controls.lightbox.gallery.component.ts @@ -496,7 +496,7 @@ export class ControlsLightboxComponent implements OnDestroy, OnInit, OnChanges { case LightBoxTitleTexts.persons: return m.metadata.faces?.map(f => f.name)?.join(', '); case LightBoxTitleTexts.date: - return this.datePipe.transform(m.metadata.creationDate, 'longDate'); + return this.datePipe.transform(m.metadata.creationDate, 'longDate', m.metadata.creationDateOffset); case LightBoxTitleTexts.location: return ( m.metadata.positionData?.city || diff --git a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html index 3e1cbbfa7..341aa65ac 100644 --- a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html +++ b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.html @@ -54,10 +54,10 @@

- {{ media.metadata.creationDate | date: (isThisYear() ? 'MMMM d' : 'longDate') : 'UTC' }} + {{ media.metadata.creationDate | date: (isThisYear() ? 'MMMM d' : 'longDate') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}
-
{{ media.metadata.creationDate | date : 'EEEE, HH:mm:ss' : 'UTC' }}
+
{{ media.metadata.creationDate | date : (media.metadata.creationDateOffset ? 'EEEE, HH:mm:ss ZZZZZ' : 'EEEE, HH:mm:ss') : (media.metadata.creationDateOffset ? media.metadata.creationDateOffset : 'UTC') }}
diff --git a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts index a1254d2a1..542cc42f9 100644 --- a/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts +++ b/src/frontend/app/ui/gallery/lightbox/infopanel/info-panel.lightbox.gallery.component.ts @@ -148,7 +148,7 @@ export class InfoPanelLightboxComponent implements OnInit, OnChanges { isThisYear(): boolean { return ( new Date().getFullYear() === - new Date(this.media.metadata.creationDate).getUTCFullYear() + Utils.getUTCFullYear(this.media.metadata.creationDate, this.media.metadata.creationDateOffset) ); } diff --git a/src/frontend/app/ui/gallery/navigator/sorting.service.ts b/src/frontend/app/ui/gallery/navigator/sorting.service.ts index 2a3cca0d6..7e74d7375 100644 --- a/src/frontend/app/ui/gallery/navigator/sorting.service.ts +++ b/src/frontend/app/ui/gallery/navigator/sorting.service.ts @@ -184,7 +184,7 @@ export class GallerySortingService { private getGroupByNameFn(grouping: GroupingMethod) { switch (grouping.method) { case SortByTypes.Date: - return (m: MediaDTO) => this.datePipe.transform(m.metadata.creationDate, 'longDate', 'UTC'); + return (m: MediaDTO) => this.datePipe.transform(m.metadata.creationDate, 'longDate', m.metadata.creationDateOffset); case SortByTypes.Name: return (m: MediaDTO) => m.name.at(0).toUpperCase(); diff --git a/test/TestHelper.ts b/test/TestHelper.ts index 0c11a5cc9..8c24e85c2 100644 --- a/test/TestHelper.ts +++ b/test/TestHelper.ts @@ -55,6 +55,7 @@ export class TestHelper { m.caption = null; m.size = sd; m.creationDate = 1656069387772; + m.creationDateOffset = "+02:00" m.fileSize = 123456789; // m.rating = 0; no rating by default @@ -101,6 +102,7 @@ export class TestHelper { m.positionData = pd; m.size = sd; m.creationDate = 1656069387772; + m.creationDateOffset = "-05:00"; m.fileSize = 123456789; // m.rating = 0; no rating by default @@ -177,6 +179,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = 10; p.metadata.positionData.GPSData.longitude = 10; p.metadata.creationDate = 1656069387772 - 1000; + p.metadata.creationDateOffset = "+00:00"; p.metadata.rating = 1; p.metadata.size.height = 1000; p.metadata.size.width = 1000; @@ -215,6 +218,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = -10; p.metadata.positionData.GPSData.longitude = -10; p.metadata.creationDate = 1656069387772 - 2000; + p.metadata.creationDateOffset = "+11:00"; p.metadata.rating = 2; p.metadata.size.height = 2000; p.metadata.size.width = 1000; @@ -247,6 +251,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = 10; p.metadata.positionData.GPSData.longitude = 15; p.metadata.creationDate = 1656069387772 - 3000; + p.metadata.creationDateOffset = "-03:45"; p.metadata.rating = 3; p.metadata.size.height = 1000; p.metadata.size.width = 2000; @@ -275,6 +280,7 @@ export class TestHelper { p.metadata.positionData.GPSData.latitude = 15; p.metadata.positionData.GPSData.longitude = 10; p.metadata.creationDate = 1656069387772 - 4000; + p.metadata.creationDateOffset = "+04:30"; p.metadata.size.height = 3000; p.metadata.size.width = 2000; @@ -394,6 +400,7 @@ export class TestHelper { positionData: pd, size: sd, creationDate: Date.now() + ++TestHelper.creationCounter, + creationDateOffset: "+01:00", fileSize: rndInt(10000), caption: rndStr(), rating: rndInt(5) as any, diff --git a/test/backend/assets/Chars.json b/test/backend/assets/Chars.json index 281eb7c9f..83bec784c 100644 --- a/test/backend/assets/Chars.json +++ b/test/backend/assets/Chars.json @@ -3,7 +3,8 @@ "width": 1920, "height": 1080 }, - "creationDate": 1706659327000, + "creationDate": 1706655727000, + "creationDateOffset": "+01:00", "fileSize": 111432, "positionData": { "GPSData": { diff --git a/test/backend/assets/edge_case_exif_data/before_epoch.json b/test/backend/assets/edge_case_exif_data/before_epoch.json index 51ddb9a6d..982679ddb 100644 --- a/test/backend/assets/edge_case_exif_data/before_epoch.json +++ b/test/backend/assets/edge_case_exif_data/before_epoch.json @@ -9,7 +9,8 @@ "model": "Canon EOS 600D" }, "caption": "Bambi Caption", - "creationDate": -11630935227000, + "creationDate": -11630942427000, + "creationDateOffset": "+02:00", "faces": [ { "box": { diff --git a/test/backend/assets/edge_case_exif_data/date_error.json b/test/backend/assets/edge_case_exif_data/date_error.json index 7600f4da0..7d9733028 100644 --- a/test/backend/assets/edge_case_exif_data/date_error.json +++ b/test/backend/assets/edge_case_exif_data/date_error.json @@ -7,7 +7,7 @@ "make": "NIKON", "model": "E880" }, - "creationDate": -2211753600000, + "creationDate": 0, "fileSize": 72850, "size": { "height": 768, diff --git a/test/backend/assets/png_with_keyword_and_dates.json b/test/backend/assets/png_with_faces_and_dates.json similarity index 85% rename from test/backend/assets/png_with_keyword_and_dates.json rename to test/backend/assets/png_with_faces_and_dates.json index 2f4ad94e5..a72722495 100644 --- a/test/backend/assets/png_with_keyword_and_dates.json +++ b/test/backend/assets/png_with_faces_and_dates.json @@ -4,7 +4,8 @@ "width": 26, "height": 26 }, - "creationDate": 1707167247786, + "creationDate": 1599990007000, + "creationDateOffset": "+05:00", "fileSize": 5758, "keywords": [ ], diff --git a/test/backend/assets/png_with_keyword_and_dates.png b/test/backend/assets/png_with_faces_and_dates.png similarity index 100% rename from test/backend/assets/png_with_keyword_and_dates.png rename to test/backend/assets/png_with_faces_and_dates.png diff --git a/test/backend/assets/test_png.json b/test/backend/assets/test_png.json index 88800147e..0edd56615 100644 --- a/test/backend/assets/test_png.json +++ b/test/backend/assets/test_png.json @@ -24,5 +24,6 @@ "size": { "height": 26, "width": 26 - } + }, + "creationDate": 1544748139000 } diff --git a/test/backend/assets/timestamps/big_ben.jpg b/test/backend/assets/timestamps/big_ben.jpg new file mode 100644 index 000000000..0649be533 Binary files /dev/null and b/test/backend/assets/timestamps/big_ben.jpg differ diff --git a/test/backend/assets/timestamps/big_ben.json b/test/backend/assets/timestamps/big_ben.json new file mode 100644 index 000000000..c4080036a --- /dev/null +++ b/test/backend/assets/timestamps/big_ben.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 200, + "height": 300 + }, + "creationDate": 1686141955000, + "creationDateOffset": "+01:00", + "fileSize": 18532, + "cameraData": { + "model": "Canon EOS R5", + "make": "Canon" + }, + "positionData": { + "GPSData": { + "longitude": -0.124575, + "latitude": 51.500694 + }, + "country": "Storbritannien", + "state": "England", + "city": "St James's" + }, + "keywords": [ + "Big Ben" + ] +} \ No newline at end of file diff --git a/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 000000000..48897cf14 Binary files /dev/null and b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg differ diff --git a/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json new file mode 100644 index 000000000..28844e964 --- /dev/null +++ b/test/backend/assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 200, + "height": 300 + }, + "creationDate": 1686141955000, + "creationDateOffset": "+01:00", + "fileSize": 18663, + "cameraData": { + "model": "Canon EOS R5", + "make": "Canon" + }, + "positionData": { + "GPSData": { + "longitude": -0.124575, + "latitude": 51.500694 + }, + "country": "Storbritannien", + "state": "England", + "city": "St James's" + }, + "keywords": [ + "Big Ben" + ] +} \ No newline at end of file diff --git a/test/backend/assets/timestamps/big_ben_only_time.jpg b/test/backend/assets/timestamps/big_ben_only_time.jpg new file mode 100644 index 000000000..88d3bc35c Binary files /dev/null and b/test/backend/assets/timestamps/big_ben_only_time.jpg differ diff --git a/test/backend/assets/timestamps/big_ben_only_time.json b/test/backend/assets/timestamps/big_ben_only_time.json new file mode 100644 index 000000000..5113607de --- /dev/null +++ b/test/backend/assets/timestamps/big_ben_only_time.json @@ -0,0 +1,20 @@ +{ + "size": { + "width": 200, + "height": 300 + }, + "creationDate": 1686145555000, + "fileSize": 17850, + "cameraData": { + "model": "Canon EOS R5", + "make": "Canon" + }, + "positionData": { + "country": "Storbritannien", + "state": "England", + "city": "St James's" + }, + "keywords": [ + "Big Ben" + ] +} \ No newline at end of file diff --git a/test/backend/assets/timestamps/sydney_opera_house.jpg b/test/backend/assets/timestamps/sydney_opera_house.jpg new file mode 100644 index 000000000..b595487ed Binary files /dev/null and b/test/backend/assets/timestamps/sydney_opera_house.jpg differ diff --git a/test/backend/assets/timestamps/sydney_opera_house.json b/test/backend/assets/timestamps/sydney_opera_house.json new file mode 100644 index 000000000..dda6cff5d --- /dev/null +++ b/test/backend/assets/timestamps/sydney_opera_house.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 300, + "height": 200 + }, + "creationDate": 1600512957000, + "creationDateOffset": "+10:00", + "fileSize": 22755, + "cameraData": { + "model": "ILCE-7RM3", + "make": "Sony" + }, + "positionData": { + "GPSData": { + "longitude": 151.210381, + "latitude": -33.855698 + }, + "country": "Australien", + "state": "New South Wales", + "city": "Dawes Point" + }, + "keywords": [ + "Sydney Opera House" + ] +} \ No newline at end of file diff --git a/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg new file mode 100644 index 000000000..184331398 Binary files /dev/null and b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg differ diff --git a/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json new file mode 100644 index 000000000..7a231aeb5 --- /dev/null +++ b/test/backend/assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json @@ -0,0 +1,25 @@ +{ + "size": { + "width": 300, + "height": 200 + }, + "creationDate": 1600512957000, + "creationDateOffset": "+10:00", + "fileSize": 22641, + "cameraData": { + "model": "ILCE-7RM3", + "make": "Sony" + }, + "positionData": { + "GPSData": { + "longitude": 151.210381, + "latitude": -33.855698 + }, + "country": "Australien", + "state": "New South Wales", + "city": "Dawes Point" + }, + "keywords": [ + "Sydney Opera House" + ] +} \ No newline at end of file diff --git a/test/backend/assets/two_ratings.json b/test/backend/assets/two_ratings.json index 924232b03..67f1b08e2 100644 --- a/test/backend/assets/two_ratings.json +++ b/test/backend/assets/two_ratings.json @@ -7,7 +7,8 @@ "make": "samsung", "model": "SM-G975F" }, - "creationDate": 1619181527000, + "creationDate": 1619174327000, + "creationDateOffset": "+02:00", "fileSize": 4877, "rating":3, "size": { diff --git a/test/backend/assets/xmp/xmp_subject.json b/test/backend/assets/xmp/xmp_subject.json index a9a46f4a3..a8a641efe 100644 --- a/test/backend/assets/xmp/xmp_subject.json +++ b/test/backend/assets/xmp/xmp_subject.json @@ -7,7 +7,8 @@ "make": "samsung", "model": "SM-G975F" }, - "creationDate": 1614703656000, + "creationDate": 1614700056000, + "creationDateOffset": "+01:00", "fileSize": 4709, "keywords": [ "Max", diff --git a/test/backend/unit/model/threading/MetaDataLoader.spec.ts b/test/backend/unit/model/threading/MetaDataLoader.spec.ts index 0e0740696..5a0e7e49c 100644 --- a/test/backend/unit/model/threading/MetaDataLoader.spec.ts +++ b/test/backend/unit/model/threading/MetaDataLoader.spec.ts @@ -24,11 +24,16 @@ describe('MetadataLoader', () => { it('should load png', async () => { const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/test_png.png')); - delete data.creationDate; // creation time for png not supported const expected = require(path.join(__dirname, '/../../../assets/test_png.json')); expect(Utils.clone(data)).to.be.deep.equal(expected); }); + it('should load png with faces and dates', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/png_with_faces_and_dates.png')); + const expected = require(path.join(__dirname, '/../../../assets/png_with_faces_and_dates.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg', async () => { const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/test image öüóőúéáű-.,.jpg')); const expected = require(path.join(__dirname, '/../../../assets/test image öüóőúéáű-.,.json')); @@ -69,6 +74,31 @@ describe('MetadataLoader', () => { expect(Utils.clone(data)).to.be.deep.equal(expected); }); + it('should load jpg with timestamps, timezone AEST (UTC+10) and gps (UTC)', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps and gps (UTC) and calculate offset +10', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/sydney_opera_house_no_tsoffset_but_gps_utc.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps, timezone BST (UTC+1) and gps (UTC)', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/big_ben.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/big_ben.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps and gps (UTC) and calculate offset +1', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/big_ben_no_tsoffset_but_gps_utc.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/big_ben_no_tsoffset_but_gps_utc.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + it('should load jpg with timestamps but no offset and no GPS to calculate it from', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/timestamps/big_ben_only_time.jpg')); + const expected = require(path.join(__dirname, '/../../../assets/timestamps/big_ben_only_time.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); describe('should load jpg with proper height and orientation', () => { it('jpg 1', async () => { const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../../assets/orientation/broken_orientation_exif.jpg'));