diff --git a/backend/model/threading/DiskMangerWorker.ts b/backend/model/threading/DiskMangerWorker.ts index 819407bf..353eaa8e 100644 --- a/backend/model/threading/DiskMangerWorker.ts +++ b/backend/model/threading/DiskMangerWorker.ts @@ -1,22 +1,15 @@ import * as fs from 'fs'; import * as path from 'path'; import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; -import {PhotoDTO, PhotoMetadata} from '../../../common/entities/PhotoDTO'; -import {Logger} from '../../Logger'; -import {IptcParser} from 'ts-node-iptc'; -import {ExifParserFactory, OrientationTypes} from 'ts-exif-parser'; -import {FfprobeData} from 'fluent-ffmpeg'; +import {PhotoDTO} from '../../../common/entities/PhotoDTO'; import {ProjectPath} from '../../ProjectPath'; import {Config} from '../../../common/config/private/Config'; -import {VideoDTO, VideoMetadata} from '../../../common/entities/VideoDTO'; -import {FFmpegFactory} from '../FFmpegFactory'; +import {VideoDTO} from '../../../common/entities/VideoDTO'; import {FileDTO} from '../../../common/entities/FileDTO'; -import * as sizeOf from 'image-size'; +import {MetadataLoader} from './MetadataLoader'; const LOG_TAG = '[DiskManagerTask]'; -const ffmpeg = FFmpegFactory.get(); - export class DiskMangerWorker { private static readonly SupportedEXT = { @@ -98,7 +91,7 @@ export class DiskMangerWorker { directory.media.push({ name: file, directory: null, - metadata: await DiskMangerWorker.loadPhotoMetadata(fullFilePath) + metadata: await MetadataLoader.loadPhotoMetadata(fullFilePath) }); if (maxPhotos != null && directory.media.length > maxPhotos) { @@ -109,7 +102,7 @@ export class DiskMangerWorker { directory.media.push({ name: file, directory: null, - metadata: await DiskMangerWorker.loadVideoMetadata(fullFilePath) + metadata: await MetadataLoader.loadVideoMetadata(fullFilePath) }); } else if (photosOnly === false && Config.Client.MetaFile.enabled === true && @@ -132,155 +125,4 @@ export class DiskMangerWorker { } - public static loadVideoMetadata(fullPath: string): Promise { - return new Promise((resolve, reject) => { - const metadata: VideoMetadata = { - size: { - width: 1, - height: 1 - }, - bitRate: 0, - duration: 0, - creationDate: 0, - fileSize: 0 - }; - try { - const stat = fs.statSync(fullPath); - metadata.fileSize = stat.size; - } catch (err) { - } - ffmpeg(fullPath).ffprobe((err: any, data: FfprobeData) => { - if (!!err || data === null) { - return reject(err); - } - - if (!data.streams[0]) { - return resolve(metadata); - } - - try { - for (let i = 0; i < data.streams.length; i++) { - if (data.streams[i].width) { - metadata.size.width = data.streams[i].width; - metadata.size.height = data.streams[i].height; - - metadata.duration = Math.floor(data.streams[i].duration * 1000); - metadata.bitRate = parseInt(data.streams[i].bit_rate, 10) || null; - metadata.creationDate = Date.parse(data.streams[i].tags.creation_time); - break; - } - } - - } catch (err) { - } - - return resolve(metadata); - }); - }); - } - - public static loadPhotoMetadata(fullPath: string): Promise { - return new Promise((resolve, reject) => { - const fd = fs.openSync(fullPath, 'r'); - - const data = Buffer.allocUnsafe(Config.Server.photoMetadataSize); - fs.read(fd, data, 0, Config.Server.photoMetadataSize, 0, (err) => { - if (err) { - fs.closeSync(fd); - return reject({file: fullPath, error: err}); - } - const metadata: PhotoMetadata = { - keywords: [], - cameraData: {}, - positionData: null, - size: {width: 1, height: 1}, - caption: null, - orientation: OrientationTypes.TOP_LEFT, - creationDate: 0, - fileSize: 0 - }; - try { - - try { - const stat = fs.statSync(fullPath); - metadata.fileSize = stat.size; - } catch (err) { - } - - try { - const exif = ExifParserFactory.create(data).parse(); - metadata.cameraData = { - ISO: exif.tags.ISO, - model: exif.tags.Model, - make: exif.tags.Make, - fStop: exif.tags.FNumber, - exposure: exif.tags.ExposureTime, - focalLength: exif.tags.FocalLength, - lens: exif.tags.LensModel, - }; - if (!isNaN(exif.tags.GPSLatitude) || exif.tags.GPSLongitude || exif.tags.GPSAltitude) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.GPSData = { - latitude: exif.tags.GPSLatitude, - longitude: exif.tags.GPSLongitude, - altitude: exif.tags.GPSAltitude - }; - } - - if (exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate) { - metadata.creationDate = exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate; - } - - if (exif.tags.Orientation) { - metadata.orientation = exif.tags.Orientation; - } - - 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 { - const info = sizeOf(fullPath); - metadata.size = {width: info.width, height: info.height}; - } - } catch (err) { - Logger.debug(LOG_TAG, 'Error parsing exif', fullPath, err); - try { - const info = sizeOf(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 || iptcData.province_or_state || iptcData.city) { - metadata.positionData = metadata.positionData || {}; - metadata.positionData.country = iptcData.country_or_primary_location_name; - metadata.positionData.state = iptcData.province_or_state; - metadata.positionData.city = iptcData.city; - } - if (iptcData.caption) { - metadata.caption = iptcData.caption.replace(/\0/g, '').trim(); - } - metadata.keywords = iptcData.keywords || []; - metadata.creationDate = (iptcData.date_time ? iptcData.date_time.getTime() : metadata.creationDate); - - } catch (err) { - // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); - } - - metadata.creationDate = metadata.creationDate || 0; - - fs.closeSync(fd); - return resolve(metadata); - } catch (err) { - fs.closeSync(fd); - return reject({file: fullPath, error: err}); - } - }); - } - ); - } } diff --git a/backend/model/threading/MetadataLoader.ts b/backend/model/threading/MetadataLoader.ts new file mode 100644 index 00000000..1aa25e6d --- /dev/null +++ b/backend/model/threading/MetadataLoader.ts @@ -0,0 +1,170 @@ +import {VideoMetadata} from '../../../common/entities/VideoDTO'; +import {PhotoMetadata} from '../../../common/entities/PhotoDTO'; +import {Config} from '../../../common/config/private/Config'; +import {Logger} from '../../Logger'; +import * as fs from 'fs'; +import * as sizeOf from 'image-size'; +import {OrientationTypes, ExifParserFactory} from 'ts-exif-parser'; +import {IptcParser} from 'ts-node-iptc'; +import {FFmpegFactory} from '../FFmpegFactory'; +import {FfprobeData} from 'fluent-ffmpeg'; + +const LOG_TAG = '[MetadataLoader]'; +const ffmpeg = FFmpegFactory.get(); + +export class MetadataLoader { + + public static loadVideoMetadata(fullPath: string): Promise { + return new Promise((resolve, reject) => { + const metadata: VideoMetadata = { + size: { + width: 1, + height: 1 + }, + bitRate: 0, + duration: 0, + creationDate: 0, + fileSize: 0 + }; + try { + const stat = fs.statSync(fullPath); + metadata.fileSize = stat.size; + } catch (err) { + } + ffmpeg(fullPath).ffprobe((err: any, data: FfprobeData) => { + if (!!err || data === null) { + return reject(err); + } + + if (!data.streams[0]) { + return resolve(metadata); + } + + try { + for (let i = 0; i < data.streams.length; i++) { + if (data.streams[i].width) { + metadata.size.width = data.streams[i].width; + metadata.size.height = data.streams[i].height; + + metadata.duration = Math.floor(data.streams[i].duration * 1000); + metadata.bitRate = parseInt(data.streams[i].bit_rate, 10) || null; + metadata.creationDate = Date.parse(data.streams[i].tags.creation_time); + break; + } + } + + } catch (err) { + } + + return resolve(metadata); + }); + }); + } + + public static loadPhotoMetadata(fullPath: string): Promise { + return new Promise((resolve, reject) => { + const fd = fs.openSync(fullPath, 'r'); + + const data = Buffer.allocUnsafe(Config.Server.photoMetadataSize); + fs.read(fd, data, 0, Config.Server.photoMetadataSize, 0, (err) => { + if (err) { + fs.closeSync(fd); + return reject({file: fullPath, error: err}); + } + const metadata: PhotoMetadata = { + size: {width: 1, height: 1}, + orientation: OrientationTypes.TOP_LEFT, + creationDate: 0, + fileSize: 0 + }; + try { + + try { + const stat = fs.statSync(fullPath); + metadata.fileSize = stat.size; + metadata.creationDate = stat.ctime.getTime(); + } catch (err) { + } + + 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) { + metadata.cameraData = { + ISO: exif.tags.ISO, + model: exif.tags.Model, + make: exif.tags.Make, + fStop: exif.tags.FNumber, + exposure: exif.tags.ExposureTime, + focalLength: exif.tags.FocalLength, + lens: exif.tags.LensModel, + }; + } + if (!isNaN(exif.tags.GPSLatitude) || exif.tags.GPSLongitude || exif.tags.GPSAltitude) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.GPSData = { + latitude: exif.tags.GPSLatitude, + longitude: exif.tags.GPSLongitude, + altitude: exif.tags.GPSAltitude + }; + } + + if (exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate) { + metadata.creationDate = exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate; + } + + if (exif.tags.Orientation) { + metadata.orientation = exif.tags.Orientation; + } + + 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 { + const info = sizeOf(fullPath); + metadata.size = {width: info.width, height: info.height}; + } + } catch (err) { + Logger.debug(LOG_TAG, 'Error parsing exif', fullPath, err); + try { + const info = sizeOf(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 || iptcData.province_or_state || iptcData.city) { + metadata.positionData = metadata.positionData || {}; + metadata.positionData.country = iptcData.country_or_primary_location_name.replace(/\0/g, '').trim(); + metadata.positionData.state = iptcData.province_or_state.replace(/\0/g, '').trim(); + metadata.positionData.city = iptcData.city.replace(/\0/g, '').trim(); + } + if (iptcData.caption) { + metadata.caption = iptcData.caption.replace(/\0/g, '').trim(); + } + metadata.keywords = iptcData.keywords || []; + metadata.creationDate = (iptcData.date_time ? iptcData.date_time.getTime() : metadata.creationDate); + + } catch (err) { + // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); + } + + metadata.creationDate = metadata.creationDate || 0; + + fs.closeSync(fd); + return resolve(metadata); + } catch (err) { + fs.closeSync(fd); + return reject({file: fullPath, error: err}); + } + }); + } + ); + } +} diff --git a/common/entities/PhotoDTO.ts b/common/entities/PhotoDTO.ts index b7710eab..0eb994b5 100644 --- a/common/entities/PhotoDTO.ts +++ b/common/entities/PhotoDTO.ts @@ -12,10 +12,10 @@ export interface PhotoDTO extends MediaDTO { } export interface PhotoMetadata extends MediaMetadata { - caption: string; - keywords: Array; - cameraData: CameraMetadata; - positionData: PositionMetaData; + caption?: string; + keywords?: string[]; + cameraData?: CameraMetadata; + positionData?: PositionMetaData; orientation: OrientationTypes; size: MediaDimension; creationDate: number; diff --git a/test/backend/unit/assets/test image öüóőúéáű-.,.jpg b/test/backend/unit/assets/test image öüóőúéáű-.,.jpg index b6347849..4f838c4a 100644 Binary files a/test/backend/unit/assets/test image öüóőúéáű-.,.jpg and b/test/backend/unit/assets/test image öüóőúéáű-.,.jpg differ diff --git a/test/backend/unit/assets/test_png.png b/test/backend/unit/assets/test_png.png new file mode 100644 index 00000000..fc3e635c Binary files /dev/null and b/test/backend/unit/assets/test_png.png differ diff --git a/test/backend/unit/model/threading/DiskMangerWorker.spec.ts b/test/backend/unit/model/threading/DiskMangerWorker.spec.ts index a4f3682c..9c541d90 100644 --- a/test/backend/unit/model/threading/DiskMangerWorker.spec.ts +++ b/test/backend/unit/model/threading/DiskMangerWorker.spec.ts @@ -11,10 +11,10 @@ describe('DiskMangerWorker', () => { Config.Server.imagesFolder = path.join(__dirname, '/../../assets'); ProjectPath.ImageFolder = path.join(__dirname, '/../../assets'); const dir = await DiskMangerWorker.scanDirectory('/'); - expect(dir.media.length).to.be.equals(1); + expect(dir.media.length).to.be.equals(2); expect(dir.media[0].name).to.be.equals('test image öüóőúéáű-.,.jpg'); expect((dir.media[0]).metadata.keywords).to.deep.equals(['Berkley', 'USA', 'űáéúőóüö ŰÁÉÚŐÓÜÖ']); - expect(dir.media[0].metadata.fileSize).to.deep.equals(62392); + expect(dir.media[0].metadata.fileSize).to.deep.equals(62786); expect(dir.media[0].metadata.size).to.deep.equals({width: 140, height: 93}); expect((dir.media[0]).metadata.cameraData).to.deep.equals({ ISO: 3200, @@ -32,7 +32,7 @@ describe('DiskMangerWorker', () => { longitude: -122.25678, altitude: 102.4498997995992 }, - country: 'mmóüöúőűáé ÓÜÖÚŐŰÁÉmm-.,|\\mm\u0000', + country: 'mmóüöúőűáé ÓÜÖÚŐŰÁÉmm-.,|\\mm', state: 'óüöúőűáé ÓÜÖÚŐŰÁ', city: 'óüöúőűáé ÓÜÖÚŐŰÁ' }); diff --git a/test/backend/unit/model/threading/MetaDataLoader.spec.ts b/test/backend/unit/model/threading/MetaDataLoader.spec.ts new file mode 100644 index 00000000..92d82bd6 --- /dev/null +++ b/test/backend/unit/model/threading/MetaDataLoader.spec.ts @@ -0,0 +1,55 @@ +import {expect} from 'chai'; +import {MetadataLoader} from '../../../../../backend/model/threading/MetadataLoader'; +import {Utils} from '../../../../../common/Utils'; +import * as path from 'path'; + +describe('MetadataLoader', () => { + + it('should load png', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../assets/test_png.png')); + expect(Utils.clone(data)).to.be.deep.equal(Utils.clone({ + creationDate: 1545342192328, + fileSize: 2110, + orientation: 1, + size: { + height: 26, + width: 26 + } + })); + }); + + it('should load jpg', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../assets/test image öüóőúéáű-.,.jpg')); + expect(Utils.clone(data)).to.be.deep.equal(Utils.clone({ + size: {width: 140, height: 93}, + orientation: 1, + caption: 'Test caption', + creationDate: 1434018566000, + fileSize: 62786, + cameraData: + { + ISO: 3200, + model: 'óüöúőűáé ÓÜÖÚŐŰÁÉ', + make: 'Canon', + fStop: 5.6, + exposure: 0.00125, + focalLength: 85, + lens: 'EF-S15-85mm f/3.5-5.6 IS USM' + }, + positionData: + { + GPSData: + { + latitude: 37.871093333333334, + longitude: -122.25678, + altitude: 102.4498997995992 + }, + country: 'mmóüöúőűáé ÓÜÖÚŐŰÁÉmm-.,|\\mm', + state: 'óüöúőűáé ÓÜÖÚŐŰÁ', + city: 'óüöúőűáé ÓÜÖÚŐŰÁ' + }, + keywords: ['Berkley', 'USA', 'űáéúőóüö ŰÁÉÚŐÓÜÖ'] + })); + }); + +});