From 22aecea2637bdcb6d17b34e54d8b4c9b46211275 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 12 Jan 2019 16:41:45 +0100 Subject: [PATCH 01/20] Implementing XMP face region parsing --- backend/model/sql/GalleryManager.ts | 104 ++++++++++++++++-- backend/model/sql/SQLConnection.ts | 8 +- backend/model/sql/enitites/FaceRegionEntry.ts | 38 +++++++ backend/model/sql/enitites/MediaEntity.ts | 7 +- backend/model/sql/enitites/PersonEntry.ts | 17 +++ backend/model/sql/enitites/PhotoEntity.ts | 13 ++- backend/model/threading/MetadataLoader.ts | 51 ++++++++- common/DataStructureVersion.ts | 2 +- common/entities/PhotoDTO.ts | 13 +++ package.json | 5 +- .../unit/assets/test image öüóőúéáű-.,.jpg | Bin 62786 -> 59011 bytes 11 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 backend/model/sql/enitites/FaceRegionEntry.ts create mode 100644 backend/model/sql/enitites/PersonEntry.ts diff --git a/backend/model/sql/GalleryManager.ts b/backend/model/sql/GalleryManager.ts index 752eb13..45e948a 100644 --- a/backend/model/sql/GalleryManager.ts +++ b/backend/model/sql/GalleryManager.ts @@ -5,13 +5,13 @@ import * as fs from 'fs'; import {DirectoryEntity} from './enitites/DirectoryEntity'; import {SQLConnection} from './SQLConnection'; import {DiskManager} from '../DiskManger'; -import {PhotoEntity} from './enitites/PhotoEntity'; +import {PhotoEntity, PhotoMetadataEntity} from './enitites/PhotoEntity'; import {Utils} from '../../../common/Utils'; import {ProjectPath} from '../../ProjectPath'; import {Config} from '../../../common/config/private/Config'; import {ISQLGalleryManager} from './IGalleryManager'; import {DatabaseType, ReIndexingSensitivity} from '../../../common/config/private/IPrivateConfig'; -import {PhotoDTO} from '../../../common/entities/PhotoDTO'; +import {FaceRegion, PhotoDTO, PhotoMetadata} from '../../../common/entities/PhotoDTO'; import {OrientationType} from '../../../common/entities/RandomQueryDTO'; import {Brackets, Connection, Transaction, TransactionRepository, Repository} from 'typeorm'; import {MediaEntity} from './enitites/MediaEntity'; @@ -22,6 +22,8 @@ import {FileDTO} from '../../../common/entities/FileDTO'; import {NotificationManager} from '../NotifocationManager'; import {DiskMangerWorker} from '../threading/DiskMangerWorker'; import {Logger} from '../../Logger'; +import {FaceRegionEntry} from './enitites/FaceRegionEntry'; +import {PersonEntry} from './enitites/PersonEntry'; const LOG_TAG = '[GalleryManager]'; @@ -50,11 +52,23 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { protected async fillParentDir(connection: Connection, dir: DirectoryEntity): Promise { if (dir.media) { + const indexedFaces = await connection.getRepository(FaceRegionEntry) + .createQueryBuilder('face') + .leftJoinAndSelect('face.media', 'media') + .where('media.directory = :directory', { + directory: dir.id + }) + .leftJoinAndSelect('face.person', 'person') + .getMany(); for (let i = 0; i < dir.media.length; i++) { dir.media[i].directory = dir; dir.media[i].readyThumbnails = []; dir.media[i].readyIcon = false; + (dir.media[i]).metadata.faces = indexedFaces + .filter(fe => fe.media.id === dir.media[i].id) + .map(f => ({box: f.box, name: f.person.name})); } + } if (dir.directories) { for (let i = 0; i < dir.directories.length; i++) { @@ -235,6 +249,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { } protected async saveToDB(scannedDirectory: DirectoryDTO) { + console.log('saving'); this.isSaving = true; try { const connection = await SQLConnection.getConnection(); @@ -259,7 +274,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { currentDir = await directoryRepository.save(scannedDirectory); } - + // TODO: fix when first opened directory is not root // save subdirectories const childDirectories = await directoryRepository.createQueryBuilder('directory') .where('directory.parent = :dir', { @@ -299,10 +314,11 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { await directoryRepository.remove(childDirectories, {chunk: Math.max(Math.ceil(childDirectories.length / 500), 1)}); // save media - const indexedMedia = await mediaRepository.createQueryBuilder('media') + const indexedMedia = (await mediaRepository.createQueryBuilder('media') .where('media.directory = :dir', { dir: currentDir.id - }).getMany(); + }) + .getMany()); const mediaToSave = []; @@ -315,18 +331,30 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { break; } } + + if (media == null) { // not in DB yet scannedDirectory.media[i].directory = null; media = Utils.clone(scannedDirectory.media[i]); scannedDirectory.media[i].directory = scannedDirectory; media.directory = currentDir; mediaToSave.push(media); - } else if (!Utils.equalsFilter(media.metadata, scannedDirectory.media[i].metadata)) { - media.metadata = scannedDirectory.media[i].metadata; - mediaToSave.push(media); + } else { + delete (media.metadata).faces; + + if (!Utils.equalsFilter(media.metadata, scannedDirectory.media[i].metadata)) { + media.metadata = scannedDirectory.media[i].metadata; + mediaToSave.push(media); + } } + const scannedFaces = (scannedDirectory.media[i].metadata).faces; + delete (scannedDirectory.media[i].metadata).faces; + + const mediaEntry = await this.saveAMedia(connection, media); + + await this.saveFaces(connection, mediaEntry, scannedFaces); } - await this.saveMedia(connection, mediaToSave); + // await this.saveMedia(connection, mediaToSave); await mediaRepository.remove(indexedMedia); @@ -364,6 +392,64 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { } } + protected async saveFaces(connection: Connection, media: MediaEntity, scannedFaces: FaceRegion[]) { + + const faceRepository = connection.getRepository(FaceRegionEntry); + const personRepository = connection.getRepository(PersonEntry); + const indexedPersons = await personRepository.createQueryBuilder('person').getMany(); + + const indexedFaces = await faceRepository.createQueryBuilder('face') + .where('face.media = :media', { + media: media.id + }) + .leftJoinAndSelect('face.person', 'person') + .getMany(); + + + const getPerson = async (name: string) => { + let person = indexedPersons.find(p => p.name === name); + if (!person) { + person = await personRepository.save({name: name}); + indexedPersons.push(person); + } + return person; + }; + + const faceToSave = []; + for (let i = 0; i < scannedFaces.length; i++) { + let face: FaceRegionEntry = null; + for (let j = 0; j < indexedFaces.length; j++) { + if (indexedFaces[j].box.height === scannedFaces[i].box.height && + indexedFaces[j].box.width === scannedFaces[i].box.width && + indexedFaces[j].box.x === scannedFaces[i].box.x && + indexedFaces[j].box.y === scannedFaces[i].box.y && + indexedFaces[j].person.name === scannedFaces[i].name) { + face = indexedFaces[j]; + indexedFaces.splice(j, 1); + break; + } + } + + if (face == null) { + (scannedFaces[i]).person = await getPerson(scannedFaces[i].name); + (scannedFaces[i]).media = media; + // console.log('inserting', (scannedFaces[i]).person, (scannedFaces[i]).media); + // console.log('inserting', (scannedFaces[i]).person.id, (scannedFaces[i]).media.id); + faceToSave.push(scannedFaces[i]); + } + } + await faceRepository.save(faceToSave, {chunk: Math.max(Math.ceil(faceToSave.length / 500), 1)}); + await faceRepository.remove(indexedFaces, {chunk: Math.max(Math.ceil(indexedFaces.length / 500), 1)}); + + } + + protected async saveAMedia(connection: Connection, media: MediaDTO): Promise { + if (MediaDTO.isPhoto(media)) { + return await connection.getRepository(PhotoEntity).save(media); + } + return await connection.getRepository(VideoEntity).save(media); + } + protected async saveMedia(connection: Connection, mediaList: MediaDTO[]): Promise { const chunked = Utils.chunkArrays(mediaList, 100); let list: MediaEntity[] = []; diff --git a/backend/model/sql/SQLConnection.ts b/backend/model/sql/SQLConnection.ts index 0afc79f..658e863 100644 --- a/backend/model/sql/SQLConnection.ts +++ b/backend/model/sql/SQLConnection.ts @@ -15,6 +15,8 @@ import {MediaEntity} from './enitites/MediaEntity'; import {VideoEntity} from './enitites/VideoEntity'; import {DataStructureVersion} from '../../../common/DataStructureVersion'; import {FileEntity} from './enitites/FileEntity'; +import {FaceRegionEntry} from './enitites/FaceRegionEntry'; +import {PersonEntry} from './enitites/PersonEntry'; export class SQLConnection { @@ -32,6 +34,8 @@ export class SQLConnection { options.entities = [ UserEntity, FileEntity, + FaceRegionEntry, + PersonEntry, MediaEntity, PhotoEntity, VideoEntity, @@ -40,7 +44,7 @@ export class SQLConnection { VersionEntity ]; options.synchronize = false; - // options.logging = 'all'; + options.logging = 'all'; this.connection = await createConnection(options); await SQLConnection.schemeSync(this.connection); } @@ -57,6 +61,8 @@ export class SQLConnection { options.entities = [ UserEntity, FileEntity, + FaceRegionEntry, + PersonEntry, MediaEntity, PhotoEntity, VideoEntity, diff --git a/backend/model/sql/enitites/FaceRegionEntry.ts b/backend/model/sql/enitites/FaceRegionEntry.ts new file mode 100644 index 0000000..f9d1afd --- /dev/null +++ b/backend/model/sql/enitites/FaceRegionEntry.ts @@ -0,0 +1,38 @@ +import {FaceRegionBox} from '../../../../common/entities/PhotoDTO'; +import {Column, ManyToOne, Entity, PrimaryGeneratedColumn} from 'typeorm'; +import {PersonEntry} from './PersonEntry'; +import {MediaEntity, MediaMetadataEntity} from './MediaEntity'; + +export class FaceRegionBoxEntry implements FaceRegionBox { + @Column('int') + height: number; + @Column('int') + width: number; + @Column('int') + x: number; + @Column('int') + y: number; +} + +/** + * This is a switching table between media and persons + */ +@Entity() +export class FaceRegionEntry { + + @PrimaryGeneratedColumn() + id: number; + + @Column(type => FaceRegionBoxEntry) + box: FaceRegionBoxEntry; + + // @PrimaryColumn('int') + @ManyToOne(type => MediaEntity, media => media.metadata.faces, {onDelete: 'CASCADE', nullable: false}) + media: MediaEntity; + + // @PrimaryColumn('int') + @ManyToOne(type => PersonEntry, person => person.faces, {onDelete: 'CASCADE', nullable: false}) + person: PersonEntry; + + name: string; +} diff --git a/backend/model/sql/enitites/MediaEntity.ts b/backend/model/sql/enitites/MediaEntity.ts index 2a1aa00..026290d 100644 --- a/backend/model/sql/enitites/MediaEntity.ts +++ b/backend/model/sql/enitites/MediaEntity.ts @@ -1,8 +1,9 @@ -import {Column, Entity, ManyToOne, PrimaryGeneratedColumn, TableInheritance, Unique, Index} from 'typeorm'; +import {Column, Entity, OneToMany, ManyToOne, PrimaryGeneratedColumn, TableInheritance, Unique, Index} from 'typeorm'; import {DirectoryEntity} from './DirectoryEntity'; import {MediaDimension, MediaDTO, MediaMetadata} from '../../../../common/entities/MediaDTO'; import {OrientationTypes} from 'ts-exif-parser'; import {CameraMetadataEntity, PositionMetaDataEntity} from './PhotoEntity'; +import {FaceRegionEntry} from './FaceRegionEntry'; export class MediaDimensionEntity implements MediaDimension { @@ -27,7 +28,6 @@ export class MediaMetadataEntity implements MediaMetadata { @Column('int') fileSize: number; - @Column('simple-array') keywords: string[]; @@ -40,6 +40,9 @@ export class MediaMetadataEntity implements MediaMetadata { @Column('tinyint', {default: OrientationTypes.TOP_LEFT}) orientation: OrientationTypes; + @OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.media) + faces: FaceRegionEntry[]; + @Column('int') bitRate: number; diff --git a/backend/model/sql/enitites/PersonEntry.ts b/backend/model/sql/enitites/PersonEntry.ts new file mode 100644 index 0000000..d539bd5 --- /dev/null +++ b/backend/model/sql/enitites/PersonEntry.ts @@ -0,0 +1,17 @@ +import {Column, Entity, OneToMany, PrimaryGeneratedColumn, Unique, Index} from 'typeorm'; +import {FaceRegionEntry} from './FaceRegionEntry'; + + +@Entity() +@Unique(['name']) +export class PersonEntry { + @Index() + @PrimaryGeneratedColumn() + id: number; + + @Column() + name: string; + + @OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.person) + public faces: FaceRegionEntry[]; +} diff --git a/backend/model/sql/enitites/PhotoEntity.ts b/backend/model/sql/enitites/PhotoEntity.ts index fa9be6f..4fe4915 100644 --- a/backend/model/sql/enitites/PhotoEntity.ts +++ b/backend/model/sql/enitites/PhotoEntity.ts @@ -1,6 +1,13 @@ import {Column, Entity, ChildEntity, Unique} from 'typeorm'; -import {CameraMetadata, GPSMetadata, PhotoDTO, PhotoMetadata, PositionMetaData} from '../../../../common/entities/PhotoDTO'; -import {OrientationTypes} from 'ts-exif-parser'; +import { + CameraMetadata, + FaceRegion, + FaceRegionBox, + GPSMetadata, + PhotoDTO, + PhotoMetadata, + PositionMetaData +} from '../../../../common/entities/PhotoDTO'; import {MediaEntity, MediaMetadataEntity} from './MediaEntity'; export class CameraMetadataEntity implements CameraMetadata { @@ -38,6 +45,7 @@ export class GPSMetadataEntity implements GPSMetadata { altitude: number; } + export class PositionMetaDataEntity implements PositionMetaData { @Column(type => GPSMetadataEntity) @@ -75,5 +83,4 @@ export class PhotoMetadataEntity extends MediaMetadataEntity implements PhotoMet export class PhotoEntity extends MediaEntity implements PhotoDTO { @Column(type => PhotoMetadataEntity) metadata: PhotoMetadataEntity; - } diff --git a/backend/model/threading/MetadataLoader.ts b/backend/model/threading/MetadataLoader.ts index 16e506e..27123fc 100644 --- a/backend/model/threading/MetadataLoader.ts +++ b/backend/model/threading/MetadataLoader.ts @@ -1,5 +1,5 @@ import {VideoMetadata} from '../../../common/entities/VideoDTO'; -import {PhotoMetadata} from '../../../common/entities/PhotoDTO'; +import {FaceRegion, PhotoMetadata} from '../../../common/entities/PhotoDTO'; import {Config} from '../../../common/config/private/Config'; import {Logger} from '../../Logger'; import * as fs from 'fs'; @@ -9,6 +9,14 @@ import {IptcParser} from 'ts-node-iptc'; import {FFmpegFactory} from '../FFmpegFactory'; import {FfprobeData} from 'fluent-ffmpeg'; +// TODO: fix up different metadata loaders +// @ts-ignore +global.DataView = require('jdataview'); +// @ts-ignore +global.DOMParser = require('xmldom').DOMParser; +// @ts-ignore +const ExifReader = require('exifreader'); + const LOG_TAG = '[MetadataLoader]'; const ffmpeg = FFmpegFactory.get(); @@ -157,6 +165,47 @@ export class MetadataLoader { metadata.creationDate = metadata.creationDate || 0; + + try { + const ret = ExifReader.load(data); + const faces: FaceRegion[] = []; + if (ret.Regions && ret.Regions.value.RegionList && ret.Regions.value.RegionList.value) { + for (let i = 0; i < ret.Regions.value.RegionList.value.length; i++) { + if (!ret.Regions.value.RegionList.value[i].value || + !ret.Regions.value.RegionList.value[i].value['rdf:Description'] || + !ret.Regions.value.RegionList.value[i].value['rdf:Description'].value || + !ret.Regions.value.RegionList.value[i].value['rdf:Description'].value['mwg-rs:Area']) { + continue; + } + const region = ret.Regions.value.RegionList.value[i].value['rdf:Description']; + const regionBox = ret.Regions.value.RegionList.value[i].value['rdf:Description'].value['mwg-rs:Area'].attributes; + if (region.attributes['mwg-rs:Type'] !== 'Face' || + !region.attributes['mwg-rs:Name']) { + continue; + } + const name = region.attributes['mwg-rs:Name']; + const box = { + width: Math.round(regionBox['stArea:w'] * metadata.size.width), + height: Math.round(regionBox['stArea:h'] * metadata.size.height), + x: Math.round(regionBox['stArea:x'] * metadata.size.width), + y: Math.round(regionBox['stArea:y'] * metadata.size.height) + }; + faces.push({name: name, box: box}); + } + } + if (faces.length > 0) { + metadata.faces = faces; // save faces + // 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) { + } + return resolve(metadata); } catch (err) { return reject({file: fullPath, error: err}); diff --git a/common/DataStructureVersion.ts b/common/DataStructureVersion.ts index 9313616..5611455 100644 --- a/common/DataStructureVersion.ts +++ b/common/DataStructureVersion.ts @@ -1 +1 @@ -export const DataStructureVersion = 7; +export const DataStructureVersion = 8; diff --git a/common/entities/PhotoDTO.ts b/common/entities/PhotoDTO.ts index 0eb994b..23e06a7 100644 --- a/common/entities/PhotoDTO.ts +++ b/common/entities/PhotoDTO.ts @@ -11,6 +11,18 @@ export interface PhotoDTO extends MediaDTO { readyIcon: boolean; } +export interface FaceRegionBox { + width: number; + height: number; + x: number; + y: number; +} + +export interface FaceRegion { + name: string; + box: FaceRegionBox; +} + export interface PhotoMetadata extends MediaMetadata { caption?: string; keywords?: string[]; @@ -20,6 +32,7 @@ export interface PhotoMetadata extends MediaMetadata { size: MediaDimension; creationDate: number; fileSize: number; + faces?: FaceRegion[]; } diff --git a/package.json b/package.json index 269a7cf..79fa259 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,11 @@ "cookie-parser": "1.4.3", "cookie-session": "2.0.0-beta.3", "ejs": "2.6.1", + "exifreader": "2.5.0", "express": "4.16.4", "fluent-ffmpeg": "2.1.2", "image-size": "0.6.3", + "jdataview": "2.5.0", "jimp": "0.6.0", "locale": "0.1.0", "reflect-metadata": "0.1.12", @@ -41,7 +43,8 @@ "ts-node-iptc": "1.0.11", "typeconfig": "1.0.7", "typeorm": "0.2.9", - "winston": "2.4.2" + "winston": "2.4.2", + "xmldom": "0.1.27" }, "devDependencies": { "@angular-devkit/build-angular": "0.11.4", diff --git a/test/backend/unit/assets/test image öüóőúéáű-.,.jpg b/test/backend/unit/assets/test image öüóőúéáű-.,.jpg index 4f838c4a9b754d25c1845de955fb5a9effee0d4c..546a986dd80103a3ef4cb52b272b3c8f7b918208 100644 GIT binary patch delta 5400 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 delta 8694 zcmc&)O>7&-6{cicF&+QJjg;83V#{s+7)?`Ph;d2e=B-}%jUx$D9**I~E{XYIT zN+0?jN+b^b`GZ$R3**E2ALEPa1b#ntdTQ#^pZ{WN(loA2ZO>MfYNw3$%+>o2s@=a{ zZ`{A#STLqu(oP)1e@V@ZueJ4ER46RnGn;kWZcVC6DyLimZW3>$ONZe%iGP^h+9s4_ zvbeILTXtdDbX;#^&-Uz!?jbFssB<&Q{EV6}regs_wwQK|W}$-IhGTX-)3&Ha(ETj_ zSGpkduXVge?q0i7wjER`+g;0Z4$8KHw5?h(J&^ej{W)c-$Nks4_X?#>$Fc93ZE$_r zZgky>>3X`=fO)9dw5lk@^yi{jAP5A)(B9V_q^T(-m0@B&O@g&_7w=_HjA%+)EvE4| zvttXAJ9GuEciTJ2(YS4y6zEw;maE|(WhZ*f6di}zYZWawKOeAR906EOTT^G#>f}86 zPf07}pCmx|VUZegOuZ!>R1`(+WfroTTp$!e1i~S*n#n5ptdhyh3;#lFA{Cz@#Yoyr zItG3(eH!m5&xu7f8ij^~bkBB}>q#n8LwER)ja#O+p?i*bo7sUOb(Nd-(ZzIBT@eUP z+kt%1AZd0{`c?<+64*sSW^)FZ+ugZ^8lFZSSk!m%H_{9E!|It)N&u5vwUTch|K#ON zij4ruqfV^*THx6;y%u8nN-dA-`=peCWFuh#iX_INAY@3pj9QjNVUPBUh6Dz0)bA=O z5%Uh&{qCOKDU?m`KwCu4ZPY>s#dL(hLL(8`p4PT3(?iB&&4XR&hNL(kDo83#*+KY0 z?(kVsab{iJDQ;9jfE+w^nmJND#LnARUpnG?o2ZHZshr21 z%m{u%J&IpekK?~9QzI-+SS>$^pIAS8hI*GQQhdRHa@$6h2W1JT@{`$Ox{n^h+m5+w zT6(JzmfH|=F?<#cGRbNcx{YJrMaHsYw}a&)^gtf~QwDxenX1spgqchk)3pj+!gdar zobKKa=oRyUO{irZf7uGi0cwHg6-&z-bNmBpwJIhmY`cOv_$6;EL;5| zCQ2M}pchff9dLCBg;2<85oBXew|0@Cr3Fk7A~@4x9TV1(#LqO1!p_c46Xj8Erjf;O zSI$D7G0!DvM2TxRJ<>f(yO0rdFFyF+km&ie^pUA-Yjr{0wP0i-AaMU6Nl@LT*BtnZVy>Z)x7M0Ov zRIW&(1tu3;U9{Ro!k8F_0X5z0Iyx;OzOF8ks1)8S63b;M9*(}@*i91-4QxbOtYzp9 zZ2f*nV5;2G9cbj#>*)*}5-3ICHJJ)T~MVrXzvg$USfwiL|G0cc49x9HW)Moi? z%0z&W9^=c0x92V%p=|Ob2exd(>wxZh%t;lJj9HYZvMK8>a=f~|;^+r%Lx+GY>n)e3 z0jP>{B~i!jl&oElP>MZf!>5E%i?-)MZ-F{ZJpmM_gkfOK1`=EM?%4}==vDyZq0&<0i< zgsjqQZE_wHE`)iEH9!&)d)3tPe3e6jjK$=zHUHEREeA02B(Js*+2}f*7AnR15#seb z!r08*T*MQZ=S%kB$iQarv)64cJ4h6Gz9>lU>G%E3%trxx>0ZZnyAEQ>tE5EwhUl#O z(7g<4Yg*x1%cMXtT_>rG_~QsfDj9q}6e*EU9P5#He3C$`x=XTCDx886Xa!0Qc^>47 z8^-xm2@6TuB8kfD;yj%FsGTrfnm^2_WZdey9yu_U?sRpLgZ&K2j4VXzX{-=s#0vE+ zj|L@HjB#S+EUyYLhL#uaq~Hw@z2T5k7d80|3B5cA&SR4Jbm8g41V|4-6$p}BhpKLMdvGkLM`F^N!g(qnF^I4p-l>)>eTNoXni51h%!n20 zL6&tp8bQOJ)w?YZUL#3LN%9OLiFe!ZIssQb@tL(gfE}x#CvsCxvltDLWQ#$BGHxJ0 z>co}MG(?K=ogJe^AebJ!a`_pSO7P%!UQ-a^!6&|Xd7L5P!6&{aknrFWKfe5BhJ*)+ zcn+T`j_{8D@nt%alh^mP*0ybL56TH##POvR3lgw`Fl?}QCC6-3bmz7!vNuYO6IjJ> z6ptRO!P(H$=HTFh?-ZXt&W1cZcZz4&RNz5&HqgFKt69HQPLDHNH4V?U5HlpsKY_*& zTavf@iPrXX!`^4|;=mQi%Unr3A{`SbCg}3&@ck=ebn5UMnmN3bcHnLcUZ_{$z570# z#q^dx{aB{tM8My)$(0YBLU^if^sxdlRbtlY5sqHZl3m`%VN%$>@)TP*co^XRO*j^s zpe@FEpB4D9VY?n_g*VMz3%Xt8ATof<#Cz0H$8I3k1SV4YgAC{aVnR^q$>_w_;FXEBbD&6lo<}}R43tU8 zX_b2`(HIbyUOIDBv}H1QoDL+WsEd>-RY5vtHE$+Eni&WmmwPJ(h;qdNC&f50fIrnn zMzrY+ct|A3&@VbLKE_UPBqoG?f)4}vBx#7#TMo(~kCm$QiNl)4$5h$RY> zCX2*36g%qW`I%fMhM-OdSrxc5Fk70~)PVJ=y_VFrIb+G#MOEFsO^PL7%@|Ifl}+6+ zyRJ43-z!))aUo8R;4Mg7ZFUxJW%!(&4kbx2QbFss1#T?rE&2^bDDLOPimi(CpOn6| zzA>$YeTtqNX+qO{#$UO5iEVLs@K^lCRbH^+K_ZTwlrop3!i}qAJ&6=JRY&a(ygb9p zHqvrg@?KA7EmI{dNq}jRyc9!55)(>%x$E484M_a$zbeFw*G3;>yd+ju&ek}N-n&b; zEmU_{89kdrXqbUuQ}r3arE6odjaMGUMxZZjN7|eZHMH|6h9reyOSI^%n--$+Nll>0$Y1bxR!5)W4v@<|KbY_lGZK)oSU>TDHCfq{=Yo(tbmoIK9t*T! zWo`V6dU$Sx$KjpTC-H}Chrg)4F`{IK=?vwZUlJcWcYbTycVQT>&R_GY-l=&GvUa^a zQN~De%jCc<(Lond{B$S?0D<>bs`RG0120qXDR)|huR+ARPmvVGcJfGn58&H-@TE$W zw~{+qgD+o9cfi>KlMxB_qC3rfrBFs9xcH0A=m!`4ky`jtntl9}zQYjmkcJ;zyKt0B zJj!>*)6Amuoe{rv?L6o>cVca$UViisma&S)Ri>ox%OSJ6Sy_hYz};>EE``bMIE?UH z*Nvh7&;Jwn&6_8aQ~U5o5TE^H>jnJo&D@!xiHV`FCJvo@Ze;@g?qWD`cw(6T;LmQJ z!Ow2%Ll^Mz!>=Xi)5(cpBQd;Hn;1rk7u#)MUL0??XHqYI>+9|I#PGeu@j7z7 r$%an<)Zv4VwtroF;-}NU`@`$|uXlg{;DvAh_TTSLfAIFt{#5u6Av{&) From 550d8d4f5ff3c602360f1ee92a711cb814635aa5 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 13 Jan 2019 17:38:39 +0100 Subject: [PATCH 02/20] improving sql, fixing tests --- backend/middlewares/AdminMWs.ts | 8 +- backend/model/ObjectManagerRepository.ts | 45 +- backend/model/interfaces/IIndexingManager.ts | 10 +- .../model/interfaces/IIndexingTaskManager.ts | 11 + backend/model/interfaces/IPersonManager.ts | 7 + backend/model/memory/IndexingManager.ts | 14 +- backend/model/memory/IndexingTaskManager.ts | 21 + backend/model/memory/PersonManager.ts | 11 + backend/model/sql/GalleryManager.ts | 398 +++-------------- backend/model/sql/IGalleryManager.ts | 2 - backend/model/sql/IndexingManager.ts | 404 +++++++++++++----- backend/model/sql/IndexingTaskManager.ts | 118 +++++ backend/model/sql/PersonManager.ts | 50 +++ backend/model/sql/SQLConnection.ts | 2 +- backend/{tsconfig.json => tsconfigX.jsonX} | 0 common/entities/PhotoDTO.ts | 2 +- package.json | 2 +- .../unit/assets/test image öüóőúéáű-.,.jpg | Bin 59011 -> 59187 bytes .../unit/assets/test image öüóőúéáű-.,.json | 45 ++ .../{GalleryManager.ts => IndexingManager.ts} | 58 ++- test/backend/unit/model/sql/TestHelper.ts | 37 +- .../model/threading/DiskMangerWorker.spec.ts | 32 +- .../model/threading/MetaDataLoader.spec.ts | 32 +- 23 files changed, 761 insertions(+), 548 deletions(-) create mode 100644 backend/model/interfaces/IIndexingTaskManager.ts create mode 100644 backend/model/interfaces/IPersonManager.ts create mode 100644 backend/model/memory/IndexingTaskManager.ts create mode 100644 backend/model/memory/PersonManager.ts create mode 100644 backend/model/sql/IndexingTaskManager.ts create mode 100644 backend/model/sql/PersonManager.ts rename backend/{tsconfig.json => tsconfigX.jsonX} (100%) create mode 100644 test/backend/unit/assets/test image öüóőúéáű-.,.json rename test/backend/unit/model/sql/{GalleryManager.ts => IndexingManager.ts} (85%) diff --git a/backend/middlewares/AdminMWs.ts b/backend/middlewares/AdminMWs.ts index 0175fa9..c3d4938 100644 --- a/backend/middlewares/AdminMWs.ts +++ b/backend/middlewares/AdminMWs.ts @@ -399,7 +399,7 @@ export class AdminMWs { public static startIndexing(req: Request, res: Response, next: NextFunction) { try { const createThumbnails: boolean = (req.body).createThumbnails || false; - ObjectManagerRepository.getInstance().IndexingManager.startIndexing(createThumbnails); + ObjectManagerRepository.getInstance().IndexingTaskManager.startIndexing(createThumbnails); req.resultPipe = 'ok'; return next(); } catch (err) { @@ -413,7 +413,7 @@ export class AdminMWs { public static getIndexingProgress(req: Request, res: Response, next: NextFunction) { try { - req.resultPipe = ObjectManagerRepository.getInstance().IndexingManager.getProgress(); + req.resultPipe = ObjectManagerRepository.getInstance().IndexingTaskManager.getProgress(); return next(); } catch (err) { if (err instanceof Error) { @@ -425,7 +425,7 @@ export class AdminMWs { public static cancelIndexing(req: Request, res: Response, next: NextFunction) { try { - ObjectManagerRepository.getInstance().IndexingManager.cancelIndexing(); + ObjectManagerRepository.getInstance().IndexingTaskManager.cancelIndexing(); req.resultPipe = 'ok'; return next(); } catch (err) { @@ -438,7 +438,7 @@ export class AdminMWs { public static async resetIndexes(req: Express.Request, res: Response, next: NextFunction) { try { - await ObjectManagerRepository.getInstance().IndexingManager.reset(); + await ObjectManagerRepository.getInstance().IndexingTaskManager.reset(); req.resultPipe = 'ok'; return next(); } catch (err) { diff --git a/backend/model/ObjectManagerRepository.ts b/backend/model/ObjectManagerRepository.ts index 87a1e0e..01a8cfd 100644 --- a/backend/model/ObjectManagerRepository.ts +++ b/backend/model/ObjectManagerRepository.ts @@ -4,7 +4,9 @@ import {ISearchManager} from './interfaces/ISearchManager'; import {SQLConnection} from './sql/SQLConnection'; import {ISharingManager} from './interfaces/ISharingManager'; import {Logger} from '../Logger'; +import {IIndexingTaskManager} from './interfaces/IIndexingTaskManager'; import {IIndexingManager} from './interfaces/IIndexingManager'; +import {IPersonManager} from './interfaces/IPersonManager'; export class ObjectManagerRepository { @@ -15,6 +17,16 @@ export class ObjectManagerRepository { private _searchManager: ISearchManager; private _sharingManager: ISharingManager; private _indexingManager: IIndexingManager; + private _indexingTaskManager: IIndexingTaskManager; + private _personManager: IPersonManager; + + get PersonManager(): IPersonManager { + return this._personManager; + } + + set PersonManager(value: IPersonManager) { + this._personManager = value; + } get IndexingManager(): IIndexingManager { return this._indexingManager; @@ -24,19 +36,14 @@ export class ObjectManagerRepository { this._indexingManager = value; } - public static getInstance() { - if (this._instance === null) { - this._instance = new ObjectManagerRepository(); - } - return this._instance; + get IndexingTaskManager(): IIndexingTaskManager { + return this._indexingTaskManager; } - public static async reset() { - await SQLConnection.close(); - this._instance = null; + set IndexingTaskManager(value: IIndexingTaskManager) { + this._indexingTaskManager = value; } - get GalleryManager(): IGalleryManager { return this._galleryManager; } @@ -69,18 +76,34 @@ export class ObjectManagerRepository { this._sharingManager = value; } + public static getInstance() { + if (this._instance === null) { + this._instance = new ObjectManagerRepository(); + } + return this._instance; + } + + public static async reset() { + await SQLConnection.close(); + this._instance = null; + } + public static async InitMemoryManagers() { await ObjectManagerRepository.reset(); const GalleryManager = require('./memory/GalleryManager').GalleryManager; const UserManager = require('./memory/UserManager').UserManager; const SearchManager = require('./memory/SearchManager').SearchManager; const SharingManager = require('./memory/SharingManager').SharingManager; + const IndexingTaskManager = require('./memory/IndexingTaskManager').IndexingTaskManager; const IndexingManager = require('./memory/IndexingManager').IndexingManager; + const PersonManager = require('./memory/PersonManager').PersonManager; ObjectManagerRepository.getInstance().GalleryManager = new GalleryManager(); ObjectManagerRepository.getInstance().UserManager = new UserManager(); ObjectManagerRepository.getInstance().SearchManager = new SearchManager(); ObjectManagerRepository.getInstance().SharingManager = new SharingManager(); + ObjectManagerRepository.getInstance().IndexingTaskManager = new IndexingTaskManager(); ObjectManagerRepository.getInstance().IndexingManager = new IndexingManager(); + ObjectManagerRepository.getInstance().PersonManager = new PersonManager(); } public static async InitSQLManagers() { @@ -90,12 +113,16 @@ export class ObjectManagerRepository { const UserManager = require('./sql/UserManager').UserManager; const SearchManager = require('./sql/SearchManager').SearchManager; const SharingManager = require('./sql/SharingManager').SharingManager; + const IndexingTaskManager = require('./sql/IndexingManager').IndexingTaskManager; const IndexingManager = require('./sql/IndexingManager').IndexingManager; + const PersonManager = require('./sql/PersonManager').PersonManager; ObjectManagerRepository.getInstance().GalleryManager = new GalleryManager(); ObjectManagerRepository.getInstance().UserManager = new UserManager(); ObjectManagerRepository.getInstance().SearchManager = new SearchManager(); ObjectManagerRepository.getInstance().SharingManager = new SharingManager(); + ObjectManagerRepository.getInstance().IndexingTaskManager = new IndexingTaskManager(); ObjectManagerRepository.getInstance().IndexingManager = new IndexingManager(); + ObjectManagerRepository.getInstance().PersonManager = new PersonManager(); Logger.debug('SQL DB inited'); } diff --git a/backend/model/interfaces/IIndexingManager.ts b/backend/model/interfaces/IIndexingManager.ts index c741e9a..869b9b8 100644 --- a/backend/model/interfaces/IIndexingManager.ts +++ b/backend/model/interfaces/IIndexingManager.ts @@ -1,11 +1,5 @@ -import {IndexingProgressDTO} from '../../../common/entities/settings/IndexingProgressDTO'; +import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; export interface IIndexingManager { - startIndexing(createThumbnails?: boolean): void; - - getProgress(): IndexingProgressDTO; - - cancelIndexing(): void; - - reset(): Promise; + indexDirectory(relativeDirectoryName: string): Promise; } diff --git a/backend/model/interfaces/IIndexingTaskManager.ts b/backend/model/interfaces/IIndexingTaskManager.ts new file mode 100644 index 0000000..af4eea1 --- /dev/null +++ b/backend/model/interfaces/IIndexingTaskManager.ts @@ -0,0 +1,11 @@ +import {IndexingProgressDTO} from '../../../common/entities/settings/IndexingProgressDTO'; + +export interface IIndexingTaskManager { + startIndexing(createThumbnails?: boolean): void; + + getProgress(): IndexingProgressDTO; + + cancelIndexing(): void; + + reset(): Promise; +} diff --git a/backend/model/interfaces/IPersonManager.ts b/backend/model/interfaces/IPersonManager.ts new file mode 100644 index 0000000..dd23adf --- /dev/null +++ b/backend/model/interfaces/IPersonManager.ts @@ -0,0 +1,7 @@ +import {PersonEntry} from '../sql/enitites/PersonEntry'; + +export interface IPersonManager { + get(name: string): Promise; + + saveAll(names: string[]): Promise; +} diff --git a/backend/model/memory/IndexingManager.ts b/backend/model/memory/IndexingManager.ts index 6bd861e..71b1d88 100644 --- a/backend/model/memory/IndexingManager.ts +++ b/backend/model/memory/IndexingManager.ts @@ -1,21 +1,11 @@ import {IIndexingManager} from '../interfaces/IIndexingManager'; -import {IndexingProgressDTO} from '../../../common/entities/settings/IndexingProgressDTO'; +import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; export class IndexingManager implements IIndexingManager { - startIndexing(): void { + indexDirectory(relativeDirectoryName: string): Promise { throw new Error('not supported by memory DB'); } - getProgress(): IndexingProgressDTO { - throw new Error('not supported by memory DB'); - } - cancelIndexing(): void { - throw new Error('not supported by memory DB'); - } - - reset(): Promise { - throw new Error('Method not implemented.'); - } } diff --git a/backend/model/memory/IndexingTaskManager.ts b/backend/model/memory/IndexingTaskManager.ts new file mode 100644 index 0000000..9b9880e --- /dev/null +++ b/backend/model/memory/IndexingTaskManager.ts @@ -0,0 +1,21 @@ +import {IIndexingTaskManager} from '../interfaces/IIndexingTaskManager'; +import {IndexingProgressDTO} from '../../../common/entities/settings/IndexingProgressDTO'; + +export class IndexingTaskManager implements IIndexingTaskManager { + + startIndexing(): void { + throw new Error('not supported by memory DB'); + } + + getProgress(): IndexingProgressDTO { + throw new Error('not supported by memory DB'); + } + + cancelIndexing(): void { + throw new Error('not supported by memory DB'); + } + + reset(): Promise { + throw new Error('Method not implemented.'); + } +} diff --git a/backend/model/memory/PersonManager.ts b/backend/model/memory/PersonManager.ts new file mode 100644 index 0000000..b8c1265 --- /dev/null +++ b/backend/model/memory/PersonManager.ts @@ -0,0 +1,11 @@ +import {IPersonManager} from '../interfaces/IPersonManager'; + +export class IndexingTaskManager implements IPersonManager { + get(name: string): Promise { + throw new Error('not supported by memory DB'); + } + + saveAll(names: string[]): Promise { + throw new Error('not supported by memory DB'); + } +} diff --git a/backend/model/sql/GalleryManager.ts b/backend/model/sql/GalleryManager.ts index 45e948a..ae13d46 100644 --- a/backend/model/sql/GalleryManager.ts +++ b/backend/model/sql/GalleryManager.ts @@ -4,94 +4,25 @@ import * as path from 'path'; import * as fs from 'fs'; import {DirectoryEntity} from './enitites/DirectoryEntity'; import {SQLConnection} from './SQLConnection'; -import {DiskManager} from '../DiskManger'; -import {PhotoEntity, PhotoMetadataEntity} from './enitites/PhotoEntity'; -import {Utils} from '../../../common/Utils'; +import {PhotoEntity} from './enitites/PhotoEntity'; import {ProjectPath} from '../../ProjectPath'; import {Config} from '../../../common/config/private/Config'; import {ISQLGalleryManager} from './IGalleryManager'; import {DatabaseType, ReIndexingSensitivity} from '../../../common/config/private/IPrivateConfig'; -import {FaceRegion, PhotoDTO, PhotoMetadata} from '../../../common/entities/PhotoDTO'; +import {PhotoDTO} from '../../../common/entities/PhotoDTO'; import {OrientationType} from '../../../common/entities/RandomQueryDTO'; -import {Brackets, Connection, Transaction, TransactionRepository, Repository} from 'typeorm'; +import {Brackets, Connection} from 'typeorm'; import {MediaEntity} from './enitites/MediaEntity'; -import {MediaDTO} from '../../../common/entities/MediaDTO'; import {VideoEntity} from './enitites/VideoEntity'; -import {FileEntity} from './enitites/FileEntity'; -import {FileDTO} from '../../../common/entities/FileDTO'; -import {NotificationManager} from '../NotifocationManager'; import {DiskMangerWorker} from '../threading/DiskMangerWorker'; import {Logger} from '../../Logger'; import {FaceRegionEntry} from './enitites/FaceRegionEntry'; -import {PersonEntry} from './enitites/PersonEntry'; +import {ObjectManagerRepository} from '../ObjectManagerRepository'; const LOG_TAG = '[GalleryManager]'; export class GalleryManager implements IGalleryManager, ISQLGalleryManager { - private savingQueue: DirectoryDTO[] = []; - private isSaving = false; - - protected async selectParentDir(connection: Connection, directoryName: string, directoryParent: string): Promise { - const query = connection - .getRepository(DirectoryEntity) - .createQueryBuilder('directory') - .where('directory.name = :name AND directory.path = :path', { - name: directoryName, - path: directoryParent - }) - .leftJoinAndSelect('directory.directories', 'directories') - .leftJoinAndSelect('directory.media', 'media'); - - if (Config.Client.MetaFile.enabled === true) { - query.leftJoinAndSelect('directory.metaFile', 'metaFile'); - } - - return await query.getOne(); - } - - protected async fillParentDir(connection: Connection, dir: DirectoryEntity): Promise { - if (dir.media) { - const indexedFaces = await connection.getRepository(FaceRegionEntry) - .createQueryBuilder('face') - .leftJoinAndSelect('face.media', 'media') - .where('media.directory = :directory', { - directory: dir.id - }) - .leftJoinAndSelect('face.person', 'person') - .getMany(); - for (let i = 0; i < dir.media.length; i++) { - dir.media[i].directory = dir; - dir.media[i].readyThumbnails = []; - dir.media[i].readyIcon = false; - (dir.media[i]).metadata.faces = indexedFaces - .filter(fe => fe.media.id === dir.media[i].id) - .map(f => ({box: f.box, name: f.person.name})); - } - - } - if (dir.directories) { - for (let i = 0; i < dir.directories.length; i++) { - dir.directories[i].media = await connection - .getRepository(MediaEntity) - .createQueryBuilder('media') - .where('media.directory = :dir', { - dir: dir.directories[i].id - }) - .orderBy('media.metadata.creationDate', 'ASC') - .limit(Config.Server.indexing.folderPreviewSize) - .getMany(); - dir.directories[i].isPartial = true; - - for (let j = 0; j < dir.directories[i].media.length; j++) { - dir.directories[i].media[j].directory = dir.directories[i]; - dir.directories[i].media[j].readyThumbnails = []; - dir.directories[i].media[j].readyIcon = false; - } - } - } - } - public async listDirectory(relativeDirectoryName: string, knownLastModified?: number, @@ -124,7 +55,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { if (dir.lastModified !== lastModified) { Logger.silly(LOG_TAG, 'Reindexing reason: lastModified mismatch: known: ' + dir.lastModified + ', current:' + lastModified); - return this.indexDirectory(relativeDirectoryName); + return ObjectManagerRepository.getInstance().IndexingManager.indexDirectory(relativeDirectoryName); } @@ -136,7 +67,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { Logger.silly(LOG_TAG, 'lazy reindexing reason: cache timeout: lastScanned: ' + (Date.now() - dir.lastScanned) + ', cachedFolderTimeout:' + Config.Server.indexing.cachedFolderTimeout); - this.indexDirectory(relativeDirectoryName).catch((err) => { + ObjectManagerRepository.getInstance().IndexingManager.indexDirectory(relativeDirectoryName).catch((err) => { console.error(err); }); } @@ -146,33 +77,11 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { // never scanned (deep indexed), do it and return with it Logger.silly(LOG_TAG, 'Reindexing reason: never scanned'); - return this.indexDirectory(relativeDirectoryName); + return ObjectManagerRepository.getInstance().IndexingManager.indexDirectory(relativeDirectoryName); } - - public indexDirectory(relativeDirectoryName: string): Promise { - return new Promise(async (resolve, reject) => { - try { - const scannedDirectory = await DiskManager.scanDirectory(relativeDirectoryName); - - // returning with the result - scannedDirectory.media.forEach(p => p.readyThumbnails = []); - resolve(scannedDirectory); - - this.queueForSave(scannedDirectory).catch(console.error); - - } catch (error) { - NotificationManager.warning('Unknown indexing error for: ' + relativeDirectoryName, error.toString()); - console.error(error); - return reject(error); - } - - }); - } - - public async getRandomPhoto(queryFilter: RandomQuery): Promise { const connection = await SQLConnection.getConnection(); const photosRepository = connection.getRepository(PhotoEntity); @@ -230,236 +139,6 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { } - // Todo fix it, once typeorm support connection pools ofr sqlite - protected async queueForSave(scannedDirectory: DirectoryDTO) { - if (this.savingQueue.findIndex(dir => dir.name === scannedDirectory.name && - dir.path === scannedDirectory.path && - dir.lastModified === scannedDirectory.lastModified && - dir.lastScanned === scannedDirectory.lastScanned && - (dir.media || dir.media.length) === (scannedDirectory.media || scannedDirectory.media.length) && - (dir.metaFile || dir.metaFile.length) === (scannedDirectory.metaFile || scannedDirectory.metaFile.length)) !== -1) { - return; - } - this.savingQueue.push(scannedDirectory); - while (this.isSaving === false && this.savingQueue.length > 0) { - await this.saveToDB(this.savingQueue[0]); - this.savingQueue.shift(); - } - - } - - protected async saveToDB(scannedDirectory: DirectoryDTO) { - console.log('saving'); - this.isSaving = true; - try { - const connection = await SQLConnection.getConnection(); - - // saving to db - const directoryRepository = connection.getRepository(DirectoryEntity); - const mediaRepository = connection.getRepository(MediaEntity); - const fileRepository = connection.getRepository(FileEntity); - - - let currentDir: DirectoryEntity = await directoryRepository.createQueryBuilder('directory') - .where('directory.name = :name AND directory.path = :path', { - name: scannedDirectory.name, - path: scannedDirectory.path - }).getOne(); - if (!!currentDir) {// Updated parent dir (if it was in the DB previously) - currentDir.lastModified = scannedDirectory.lastModified; - currentDir.lastScanned = scannedDirectory.lastScanned; - currentDir.mediaCount = scannedDirectory.mediaCount; - currentDir = await directoryRepository.save(currentDir); - } else { - currentDir = await directoryRepository.save(scannedDirectory); - } - - // TODO: fix when first opened directory is not root - // save subdirectories - const childDirectories = await directoryRepository.createQueryBuilder('directory') - .where('directory.parent = :dir', { - dir: currentDir.id - }).getMany(); - - for (let i = 0; i < scannedDirectory.directories.length; i++) { - // Was this child Dir already indexed before? - let directory: DirectoryEntity = null; - for (let j = 0; j < childDirectories.length; j++) { - if (childDirectories[j].name === scannedDirectory.directories[i].name) { - directory = childDirectories[j]; - childDirectories.splice(j, 1); - break; - } - } - - if (directory != null) { // update existing directory - if (!directory.parent || !directory.parent.id) { // set parent if not set yet - directory.parent = currentDir; - delete directory.media; - await directoryRepository.save(directory); - } - } else { // dir does not exists yet - scannedDirectory.directories[i].parent = currentDir; - (scannedDirectory.directories[i]).lastScanned = null; // new child dir, not fully scanned yet - const d = await directoryRepository.save(scannedDirectory.directories[i]); - for (let j = 0; j < scannedDirectory.directories[i].media.length; j++) { - scannedDirectory.directories[i].media[j].directory = d; - } - - await this.saveMedia(connection, scannedDirectory.directories[i].media); - } - } - - // Remove child Dirs that are not anymore in the parent dir - await directoryRepository.remove(childDirectories, {chunk: Math.max(Math.ceil(childDirectories.length / 500), 1)}); - - // save media - const indexedMedia = (await mediaRepository.createQueryBuilder('media') - .where('media.directory = :dir', { - dir: currentDir.id - }) - .getMany()); - - - const mediaToSave = []; - for (let i = 0; i < scannedDirectory.media.length; i++) { - let media: MediaDTO = null; - for (let j = 0; j < indexedMedia.length; j++) { - if (indexedMedia[j].name === scannedDirectory.media[i].name) { - media = indexedMedia[j]; - indexedMedia.splice(j, 1); - break; - } - } - - - if (media == null) { // not in DB yet - scannedDirectory.media[i].directory = null; - media = Utils.clone(scannedDirectory.media[i]); - scannedDirectory.media[i].directory = scannedDirectory; - media.directory = currentDir; - mediaToSave.push(media); - } else { - delete (media.metadata).faces; - - if (!Utils.equalsFilter(media.metadata, scannedDirectory.media[i].metadata)) { - media.metadata = scannedDirectory.media[i].metadata; - mediaToSave.push(media); - } - } - const scannedFaces = (scannedDirectory.media[i].metadata).faces; - delete (scannedDirectory.media[i].metadata).faces; - - const mediaEntry = await this.saveAMedia(connection, media); - - await this.saveFaces(connection, mediaEntry, scannedFaces); - } - // await this.saveMedia(connection, mediaToSave); - await mediaRepository.remove(indexedMedia); - - - // save files - const indexedMetaFiles = await fileRepository.createQueryBuilder('file') - .where('file.directory = :dir', { - dir: currentDir.id - }).getMany(); - - - const metaFilesToSave = []; - for (let i = 0; i < scannedDirectory.metaFile.length; i++) { - let metaFile: FileDTO = null; - for (let j = 0; j < indexedMetaFiles.length; j++) { - if (indexedMetaFiles[j].name === scannedDirectory.metaFile[i].name) { - metaFile = indexedMetaFiles[j]; - indexedMetaFiles.splice(j, 1); - break; - } - } - if (metaFile == null) { // not in DB yet - scannedDirectory.metaFile[i].directory = null; - metaFile = Utils.clone(scannedDirectory.metaFile[i]); - scannedDirectory.metaFile[i].directory = scannedDirectory; - metaFile.directory = currentDir; - metaFilesToSave.push(metaFile); - } - } - await fileRepository.save(metaFilesToSave, {chunk: Math.max(Math.ceil(metaFilesToSave.length / 500), 1)}); - await fileRepository.remove(indexedMetaFiles, {chunk: Math.max(Math.ceil(indexedMetaFiles.length / 500), 1)}); - } catch (e) { - throw e; - } finally { - this.isSaving = false; - } - } - - protected async saveFaces(connection: Connection, media: MediaEntity, scannedFaces: FaceRegion[]) { - - const faceRepository = connection.getRepository(FaceRegionEntry); - const personRepository = connection.getRepository(PersonEntry); - const indexedPersons = await personRepository.createQueryBuilder('person').getMany(); - - const indexedFaces = await faceRepository.createQueryBuilder('face') - .where('face.media = :media', { - media: media.id - }) - .leftJoinAndSelect('face.person', 'person') - .getMany(); - - - const getPerson = async (name: string) => { - let person = indexedPersons.find(p => p.name === name); - if (!person) { - person = await personRepository.save({name: name}); - indexedPersons.push(person); - } - return person; - }; - - const faceToSave = []; - for (let i = 0; i < scannedFaces.length; i++) { - let face: FaceRegionEntry = null; - for (let j = 0; j < indexedFaces.length; j++) { - if (indexedFaces[j].box.height === scannedFaces[i].box.height && - indexedFaces[j].box.width === scannedFaces[i].box.width && - indexedFaces[j].box.x === scannedFaces[i].box.x && - indexedFaces[j].box.y === scannedFaces[i].box.y && - indexedFaces[j].person.name === scannedFaces[i].name) { - face = indexedFaces[j]; - indexedFaces.splice(j, 1); - break; - } - } - - if (face == null) { - (scannedFaces[i]).person = await getPerson(scannedFaces[i].name); - (scannedFaces[i]).media = media; - // console.log('inserting', (scannedFaces[i]).person, (scannedFaces[i]).media); - // console.log('inserting', (scannedFaces[i]).person.id, (scannedFaces[i]).media.id); - faceToSave.push(scannedFaces[i]); - } - } - await faceRepository.save(faceToSave, {chunk: Math.max(Math.ceil(faceToSave.length / 500), 1)}); - await faceRepository.remove(indexedFaces, {chunk: Math.max(Math.ceil(indexedFaces.length / 500), 1)}); - - } - - protected async saveAMedia(connection: Connection, media: MediaDTO): Promise { - if (MediaDTO.isPhoto(media)) { - return await connection.getRepository(PhotoEntity).save(media); - } - return await connection.getRepository(VideoEntity).save(media); - } - - protected async saveMedia(connection: Connection, mediaList: MediaDTO[]): Promise { - const chunked = Utils.chunkArrays(mediaList, 100); - let list: MediaEntity[] = []; - for (let i = 0; i < chunked.length; i++) { - list = list.concat(await connection.getRepository(PhotoEntity).save(chunked[i].filter(m => MediaDTO.isPhoto(m)))); - list = list.concat(await connection.getRepository(VideoEntity).save(chunked[i].filter(m => MediaDTO.isVideo(m)))); - } - return list; - } - async countDirectories(): Promise { const connection = await SQLConnection.getConnection(); return await connection.getRepository(DirectoryEntity) @@ -490,5 +169,68 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { .getCount(); } + protected async selectParentDir(connection: Connection, directoryName: string, directoryParent: string): Promise { + const query = connection + .getRepository(DirectoryEntity) + .createQueryBuilder('directory') + .where('directory.name = :name AND directory.path = :path', { + name: directoryName, + path: directoryParent + }) + .leftJoinAndSelect('directory.directories', 'directories') + .leftJoinAndSelect('directory.media', 'media'); + + if (Config.Client.MetaFile.enabled === true) { + query.leftJoinAndSelect('directory.metaFile', 'metaFile'); + } + + return await query.getOne(); + } + + protected async fillParentDir(connection: Connection, dir: DirectoryEntity): Promise { + if (dir.media) { + const indexedFaces = await connection.getRepository(FaceRegionEntry) + .createQueryBuilder('face') + .leftJoinAndSelect('face.media', 'media') + .where('media.directory = :directory', { + directory: dir.id + }) + .leftJoinAndSelect('face.person', 'person') + .select(['face.id', 'face.box.x', + 'face.box.y', 'face.box.width', 'face.box.height', + 'media.id', 'person.name', 'person.id']) + .getMany(); + for (let i = 0; i < dir.media.length; i++) { + dir.media[i].directory = dir; + dir.media[i].readyThumbnails = []; + dir.media[i].readyIcon = false; + (dir.media[i]).metadata.faces = indexedFaces + .filter(fe => fe.media.id === dir.media[i].id) + .map(f => ({box: f.box, name: f.person.name})); + } + + } + if (dir.directories) { + for (let i = 0; i < dir.directories.length; i++) { + dir.directories[i].media = await connection + .getRepository(MediaEntity) + .createQueryBuilder('media') + .where('media.directory = :dir', { + dir: dir.directories[i].id + }) + .orderBy('media.metadata.creationDate', 'ASC') + .limit(Config.Server.indexing.folderPreviewSize) + .getMany(); + dir.directories[i].isPartial = true; + + for (let j = 0; j < dir.directories[i].media.length; j++) { + dir.directories[i].media[j].directory = dir.directories[i]; + dir.directories[i].media[j].readyThumbnails = []; + dir.directories[i].media[j].readyIcon = false; + } + } + } + } + } diff --git a/backend/model/sql/IGalleryManager.ts b/backend/model/sql/IGalleryManager.ts index 8667c35..80db0ae 100644 --- a/backend/model/sql/IGalleryManager.ts +++ b/backend/model/sql/IGalleryManager.ts @@ -6,8 +6,6 @@ export interface ISQLGalleryManager extends IGalleryManager { knownLastModified?: number, knownLastScanned?: number): Promise; - indexDirectory(relativeDirectoryName: string): Promise; - countDirectories(): Promise; countPhotos(): Promise; diff --git a/backend/model/sql/IndexingManager.ts b/backend/model/sql/IndexingManager.ts index 21fb483..1c32db4 100644 --- a/backend/model/sql/IndexingManager.ts +++ b/backend/model/sql/IndexingManager.ts @@ -1,119 +1,325 @@ -import {IIndexingManager} from '../interfaces/IIndexingManager'; -import {IndexingProgressDTO} from '../../../common/entities/settings/IndexingProgressDTO'; -import {ObjectManagerRepository} from '../ObjectManagerRepository'; -import {ISQLGalleryManager} from './IGalleryManager'; -import * as path from 'path'; -import * as fs from 'fs'; -import {SQLConnection} from './SQLConnection'; +import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; import {DirectoryEntity} from './enitites/DirectoryEntity'; -import {Logger} from '../../Logger'; -import {RendererInput, ThumbnailSourceType, ThumbnailWorker} from '../threading/ThumbnailWorker'; -import {Config} from '../../../common/config/private/Config'; +import {SQLConnection} from './SQLConnection'; +import {DiskManager} from '../DiskManger'; +import {PhotoEntity} from './enitites/PhotoEntity'; +import {Utils} from '../../../common/Utils'; +import {FaceRegion, PhotoMetadata} from '../../../common/entities/PhotoDTO'; +import {Connection, Repository} from 'typeorm'; +import {MediaEntity} from './enitites/MediaEntity'; import {MediaDTO} from '../../../common/entities/MediaDTO'; -import {ProjectPath} from '../../ProjectPath'; -import {ThumbnailGeneratorMWs} from '../../middlewares/thumbnail/ThumbnailGeneratorMWs'; +import {VideoEntity} from './enitites/VideoEntity'; +import {FileEntity} from './enitites/FileEntity'; +import {FileDTO} from '../../../common/entities/FileDTO'; +import {NotificationManager} from '../NotifocationManager'; +import {FaceRegionEntry} from './enitites/FaceRegionEntry'; +import {ObjectManagerRepository} from '../ObjectManagerRepository'; const LOG_TAG = '[IndexingManager]'; -export class IndexingManager implements IIndexingManager { - directoriesToIndex: string[] = []; - indexingProgress: IndexingProgressDTO = null; - enabled = false; - private indexNewDirectory = async (createThumbnails: boolean = false) => { - if (this.directoriesToIndex.length === 0) { - this.indexingProgress = null; - if (global.gc) { - global.gc(); - } - return; - } - const directory = this.directoriesToIndex.shift(); - this.indexingProgress.current = directory; - this.indexingProgress.left = this.directoriesToIndex.length; - const scanned = await (ObjectManagerRepository.getInstance().GalleryManager).indexDirectory(directory); - if (this.enabled === false) { - return; - } - this.indexingProgress.indexed++; - this.indexingProgress.time.current = Date.now(); - for (let i = 0; i < scanned.directories.length; i++) { - this.directoriesToIndex.push(path.join(scanned.directories[i].path, scanned.directories[i].name)); - } - if (createThumbnails) { - for (let i = 0; i < scanned.media.length; i++) { - try { - const media = scanned.media[i]; - const mPath = path.join(ProjectPath.ImageFolder, media.directory.path, media.directory.name, media.name); - const thPath = path.join(ProjectPath.ThumbnailFolder, - ThumbnailGeneratorMWs.generateThumbnailName(mPath, Config.Client.Thumbnail.thumbnailSizes[0])); - if (fs.existsSync(thPath)) { // skip existing thumbnails - continue; - } - await ThumbnailWorker.render({ - type: MediaDTO.isVideo(media) ? ThumbnailSourceType.Video : ThumbnailSourceType.Image, - mediaPath: mPath, - size: Config.Client.Thumbnail.thumbnailSizes[0], - thPath: thPath, - makeSquare: false, - qualityPriority: Config.Server.thumbnail.qualityPriority - }, Config.Server.thumbnail.processingLibrary); - } catch (e) { - console.error(e); - Logger.error(LOG_TAG, 'Error during indexing job: ' + e.toString()); - } +export class IndexingManager { + + private savingQueue: DirectoryDTO[] = []; + private isSaving = false; + + public indexDirectory(relativeDirectoryName: string): Promise { + return new Promise(async (resolve, reject) => { + try { + const scannedDirectory = await DiskManager.scanDirectory(relativeDirectoryName); + + // returning with the result + scannedDirectory.media.forEach(p => p.readyThumbnails = []); + resolve(scannedDirectory); + + this.queueForSave(scannedDirectory).catch(console.error); + + } catch (error) { + NotificationManager.warning('Unknown indexing error for: ' + relativeDirectoryName, error.toString()); + console.error(error); + return reject(error); } - } - process.nextTick(() => { - this.indexNewDirectory(createThumbnails); }); - }; + } + + // Todo fix it, once typeorm support connection pools ofr sqlite + protected async queueForSave(scannedDirectory: DirectoryDTO) { + if (this.savingQueue.findIndex(dir => dir.name === scannedDirectory.name && + dir.path === scannedDirectory.path && + dir.lastModified === scannedDirectory.lastModified && + dir.lastScanned === scannedDirectory.lastScanned && + (dir.media || dir.media.length) === (scannedDirectory.media || scannedDirectory.media.length) && + (dir.metaFile || dir.metaFile.length) === (scannedDirectory.metaFile || scannedDirectory.metaFile.length)) !== -1) { + return; + } + this.savingQueue.push(scannedDirectory); + while (this.isSaving === false && this.savingQueue.length > 0) { + await this.saveToDB(this.savingQueue[0]); + this.savingQueue.shift(); + } + + } + + protected async saveParentDir(connection: Connection, scannedDirectory: DirectoryDTO): Promise { + const directoryRepository = connection.getRepository(DirectoryEntity); + + const currentDir: DirectoryEntity = await directoryRepository.createQueryBuilder('directory') + .where('directory.name = :name AND directory.path = :path', { + name: scannedDirectory.name, + path: scannedDirectory.path + }).getOne(); + if (!!currentDir) {// Updated parent dir (if it was in the DB previously) + currentDir.lastModified = scannedDirectory.lastModified; + currentDir.lastScanned = scannedDirectory.lastScanned; + currentDir.mediaCount = scannedDirectory.mediaCount; + await directoryRepository.save(currentDir); + return currentDir.id; - startIndexing(createThumbnails: boolean = false): void { - if (this.directoriesToIndex.length === 0 && this.enabled === false) { - Logger.info(LOG_TAG, 'Starting indexing'); - this.indexingProgress = { - indexed: 0, - left: 0, - current: '', - time: { - start: Date.now(), - current: Date.now() - } - }; - this.directoriesToIndex.push('/'); - this.enabled = true; - this.indexNewDirectory(createThumbnails); } else { - Logger.info(LOG_TAG, 'Already indexing..'); + return (await directoryRepository.insert({ + mediaCount: scannedDirectory.mediaCount, + lastModified: scannedDirectory.lastModified, + lastScanned: scannedDirectory.lastScanned, + name: scannedDirectory.name, + path: scannedDirectory.path + })).identifiers[0].id; } } - getProgress(): IndexingProgressDTO { - return this.indexingProgress; + protected async saveChildDirs(connection: Connection, currentDirId: number, scannedDirectory: DirectoryDTO) { + const directoryRepository = connection.getRepository(DirectoryEntity); + // TODO: fix when first opened directory is not root + // save subdirectories + const childDirectories = await directoryRepository.createQueryBuilder('directory') + .where('directory.parent = :dir', { + dir: currentDirId + }).getMany(); + + for (let i = 0; i < scannedDirectory.directories.length; i++) { + // Was this child Dir already indexed before? + let directory: DirectoryEntity = null; + for (let j = 0; j < childDirectories.length; j++) { + if (childDirectories[j].name === scannedDirectory.directories[i].name) { + directory = childDirectories[j]; + childDirectories.splice(j, 1); + break; + } + } + + if (directory != null) { // update existing directory + if (!directory.parent || !directory.parent.id) { // set parent if not set yet + directory.parent = {id: currentDirId}; + delete directory.media; + await directoryRepository.save(directory); + } + } else { // dir does not exists yet + scannedDirectory.directories[i].parent = {id: currentDirId}; + (scannedDirectory.directories[i]).lastScanned = null; // new child dir, not fully scanned yet + const d = await directoryRepository.insert(scannedDirectory.directories[i]); + + await this.saveMedia(connection, d.identifiers[0].id, scannedDirectory.directories[i].media); + } + } + + // Remove child Dirs that are not anymore in the parent dir + await directoryRepository.remove(childDirectories, {chunk: Math.max(Math.ceil(childDirectories.length / 500), 1)}); + } - cancelIndexing(): void { - Logger.info(LOG_TAG, 'Canceling indexing'); - this.directoriesToIndex = []; - this.indexingProgress = null; - this.enabled = false; - if (global.gc) { - global.gc(); + protected async saveMetaFiles(connection: Connection, currentDirID: number, scannedDirectory: DirectoryDTO) { + const fileRepository = connection.getRepository(FileEntity); + // save files + const indexedMetaFiles = await fileRepository.createQueryBuilder('file') + .where('file.directory = :dir', { + dir: currentDirID + }).getMany(); + + + const metaFilesToSave = []; + for (let i = 0; i < scannedDirectory.metaFile.length; i++) { + let metaFile: FileDTO = null; + for (let j = 0; j < indexedMetaFiles.length; j++) { + if (indexedMetaFiles[j].name === scannedDirectory.metaFile[i].name) { + metaFile = indexedMetaFiles[j]; + indexedMetaFiles.splice(j, 1); + break; + } + } + if (metaFile == null) { // not in DB yet + scannedDirectory.metaFile[i].directory = null; + metaFile = Utils.clone(scannedDirectory.metaFile[i]); + scannedDirectory.metaFile[i].directory = scannedDirectory; + metaFile.directory = {id: currentDirID}; + metaFilesToSave.push(metaFile); + } + } + await fileRepository.save(metaFilesToSave, {chunk: Math.max(Math.ceil(metaFilesToSave.length / 500), 1)}); + await fileRepository.remove(indexedMetaFiles, {chunk: Math.max(Math.ceil(indexedMetaFiles.length / 500), 1)}); + } + + protected async saveMedia(connection: Connection, parentDirId: number, media: MediaDTO[]) { + const mediaRepository = connection.getRepository(MediaEntity); + const photoRepository = connection.getRepository(PhotoEntity); + const videoRepository = connection.getRepository(VideoEntity); + // save media + let indexedMedia = (await mediaRepository.createQueryBuilder('media') + .where('media.directory = :dir', { + dir: parentDirId + }) + .getMany()); + + const mediaChange: any = { + saveP: [], + saveV: [], + insertP: [], + insertV: [] + }; + const facesPerPhoto: { faces: FaceRegionEntry[], mediaName: string }[] = []; + for (let i = 0; i < media.length; i++) { + let mediaItem: MediaEntity = null; + for (let j = 0; j < indexedMedia.length; j++) { + if (indexedMedia[j].name === media[i].name) { + mediaItem = indexedMedia[j]; + indexedMedia.splice(j, 1); + break; + } + } + + const scannedFaces = (media[i].metadata).faces || []; + delete (media[i].metadata).faces; + + // let mediaItemId: number = null; + if (mediaItem == null) { // not in DB yet + media[i].directory = null; + mediaItem = Utils.clone(media[i]); + mediaItem.directory = {id: parentDirId}; + (MediaDTO.isPhoto(mediaItem) ? mediaChange.insertP : mediaChange.insertV).push(mediaItem); + } else { + delete (mediaItem.metadata).faces; + if (!Utils.equalsFilter(mediaItem.metadata, media[i].metadata)) { + mediaItem.metadata = media[i].metadata; + (MediaDTO.isPhoto(mediaItem) ? mediaChange.saveP : mediaChange.saveV).push(mediaItem); + + } + } + + facesPerPhoto.push({faces: scannedFaces as FaceRegionEntry[], mediaName: mediaItem.name}); + } + + await this.saveChunk(photoRepository, mediaChange.saveP, 100); + await this.saveChunk(videoRepository, mediaChange.saveV, 100); + await this.saveChunk(photoRepository, mediaChange.insertP, 100); + await this.saveChunk(videoRepository, mediaChange.insertV, 100); + + indexedMedia = (await mediaRepository.createQueryBuilder('media') + .where('media.directory = :dir', { + dir: parentDirId + }) + .select(['media.name', 'media.id']) + .getMany()); + + const faces: FaceRegionEntry[] = []; + facesPerPhoto.forEach(group => { + const mIndex = indexedMedia.findIndex(m => m.name === group.mediaName); + group.faces.forEach((sf: FaceRegionEntry) => sf.media = {id: indexedMedia[mIndex].id}); + + faces.push(...group.faces); + indexedMedia.splice(mIndex, 1); + }); + + await this.saveFaces(connection, parentDirId, faces); + await mediaRepository.remove(indexedMedia); + } + + protected async saveFaces(connection: Connection, parentDirId: number, scannedFaces: FaceRegion[]) { + const faceRepository = connection.getRepository(FaceRegionEntry); + + const persons: string[] = []; + + for (let i = 0; i < scannedFaces.length; i++) { + if (persons.indexOf(scannedFaces[i].name) === -1) { + persons.push(scannedFaces[i].name); + } + } + await ObjectManagerRepository.getInstance().PersonManager.saveAll(persons); + + + const indexedFaces = await faceRepository.createQueryBuilder('face') + .leftJoin('face.media', 'media') + .where('media.directory = :directory', { + directory: parentDirId + }) + .leftJoinAndSelect('face.person', 'person') + .getMany(); + + + const faceToInsert = []; + for (let i = 0; i < scannedFaces.length; i++) { + let face: FaceRegionEntry = null; + for (let j = 0; j < indexedFaces.length; j++) { + if (indexedFaces[j].box.height === scannedFaces[i].box.height && + indexedFaces[j].box.width === scannedFaces[i].box.width && + indexedFaces[j].box.x === scannedFaces[i].box.x && + indexedFaces[j].box.y === scannedFaces[i].box.y && + indexedFaces[j].person.name === scannedFaces[i].name) { + face = indexedFaces[j]; + indexedFaces.splice(j, 1); + break; + } + } + + if (face == null) { + (scannedFaces[i]).person = await ObjectManagerRepository.getInstance().PersonManager.get(scannedFaces[i].name); + faceToInsert.push(scannedFaces[i]); + } + } + if (faceToInsert.length > 0) { + await this.insertChunk(faceRepository, faceToInsert, 100); + } + await faceRepository.remove(indexedFaces, {chunk: Math.max(Math.ceil(indexedFaces.length / 500), 1)}); + + } + + protected async saveToDB(scannedDirectory: DirectoryDTO): Promise { + this.isSaving = true; + try { + const connection = await SQLConnection.getConnection(); + const currentDirId: number = await this.saveParentDir(connection, scannedDirectory); + await this.saveChildDirs(connection, currentDirId, scannedDirectory); + await this.saveMedia(connection, currentDirId, scannedDirectory.media); + await this.saveMetaFiles(connection, currentDirId, scannedDirectory); + } catch (e) { + throw e; + } finally { + this.isSaving = false; } } - async reset(): Promise { - Logger.info(LOG_TAG, 'Resetting DB'); - this.directoriesToIndex = []; - this.indexingProgress = null; - this.enabled = false; - const connection = await SQLConnection.getConnection(); - return connection - .getRepository(DirectoryEntity) - .createQueryBuilder('directory') - .delete() - .execute().then(() => { - }); + private async saveChunk(repository: Repository, entities: T[], size: number): Promise { + if (entities.length === 0) { + return []; + } + if (entities.length < size) { + return await repository.save(entities); + } + let list: T[] = []; + for (let i = 0; i < entities.length / size; i++) { + list = list.concat(await repository.save(entities.slice(i * size, (i + 1) * size))); + } + return list; + } + + private async insertChunk(repository: Repository, entities: T[], size: number): Promise { + if (entities.length === 0) { + return []; + } + if (entities.length < size) { + return (await repository.insert(entities)).identifiers.map((i: any) => i.id); + } + let list: number[] = []; + for (let i = 0; i < entities.length / size; i++) { + list = list.concat((await repository.insert(entities.slice(i * size, (i + 1) * size))).identifiers.map(ids => ids.id)); + } + return list; } } diff --git a/backend/model/sql/IndexingTaskManager.ts b/backend/model/sql/IndexingTaskManager.ts new file mode 100644 index 0000000..ea88b6d --- /dev/null +++ b/backend/model/sql/IndexingTaskManager.ts @@ -0,0 +1,118 @@ +import {IIndexingTaskManager} from '../interfaces/IIndexingTaskManager'; +import {IndexingProgressDTO} from '../../../common/entities/settings/IndexingProgressDTO'; +import {ObjectManagerRepository} from '../ObjectManagerRepository'; +import * as path from 'path'; +import * as fs from 'fs'; +import {SQLConnection} from './SQLConnection'; +import {DirectoryEntity} from './enitites/DirectoryEntity'; +import {Logger} from '../../Logger'; +import {RendererInput, ThumbnailSourceType, ThumbnailWorker} from '../threading/ThumbnailWorker'; +import {Config} from '../../../common/config/private/Config'; +import {MediaDTO} from '../../../common/entities/MediaDTO'; +import {ProjectPath} from '../../ProjectPath'; +import {ThumbnailGeneratorMWs} from '../../middlewares/thumbnail/ThumbnailGeneratorMWs'; + +const LOG_TAG = '[IndexingTaskManager]'; + +export class IndexingTaskManager implements IIndexingTaskManager { + directoriesToIndex: string[] = []; + indexingProgress: IndexingProgressDTO = null; + enabled = false; + private indexNewDirectory = async (createThumbnails: boolean = false) => { + if (this.directoriesToIndex.length === 0) { + this.indexingProgress = null; + if (global.gc) { + global.gc(); + } + return; + } + const directory = this.directoriesToIndex.shift(); + this.indexingProgress.current = directory; + this.indexingProgress.left = this.directoriesToIndex.length; + const scanned = await ObjectManagerRepository.getInstance().IndexingManager.indexDirectory(directory); + if (this.enabled === false) { + return; + } + this.indexingProgress.indexed++; + this.indexingProgress.time.current = Date.now(); + for (let i = 0; i < scanned.directories.length; i++) { + this.directoriesToIndex.push(path.join(scanned.directories[i].path, scanned.directories[i].name)); + } + if (createThumbnails) { + for (let i = 0; i < scanned.media.length; i++) { + try { + const media = scanned.media[i]; + const mPath = path.join(ProjectPath.ImageFolder, media.directory.path, media.directory.name, media.name); + const thPath = path.join(ProjectPath.ThumbnailFolder, + ThumbnailGeneratorMWs.generateThumbnailName(mPath, Config.Client.Thumbnail.thumbnailSizes[0])); + if (fs.existsSync(thPath)) { // skip existing thumbnails + continue; + } + await ThumbnailWorker.render({ + type: MediaDTO.isVideo(media) ? ThumbnailSourceType.Video : ThumbnailSourceType.Image, + mediaPath: mPath, + size: Config.Client.Thumbnail.thumbnailSizes[0], + thPath: thPath, + makeSquare: false, + qualityPriority: Config.Server.thumbnail.qualityPriority + }, Config.Server.thumbnail.processingLibrary); + } catch (e) { + console.error(e); + Logger.error(LOG_TAG, 'Error during indexing job: ' + e.toString()); + } + } + + } + process.nextTick(() => { + this.indexNewDirectory(createThumbnails).catch(console.error); + }); + }; + + startIndexing(createThumbnails: boolean = false): void { + if (this.directoriesToIndex.length === 0 && this.enabled === false) { + Logger.info(LOG_TAG, 'Starting indexing'); + this.indexingProgress = { + indexed: 0, + left: 0, + current: '', + time: { + start: Date.now(), + current: Date.now() + } + }; + this.directoriesToIndex.push('/'); + this.enabled = true; + this.indexNewDirectory(createThumbnails).catch(console.error); + } else { + Logger.info(LOG_TAG, 'Already indexing..'); + } + } + + getProgress(): IndexingProgressDTO { + return this.indexingProgress; + } + + cancelIndexing(): void { + Logger.info(LOG_TAG, 'Canceling indexing'); + this.directoriesToIndex = []; + this.indexingProgress = null; + this.enabled = false; + if (global.gc) { + global.gc(); + } + } + + async reset(): Promise { + Logger.info(LOG_TAG, 'Resetting DB'); + this.directoriesToIndex = []; + this.indexingProgress = null; + this.enabled = false; + const connection = await SQLConnection.getConnection(); + return connection + .getRepository(DirectoryEntity) + .createQueryBuilder('directory') + .delete() + .execute().then(() => { + }); + } +} diff --git a/backend/model/sql/PersonManager.ts b/backend/model/sql/PersonManager.ts new file mode 100644 index 0000000..e13c8f4 --- /dev/null +++ b/backend/model/sql/PersonManager.ts @@ -0,0 +1,50 @@ +import {IPersonManager} from '../interfaces/IPersonManager'; +import {PersonEntry} from './enitites/PersonEntry'; +import {SQLConnection} from './SQLConnection'; + +const LOG_TAG = '[PersonManager]'; + +export class PersonManager implements IPersonManager { + + persons: PersonEntry[] = []; + + async get(name: string): Promise { + + let person = this.persons.find(p => p.name === name); + if (!person) { + const connection = await SQLConnection.getConnection(); + const personRepository = connection.getRepository(PersonEntry); + person = await personRepository.findOne({name: name}); + if (!person) { + person = await personRepository.save({name: name}); + } + this.persons.push(person); + } + return person; + } + + + async saveAll(names: string[]): Promise { + const toSave: { name: string }[] = []; + const connection = await SQLConnection.getConnection(); + const personRepository = connection.getRepository(PersonEntry); + this.persons = await personRepository.find(); + + for (let i = 0; i < names.length; i++) { + + const person = this.persons.find(p => p.name === names[i]); + if (!person) { + toSave.push({name: names[i]}); + } + } + + if (toSave.length > 0) { + for (let i = 0; i < toSave.length / 200; i++) { + await personRepository.insert(toSave.slice(i * 200, (i + 1) * 200)); + } + this.persons = await personRepository.find(); + } + + } + +} diff --git a/backend/model/sql/SQLConnection.ts b/backend/model/sql/SQLConnection.ts index 658e863..671b86b 100644 --- a/backend/model/sql/SQLConnection.ts +++ b/backend/model/sql/SQLConnection.ts @@ -44,7 +44,7 @@ export class SQLConnection { VersionEntity ]; options.synchronize = false; - options.logging = 'all'; + // options.logging = 'all'; this.connection = await createConnection(options); await SQLConnection.schemeSync(this.connection); } diff --git a/backend/tsconfig.json b/backend/tsconfigX.jsonX similarity index 100% rename from backend/tsconfig.json rename to backend/tsconfigX.jsonX diff --git a/common/entities/PhotoDTO.ts b/common/entities/PhotoDTO.ts index 23e06a7..7f605b3 100644 --- a/common/entities/PhotoDTO.ts +++ b/common/entities/PhotoDTO.ts @@ -1,6 +1,6 @@ import {DirectoryDTO} from './DirectoryDTO'; import {OrientationTypes} from 'ts-exif-parser'; -import {MediaDTO, MediaMetadata, MediaDimension} from './MediaDTO'; +import {MediaDimension, MediaDTO, MediaMetadata} from './MediaDTO'; export interface PhotoDTO extends MediaDTO { id: number; diff --git a/package.json b/package.json index 79fa259..5a8e5da 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "ts-exif-parser": "0.1.4", "ts-node-iptc": "1.0.11", "typeconfig": "1.0.7", - "typeorm": "0.2.9", + "typeorm": "0.2.11", "winston": "2.4.2", "xmldom": "0.1.27" }, diff --git a/test/backend/unit/assets/test image öüóőúéáű-.,.jpg b/test/backend/unit/assets/test image öüóőúéáű-.,.jpg index 546a986dd80103a3ef4cb52b272b3c8f7b918208..e82c52e7b1b7893bb4353699b3f140505192009f 100644 GIT binary patch delta 515 zcmZoZ%e?s<^M=WKj75_b4P`cO&|Az}Uy@o}qL7?fQmJrs;^AF~R~}w?bmQU8hqoPG ztE;D@sbQ { return super.queueForSave(scannedDirectory); } + + public async saveToDB(scannedDirectory: DirectoryDTO): Promise { + return super.saveToDB(scannedDirectory); + } } -describe('GalleryManager', () => { +describe('IndexingManager', () => { const tempDir = path.join(__dirname, '../../tmp'); @@ -50,6 +58,7 @@ describe('GalleryManager', () => { Config.Server.database.type = DatabaseType.sqlite; Config.Server.database.sqlite.storage = dbPath; + ObjectManagerRepository.getInstance().PersonManager = new PersonManager(); }; @@ -94,18 +103,19 @@ describe('GalleryManager', () => { it('should save parent directory', async () => { const gm = new GalleryManagerTest(); + const im = new IndexingManagerTest(); const parent = TestHelper.getRandomizedDirectoryEntry(); const p1 = TestHelper.getRandomizedPhotoEntry(parent, 'Photo1'); const p2 = TestHelper.getRandomizedPhotoEntry(parent, 'Photo2'); const gpx = TestHelper.getRandomizedGPXEntry(parent, 'GPX1'); const subDir = TestHelper.getRandomizedDirectoryEntry(parent, 'subDir'); - const sp1 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto1'); - const sp2 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto2'); + const sp1 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto1', 0); + const sp2 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto2', 0); DirectoryDTO.removeReferences(parent); - await gm.saveToDB(Utils.clone(parent)); + await im.saveToDB(Utils.clone(parent)); const conn = await SQLConnection.getConnection(); const selected = await gm.selectParentDir(conn, parent.name, parent.path); @@ -122,13 +132,14 @@ describe('GalleryManager', () => { it('should skip meta files', async () => { const gm = new GalleryManagerTest(); + const im = new IndexingManagerTest(); const parent = TestHelper.getRandomizedDirectoryEntry(); const p1 = TestHelper.getRandomizedPhotoEntry(parent, 'Photo1'); const p2 = TestHelper.getRandomizedPhotoEntry(parent, 'Photo2'); const gpx = TestHelper.getRandomizedGPXEntry(parent, 'GPX1'); DirectoryDTO.removeReferences(parent); Config.Client.MetaFile.enabled = true; - await gm.saveToDB(Utils.clone(parent)); + await im.saveToDB(Utils.clone(parent)); Config.Client.MetaFile.enabled = false; const conn = await SQLConnection.getConnection(); @@ -144,6 +155,7 @@ describe('GalleryManager', () => { it('should update sub directory', async () => { const gm = new GalleryManagerTest(); + const im = new IndexingManagerTest(); const parent = TestHelper.getRandomizedDirectoryEntry(); parent.name = 'parent'; @@ -153,13 +165,13 @@ describe('GalleryManager', () => { const sp1 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto1'); DirectoryDTO.removeReferences(parent); - await gm.saveToDB(Utils.clone(parent)); + await im.saveToDB(Utils.clone(parent)); const sp2 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto2'); const sp3 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto3'); DirectoryDTO.removeReferences(subDir); - await gm.saveToDB(Utils.clone(subDir)); + await im.saveToDB(Utils.clone(subDir)); const conn = await SQLConnection.getConnection(); const selected = await gm.selectParentDir(conn, subDir.name, subDir.path); @@ -179,20 +191,21 @@ describe('GalleryManager', () => { it('should avoid race condition', async () => { const conn = await SQLConnection.getConnection(); const gm = new GalleryManagerTest(); + const im = new IndexingManagerTest(); Config.Client.MetaFile.enabled = true; const parent = TestHelper.getRandomizedDirectoryEntry(); const p1 = TestHelper.getRandomizedPhotoEntry(parent, 'Photo1'); const p2 = TestHelper.getRandomizedPhotoEntry(parent, 'Photo2'); const gpx = TestHelper.getRandomizedGPXEntry(parent, 'GPX1'); const subDir = TestHelper.getRandomizedDirectoryEntry(parent, 'subDir'); - const sp1 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto1'); - const sp2 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto2'); + const sp1 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto1', 1); + const sp2 = TestHelper.getRandomizedPhotoEntry(subDir, 'subPhoto2', 1); DirectoryDTO.removeReferences(parent); - const s1 = gm.queueForSave(Utils.clone(parent)); - const s2 = gm.queueForSave(Utils.clone(parent)); - const s3 = gm.queueForSave(Utils.clone(parent)); + const s1 = im.queueForSave(Utils.clone(parent)); + const s2 = im.queueForSave(Utils.clone(parent)); + const s3 = im.queueForSave(Utils.clone(parent)); await Promise.all([s1, s2, s3]); @@ -204,30 +217,33 @@ describe('GalleryManager', () => { subDir.isPartial = true; delete subDir.directories; delete subDir.metaFile; + delete sp1.metadata.faces; + delete sp2.metadata.faces; expect(Utils.clone(Utils.removeNullOrEmptyObj(selected))) .to.deep.equal(Utils.clone(Utils.removeNullOrEmptyObj(parent))); }); - (it('should save 1500 photos', async () => { + (it('should save 1500 photos', async () => { const conn = await SQLConnection.getConnection(); const gm = new GalleryManagerTest(); + const im = new IndexingManagerTest(); Config.Client.MetaFile.enabled = true; const parent = TestHelper.getRandomizedDirectoryEntry(); DirectoryDTO.removeReferences(parent); - await gm.saveToDB(Utils.clone(parent)); + await im.saveToDB(Utils.clone(parent)); const subDir = TestHelper.getRandomizedDirectoryEntry(parent, 'subDir'); for (let i = 0; i < 1500; i++) { TestHelper.getRandomizedPhotoEntry(subDir, 'p' + i); } DirectoryDTO.removeReferences(parent); - await gm.saveToDB(subDir); + await im.saveToDB(subDir); const selected = await gm.selectParentDir(conn, subDir.name, subDir.path); expect(selected.media.length).to.deep.equal(subDir.media.length); - })).timeout(20000); + }) as any).timeout(40000); describe('Test listDirectory', () => { const statSync = fs.statSync; @@ -239,6 +255,8 @@ describe('GalleryManager', () => { beforeEach(() => { dirTime = 0; + + ObjectManagerRepository.getInstance().IndexingManager = new IndexingManagerTest(); indexedTime.lastModified = 0; indexedTime.lastScanned = 0; }); @@ -261,7 +279,7 @@ describe('GalleryManager', () => { return Promise.resolve(); }; - gm.indexDirectory = (...args) => { + ObjectManagerRepository.getInstance().IndexingManager.indexDirectory = (...args) => { return Promise.resolve('indexing'); }; diff --git a/test/backend/unit/model/sql/TestHelper.ts b/test/backend/unit/model/sql/TestHelper.ts index 623e3fc..5c78941 100644 --- a/test/backend/unit/model/sql/TestHelper.ts +++ b/test/backend/unit/model/sql/TestHelper.ts @@ -1,7 +1,8 @@ import {MediaDimensionEntity} from '../../../../../backend/model/sql/enitites/MediaEntity'; import { CameraMetadataEntity, - GPSMetadataEntity, PhotoEntity, + GPSMetadataEntity, + PhotoEntity, PhotoMetadataEntity, PositionMetaDataEntity } from '../../../../../backend/model/sql/enitites/PhotoEntity'; @@ -9,9 +10,8 @@ import * as path from 'path'; import {OrientationTypes} from 'ts-exif-parser'; import {DirectoryEntity} from '../../../../../backend/model/sql/enitites/DirectoryEntity'; import {VideoEntity, VideoMetadataEntity} from '../../../../../backend/model/sql/enitites/VideoEntity'; -import {FileEntity} from '../../../../../backend/model/sql/enitites/FileEntity'; import {MediaDimension} from '../../../../../common/entities/MediaDTO'; -import {CameraMetadata, GPSMetadata, PhotoDTO, PhotoMetadata, PositionMetaData} from '../../../../../common/entities/PhotoDTO'; +import {CameraMetadata, FaceRegion, GPSMetadata, PhotoDTO, PhotoMetadata, PositionMetaData} from '../../../../../common/entities/PhotoDTO'; import {DirectoryDTO} from '../../../../../common/entities/DirectoryDTO'; import {FileDTO} from '../../../../../common/entities/FileDTO'; @@ -157,14 +157,37 @@ export class TestHelper { return d; } - public static getRandomizedPhotoEntry(dir: DirectoryDTO, forceStr: string = null) { + + public static getRandomizedFace(media: PhotoDTO, forceStr: string = null) { + const rndStr = () => { + return forceStr + '_' + Math.random().toString(36).substring(7); + }; + + const rndInt = (max = 5000) => { + return Math.floor(Math.random() * max); + }; + + const f: FaceRegion = { + name: rndStr() + '.jpg', + box: { + x: rndInt(), + y: rndInt(), + width: rndInt(), + height: rndInt() + } + }; + media.metadata.faces = (media.metadata.faces || []); + media.metadata.faces.push(f); + return f; + } + + public static getRandomizedPhotoEntry(dir: DirectoryDTO, forceStr: string = null, faces: number = 2): PhotoDTO { const rndStr = () => { return forceStr + '_' + Math.random().toString(36).substring(7); }; - const rndInt = (max = 5000) => { return Math.floor(Math.random() * max); }; @@ -215,6 +238,10 @@ export class TestHelper { readyIcon: false }; + for (let i = 0; i < faces; i++) { + this.getRandomizedFace(d, 'Person ' + i); + } + dir.media.push(d); return d; } diff --git a/test/backend/unit/model/threading/DiskMangerWorker.spec.ts b/test/backend/unit/model/threading/DiskMangerWorker.spec.ts index 9c541d9..641c8d8 100644 --- a/test/backend/unit/model/threading/DiskMangerWorker.spec.ts +++ b/test/backend/unit/model/threading/DiskMangerWorker.spec.ts @@ -3,7 +3,7 @@ import {DiskMangerWorker} from '../../../../../backend/model/threading/DiskMange import * as path from 'path'; import {Config} from '../../../../../common/config/private/Config'; import {ProjectPath} from '../../../../../backend/ProjectPath'; -import {PhotoDTO} from '../../../../../common/entities/PhotoDTO'; +import {Utils} from '../../../../../common/Utils'; describe('DiskMangerWorker', () => { @@ -12,33 +12,9 @@ describe('DiskMangerWorker', () => { ProjectPath.ImageFolder = path.join(__dirname, '/../../assets'); const dir = await DiskMangerWorker.scanDirectory('/'); 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(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, - model: 'óüöúőűáé ÓÜÖÚŐŰÁÉ', - make: 'Canon', - fStop: 5.6, - exposure: 0.00125, - focalLength: 85, - lens: 'EF-S15-85mm f/3.5-5.6 IS USM' - }); - - expect((dir.media[0]).metadata.positionData).to.deep.equals({ - GPSData: { - latitude: 37.871093333333334, - longitude: -122.25678, - altitude: 102.4498997995992 - }, - country: 'mmóüöúőűáé ÓÜÖÚŐŰÁÉmm-.,|\\mm', - state: 'óüöúőűáé ÓÜÖÚŐŰÁ', - city: 'óüöúőűáé ÓÜÖÚŐŰÁ' - }); - - expect(dir.media[0].metadata.creationDate).to.be.equals(1434018566000); - + const expected = require(path.join(__dirname, '/../../assets/test image öüóőúéáű-.,.json')); + expect(Utils.clone(dir.media[0].name)).to.be.deep.equal('test image öüóőúéáű-.,.jpg'); + expect(Utils.clone(dir.media[0].metadata)).to.be.deep.equal(expected); }); }); diff --git a/test/backend/unit/model/threading/MetaDataLoader.spec.ts b/test/backend/unit/model/threading/MetaDataLoader.spec.ts index a5ddaf5..19ad209 100644 --- a/test/backend/unit/model/threading/MetaDataLoader.spec.ts +++ b/test/backend/unit/model/threading/MetaDataLoader.spec.ts @@ -20,36 +20,8 @@ describe('MetadataLoader', () => { 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', 'űáéúőóüö ŰÁÉÚŐÓÜÖ'] - })); + const expected = require(path.join(__dirname, '/../../assets/test image öüóőúéáű-.,.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); }); }); From 49bea44b05f318dcf7ce2477c70e25f73de5df5a Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 13 Jan 2019 20:18:18 +0100 Subject: [PATCH 03/20] adding persons support for frontend --- backend/model/ObjectManagerRepository.ts | 2 +- backend/model/sql/SearchManager.ts | 60 +++++++++++++++--- backend/model/threading/MetadataLoader.ts | 12 +++- common/entities/AutoCompleteItem.ts | 9 +-- demo/images/IMG_5910.jpg | Bin 711659 -> 712419 bytes demo/images/IMG_6253.jpg | Bin 904058 -> 905152 bytes demo/images/IMG_6297.jpg | Bin 462831 -> 463924 bytes demo/images/IMG_9398-2.jpg | Bin 452490 -> 455762 bytes demo/images/IMG_9516.jpg | Bin 540292 -> 542910 bytes .../photo/photo.grid.gallery.component.css | 14 ++-- .../photo/photo.grid.gallery.component.html | 16 +++-- .../photo/photo.grid.gallery.component.ts | 55 ++++++++-------- .../navigator.gallery.component.html | 1 + .../search/search.gallery.component.html | 1 + 14 files changed, 117 insertions(+), 53 deletions(-) diff --git a/backend/model/ObjectManagerRepository.ts b/backend/model/ObjectManagerRepository.ts index 01a8cfd..c66b12d 100644 --- a/backend/model/ObjectManagerRepository.ts +++ b/backend/model/ObjectManagerRepository.ts @@ -113,7 +113,7 @@ export class ObjectManagerRepository { const UserManager = require('./sql/UserManager').UserManager; const SearchManager = require('./sql/SearchManager').SearchManager; const SharingManager = require('./sql/SharingManager').SharingManager; - const IndexingTaskManager = require('./sql/IndexingManager').IndexingTaskManager; + const IndexingTaskManager = require('./sql/IndexingTaskManager').IndexingTaskManager; const IndexingManager = require('./sql/IndexingManager').IndexingManager; const PersonManager = require('./sql/PersonManager').PersonManager; ObjectManagerRepository.getInstance().GalleryManager = new GalleryManager(); diff --git a/backend/model/sql/SearchManager.ts b/backend/model/sql/SearchManager.ts index b2c44de..982c072 100644 --- a/backend/model/sql/SearchManager.ts +++ b/backend/model/sql/SearchManager.ts @@ -6,6 +6,8 @@ import {PhotoEntity} from './enitites/PhotoEntity'; import {DirectoryEntity} from './enitites/DirectoryEntity'; import {MediaEntity} from './enitites/MediaEntity'; import {VideoEntity} from './enitites/VideoEntity'; +import {PersonEntry} from './enitites/PersonEntry'; +import {FaceRegionEntry} from './enitites/FaceRegionEntry'; export class SearchManager implements ISearchManager { @@ -29,7 +31,7 @@ export class SearchManager implements ISearchManager { let result: AutoCompleteItem[] = []; const photoRepository = connection.getRepository(PhotoEntity); const videoRepository = connection.getRepository(VideoEntity); - const mediaRepository = connection.getRepository(MediaEntity); + const personRepository = connection.getRepository(PersonEntry); const directoryRepository = connection.getRepository(DirectoryEntity); @@ -45,6 +47,14 @@ export class SearchManager implements ISearchManager { .filter(k => k.toLowerCase().indexOf(text.toLowerCase()) !== -1), SearchTypes.keyword)); }); + result = result.concat(this.encapsulateAutoComplete((await personRepository + .createQueryBuilder('person') + .select('DISTINCT(person.name)') + .where('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .limit(5) + .getRawMany()) + .map(r => r.name), SearchTypes.person)); + (await photoRepository .createQueryBuilder('photo') .select('photo.metadata.positionData.country as country, ' + @@ -112,16 +122,19 @@ export class SearchManager implements ISearchManager { resultOverflow: false }; - let repostiroy = connection.getRepository(MediaEntity); + let repository = connection.getRepository(MediaEntity); + const faceRepository = connection.getRepository(FaceRegionEntry); if (searchType === SearchTypes.photo) { - repostiroy = connection.getRepository(PhotoEntity); + repository = connection.getRepository(PhotoEntity); } else if (searchType === SearchTypes.video) { - repostiroy = connection.getRepository(VideoEntity); + repository = connection.getRepository(VideoEntity); } - const query = repostiroy.createQueryBuilder('media') - .innerJoinAndSelect('media.directory', 'directory') + const query = repository.createQueryBuilder('media') + .leftJoinAndSelect('media.directory', 'directory') + .leftJoin('media.metadata.faces', 'faces') + .leftJoin('faces.person', 'person') .orderBy('media.metadata.creationDate', 'ASC'); @@ -136,6 +149,9 @@ export class SearchManager implements ISearchManager { if (!searchType || searchType === SearchTypes.photo) { query.orWhere('media.metadata.caption LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); } + if (!searchType || searchType === SearchTypes.person) { + query.orWhere('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); + } if (!searchType || searchType === SearchTypes.position) { query.orWhere('media.metadata.positionData.country LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) @@ -147,9 +163,20 @@ export class SearchManager implements ISearchManager { query.orWhere('media.metadata.keywords LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); } - result.media = await query - .limit(2001) - .getMany(); + + result.media = (await query + .limit(5000).getMany()).slice(0, 2001); + + for (let i = 0; i < result.media.length; i++) { + const faces = (await faceRepository + .createQueryBuilder('faces') + .leftJoinAndSelect('faces.person', 'person') + .where('faces.media = :media', {media: result.media[i].id}) + .getMany()).map(fE => ({name: fE.person.name, box: fE.box})); + if (faces.length > 0) { + result.media[i].metadata.faces = faces; + } + } if (result.media.length > 2000) { result.resultOverflow = true; @@ -181,6 +208,8 @@ export class SearchManager implements ISearchManager { resultOverflow: false }; + const faceRepository = connection.getRepository(FaceRegionEntry); + result.media = await connection .getRepository(MediaEntity) .createQueryBuilder('media') @@ -191,10 +220,23 @@ export class SearchManager implements ISearchManager { .orWhere('media.metadata.positionData.city LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) .orWhere('media.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) .orWhere('media.metadata.caption LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) .innerJoinAndSelect('media.directory', 'directory') + .leftJoin('media.metadata.faces', 'faces') + .leftJoin('faces.person', 'person') .limit(10) .getMany(); + for (let i = 0; i < result.media.length; i++) { + const faces = (await faceRepository + .createQueryBuilder('faces') + .leftJoinAndSelect('faces.person', 'person') + .where('faces.media = :media', {media: result.media[i].id}) + .getMany()).map(fE => ({name: fE.person.name, box: fE.box})); + if (faces.length > 0) { + result.media[i].metadata.faces = faces; + } + } result.directories = await connection .getRepository(DirectoryEntity) diff --git a/backend/model/threading/MetadataLoader.ts b/backend/model/threading/MetadataLoader.ts index 27123fc..34af889 100644 --- a/backend/model/threading/MetadataLoader.ts +++ b/backend/model/threading/MetadataLoader.ts @@ -4,7 +4,7 @@ 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 {ExifParserFactory, OrientationTypes} from 'ts-exif-parser'; import {IptcParser} from 'ts-node-iptc'; import {FFmpegFactory} from '../FFmpegFactory'; import {FfprobeData} from 'fluent-ffmpeg'; @@ -147,10 +147,16 @@ export class MetadataLoader { try { const iptcData = IptcParser.parse(data); - if (iptcData.country_or_primary_location_name || iptcData.province_or_state || iptcData.city) { + 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.caption) { @@ -160,7 +166,7 @@ export class MetadataLoader { metadata.creationDate = (iptcData.date_time ? iptcData.date_time.getTime() : metadata.creationDate); } catch (err) { - // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); + Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); } metadata.creationDate = metadata.creationDate || 0; diff --git a/common/entities/AutoCompleteItem.ts b/common/entities/AutoCompleteItem.ts index 9fe6e5a..f61323d 100644 --- a/common/entities/AutoCompleteItem.ts +++ b/common/entities/AutoCompleteItem.ts @@ -1,9 +1,10 @@ export enum SearchTypes { directory = 1, - keyword = 2, - position = 3, - photo = 4, - video = 5 + person = 2, + keyword = 3, + position = 5, + photo = 6, + video = 7 } export class AutoCompleteItem { diff --git a/demo/images/IMG_5910.jpg b/demo/images/IMG_5910.jpg index 852d0a6899d951e6ea093412f0cf3cd469636998..2ca467ad26e2fbc9d8bb0dd560df7b484b9c6b0a 100644 GIT binary patch delta 2169 zcmcIlPiP}m7;n<8Zmp}k+PW>N>~yp$bd#A$CY|Kjq^5sEZFWnOrHi-e%u6yl$;`et zY1&f|1ur7#I|&~2A__eSJ?udQz39;u6w#C5MUWLdE(rR)nMvBTwiLm@m%Q(L-=E+2 z=Y9G8?#!=W%zU;uI(X^x4-ZyGzrPlJ?s4|#>?o7fd_-WbT8JyEENIz;GApZT<+_lR z6-|=Uk~|xi6(na+QW3(Bm>1YDY9S&VOsD^fF!QFiA?!BnCPj^QEly}otb3k|B&pqQ zCmPt(P2JPm4tdRL)@sCQxe3E*NYtp~hE64dYnIcbk{J4+-jdZ|AEloHY!|Q5%ASiQ zkl-feYR??16$sY3-o$Rhl>$93>S@@7-wxYWt?m)WX|Qqa)9EZ^6IW6dC5uv7Bx^G~ zIhi|k(WdSJ!km~o_)(jWKC5J84R0sYabs4=#!XXI2Qq7qLG60w75MYQ|MP+;kD)`RwRY{nWdV)ErPZ1c;@@yR1 z6l@P{@;9>O(8d?|HeEHapsnp!u>qQXoCUoWo@^^$uy?Gc;MK8EzSFV@!FCWEt_1eS z^7vX{1MIDG;qWRr1)ey-!wA5bm>R@#J!s5vn3)qW`<0>Ir4lMy6w0y}8V7Z0tn9%= z-e38_|JL3sejFT%=jn$iP;Yt1L+v_l=0up`f9W@~SJjb4Tc^}AgiJzao#OLP_jlqL zI(D!4W=xnf2t^gAi5FXBr`wkR^Sf(f0CH+=0`>bjZqdhYVzV#to|E}AoCHRFepU&_ z-TEJc^U@ifx@LX87VOq3nN6MHDzIX&zdSZ_bi#WHJ$lrZO_j!?FmqSd$Iq!f)gPkPsFs@E0=36KyZer@Rh=KX54mAs| zYg^c?IEO0=Mi3a(&WTDYtMZBDY&|DtQ?O_PuGO@>oakD+*cPR(w+;;hufwwlN2Biz z2gO}Fe04$>uj9e)a$w};gv>u;_vVJ)0N^Jde@`LEY(d_OW+YiXugPVApg`s;hyPc<#!=0 zhSVpdU}6(gw=mI(QMU};F8{aP=Wj}Yuz(4BuzU%hV-21f__Pc+msWU+y9o9Q|3A#L zsj|uB@~!`c+>M)Tee1Ddu`SN$|Gr=OjTu|%Cx4!dyfQEtn_iua4h%#GE=)#;m~?yW z>ST0c;E9v-!9Lmg;9&Xo*X7IB3-5pP;+MjM?^eGO-oE@3eIvI2R%|~46a^Xtx&Sl; T^bpX)Ko^0A@5c5YxhMSt^`XEX delta 1925 zcmaF7SNrvL?G4T%%=`;KZ*~*8BG07kI{Bfw=;T;a_Q@6|W=y}oQ*!ZW{_#crAb+-$tB39IVGmUO`G5@!45HvbMnSO5g%le;p)I9 zqZnjUkz3&FYvqwy40LHFvf;>1o#-VtxzZyUZoxz^^T{Hf^HBJ&JeO%9TME~2qmRXo z$&lVPqTGP6 z3e^b#VZeN6L{Ugg-tEIrlpB!k!sZHiTw!EYn2)igKCs;6Z9XC-xB+exHfJD37fBA8 zywOL}l$bOL2?r!=ChPf28bNiU6hy#c48+Hi@5FIfI9cCc!VoM6DqyUF5=%1k((R1F zMFETlD%46cOL9`}z_vK%luUkVVmX<`-O>n>#epJ_lmRZbph|(7Qc{bPi!uvJGV}8= zw7Ox_ilPBr*18}W0dgkT1xSLECCshj>}-59lZ*0;^V3QKGV)9EVGcJ17D3o$AeJMU zV+Rola&ZIt1n7G!U})r~mLvjslNEzf80kk^P8N&e<5VwKx3~NMHe}ai-6&ImH8PA* z49wE1?lMda3``6xlNUzKVPx2B8+|NT;LNw3^&8C(B}=JtW+%zANHq(uZx>$A2*gZ4 b%nZaVK+FonY(UHo#2i4(xm|cY*9Cn5a0mg5 diff --git a/demo/images/IMG_6253.jpg b/demo/images/IMG_6253.jpg index 507ac5a641bcf52d9c5623a9fd43e9107c3d320f..9cc007cf98b6af9f845a08f009ffd2c54b18e8f4 100644 GIT binary patch delta 2117 zcmcIlO>7%g5cW?(n;)uDsKJUwUbYPdioHKxd+o(`;x_#eB48ycQsA^+zt~H6z1z3z z*ikAu5=cm#5KrP5jtGeZk`KKhPF#ATf-8a>mm+aRNHFVN|Ez1G^uS8v-I+J@y_tFQ zKL7QF^!=C8w?9UsS1%m(ev0OnBA0%l>nK5AU(SXRtuB|7yvQLbpW?HenBm_*0>?`% zmtnbVlH+AACks6CAEt!pqvdiK^pNBG%e0qn^%jvu<2v<42_XBe! za#<9`YE=|!NlD4^Noh~aCY7qFB-7apm*ym1$ffz-bE}p4E-KViS%sY(lax?_)b`|U ze9!;VwNgcKh<3NYx(lEp@5^&x1EHYUt?GwZb^PdSN_~V=*7vn0avB(I-)m`vV7-sj z^+0{}JKN>4oqXf2X=6ik@YqT}8xP*iVYDqYZ*x<=t=Uk`fxr29ImmWy>^rieI?(ov4C76h2-;1(n$Hj@iW5TVx-P*Y>%15z_r58<&HpGZ_%jw{UGL#@Y!O zUQ7Vy7QwhV6=?kgpg~js>YCn~URL=8BtIwsRCx~$L71A<;y4ujGZTZxEoNU@7#sXy zHz$2BQk0#EpaOkaik~jvj9(`$oHl}1q*=I<9`nds9+@EbB3KHsu}dFCW((V9i>MeV zYv5V7EM3F39dpDjFqNRl?IOcx^O8HHuG#>VPYVOJ)zq9KV`&|%_ovX^xC?XU?ff>} zBfpJ-yX^R-BeWE9j^ZjNa)Yo&i47cBZ+DRbpG5q>`uU0S`Bh=f9s&`cr{4_$b2 zSZH*u)4RX+>6_=jdHBKZTk#6IzdHNc`4>Jr{wQ|%N$fBT5&?;V%z(^-JOgqD Q$UMjb$l`<8;j^{MzuLm7Y5)KL delta 1437 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 h+WH_By+3 zFqa~=X*u-LbPnw)Y9y2^s(gslNNKOVCApL{7Rk9X$1 zneV;%-n{4W!+*hxM}uEn3Vipd_cJzl_1kZr_OnbU=??TRZXt$c80I{~1Xs2qOegJk z(0+AGbmaZC|235{C=!LSD2zlBiG)37-lHu*KO^wO*42lvO0TGDe0+a+8;~|n^LXIHd zK3ocrUxO0?PLSRhT6O0MIiF9CAmR7K8xvM6XWx6WN_xIFM z3R-F*Gf@YieLzke%ylyzVRH+ zwg$Rz3}_w1hkL`oxYQc*;xWh#7$5dzq0%A~?;iv0qWF+!7il0Pt+{>9i%kZl*WRJ) zjzDXf;X{C(&Qfwg>>u=`So63a0+=|GzWOFxC-ER`Kh2VNrMKv}E)C71NIYui!jx8& z^GPltjy6V>l4d2jvR1_f=d2gkmU()Z=kT(omu}Leba}34u3a&lgx{5-S8gdqoa8*& z&H;Dz{Cb&ct}80G3+r_3?uHwNDQv2SR<>}kfvt7S#Kk1c>p*(A5dji+{Z z(jSvrTIzlt3fyH*1&3~g=%06%=^+24QL>LY>$y#Z*f?{V-rFS@htUu_#&n^Gh@vpW zro8fTS5EFtUFpp->_R5QV&?n;y`|zT$XJSnO{VtM)JK!uUk3kvy7SZR)gS)a)_?qY l_IYg~{=vCVhBr5dHv_b>w4I`@gSJlEx}FVhzVhL-{{Y%yrrH1i delta 1886 zcmdltQL*K(!US?<857$seT6n0UD+tIOyLNB9OPIOi9o zDwygS>KQ57@p5rao+{(W0;D-NUzb_WH2IGFg2@LABsNb`5M-L%ZJ@UKw9->94Uj@k zPMefutK_28#FG3XhybTeQA(OsaB3lx%Q;zAPuvr%meWQbMb1VaT{FnE;?kt7)Z`Lm z)0`61;imChNU=jqhhriE-FT(6Bj7V{^Y7`3>8mD?m26EM}wH7nGVprj_-?SP3^C)mHbS)YLpO?1cq`k*TT9 zKqPQG9&!3vg}>}jUS22nM+*i2R5#K^=*NujvJB{SD5!&b@2!pH)~EzQd; zu~jO_tVqpK(ubyr$p_7ZH6hv|j)9kBpu!gFI;cD+=j0b=#$q9r1*x`5Zi&gM;KCcI z4g@AUnMXmR2*`t!Aug%K$wiq3C7JoK%m|Y~lr9jJ2wQFRbIa3pfkjqOYC2G#53taL zI2BEz7%Ylvj|tQwh-i?D8&D0fbhHANkGZKOi6EYRML}Y6c4~=2YF>)1dbzs2-DcL- zXddRb&Lxvu9Zdz+$uKf7Ffp*mFh(&jORKt1KILd=ZqC2~%todL<_5+xOg0SMhDIia z#s=C3Kp?|phb(E2EIHZ2NnIe7fj1Nw?x`sX!6k_$sl}Vio$8zfW`5Y;BH?4u_ck)I jPQ7=zS#$iv_V|g6K+FWh%s|Wn#H>Kfwmp6#d;E3)VIJGu diff --git a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.css b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.css index 62ed865..ea791be 100644 --- a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.css +++ b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.css @@ -92,7 +92,7 @@ a { width: 100%; } -.video-indicator{ +.video-indicator { font-size: large; padding: 5px; position: absolute; @@ -102,12 +102,16 @@ a { background-color: rgba(0, 0, 0, 0.5); color: white; transition: background-color .3s ease-out; - -moz-transition: background-color .3s ease-out; + -moz-transition: background-color .3s ease-out; -webkit-transition: background-color .3s ease-out; - -o-transition: background-color .3s ease-out; - -ms-transition: background-color .3s ease-out; + -o-transition: background-color .3s ease-out; + -ms-transition: background-color .3s ease-out; } -.photo-container:hover .video-indicator{ +.photo-container:hover .video-indicator { background-color: rgba(0, 0, 0, 0.8); } + +.photo-keywords .oi-person{ + margin-right: 2px; +} diff --git a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html index f786253..d842117 100644 --- a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html +++ b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.html @@ -36,12 +36,18 @@ -
- +
+ #{{keyword}} - #{{keyword}} - , + [routerLink]="['/search', keyword.value, {type: SearchTypes[keyword.type]}]" [ngSwitch]="keyword.type"> + #{{keyword.value}} + + #{{keyword.value}} + ,
diff --git a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.ts b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.ts index 8af6b46..43e56ed 100644 --- a/frontend/app/gallery/grid/photo/photo.grid.gallery.component.ts +++ b/frontend/app/gallery/grid/photo/photo.grid.gallery.component.ts @@ -5,9 +5,8 @@ import {SearchTypes} from '../../../../../common/entities/AutoCompleteItem'; import {RouterLink} from '@angular/router'; import {Thumbnail, ThumbnailManagerService} from '../../thumbnailManager.service'; import {Config} from '../../../../../common/config/public/Config'; -import {AnimationBuilder} from '@angular/animations'; import {PageHelper} from '../../../model/page.helper'; -import {PhotoDTO} from '../../../../../common/entities/PhotoDTO'; +import {PhotoDTO, PhotoMetadata} from '../../../../../common/entities/PhotoDTO'; @Component({ selector: 'app-gallery-grid-photo', @@ -22,6 +21,7 @@ export class GalleryPhotoComponent implements IRenderable, OnInit, OnDestroy { @ViewChild('photoContainer') container: ElementRef; thumbnail: Thumbnail; + keywords: { value: string, type: SearchTypes }[] = null; infoBar = { marginTop: 0, visible: false, @@ -34,17 +34,40 @@ export class GalleryPhotoComponent implements IRenderable, OnInit, OnDestroy { wasInView: boolean = null; - constructor(private thumbnailService: ThumbnailManagerService, - private _animationBuilder: AnimationBuilder) { + constructor(private thumbnailService: ThumbnailManagerService) { this.SearchTypes = SearchTypes; this.searchEnabled = Config.Client.Search.enabled; } - ngOnInit() { - this.thumbnail = this.thumbnailService.getThumbnail(this.gridPhoto); + get ScrollListener(): boolean { + return !this.thumbnail.Available && !this.thumbnail.Error; } + get Title(): string { + if (Config.Client.Other.captionFirstNaming === false) { + return this.gridPhoto.media.name; + } + if ((this.gridPhoto.media).metadata.caption) { + if ((this.gridPhoto.media).metadata.caption.length > 20) { + return (this.gridPhoto.media).metadata.caption.substring(0, 17) + '...'; + } + return (this.gridPhoto.media).metadata.caption; + } + return this.gridPhoto.media.name; + } + + ngOnInit() { + this.thumbnail = this.thumbnailService.getThumbnail(this.gridPhoto); + const metadata = this.gridPhoto.media.metadata as PhotoMetadata; + if ((metadata.keywords && metadata.keywords.length > 0) || + (metadata.faces && metadata.faces.length > 0)) { + this.keywords = (metadata.faces || []).map(f => ({value: f.name, type: SearchTypes.person})) + .concat((metadata.keywords || []).map(k => ({value: k, type: SearchTypes.keyword}))); + } + + } + ngOnDestroy() { this.thumbnail.destroy(); @@ -53,16 +76,11 @@ export class GalleryPhotoComponent implements IRenderable, OnInit, OnDestroy { } } - isInView(): boolean { return PageHelper.ScrollY < this.container.nativeElement.offsetTop + this.container.nativeElement.clientHeight && PageHelper.ScrollY + window.innerHeight > this.container.nativeElement.offsetTop; } - get ScrollListener(): boolean { - return !this.thumbnail.Available && !this.thumbnail.Error; - } - onScroll() { if (this.thumbnail.Available === true || this.thumbnail.Error === true) { return; @@ -74,7 +92,6 @@ export class GalleryPhotoComponent implements IRenderable, OnInit, OnDestroy { } } - getPositionText(): string { if (!this.gridPhoto || !this.gridPhoto.isPhoto()) { return ''; @@ -84,7 +101,6 @@ export class GalleryPhotoComponent implements IRenderable, OnInit, OnDestroy { (this.gridPhoto.media).metadata.positionData.country; } - mouseOver() { this.infoBar.visible = true; if (this.animationTimer != null) { @@ -124,19 +140,6 @@ export class GalleryPhotoComponent implements IRenderable, OnInit, OnDestroy { } - get Title(): string { - if (Config.Client.Other.captionFirstNaming === false) { - return this.gridPhoto.media.name; - } - if ((this.gridPhoto.media).metadata.caption) { - if ((this.gridPhoto.media).metadata.caption.length > 20) { - return (this.gridPhoto.media).metadata.caption.substring(0, 17) + '...'; - } - return (this.gridPhoto.media).metadata.caption; - } - return this.gridPhoto.media.name; - } - /* onImageLoad() { this.loading.show = false; diff --git a/frontend/app/gallery/navigator/navigator.gallery.component.html b/frontend/app/gallery/navigator/navigator.gallery.component.html index 9a45dee..c2056bc 100644 --- a/frontend/app/gallery/navigator/navigator.gallery.component.html +++ b/frontend/app/gallery/navigator/navigator.gallery.component.html @@ -15,6 +15,7 @@ + {{searchResult.searchText}} diff --git a/frontend/app/gallery/search/search.gallery.component.html b/frontend/app/gallery/search/search.gallery.component.html index 40de478..2c85a97 100644 --- a/frontend/app/gallery/search/search.gallery.component.html +++ b/frontend/app/gallery/search/search.gallery.component.html @@ -25,6 +25,7 @@ + {{item.preText}}{{item.highLightText}}{{item.postText}} From 849a081cba85294af5f0d834f63e2dff08dc525e Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Thu, 17 Jan 2019 20:17:17 +0100 Subject: [PATCH 04/20] implementing duplicate backend --- backend/middlewares/AdminMWs.ts | 19 +++++++++++++++++++ backend/model/sql/GalleryManager.ts | 21 ++++++++++++++++++--- backend/model/sql/IGalleryManager.ts | 3 +++ backend/model/threading/MetadataLoader.ts | 2 +- backend/routes/AdminRouter.ts | 9 +++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/backend/middlewares/AdminMWs.ts b/backend/middlewares/AdminMWs.ts index c3d4938..1984ae1 100644 --- a/backend/middlewares/AdminMWs.ts +++ b/backend/middlewares/AdminMWs.ts @@ -42,6 +42,25 @@ export class AdminMWs { } } + + public static async getDuplicates(req: Request, res: Response, next: NextFunction) { + if (Config.Server.database.type === DatabaseType.memory) { + return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Statistic is only available for indexed content')); + } + + + const galleryManager = ObjectManagerRepository.getInstance().GalleryManager; + try { + req.resultPipe = await galleryManager.getPossibleDuplicates(); + return next(); + } catch (err) { + if (err instanceof Error) { + return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Error while getting duplicates: ' + err.toString(), err)); + } + return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Error while getting duplicates', err)); + } + } + public static async updateDatabaseSettings(req: Request, res: Response, next: NextFunction) { if ((typeof req.body === 'undefined') || (typeof req.body.settings === 'undefined')) { diff --git a/backend/model/sql/GalleryManager.ts b/backend/model/sql/GalleryManager.ts index ae13d46..7d2dae5 100644 --- a/backend/model/sql/GalleryManager.ts +++ b/backend/model/sql/GalleryManager.ts @@ -169,6 +169,22 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { .getCount(); } + public async getPossibleDuplicates() { + const connection = await SQLConnection.getConnection(); + const mediaRepository = connection.getRepository(MediaEntity); + + const duplicates = await mediaRepository.createQueryBuilder('media') + .innerJoin(query => query.from(MediaEntity, 'innerMedia') + .select(['innerMedia.name as name', 'innerMedia.metadata.fileSize as fileSize', 'count(*)']) + .groupBy('innerMedia.name, innerMedia.metadata.fileSize') + .having('count(*)>1'), + 'innerMedia', + 'media.name=innerMedia.name AND media.metadata.fileSize = innerMedia.fileSize') + .innerJoinAndSelect('media.directory', 'directory').getMany(); + return duplicates; + + } + protected async selectParentDir(connection: Connection, directoryName: string, directoryParent: string): Promise { const query = connection .getRepository(DirectoryEntity) @@ -196,9 +212,9 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { directory: dir.id }) .leftJoinAndSelect('face.person', 'person') - .select(['face.id', 'face.box.x', + .select(['face.id', 'face.box.x', 'face.box.y', 'face.box.width', 'face.box.height', - 'media.id', 'person.name', 'person.id']) + 'media.id', 'person.name', 'person.id']) .getMany(); for (let i = 0; i < dir.media.length; i++) { dir.media[i].directory = dir; @@ -232,5 +248,4 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { } } - } diff --git a/backend/model/sql/IGalleryManager.ts b/backend/model/sql/IGalleryManager.ts index 80db0ae..5f01f26 100644 --- a/backend/model/sql/IGalleryManager.ts +++ b/backend/model/sql/IGalleryManager.ts @@ -1,5 +1,6 @@ import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; import {IGalleryManager} from '../interfaces/IGalleryManager'; +import {MediaEntity} from './enitites/MediaEntity'; export interface ISQLGalleryManager extends IGalleryManager { listDirectory(relativeDirectoryName: string, @@ -13,4 +14,6 @@ export interface ISQLGalleryManager extends IGalleryManager { countVideos(): Promise; countMediaSize(): Promise; + + getPossibleDuplicates(): Promise; } diff --git a/backend/model/threading/MetadataLoader.ts b/backend/model/threading/MetadataLoader.ts index 34af889..dea0932 100644 --- a/backend/model/threading/MetadataLoader.ts +++ b/backend/model/threading/MetadataLoader.ts @@ -166,7 +166,7 @@ export class MetadataLoader { metadata.creationDate = (iptcData.date_time ? iptcData.date_time.getTime() : metadata.creationDate); } catch (err) { - Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); + // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); } metadata.creationDate = metadata.creationDate || 0; diff --git a/backend/routes/AdminRouter.ts b/backend/routes/AdminRouter.ts index 5993c66..ea6b165 100644 --- a/backend/routes/AdminRouter.ts +++ b/backend/routes/AdminRouter.ts @@ -8,6 +8,7 @@ export class AdminRouter { public static route(app: Express) { this.addGetStatistic(app); + this.addGetDuplicates(app); this.addIndexGallery(app); this.addSettings(app); } @@ -20,6 +21,14 @@ export class AdminRouter { RenderingMWs.renderResult ); } + private static addGetDuplicates(app: Express) { + app.get('/api/admin/duplicates', + AuthenticationMWs.authenticate, + AuthenticationMWs.authorise(UserRoles.Admin), + AdminMWs.getDuplicates, + RenderingMWs.renderResult + ); + } private static addIndexGallery(app: Express) { app.get('/api/admin/indexes/job/progress', From 41dc64f80505e63e3d9c979652dd4ff6c4732eac Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Fri, 18 Jan 2019 00:26:20 +0100 Subject: [PATCH 05/20] implementing duplicates frontend --- backend/model/sql/GalleryManager.ts | 49 ++++++++++++++++++- backend/model/sql/IGalleryManager.ts | 4 +- backend/routes/GalleryRouter.ts | 12 +++++ backend/routes/PublicRouter.ts | 2 +- backend/{tsconfigX.jsonX => tsconfig.json} | 0 common/entities/DuplicatesDTO.ts | 5 ++ frontend/app/app.module.ts | 16 +++--- frontend/app/app.routing.ts | 5 ++ .../app/duplicates/duplicates.component.css | 15 ++++++ .../app/duplicates/duplicates.component.html | 32 ++++++++++++ .../app/duplicates/duplicates.component.ts | 22 +++++++++ frontend/app/duplicates/duplicates.service.ts | 20 ++++++++ .../photo/photo.duplicates.component.css | 13 +++++ .../photo/photo.duplicates.component.html | 15 ++++++ .../photo/photo.duplicates.component.ts | 40 +++++++++++++++ frontend/app/frame/frame.component.html | 6 +++ 16 files changed, 244 insertions(+), 12 deletions(-) rename backend/{tsconfigX.jsonX => tsconfig.json} (100%) create mode 100644 common/entities/DuplicatesDTO.ts create mode 100644 frontend/app/duplicates/duplicates.component.css create mode 100644 frontend/app/duplicates/duplicates.component.html create mode 100644 frontend/app/duplicates/duplicates.component.ts create mode 100644 frontend/app/duplicates/duplicates.service.ts create mode 100644 frontend/app/duplicates/photo/photo.duplicates.component.css create mode 100644 frontend/app/duplicates/photo/photo.duplicates.component.html create mode 100644 frontend/app/duplicates/photo/photo.duplicates.component.ts diff --git a/backend/model/sql/GalleryManager.ts b/backend/model/sql/GalleryManager.ts index 7d2dae5..f3e9bb6 100644 --- a/backend/model/sql/GalleryManager.ts +++ b/backend/model/sql/GalleryManager.ts @@ -18,6 +18,7 @@ import {DiskMangerWorker} from '../threading/DiskMangerWorker'; import {Logger} from '../../Logger'; import {FaceRegionEntry} from './enitites/FaceRegionEntry'; import {ObjectManagerRepository} from '../ObjectManagerRepository'; +import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO'; const LOG_TAG = '[GalleryManager]'; @@ -173,7 +174,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { const connection = await SQLConnection.getConnection(); const mediaRepository = connection.getRepository(MediaEntity); - const duplicates = await mediaRepository.createQueryBuilder('media') + let duplicates = await mediaRepository.createQueryBuilder('media') .innerJoin(query => query.from(MediaEntity, 'innerMedia') .select(['innerMedia.name as name', 'innerMedia.metadata.fileSize as fileSize', 'count(*)']) .groupBy('innerMedia.name, innerMedia.metadata.fileSize') @@ -181,7 +182,51 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { 'innerMedia', 'media.name=innerMedia.name AND media.metadata.fileSize = innerMedia.fileSize') .innerJoinAndSelect('media.directory', 'directory').getMany(); - return duplicates; + + const duplicateParis: DuplicatesDTO[] = []; + let i = duplicates.length - 1; + while (i >= 0) { + const list = [duplicates[i]]; + let j = i - 1; + while (j >= 0 && duplicates[i].name === duplicates[j].name && duplicates[i].metadata.fileSize === duplicates[j].metadata.fileSize) { + list.push(duplicates[j]); + j--; + } + i = j; + duplicateParis.push({media: list}); + } + + + duplicates = await mediaRepository.createQueryBuilder('media') + .innerJoin(query => query.from(MediaEntity, 'innerMedia') + .select(['innerMedia.metadata.creationDate as creationDate', 'innerMedia.metadata.fileSize as fileSize', 'count(*)']) + .groupBy('innerMedia.name, innerMedia.metadata.fileSize') + .having('count(*)>1'), + 'innerMedia', + 'media.metadata.creationDate=innerMedia.creationDate AND media.metadata.fileSize = innerMedia.fileSize') + .innerJoinAndSelect('media.directory', 'directory').getMany(); + + i = duplicates.length - 1; + while (i >= 0) { + const list = [duplicates[i]]; + let j = i - 1; + while (j >= 0 && duplicates[i].metadata.creationDate === duplicates[j].metadata.creationDate && duplicates[i].metadata.fileSize === duplicates[j].metadata.fileSize) { + list.push(duplicates[j]); + j--; + } + i = j; + if (list.filter(paired => + !!duplicateParis.find(dp => + !!dp.media.find(m => + m.id === paired.id))).length === list.length) { + continue; + } + + duplicateParis.push({media: list}); + } + + + return duplicateParis; } diff --git a/backend/model/sql/IGalleryManager.ts b/backend/model/sql/IGalleryManager.ts index 5f01f26..5dd5fa8 100644 --- a/backend/model/sql/IGalleryManager.ts +++ b/backend/model/sql/IGalleryManager.ts @@ -1,6 +1,6 @@ import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; import {IGalleryManager} from '../interfaces/IGalleryManager'; -import {MediaEntity} from './enitites/MediaEntity'; +import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO'; export interface ISQLGalleryManager extends IGalleryManager { listDirectory(relativeDirectoryName: string, @@ -15,5 +15,5 @@ export interface ISQLGalleryManager extends IGalleryManager { countMediaSize(): Promise; - getPossibleDuplicates(): Promise; + getPossibleDuplicates(): Promise; } diff --git a/backend/routes/GalleryRouter.ts b/backend/routes/GalleryRouter.ts index fdb4c06..96f023a 100644 --- a/backend/routes/GalleryRouter.ts +++ b/backend/routes/GalleryRouter.ts @@ -10,6 +10,7 @@ export class GalleryRouter { public static route(app: Express) { this.addGetImageIcon(app); + this.addGetVideoIcon(app); this.addGetImageThumbnail(app); this.addGetVideoThumbnail(app); this.addGetImage(app); @@ -92,6 +93,17 @@ export class GalleryRouter { ); } + + private static addGetVideoIcon(app: Express) { + app.get('/api/gallery/content/:mediaPath(*\.(mp4|ogg|ogv|webm))/icon', + AuthenticationMWs.authenticate, + // TODO: authorize path + GalleryMWs.loadFile, + ThumbnailGeneratorMWs.generateIconFactory(ThumbnailSourceType.Video), + RenderingMWs.renderFile + ); + } + private static addGetImageIcon(app: Express) { app.get('/api/gallery/content/:mediaPath(*\.(jpg|jpeg|jpe|webp|png|gif|svg))/icon', AuthenticationMWs.authenticate, diff --git a/backend/routes/PublicRouter.ts b/backend/routes/PublicRouter.ts index bc54b91..9ac4595 100644 --- a/backend/routes/PublicRouter.ts +++ b/backend/routes/PublicRouter.ts @@ -79,7 +79,7 @@ export class PublicRouter { }); - app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/search*'], + app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/search*'], AuthenticationMWs.tryAuthenticate, setLocale, renderIndex diff --git a/backend/tsconfigX.jsonX b/backend/tsconfig.json similarity index 100% rename from backend/tsconfigX.jsonX rename to backend/tsconfig.json diff --git a/common/entities/DuplicatesDTO.ts b/common/entities/DuplicatesDTO.ts new file mode 100644 index 0000000..66dd769 --- /dev/null +++ b/common/entities/DuplicatesDTO.ts @@ -0,0 +1,5 @@ +import {MediaDTO} from './MediaDTO'; + +export interface DuplicatesDTO { + media: MediaDTO[]; +} diff --git a/frontend/app/app.module.ts b/frontend/app/app.module.ts index 45694f8..afe0b6b 100644 --- a/frontend/app/app.module.ts +++ b/frontend/app/app.module.ts @@ -1,10 +1,4 @@ -import { - Injectable, - LOCALE_ID, - NgModule, - TRANSLATIONS, - TRANSLATIONS_FORMAT -} from '@angular/core'; +import {Injectable, LOCALE_ID, NgModule, TRANSLATIONS, TRANSLATIONS_FORMAT} from '@angular/core'; import {BrowserModule, HAMMER_GESTURE_CONFIG, HammerGestureConfig} from '@angular/platform-browser'; import {FormsModule} from '@angular/forms'; import {AppComponent} from './app.component'; @@ -52,6 +46,7 @@ import {MapSettingsComponent} from './settings/map/map.settings.component'; import {TooltipModule} from 'ngx-bootstrap/tooltip'; import {BsDropdownModule} from 'ngx-bootstrap/dropdown'; import {CollapseModule} from 'ngx-bootstrap/collapse'; +import {PopoverModule} from 'ngx-bootstrap/popover'; import {ThumbnailSettingsComponent} from './settings/thumbnail/thumbanil.settings.component'; import {SearchSettingsComponent} from './settings/search/search.settings.component'; import {SettingsService} from './settings/settings.service'; @@ -75,6 +70,9 @@ import {MapService} from './gallery/map/map.service'; import {MetaFileSettingsComponent} from './settings/metafiles/metafile.settings.component'; import {ThumbnailLoaderService} from './gallery/thumbnailLoader.service'; import {FileSizePipe} from './pipes/FileSizePipe'; +import {DuplicateService} from './duplicates/duplicates.service'; +import {DuplicateComponent} from './duplicates/duplicates.component'; +import {DuplicatesPhotoComponent} from './duplicates/photo/photo.duplicates.component'; @Injectable() @@ -124,6 +122,7 @@ export function translationsFactory(locale: string) { ToastrModule.forRoot(), ModalModule.forRoot(), CollapseModule.forRoot(), + PopoverModule.forRoot(), BsDropdownModule.forRoot(), SlimLoadingBarModule.forRoot(), BsDatepickerModule.forRoot(), @@ -165,6 +164,8 @@ export function translationsFactory(locale: string) { BasicSettingsComponent, OtherSettingsComponent, IndexingSettingsComponent, + DuplicateComponent, + DuplicatesPhotoComponent, StringifyRole, IconizeSortingMethod, StringifySortingMethod, @@ -190,6 +191,7 @@ export function translationsFactory(locale: string) { SettingsService, OverlayService, QueryService, + DuplicateService, { provide: TRANSLATIONS, useFactory: translationsFactory, diff --git a/frontend/app/app.routing.ts b/frontend/app/app.routing.ts index 9f039cb..6155b8e 100644 --- a/frontend/app/app.routing.ts +++ b/frontend/app/app.routing.ts @@ -5,6 +5,7 @@ import {GalleryComponent} from './gallery/gallery.component'; import {AdminComponent} from './admin/admin.component'; import {ShareLoginComponent} from './sharelogin/share-login.component'; import {QueryParams} from '../../common/QueryParams'; +import {DuplicateComponent} from './duplicates/duplicates.component'; export function galleryMatcherFunction( segments: UrlSegment[]): UrlMatchResult | null { @@ -50,6 +51,10 @@ const ROUTES: Routes = [ path: 'admin', component: AdminComponent }, + { + path: 'duplicates', + component: DuplicateComponent + }, { matcher: galleryMatcherFunction, component: GalleryComponent diff --git a/frontend/app/duplicates/duplicates.component.css b/frontend/app/duplicates/duplicates.component.css new file mode 100644 index 0000000..fb4eddb --- /dev/null +++ b/frontend/app/duplicates/duplicates.component.css @@ -0,0 +1,15 @@ +.same-data { + font-weight: bold; +} +.card{ + margin: 8px 0; +} + +.row{ + margin: 5px 0; + cursor: pointer; +} + +.row:hover{ + background-color: #f8f9fa; +} diff --git a/frontend/app/duplicates/duplicates.component.html b/frontend/app/duplicates/duplicates.component.html new file mode 100644 index 0000000..7059a32 --- /dev/null +++ b/frontend/app/duplicates/duplicates.component.html @@ -0,0 +1,32 @@ + + +
+ +
+
+
+ +
+ /{{getDirectoryPath(media)}}/{{media.name}} +
+
+ {{media.metadata.fileSize | fileSize}} +
+
+ {{media.metadata.creationDate | date}} +
+
+ {{media.metadata.size.width}}x{{media.metadata.size.height}} +
+
+
+
+
+ + loading + +
+
diff --git a/frontend/app/duplicates/duplicates.component.ts b/frontend/app/duplicates/duplicates.component.ts new file mode 100644 index 0000000..8e7018c --- /dev/null +++ b/frontend/app/duplicates/duplicates.component.ts @@ -0,0 +1,22 @@ +import {Component} from '@angular/core'; +import {DuplicateService} from './duplicates.service'; +import {MediaDTO} from '../../../common/entities/MediaDTO'; +import {Utils} from '../../../common/Utils'; +import {QueryService} from '../model/query.service'; + +@Component({ + selector: 'app-duplicate', + templateUrl: './duplicates.component.html', + styleUrls: ['./duplicates.component.css'] +}) +export class DuplicateComponent { + constructor(public _duplicateService: DuplicateService, + public queryService: QueryService) { + this._duplicateService.getDuplicates().catch(console.error); + } + + getDirectoryPath(media: MediaDTO) { + return Utils.concatUrls(media.directory.path, media.directory.name); + } +} + diff --git a/frontend/app/duplicates/duplicates.service.ts b/frontend/app/duplicates/duplicates.service.ts new file mode 100644 index 0000000..7543689 --- /dev/null +++ b/frontend/app/duplicates/duplicates.service.ts @@ -0,0 +1,20 @@ +import {Injectable} from '@angular/core'; +import {NetworkService} from '../model/network/network.service'; +import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO'; +import {BehaviorSubject} from 'rxjs'; + + +@Injectable() +export class DuplicateService { + + public duplicates: BehaviorSubject; + + constructor(private networkService: NetworkService) { + this.duplicates = new BehaviorSubject(null); + } + + public async getDuplicates() { + this.duplicates.next(await this.networkService.getJson('/admin/duplicates')); + } + +} diff --git a/frontend/app/duplicates/photo/photo.duplicates.component.css b/frontend/app/duplicates/photo/photo.duplicates.component.css new file mode 100644 index 0000000..b299679 --- /dev/null +++ b/frontend/app/duplicates/photo/photo.duplicates.component.css @@ -0,0 +1,13 @@ +.icon { + height: 30px; +} + +.big-icon { + height: 60px; +} + +.photo-container { + width: inherit; + height: inherit; + overflow: hidden; +} diff --git a/frontend/app/duplicates/photo/photo.duplicates.component.html b/frontend/app/duplicates/photo/photo.duplicates.component.html new file mode 100644 index 0000000..f6091df --- /dev/null +++ b/frontend/app/duplicates/photo/photo.duplicates.component.html @@ -0,0 +1,15 @@ +
+ + {{media.name}} + + + {{media.name}} +
diff --git a/frontend/app/duplicates/photo/photo.duplicates.component.ts b/frontend/app/duplicates/photo/photo.duplicates.component.ts new file mode 100644 index 0000000..6d0abe9 --- /dev/null +++ b/frontend/app/duplicates/photo/photo.duplicates.component.ts @@ -0,0 +1,40 @@ +import {Component, Input, OnDestroy, OnInit} from '@angular/core'; +import {MediaDTO} from '../../../../common/entities/MediaDTO'; +import {Media} from '../../gallery/Media'; +import {IconThumbnail, Thumbnail, ThumbnailManagerService} from '../../gallery/thumbnailManager.service'; +import {PhotoDTO} from '../../../../common/entities/PhotoDTO'; +import {OrientationTypes} from 'ts-exif-parser'; +import {MediaIcon} from '../../gallery/MediaIcon'; + +@Component({ + selector: 'app-duplicates-photo', + templateUrl: './photo.duplicates.component.html', + styleUrls: ['./photo.duplicates.component.css'] +}) +export class DuplicatesPhotoComponent implements OnInit, OnDestroy { + @Input() media: MediaDTO; + + thumbnail: IconThumbnail; + + + constructor(private thumbnailService: ThumbnailManagerService) { + } + + get Orientation() { + if (!this.media) { + return OrientationTypes.TOP_LEFT; + } + return (this.media).metadata.orientation || OrientationTypes.TOP_LEFT; + } + + ngOnInit() { + this.thumbnail = this.thumbnailService.getIcon(new MediaIcon(this.media)); + + } + + ngOnDestroy() { + this.thumbnail.destroy(); + } + +} + diff --git a/frontend/app/frame/frame.component.html b/frontend/app/frame/frame.component.html index 1ad69de..da009bd 100644 --- a/frontend/app/frame/frame.component.html +++ b/frontend/app/frame/frame.component.html @@ -35,6 +35,12 @@ class="dropdown-menu dropdown-menu-right" role="menu" aria-labelledby="button-basic"> +
  • + + + duplicates + +
  • From 54781ee667d13f9a9deeed216ad97c047e3ccba8 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 19 Jan 2019 00:18:20 +0100 Subject: [PATCH 06/20] improving duplicate finding and UI --- backend/model/sql/GalleryManager.ts | 81 ++++++++----- common/config/private/IPrivateConfig.ts | 5 + common/config/private/PrivateConfigClass.ts | 3 + .../app/duplicates/duplicates.component.html | 43 +++---- .../app/duplicates/duplicates.component.ts | 109 +++++++++++++++++- .../gallery/grid/grid.gallery.component.ts | 1 - frontend/app/model/page.helper.ts | 14 ++- frontend/app/settings/settings.service.ts | 9 +- 8 files changed, 200 insertions(+), 65 deletions(-) diff --git a/backend/model/sql/GalleryManager.ts b/backend/model/sql/GalleryManager.ts index f3e9bb6..5adc361 100644 --- a/backend/model/sql/GalleryManager.ts +++ b/backend/model/sql/GalleryManager.ts @@ -181,50 +181,67 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { .having('count(*)>1'), 'innerMedia', 'media.name=innerMedia.name AND media.metadata.fileSize = innerMedia.fileSize') - .innerJoinAndSelect('media.directory', 'directory').getMany(); + .innerJoinAndSelect('media.directory', 'directory') + .orderBy('media.name, media.metadata.fileSize') + .limit(Config.Server.duplicates.listingLimit).getMany(); + const duplicateParis: DuplicatesDTO[] = []; - let i = duplicates.length - 1; - while (i >= 0) { - const list = [duplicates[i]]; - let j = i - 1; - while (j >= 0 && duplicates[i].name === duplicates[j].name && duplicates[i].metadata.fileSize === duplicates[j].metadata.fileSize) { - list.push(duplicates[j]); - j--; + const processDuplicates = (duplicateList: MediaEntity[], + equalFn: (a: MediaEntity, b: MediaEntity) => boolean, + checkDuplicates: boolean = false) => { + let i = duplicateList.length - 1; + while (i >= 0) { + const list = [duplicateList[i]]; + let j = i - 1; + while (j >= 0 && equalFn(duplicateList[i], duplicateList[j])) { + list.push(duplicateList[j]); + j--; + } + i = j; + // if we cut the select list with the SQL LIMIT, filter unpaired media + if (list.length < 2) { + continue; + } + if (checkDuplicates) { + // ad to group if one already existed + const foundDuplicates = duplicateParis.find(dp => + !!dp.media.find(m => + !!list.find(lm => lm.id === m.id))); + if (foundDuplicates) { + list.forEach(lm => { + if (!!foundDuplicates.media.find(m => m.id === lm.id)) { + return; + } + foundDuplicates.media.push(lm); + }); + continue; + } + } + + duplicateParis.push({media: list}); } - i = j; - duplicateParis.push({media: list}); - } + }; + + processDuplicates(duplicates, + (a, b) => a.name === b.name && + a.metadata.fileSize === b.metadata.fileSize); duplicates = await mediaRepository.createQueryBuilder('media') .innerJoin(query => query.from(MediaEntity, 'innerMedia') .select(['innerMedia.metadata.creationDate as creationDate', 'innerMedia.metadata.fileSize as fileSize', 'count(*)']) - .groupBy('innerMedia.name, innerMedia.metadata.fileSize') + .groupBy('innerMedia.metadata.creationDate, innerMedia.metadata.fileSize') .having('count(*)>1'), 'innerMedia', 'media.metadata.creationDate=innerMedia.creationDate AND media.metadata.fileSize = innerMedia.fileSize') - .innerJoinAndSelect('media.directory', 'directory').getMany(); - - i = duplicates.length - 1; - while (i >= 0) { - const list = [duplicates[i]]; - let j = i - 1; - while (j >= 0 && duplicates[i].metadata.creationDate === duplicates[j].metadata.creationDate && duplicates[i].metadata.fileSize === duplicates[j].metadata.fileSize) { - list.push(duplicates[j]); - j--; - } - i = j; - if (list.filter(paired => - !!duplicateParis.find(dp => - !!dp.media.find(m => - m.id === paired.id))).length === list.length) { - continue; - } - - duplicateParis.push({media: list}); - } + .innerJoinAndSelect('media.directory', 'directory') + .orderBy('media.metadata.creationDate, media.metadata.fileSize') + .limit(Config.Server.duplicates.listingLimit).getMany(); + processDuplicates(duplicates, + (a, b) => a.metadata.creationDate === b.metadata.creationDate && + a.metadata.fileSize === b.metadata.fileSize, true); return duplicateParis; diff --git a/common/config/private/IPrivateConfig.ts b/common/config/private/IPrivateConfig.ts index c135669..7c23360 100644 --- a/common/config/private/IPrivateConfig.ts +++ b/common/config/private/IPrivateConfig.ts @@ -56,6 +56,10 @@ export interface ThreadingConfig { thumbnailThreads: number; } +export interface DuplicatesConfig { + listingLimit: number; // maximum number of duplicates to list +} + export interface ServerConfig { port: number; host: string; @@ -67,6 +71,7 @@ export interface ServerConfig { sessionTimeout: number; indexing: IndexingConfig; photoMetadataSize: number; + duplicates: DuplicatesConfig; } export interface IPrivateConfig { diff --git a/common/config/private/PrivateConfigClass.ts b/common/config/private/PrivateConfigClass.ts index ca3e908..585daa0 100644 --- a/common/config/private/PrivateConfigClass.ts +++ b/common/config/private/PrivateConfigClass.ts @@ -45,6 +45,9 @@ export class PrivateConfigClass extends PublicConfigClass implements IPrivateCon folderPreviewSize: 2, cachedFolderTimeout: 1000 * 60 * 60, reIndexingSensitivity: ReIndexingSensitivity.low + }, + duplicates: { + listingLimit: 1000 } }; private ConfigLoader: any; diff --git a/frontend/app/duplicates/duplicates.component.html b/frontend/app/duplicates/duplicates.component.html index 7059a32..667cb64 100644 --- a/frontend/app/duplicates/duplicates.component.html +++ b/frontend/app/duplicates/duplicates.component.html @@ -1,31 +1,34 @@
    - -
    -
    -
    - -
    - /{{getDirectoryPath(media)}}/{{media.name}} -
    -
    - {{media.metadata.fileSize | fileSize}} -
    -
    - {{media.metadata.creationDate | date}} -
    -
    - {{media.metadata.size.width}}x{{media.metadata.size.height}} + +
    + {{group.name}} +
    +
    +
    + +
    + /{{getDirectoryPath(media.directory)}}/{{media.name}} +
    +
    + {{media.metadata.fileSize | fileSize}} +
    +
    + {{media.metadata.creationDate | date}} +
    +
    + {{media.metadata.size.width}}x{{media.metadata.size.height}} +
    - + loading
    diff --git a/frontend/app/duplicates/duplicates.component.ts b/frontend/app/duplicates/duplicates.component.ts index 8e7018c..91b7519 100644 --- a/frontend/app/duplicates/duplicates.component.ts +++ b/frontend/app/duplicates/duplicates.component.ts @@ -1,22 +1,121 @@ -import {Component} from '@angular/core'; +import {Component, HostListener, OnDestroy} from '@angular/core'; import {DuplicateService} from './duplicates.service'; -import {MediaDTO} from '../../../common/entities/MediaDTO'; import {Utils} from '../../../common/Utils'; import {QueryService} from '../model/query.service'; +import {DuplicatesDTO} from '../../../common/entities/DuplicatesDTO'; +import {DirectoryDTO} from '../../../common/entities/DirectoryDTO'; +import {Subscription} from 'rxjs'; +import {Config} from '../../../common/config/public/Config'; +import {PageHelper} from '../model/page.helper'; + +interface GroupedDuplicate { + name: string; + duplicates: DuplicatesDTO[]; +} @Component({ selector: 'app-duplicate', templateUrl: './duplicates.component.html', styleUrls: ['./duplicates.component.css'] }) -export class DuplicateComponent { +export class DuplicateComponent implements OnDestroy { + + directoryGroups: GroupedDuplicate[] = null; + renderedDirGroups: GroupedDuplicate[] = null; + renderedIndex = { + group: -1, + pairs: 0 + }; + subscription: Subscription; + renderTimer: number = null; + constructor(public _duplicateService: DuplicateService, public queryService: QueryService) { this._duplicateService.getDuplicates().catch(console.error); + this.subscription = this._duplicateService.duplicates.subscribe((duplicates: DuplicatesDTO[]) => { + this.directoryGroups = []; + this.renderedIndex = {group: -1, pairs: 0}; + this.renderedDirGroups = []; + if (duplicates === null) { + return; + } + const getMostFrequentDir = (dupls: DuplicatesDTO[]) => { + if (dupls.length === 0) { + return null; + } + const dirFrequency: { [key: number]: { count: number, dir: DirectoryDTO } } = {}; + dupls.forEach(d => d.media.forEach(m => { + dirFrequency[m.directory.id] = dirFrequency[m.directory.id] || {dir: m.directory, count: 0}; + dirFrequency[m.directory.id].count++; + })); + let max: { count: number, dir: DirectoryDTO } = {count: -1, dir: null}; + for (const freq of Object.values(dirFrequency)) { + if (max.count <= freq.count) { + max = freq; + } + } + return max.dir; + }; + + while (duplicates.length > 0) { + const dir = getMostFrequentDir(duplicates); + const group = duplicates.filter(d => d.media.find(m => m.directory.id === dir.id)); + duplicates = duplicates.filter(d => !d.media.find(m => m.directory.id === dir.id)); + this.directoryGroups.push({name: this.getDirectoryPath(dir) + ' (' + group.length + ')', duplicates: group}); + } + this.renderMore(); + }); } - getDirectoryPath(media: MediaDTO) { - return Utils.concatUrls(media.directory.path, media.directory.name); + ngOnDestroy(): void { + if (this.subscription) { + this.subscription.unsubscribe(); + this.subscription = null; + } + } + + getDirectoryPath(directory: DirectoryDTO) { + return Utils.concatUrls(directory.path, directory.name); + } + + renderMore = () => { + if (this.renderTimer !== null) { + clearTimeout(this.renderTimer); + this.renderTimer = null; + } + + if (this.renderedIndex.group === this.directoryGroups.length - 1 && + this.renderedIndex.pairs >= + this.directoryGroups[this.renderedIndex.group].duplicates.length) { + return; + } + if (this.shouldRenderMore()) { + if (this.renderedDirGroups.length === 0 || + this.renderedIndex.pairs >= + this.directoryGroups[this.renderedIndex.group].duplicates.length) { + this.renderedDirGroups.push({ + name: this.directoryGroups[++this.renderedIndex.group].name, + duplicates: [] + }); + this.renderedIndex.pairs = 0; + } + this.renderedDirGroups[this.renderedDirGroups.length - 1].duplicates + .push(this.directoryGroups[this.renderedIndex.group].duplicates[this.renderedIndex.pairs++]); + + this.renderTimer = window.setTimeout(this.renderMore, 0); + } + }; + + + @HostListener('window:scroll') + onScroll() { + this.renderMore(); + } + + private shouldRenderMore(): boolean { + return Config.Client.Other.enableOnScrollRendering === false || + PageHelper.ScrollY >= PageHelper.MaxScrollY * 0.7 + || (document.body.clientHeight) * 0.85 < window.innerHeight; } } diff --git a/frontend/app/gallery/grid/grid.gallery.component.ts b/frontend/app/gallery/grid/grid.gallery.component.ts index 084e868..7c91882 100644 --- a/frontend/app/gallery/grid/grid.gallery.component.ts +++ b/frontend/app/gallery/grid/grid.gallery.component.ts @@ -292,7 +292,6 @@ export class GalleryGridComponent implements OnChanges, OnInit, AfterViewInit, O return Config.Client.Other.enableOnScrollRendering === false || PageHelper.ScrollY >= (document.body.clientHeight + offset - window.innerHeight) * 0.7 || (document.body.clientHeight + offset) * 0.85 < window.innerHeight; - } diff --git a/frontend/app/model/page.helper.ts b/frontend/app/model/page.helper.ts index 1decc2a..9f3da06 100644 --- a/frontend/app/model/page.helper.ts +++ b/frontend/app/model/page.helper.ts @@ -11,14 +11,20 @@ export class PageHelper { return this.supportPageOffset ? window.pageYOffset : this.isCSS1Compat ? document.documentElement.scrollTop : document.body.scrollTop; } - public static get ScrollX(): number { - return this.supportPageOffset ? window.pageXOffset : this.isCSS1Compat ? document.documentElement.scrollLeft : document.body.scrollLeft; - } - public static set ScrollY(value: number) { window.scrollTo(this.ScrollX, value); } + public static get MaxScrollY(): number { + return Math.max(document.body.scrollHeight, document.body.offsetHeight, + document.documentElement.clientHeight, document.documentElement.scrollHeight, + document.documentElement.offsetHeight) - window.innerHeight; + } + + public static get ScrollX(): number { + return this.supportPageOffset ? window.pageXOffset : this.isCSS1Compat ? document.documentElement.scrollLeft : document.body.scrollLeft; + } + public static showScrollY() { PageHelper.body.style.overflowY = 'scroll'; } diff --git a/frontend/app/settings/settings.service.ts b/frontend/app/settings/settings.service.ts index a2e85cf..7cecaf1 100644 --- a/frontend/app/settings/settings.service.ts +++ b/frontend/app/settings/settings.service.ts @@ -71,8 +71,8 @@ export class SettingsService { updateTimeout: 2000 }, imagesFolder: '', - port: 80, - host: '0.0.0.0', + port: 80, + host: '0.0.0.0', thumbnail: { folder: '', qualityPriority: true, @@ -88,7 +88,10 @@ export class SettingsService { folderPreviewSize: 0, reIndexingSensitivity: ReIndexingSensitivity.medium }, - photoMetadataSize: 512 * 1024 + photoMetadataSize: 512 * 1024, + duplicates: { + listingLimit: 1000 + } } }); } From ca33bb1efb6cbf9d998ee922253e9ba37f28bec2 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 19 Jan 2019 10:07:06 +0100 Subject: [PATCH 07/20] fixing date parsing bug --- backend/model/threading/MetadataLoader.ts | 5 +++-- test/backend/unit/assets/old_photo.jpg | Bin 0 -> 2582 bytes test/backend/unit/assets/old_photo.json | 17 +++++++++++++++++ .../model/threading/MetaDataLoader.spec.ts | 7 +++++++ 4 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 test/backend/unit/assets/old_photo.jpg create mode 100644 test/backend/unit/assets/old_photo.json diff --git a/backend/model/threading/MetadataLoader.ts b/backend/model/threading/MetadataLoader.ts index dea0932..14a5eb9 100644 --- a/backend/model/threading/MetadataLoader.ts +++ b/backend/model/threading/MetadataLoader.ts @@ -120,7 +120,7 @@ export class MetadataLoader { } if (exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate) { - metadata.creationDate = exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate; + metadata.creationDate = (exif.tags.CreateDate || exif.tags.DateTimeOriginal || exif.tags.ModifyDate) * 1000; } if (exif.tags.Orientation) { @@ -163,10 +163,11 @@ export class MetadataLoader { 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); + // Logger.debug(LOG_TAG, 'Error parsing iptc data', fullPath, err); } metadata.creationDate = metadata.creationDate || 0; diff --git a/test/backend/unit/assets/old_photo.jpg b/test/backend/unit/assets/old_photo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0f257df7eb707d784983b04ee4aff0a25b1aed78 GIT binary patch literal 2582 zcmeH{du&s66vxl+{%(68?O59t7;}%d8*ak|Z|}<3DrSyBVG9E{9m-53MaC>qlTCDk zKy*diB#^L+4_rnvHt-c6K?Ttm1w{oCqYT9{AKCB$@`oAX1C^wn-|cOC(fuL*XX3BD z_xI^9=bWDJx#ynPaBLJDwV|325JF%fejqj`teoEwSp`s62QvUb1RF^Q4oSjGBMzNi z$bG~G6nBCjAX#A18Q?{7Rg(9UBEtqF5WntQjCOAkRcANySuP`G6WyVovsjn9BEZLE z67=R*hZ<{ZLN$x(078*_ns1DBctT~0-_x{Y2_6B=S5l~Yr=PVs>63Tw}m}_ zbMP!}Zc}F}3V^C8vw})R(C_#7%YtfYP*K5y3nLXodlO2y1JRvHKG^L`W!zn8@DYG* z00R2)imEExXhI=(WE-F4FM3GahHw;mxMa+6jev~4sDQhuCC@2nG;vNvjZd;evm|_J zrY0CUVBIv)U<{^720M`Dq`gO5&vL-$2eha^k+WY>`iWtWS@d&>>T?hF(*%>zeoKP+ z;J~CrOulI#J;^Hu97Y`MkAbNM<1-wL{}*9xGZ?P}HXguk&ah!jO>8x>Y+`$g&*kWy zNn)|sC3I&C>&Tx*eVNEBCwjgUouO@R+?RWk`JsP_v7e0Zc)#qNKF@w9y3&q6SUU@P zc@%%C0uByeAAFuDkvOQ{oJ=`qFd|SBI}FA`fzu)D_A>f{;i8&K9|HVrh>Z%rb80zhbi4Z**zM-NVaJ34WAI_3V1{RKeJ6T{aRRS+fFh6ej^7!yi4o?0wC7J+PFLxp!*TwZ^bHKRT(_*&g+XU{G%f9~ExlN*kY z7nH7Oi%Rm8shQKVre9rHR6HXvt88|8MX-9_{Lq4hHMKY0+_?0XTbDK6v9ftp%bk(c z_pZ6`{`QWw>oz{L>EW*KM>cEQAARicCw4s9J21F=&r?r7^Xzjkzw+w7{jVK({f&3t zJ^bGL9~?P)Z0N)%pPu||_|(YQ>94=}_PaA@zdv{W#|uCG{L8NwfBT*3!YLD%l~nc@ zT~5@+@jT@Pri*axcu^;B@%pW`VWy^}HErJ!>5ie1)NunrYbINJ5Y_sDMq)F*%_DPKO&O>kJg3mZ|f6|92kj`_FgYS0~ { expect(Utils.clone(data)).to.be.deep.equal(expected); }); + + it('should load jpg 2', async () => { + const data = await MetadataLoader.loadPhotoMetadata(path.join(__dirname, '/../../assets/old_photo.jpg')); + const expected = require(path.join(__dirname, '/../../assets/old_photo.json')); + expect(Utils.clone(data)).to.be.deep.equal(expected); + }); + }); From 0e3b04c1224458a6efa5575d714fbc7c610f228e Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 19 Jan 2019 13:00:38 +0100 Subject: [PATCH 08/20] fixing url concatenation bug --- common/Utils.ts | 4 ++-- test/common/unit/Utils.spec.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 test/common/unit/Utils.spec.ts diff --git a/common/Utils.ts b/common/Utils.ts index de5a8dc..f33c68d 100644 --- a/common/Utils.ts +++ b/common/Utils.ts @@ -90,14 +90,14 @@ export class Utils { continue; } - const part = args[i].replace('\\', '/'); + const part = args[i].replace(new RegExp('\\\\', 'g'), '/'); if (part === '/' || part === './') { continue; } url += part + '/'; } - url = url.replace('//', '/'); + url = url.replace(new RegExp('/+', 'g'), '/'); if (url.trim() === '') { url = './'; diff --git a/test/common/unit/Utils.spec.ts b/test/common/unit/Utils.spec.ts new file mode 100644 index 0000000..6895a56 --- /dev/null +++ b/test/common/unit/Utils.spec.ts @@ -0,0 +1,18 @@ +import {expect} from 'chai'; +import {Utils} from '../../../common/Utils'; + +describe('Utils', () => { + it('should concat urls', () => { + expect(Utils.concatUrls('abc', 'cde')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc/', 'cde')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc\\', 'cde')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc/', 'cde/')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('./abc\\', 'cde/')).to.be.equal('./abc/cde'); + expect(Utils.concatUrls('abc/', '\\cde/')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc\\', '\\cde/')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc\\', '/cde/')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc/', '/cde/')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc\\/', '/cde/')).to.be.equal('abc/cde'); + expect(Utils.concatUrls('abc\\/', '/cde/', 'fgh')).to.be.equal('abc/cde/fgh'); + }); +}); From 0f3d664c58cf69c3cc03967173c9c5bcb2f3910e Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 19 Jan 2019 17:31:23 +0100 Subject: [PATCH 09/20] improving folder indexing texts --- .../indexing/indexing.settings.component.ts | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/frontend/app/settings/indexing/indexing.settings.component.ts b/frontend/app/settings/indexing/indexing.settings.component.ts index 53484bf..6bae8fa 100644 --- a/frontend/app/settings/indexing/indexing.settings.component.ts +++ b/frontend/app/settings/indexing/indexing.settings.component.ts @@ -4,7 +4,7 @@ import {AuthenticationService} from '../../model/network/authentication.service' import {NavigationService} from '../../model/navigation.service'; import {NotificationService} from '../../model/notification.service'; import {ErrorDTO} from '../../../../common/entities/Error'; -import {Observable, interval} from 'rxjs'; +import {interval, Observable} from 'rxjs'; import {IndexingConfig, ReIndexingSensitivity} from '../../../../common/config/private/IPrivateConfig'; import {SettingsComponent} from '../_abstract/abstract.settings.component'; import {Utils} from '../../../../common/Utils'; @@ -23,15 +23,46 @@ export class IndexingSettingsComponent extends SettingsComponent = null; + + constructor(_authService: AuthenticationService, + _navigation: NavigationService, + _settingsService: IndexingSettingsService, + notification: NotificationService, + i18n: I18n) { + + super(i18n('Indexing'), + _authService, + _navigation, + _settingsService, + notification, + i18n, + s => s.Server.indexing); + + } + + get TimeLeft() { + const prg = this._settingsService.progress.value; + return (prg.time.current - prg.time.start) / prg.indexed * prg.left; + } + + get TimeElapsed() { + const prg = this._settingsService.progress.value; + return (prg.time.current - prg.time.start); + } + updateProgress = async () => { try { + const wasRunning = this._settingsService.progress.value !== null; await (this._settingsService).getProgress(); + if (wasRunning && this._settingsService.progress.value === null) { + this.notification.success(this.i18n('Folder indexed'), this.i18n('Success')); + } } catch (err) { if (this.subscription.timer != null) { this.subscription.timer.unsubscribe(); @@ -50,22 +81,6 @@ export class IndexingSettingsComponent extends SettingsComponent_settingsService, - notification, - i18n, - s => s.Server.indexing); - - } - async ngOnInit() { super.ngOnInit(); this.types = Utils @@ -105,7 +120,7 @@ export class IndexingSettingsComponent extends SettingsComponentthis._settingsService).cancel(); - this.notification.success(this.i18n('Folder indexed'), this.i18n('Success')); + this._settingsService.progress.next(null); + this.notification.info(this.i18n('Folder indexing interrupted')); this.inProgress = false; return true; } catch (err) { From 1f58bffa2c86804bdd7b1a06b8fa8f2bd8b3f413 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 19 Jan 2019 17:31:36 +0100 Subject: [PATCH 10/20] improving duplicate page --- .../thumbnail/ThumbnailGeneratorMWs.ts | 2 +- .../app/duplicates/duplicates.component.css | 7 +++-- .../app/duplicates/duplicates.component.html | 29 ++++++++++--------- .../app/duplicates/duplicates.component.ts | 11 +++++++ package.json | 2 +- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts b/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts index 5a1eecb..0c0b0e7 100644 --- a/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts +++ b/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts @@ -65,7 +65,7 @@ export class ThumbnailGeneratorMWs { } } catch (error) { - return next(new ErrorDTO(ErrorCodes.SERVER_ERROR, 'error during postprocessing result', error.toString())); + return next(new ErrorDTO(ErrorCodes.SERVER_ERROR, 'error during postprocessing result (adding thumbnail info)', error.toString())); } diff --git a/frontend/app/duplicates/duplicates.component.css b/frontend/app/duplicates/duplicates.component.css index fb4eddb..6d4eb79 100644 --- a/frontend/app/duplicates/duplicates.component.css +++ b/frontend/app/duplicates/duplicates.component.css @@ -1,6 +1,3 @@ -.same-data { - font-weight: bold; -} .card{ margin: 8px 0; } @@ -13,3 +10,7 @@ .row:hover{ background-color: #f8f9fa; } + +a{ + color: #212529; +} diff --git a/frontend/app/duplicates/duplicates.component.html b/frontend/app/duplicates/duplicates.component.html index 667cb64..c9dcd8e 100644 --- a/frontend/app/duplicates/duplicates.component.html +++ b/frontend/app/duplicates/duplicates.component.html @@ -2,28 +2,31 @@
    + +
    {{group.name}}
    diff --git a/frontend/app/duplicates/duplicates.component.ts b/frontend/app/duplicates/duplicates.component.ts index 91b7519..79c4b2d 100644 --- a/frontend/app/duplicates/duplicates.component.ts +++ b/frontend/app/duplicates/duplicates.component.ts @@ -28,6 +28,10 @@ export class DuplicateComponent implements OnDestroy { }; subscription: Subscription; renderTimer: number = null; + duplicateCount = { + pairs: 0, + photos: 0 + }; constructor(public _duplicateService: DuplicateService, public queryService: QueryService) { @@ -36,9 +40,16 @@ export class DuplicateComponent implements OnDestroy { this.directoryGroups = []; this.renderedIndex = {group: -1, pairs: 0}; this.renderedDirGroups = []; + this.duplicateCount = { + pairs: 0, + photos: 0 + }; if (duplicates === null) { return; } + this.duplicateCount.photos = duplicates.reduce((prev: number, curr) => prev + curr.media.length, 0); + this.duplicateCount.pairs = duplicates.length; + const getMostFrequentDir = (dupls: DuplicatesDTO[]) => { if (dupls.length === 0) { return null; diff --git a/package.json b/package.json index 5a8e5da..63bc428 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pigallery2", - "version": "1.5.6", + "version": "1.5.8", "description": "This is a photo gallery optimised for running low resource servers (especially on raspberry pi)", "author": "Patrik J. Braun", "homepage": "https://github.com/bpatrik/PiGallery2", From 0445c499e812085a539befdf3de0e15aa36b63bc Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 26 Jan 2019 18:03:40 -0500 Subject: [PATCH 11/20] fixing searching --- backend/model/sql/GalleryManager.ts | 2 +- backend/model/sql/SQLConnection.ts | 2 +- backend/model/sql/SearchManager.ts | 170 ++++++++++-------- backend/model/sql/enitites/FaceRegionEntry.ts | 23 ++- benchmark/Benchmarks.ts | 137 ++++++++++++++ benchmark/README.md | 5 + benchmark/index.ts | 70 ++++++++ common/entities/Error.ts | 4 + .../unit/assets/test image öüóőúéáű-.,.jpg | Bin 59187 -> 39424 bytes .../unit/assets/test image öüóőúéáű-.,.json | 23 ++- test/backend/unit/model/sql/SearchManager.ts | 90 +++++++--- test/backend/unit/model/sql/TestHelper.ts | 24 +++ .../model/threading/DiskMangerWorker.spec.ts | 6 +- 13 files changed, 445 insertions(+), 111 deletions(-) create mode 100644 benchmark/Benchmarks.ts create mode 100644 benchmark/README.md create mode 100644 benchmark/index.ts diff --git a/backend/model/sql/GalleryManager.ts b/backend/model/sql/GalleryManager.ts index 5adc361..146ea9e 100644 --- a/backend/model/sql/GalleryManager.ts +++ b/backend/model/sql/GalleryManager.ts @@ -67,7 +67,7 @@ export class GalleryManager implements IGalleryManager, ISQLGalleryManager { // on the fly reindexing Logger.silly(LOG_TAG, 'lazy reindexing reason: cache timeout: lastScanned: ' - + (Date.now() - dir.lastScanned) + ', cachedFolderTimeout:' + Config.Server.indexing.cachedFolderTimeout); + + (Date.now() - dir.lastScanned) + ' ms ago, cachedFolderTimeout:' + Config.Server.indexing.cachedFolderTimeout); ObjectManagerRepository.getInstance().IndexingManager.indexDirectory(relativeDirectoryName).catch((err) => { console.error(err); }); diff --git a/backend/model/sql/SQLConnection.ts b/backend/model/sql/SQLConnection.ts index 671b86b..22a8fee 100644 --- a/backend/model/sql/SQLConnection.ts +++ b/backend/model/sql/SQLConnection.ts @@ -44,7 +44,7 @@ export class SQLConnection { VersionEntity ]; options.synchronize = false; - // options.logging = 'all'; + //options.logging = 'all'; this.connection = await createConnection(options); await SQLConnection.schemeSync(this.connection); } diff --git a/backend/model/sql/SearchManager.ts b/backend/model/sql/SearchManager.ts index 982c072..f6c06fa 100644 --- a/backend/model/sql/SearchManager.ts +++ b/backend/model/sql/SearchManager.ts @@ -8,6 +8,7 @@ import {MediaEntity} from './enitites/MediaEntity'; import {VideoEntity} from './enitites/VideoEntity'; import {PersonEntry} from './enitites/PersonEntry'; import {FaceRegionEntry} from './enitites/FaceRegionEntry'; +import {SelectQueryBuilder} from 'typeorm'; export class SearchManager implements ISearchManager { @@ -24,7 +25,7 @@ export class SearchManager implements ISearchManager { return a; } - async autocomplete(text: string): Promise> { + async autocomplete(text: string): Promise { const connection = await SQLConnection.getConnection(); @@ -122,61 +123,60 @@ export class SearchManager implements ISearchManager { resultOverflow: false }; - let repository = connection.getRepository(MediaEntity); - const faceRepository = connection.getRepository(FaceRegionEntry); + let usedEntity = MediaEntity; if (searchType === SearchTypes.photo) { - repository = connection.getRepository(PhotoEntity); + usedEntity = PhotoEntity; } else if (searchType === SearchTypes.video) { - repository = connection.getRepository(VideoEntity); + usedEntity = VideoEntity; } - const query = repository.createQueryBuilder('media') + const query = await connection.getRepository(usedEntity).createQueryBuilder('media') + .innerJoin(q => { + const subQuery = q.from(usedEntity, 'media') + .select('distinct media.id') + .limit(2000); + + + if (!searchType || searchType === SearchTypes.directory) { + subQuery.leftJoin('media.directory', 'directory') + .orWhere('directory.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); + } + + if (!searchType || searchType === SearchTypes.photo || searchType === SearchTypes.video) { + subQuery.orWhere('media.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); + } + + if (!searchType || searchType === SearchTypes.photo) { + subQuery.orWhere('media.metadata.caption LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); + } + if (!searchType || searchType === SearchTypes.person) { + subQuery + .leftJoin('media.metadata.faces', 'faces') + .leftJoin('faces.person', 'person') + .orWhere('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); + } + + if (!searchType || searchType === SearchTypes.position) { + subQuery.orWhere('media.metadata.positionData.country LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('media.metadata.positionData.state LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('media.metadata.positionData.city LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); + + } + if (!searchType || searchType === SearchTypes.keyword) { + subQuery.orWhere('media.metadata.keywords LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); + } + + return subQuery; + }, + 'innerMedia', + 'media.id=innerMedia.id') .leftJoinAndSelect('media.directory', 'directory') - .leftJoin('media.metadata.faces', 'faces') - .leftJoin('faces.person', 'person') - .orderBy('media.metadata.creationDate', 'ASC'); + .leftJoinAndSelect('media.metadata.faces', 'faces') + .leftJoinAndSelect('faces.person', 'person'); - if (!searchType || searchType === SearchTypes.directory) { - query.orWhere('directory.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); - } - - if (!searchType || searchType === SearchTypes.photo || searchType === SearchTypes.video) { - query.orWhere('media.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); - } - - if (!searchType || searchType === SearchTypes.photo) { - query.orWhere('media.metadata.caption LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); - } - if (!searchType || searchType === SearchTypes.person) { - query.orWhere('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); - } - - if (!searchType || searchType === SearchTypes.position) { - query.orWhere('media.metadata.positionData.country LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('media.metadata.positionData.state LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('media.metadata.positionData.city LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); - - } - if (!searchType || searchType === SearchTypes.keyword) { - query.orWhere('media.metadata.keywords LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}); - } - - - result.media = (await query - .limit(5000).getMany()).slice(0, 2001); - - for (let i = 0; i < result.media.length; i++) { - const faces = (await faceRepository - .createQueryBuilder('faces') - .leftJoinAndSelect('faces.person', 'person') - .where('faces.media = :media', {media: result.media[i].id}) - .getMany()).map(fE => ({name: fE.person.name, box: fE.box})); - if (faces.length > 0) { - result.media[i].metadata.faces = faces; - } - } + result.media = await this.loadMediaWithFaces(query); if (result.media.length > 2000) { result.resultOverflow = true; @@ -208,35 +208,30 @@ export class SearchManager implements ISearchManager { resultOverflow: false }; - const faceRepository = connection.getRepository(FaceRegionEntry); + const query = await connection.getRepository(MediaEntity).createQueryBuilder('media') + .innerJoin(q => q.from(MediaEntity, 'media') + .select('distinct media.id') + .limit(10) + .leftJoin('media.directory', 'directory') + .leftJoin('media.metadata.faces', 'faces') + .leftJoin('faces.person', 'person') + .where('media.metadata.keywords LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('media.metadata.positionData.country LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('media.metadata.positionData.state LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('media.metadata.positionData.city LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('media.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('media.metadata.caption LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + .orWhere('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) + , + 'innerMedia', + 'media.id=innerMedia.id') + .leftJoinAndSelect('media.directory', 'directory') + .leftJoinAndSelect('media.metadata.faces', 'faces') + .leftJoinAndSelect('faces.person', 'person'); - result.media = await connection - .getRepository(MediaEntity) - .createQueryBuilder('media') - .orderBy('media.metadata.creationDate', 'ASC') - .where('media.metadata.keywords LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('media.metadata.positionData.country LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('media.metadata.positionData.state LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('media.metadata.positionData.city LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('media.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('media.metadata.caption LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .orWhere('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .innerJoinAndSelect('media.directory', 'directory') - .leftJoin('media.metadata.faces', 'faces') - .leftJoin('faces.person', 'person') - .limit(10) - .getMany(); - for (let i = 0; i < result.media.length; i++) { - const faces = (await faceRepository - .createQueryBuilder('faces') - .leftJoinAndSelect('faces.person', 'person') - .where('faces.media = :media', {media: result.media[i].id}) - .getMany()).map(fE => ({name: fE.person.name, box: fE.box})); - if (faces.length > 0) { - result.media[i].metadata.faces = faces; - } - } + result.media = await this.loadMediaWithFaces(query); + result.directories = await connection .getRepository(DirectoryEntity) @@ -255,4 +250,29 @@ export class SearchManager implements ISearchManager { }); return res; } + + private async loadMediaWithFaces(query: SelectQueryBuilder) { + const rawAndEntities = await query.orderBy('media.id').getRawAndEntities(); + const media: MediaEntity[] = rawAndEntities.entities; + + // console.log(rawAndEntities.raw); + let rawIndex = 0; + for (let i = 0; i < media.length; i++) { + if (rawAndEntities.raw[rawIndex].faces_id === null || + rawAndEntities.raw[rawIndex].media_id !== media[i].id) { + delete media[i].metadata.faces; + continue; + } + media[i].metadata.faces = []; + + while (rawAndEntities.raw[rawIndex].media_id === media[i].id) { + media[i].metadata.faces.push(FaceRegionEntry.fromRawToDTO(rawAndEntities.raw[rawIndex])); + rawIndex++; + if (rawIndex >= rawAndEntities.raw.length) { + return media; + } + } + } + return media; + } } diff --git a/backend/model/sql/enitites/FaceRegionEntry.ts b/backend/model/sql/enitites/FaceRegionEntry.ts index f9d1afd..3b569a5 100644 --- a/backend/model/sql/enitites/FaceRegionEntry.ts +++ b/backend/model/sql/enitites/FaceRegionEntry.ts @@ -1,7 +1,7 @@ -import {FaceRegionBox} from '../../../../common/entities/PhotoDTO'; -import {Column, ManyToOne, Entity, PrimaryGeneratedColumn} from 'typeorm'; +import {FaceRegion, FaceRegionBox} from '../../../../common/entities/PhotoDTO'; +import {Column, Entity, ManyToOne, PrimaryGeneratedColumn} from 'typeorm'; import {PersonEntry} from './PersonEntry'; -import {MediaEntity, MediaMetadataEntity} from './MediaEntity'; +import {MediaEntity} from './MediaEntity'; export class FaceRegionBoxEntry implements FaceRegionBox { @Column('int') @@ -35,4 +35,21 @@ export class FaceRegionEntry { person: PersonEntry; name: string; + + public static fromRawToDTO(raw: { + faces_id: number, + faces_mediaId: number, + faces_personId: number, + faces_boxHeight: number, + faces_boxWidth: number, + faces_boxX: number, + faces_boxY: number, + person_id: number, + person_name: string + }): FaceRegion { + return { + box: {width: raw.faces_boxWidth, height: raw.faces_boxHeight, x: raw.faces_boxX, y: raw.faces_boxY}, + name: raw.person_name + }; + } } diff --git a/benchmark/Benchmarks.ts b/benchmark/Benchmarks.ts new file mode 100644 index 0000000..58b34e8 --- /dev/null +++ b/benchmark/Benchmarks.ts @@ -0,0 +1,137 @@ +import {SQLConnection} from '../backend/model/sql/SQLConnection'; +import {Config} from '../common/config/private/Config'; +import {DatabaseType, ReIndexingSensitivity} from '../common/config/private/IPrivateConfig'; +import {ObjectManagerRepository} from '../backend/model/ObjectManagerRepository'; +import {DiskMangerWorker} from '../backend/model/threading/DiskMangerWorker'; +import {IndexingManager} from '../backend/model/sql/IndexingManager'; +import {SearchManager} from '../backend/model/sql/SearchManager'; +import * as fs from 'fs'; +import {SearchTypes} from '../common/entities/AutoCompleteItem'; +import {Utils} from '../common/Utils'; +import {GalleryManager} from '../backend/model/sql/GalleryManager'; +import {DirectoryDTO} from '../common/entities/DirectoryDTO'; + +export interface BenchmarkResult { + duration: number; + directories?: number; + media?: number; + items?: number; +} + +export class BMIndexingManager extends IndexingManager { + + public async saveToDB(scannedDirectory: DirectoryDTO): Promise { + return super.saveToDB(scannedDirectory); + } +} + +export class Benchmarks { + + constructor(public RUNS: number, public dbPath: string) { + + } + + async bmSaveDirectory(): Promise { + await this.resetDB(); + const dir = await DiskMangerWorker.scanDirectory('./'); + const im = new BMIndexingManager(); + return await this.benchmark(() => im.saveToDB(dir), () => this.resetDB()); + } + + async bmScanDirectory(): Promise { + return await this.benchmark(() => DiskMangerWorker.scanDirectory('./')); + } + + async bmListDirectory(): Promise { + const gm = new GalleryManager(); + await this.setupDB(); + Config.Server.indexing.reIndexingSensitivity = ReIndexingSensitivity.low; + return await this.benchmark(() => gm.listDirectory('./')); + } + + async bmAllSearch(text: string): Promise<{ result: BenchmarkResult, searchType: SearchTypes }[]> { + await this.setupDB(); + const types = Utils.enumToArray(SearchTypes).map(a => a.key).concat([null]); + const results: { result: BenchmarkResult, searchType: SearchTypes }[] = []; + const sm = new SearchManager(); + for (let i = 0; i < types.length; i++) { + results.push({result: await this.benchmark(() => sm.search(text, types[i])), searchType: types[i]}); + } + return results; + } + + async bmInstantSearch(text: string): Promise { + await this.setupDB(); + const sm = new SearchManager(); + return await this.benchmark(() => sm.instantSearch(text)); + } + + async bmAutocomplete(text: string): Promise { + await this.setupDB(); + const sm = new SearchManager(); + return await this.benchmark(() => sm.autocomplete(text)); + } + + + private async benchmark(fn: () => Promise<{ media: any[], directories: any[] } | any[] | void>, + beforeEach: () => Promise = null, + afterEach: () => Promise = null) { + const scanned = await fn(); + const start = process.hrtime(); + let skip = 0; + for (let i = 0; i < this.RUNS; i++) { + if (beforeEach) { + const startSkip = process.hrtime(); + await beforeEach(); + const endSkip = process.hrtime(startSkip); + skip += (endSkip[0] * 1000 + endSkip[1] / 1000); + } + await fn(); + if (afterEach) { + const startSkip = process.hrtime(); + await afterEach(); + const endSkip = process.hrtime(startSkip); + skip += (endSkip[0] * 1000 + endSkip[1] / 1000); + } + } + const end = process.hrtime(start); + const duration = (end[0] * 1000 + end[1] / 1000) / this.RUNS; + + if (!scanned) { + return { + duration: duration + }; + } + + if (Array.isArray(scanned)) { + return { + duration: duration, + items: scanned.length + }; + } + + return { + duration: duration, + media: scanned.media.length, + directories: scanned.directories.length + }; + } + + private resetDB = async () => { + await SQLConnection.close(); + if (fs.existsSync(this.dbPath)) { + fs.unlinkSync(this.dbPath); + } + Config.Server.database.type = DatabaseType.sqlite; + Config.Server.database.sqlite.storage = this.dbPath; + await ObjectManagerRepository.InitSQLManagers(); + }; + + private async setupDB() { + const im = new BMIndexingManager(); + await this.resetDB(); + const dir = await DiskMangerWorker.scanDirectory('./'); + await im.saveToDB(dir); + } + +} diff --git a/benchmark/README.md b/benchmark/README.md new file mode 100644 index 0000000..9ace280 --- /dev/null +++ b/benchmark/README.md @@ -0,0 +1,5 @@ +# PiGallery2 performance benchmark results + +These results are created mostly for development, but I'm making them public for curious users. + + diff --git a/benchmark/index.ts b/benchmark/index.ts new file mode 100644 index 0000000..98a6048 --- /dev/null +++ b/benchmark/index.ts @@ -0,0 +1,70 @@ +import {Config} from '../common/config/private/Config'; +import * as path from 'path'; +import {ProjectPath} from '../backend/ProjectPath'; +import {BenchmarkResult, Benchmarks} from './Benchmarks'; +import {SearchTypes} from '../common/entities/AutoCompleteItem'; +import {Utils} from '../common/Utils'; +import {DiskMangerWorker} from '../backend/model/threading/DiskMangerWorker'; + +const config: { path: string, system: string } = require(path.join(__dirname, 'config.json')); +Config.Server.imagesFolder = config.path; +const dbPath = path.join(__dirname, 'test.db'); +ProjectPath.reset(); +const RUNS = 1; + +let resultsText = ''; +const printLine = (text: string) => { + resultsText += text + '\n'; +}; + +const printHeader = async () => { + const dt = new Date(); + printLine('## PiGallery2 v' + require('./../package.json').version + + ', ' + Utils.zeroPrefix(dt.getDay(), 2) + + '.' + Utils.zeroPrefix(dt.getMonth() + 1, 2) + + '.' + dt.getFullYear()); + printLine('**System**: ' + config.system); + const dir = await DiskMangerWorker.scanDirectory('./'); + printLine('**Gallery**: directories:' + dir.directories.length + ' media:' + dir.media.length); +}; + + +const printTableHeader = () => { + printLine('| action | action details | average time | details |'); + printLine('|:------:|:--------------:|:------------:|:-------:|'); +}; +const printResult = (result: BenchmarkResult, action: string, actionDetails: string = '') => { + let details = '-'; + if (result.items) { + details = 'items: ' + result.items; + } + if (result.media) { + details = 'media: ' + result.media + ', directories:' + result.directories; + } + printLine('| ' + action + ' | ' + actionDetails + + ' | ' + (result.duration / 1000).toFixed(2) + 's | ' + details + ' |'); +}; + +const run = async () => { + const bm = new Benchmarks(RUNS, dbPath); + + // header + await printHeader(); + printTableHeader(); + printResult(await bm.bmScanDirectory(), 'Scanning directory'); + printResult(await bm.bmSaveDirectory(), 'Saving directory'); + printResult(await bm.bmListDirectory(), 'Listing Directory'); + (await bm.bmAllSearch('a')).forEach(res => { + if (res.searchType !== null) { + printResult(res.result, 'searching', '`a` as `' + SearchTypes[res.searchType] + '`'); + } else { + printResult(res.result, 'searching', '`a` as `any`'); + } + }); + printResult(await bm.bmInstantSearch('a'), 'instant search', '`a`'); + printResult(await bm.bmAutocomplete('a'), 'auto complete', '`a`'); + console.log(resultsText); +}; + +run(); + diff --git a/common/entities/Error.ts b/common/entities/Error.ts index d64cbba..6a84c06 100644 --- a/common/entities/Error.ts +++ b/common/entities/Error.ts @@ -23,4 +23,8 @@ export enum ErrorCodes { export class ErrorDTO { constructor(public code: ErrorCodes, public message?: string, public details?: any) { } + + toString(): string { + return '[' + ErrorCodes[this.code] + '] ' + this.message + (this.details ? this.details.toString() : ''); + } } diff --git a/test/backend/unit/assets/test image öüóőúéáű-.,.jpg b/test/backend/unit/assets/test image öüóőúéáű-.,.jpg index e82c52e7b1b7893bb4353699b3f140505192009f..f0fa299a4420739c78bce63e3ad5f727d9771429 100644 GIT binary patch delta 1700 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 diff --git a/test/backend/unit/assets/test image öüóőúéáű-.,.json b/test/backend/unit/assets/test image öüóőúéáű-.,.json index 3d4100d..dd0bd2c 100644 --- a/test/backend/unit/assets/test image öüóőúéáű-.,.json +++ b/test/backend/unit/assets/test image öüóőúéáű-.,.json @@ -13,15 +13,24 @@ "faces": [ { "box": { - "height": 19, - "width": 20, - "x": 82, - "y": 38 + "height": 2, + "width": 2, + "x": 8, + "y": 4 }, "name": "squirrel" + }, + { + "box": { + "height": 3, + "width": 2, + "x": 5, + "y": 5 + }, + "name": "special_chars űáéúőóüío?._:" } ], - "fileSize": 59187, + "fileSize": 39424, "keywords": [ "Berkley", "USA", @@ -39,7 +48,7 @@ "state": "test state őúéáűóöí-.,)(" }, "size": { - "height": 93, - "width": 140 + "height": 10, + "width": 14 } } diff --git a/test/backend/unit/model/sql/SearchManager.ts b/test/backend/unit/model/sql/SearchManager.ts index cefccda..671d758 100644 --- a/test/backend/unit/model/sql/SearchManager.ts +++ b/test/backend/unit/model/sql/SearchManager.ts @@ -4,13 +4,7 @@ import * as path from 'path'; import {Config} from '../../../../../common/config/private/Config'; import {DatabaseType} from '../../../../../common/config/private/IPrivateConfig'; import {SQLConnection} from '../../../../../backend/model/sql/SQLConnection'; -import { - CameraMetadataEntity, - GPSMetadataEntity, - PhotoEntity, - PhotoMetadataEntity, - PositionMetaDataEntity -} from '../../../../../backend/model/sql/enitites/PhotoEntity'; +import {PhotoEntity} from '../../../../../backend/model/sql/enitites/PhotoEntity'; import {SearchManager} from '../../../../../backend/model/sql/SearchManager'; import {AutoCompleteItem, SearchTypes} from '../../../../../common/entities/AutoCompleteItem'; import {SearchResultDTO} from '../../../../../common/entities/SearchResultDTO'; @@ -18,6 +12,9 @@ import {DirectoryEntity} from '../../../../../backend/model/sql/enitites/Directo import {Utils} from '../../../../../common/Utils'; import {TestHelper} from './TestHelper'; import {VideoEntity} from '../../../../../backend/model/sql/enitites/VideoEntity'; +import {PersonEntry} from '../../../../../backend/model/sql/enitites/PersonEntry'; +import {FaceRegionEntry} from '../../../../../backend/model/sql/enitites/FaceRegionEntry'; +import {PhotoDTO} from '../../../../../common/entities/PhotoDTO'; describe('SearchManager', () => { @@ -28,6 +25,9 @@ describe('SearchManager', () => { const dir = TestHelper.getDirectoryEntry(); const p = TestHelper.getPhotoEntry1(dir); const p2 = TestHelper.getPhotoEntry2(dir); + const p_faceLess = TestHelper.getPhotoEntry2(dir); + delete p_faceLess.metadata.faces; + p_faceLess.name = 'fl'; const v = TestHelper.getVideoEntry1(dir); const setUpSqlDB = async () => { @@ -41,13 +41,26 @@ describe('SearchManager', () => { Config.Server.database.type = DatabaseType.sqlite; Config.Server.database.sqlite.storage = dbPath; + const savePhoto = async (photo: PhotoDTO) => { + const savedPhoto = await pr.save(photo); + if (!photo.metadata.faces) { + return; + } + for (let i = 0; i < photo.metadata.faces.length; i++) { + const face = photo.metadata.faces[i]; + const person = await conn.getRepository(PersonEntry).save({name: face.name}); + await conn.getRepository(FaceRegionEntry).save({box: face.box, person: person, media: savedPhoto}); + } + }; const conn = await SQLConnection.getConnection(); const pr = conn.getRepository(PhotoEntity); await conn.getRepository(DirectoryEntity).save(p.directory); - await pr.save(p); - await pr.save(p2); + await savePhoto(p); + await savePhoto(p2); + await savePhoto(p_faceLess); + await conn.getRepository(VideoEntity).save(v); await SQLConnection.close(); @@ -76,6 +89,9 @@ describe('SearchManager', () => { const sm = new SearchManager(); const cmp = (a: AutoCompleteItem, b: AutoCompleteItem) => { + if (a.text === b.text) { + return a.type - b.type; + } return a.text.localeCompare(b.text); }; @@ -89,9 +105,14 @@ describe('SearchManager', () => { expect((await sm.autocomplete('arch'))).eql([new AutoCompleteItem('Research City', SearchTypes.position)]); expect((await sm.autocomplete('a')).sort(cmp)).eql([ new AutoCompleteItem('Boba Fett', SearchTypes.keyword), + new AutoCompleteItem('Boba Fett', SearchTypes.person), new AutoCompleteItem('star wars', SearchTypes.keyword), new AutoCompleteItem('Anakin', SearchTypes.keyword), + new AutoCompleteItem('Anakin Skywalker', SearchTypes.person), + new AutoCompleteItem('Luke Skywalker', SearchTypes.person), + new AutoCompleteItem('Han Solo', SearchTypes.person), new AutoCompleteItem('death star', SearchTypes.keyword), + new AutoCompleteItem('Padmé Amidala', SearchTypes.person), new AutoCompleteItem('Padmé Amidala', SearchTypes.keyword), new AutoCompleteItem('Natalie Portman', SearchTypes.keyword), new AutoCompleteItem('Han Solo\'s dice', SearchTypes.photo), @@ -120,6 +141,15 @@ describe('SearchManager', () => { resultOverflow: false })); + expect(Utils.clone(await sm.search('Boba', null))).to.deep.equal(Utils.clone({ + searchText: 'Boba', + searchType: null, + directories: [], + media: [p], + metaFile: [], + resultOverflow: false + })); + expect(Utils.clone(await sm.search('Tatooine', SearchTypes.position))).to.deep.equal(Utils.clone({ searchText: 'Tatooine', searchType: SearchTypes.position, @@ -133,7 +163,7 @@ describe('SearchManager', () => { searchText: 'ortm', searchType: SearchTypes.keyword, directories: [], - media: [p2], + media: [p2, p_faceLess], metaFile: [], resultOverflow: false })); @@ -142,7 +172,7 @@ describe('SearchManager', () => { searchText: 'ortm', searchType: SearchTypes.keyword, directories: [], - media: [p2], + media: [p2, p_faceLess], metaFile: [], resultOverflow: false })); @@ -151,7 +181,7 @@ describe('SearchManager', () => { searchText: 'wa', searchType: SearchTypes.keyword, directories: [dir], - media: [p, p2], + media: [p, p2, p_faceLess], metaFile: [], resultOverflow: false })); @@ -165,6 +195,15 @@ describe('SearchManager', () => { resultOverflow: false })); + expect(Utils.clone(await sm.search('sw', SearchTypes.video))).to.deep.equal(Utils.clone({ + searchText: 'sw', + searchType: SearchTypes.video, + directories: [], + media: [v], + metaFile: [], + resultOverflow: false + })); + expect(Utils.clone(await sm.search('han', SearchTypes.keyword))).to.deep.equal(Utils.clone({ searchText: 'han', searchType: SearchTypes.keyword, @@ -173,6 +212,15 @@ describe('SearchManager', () => { metaFile: [], resultOverflow: false })); + + expect(Utils.clone(await sm.search('Boba', SearchTypes.person))).to.deep.equal(Utils.clone({ + searchText: 'Boba', + searchType: SearchTypes.person, + directories: [], + media: [p], + metaFile: [], + resultOverflow: false + })); }); @@ -198,23 +246,16 @@ describe('SearchManager', () => { expect(Utils.clone(await sm.instantSearch('ortm'))).to.deep.equal(Utils.clone({ searchText: 'ortm', directories: [], - media: [p2], + media: [p2, p_faceLess], metaFile: [], resultOverflow: false })); - expect(Utils.clone(await sm.instantSearch('ortm'))).to.deep.equal(Utils.clone({ - searchText: 'ortm', - directories: [], - media: [p2], - metaFile: [], - resultOverflow: false - })); expect(Utils.clone(await sm.instantSearch('wa'))).to.deep.equal(Utils.clone({ searchText: 'wa', directories: [dir], - media: [p, p2], + media: [p, p2, p_faceLess], metaFile: [], resultOverflow: false })); @@ -226,6 +267,13 @@ describe('SearchManager', () => { metaFile: [], resultOverflow: false })); + expect(Utils.clone(await sm.instantSearch('Boba'))).to.deep.equal(Utils.clone({ + searchText: 'Boba', + directories: [], + media: [p], + metaFile: [], + resultOverflow: false + })); }); diff --git a/test/backend/unit/model/sql/TestHelper.ts b/test/backend/unit/model/sql/TestHelper.ts index 5c78941..bc9f685 100644 --- a/test/backend/unit/model/sql/TestHelper.ts +++ b/test/backend/unit/model/sql/TestHelper.ts @@ -104,6 +104,20 @@ export class TestHelper { p.metadata.positionData.city = 'Mos Eisley'; p.metadata.positionData.country = 'Tatooine'; p.name = 'sw1'; + + p.metadata.faces = [{ + box: {height: 10, width: 10, x: 10, y: 10}, + name: 'Boba Fett' + }, { + box: {height: 10, width: 10, x: 101, y: 101}, + name: 'Luke Skywalker' + }, { + box: {height: 10, width: 10, x: 101, y: 101}, + name: 'Han Solo' + }, { + box: {height: 10, width: 10, x: 101, y: 101}, + name: 'Unkle Ben' + }]; return p; } @@ -121,6 +135,16 @@ export class TestHelper { p.metadata.positionData.state = 'Research City'; p.metadata.positionData.country = 'Kamino'; p.name = 'sw2'; + p.metadata.faces = [{ + box: {height: 10, width: 10, x: 10, y: 10}, + name: 'Padmé Amidala' + }, { + box: {height: 10, width: 10, x: 101, y: 101}, + name: 'Anakin Skywalker' + }, { + box: {height: 10, width: 10, x: 101, y: 101}, + name: 'Obivan Kenobi' + }]; return p; } diff --git a/test/backend/unit/model/threading/DiskMangerWorker.spec.ts b/test/backend/unit/model/threading/DiskMangerWorker.spec.ts index 641c8d8..b045bb9 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(2); + expect(dir.media.length).to.be.equals(3); const expected = require(path.join(__dirname, '/../../assets/test image öüóőúéáű-.,.json')); - expect(Utils.clone(dir.media[0].name)).to.be.deep.equal('test image öüóőúéáű-.,.jpg'); - expect(Utils.clone(dir.media[0].metadata)).to.be.deep.equal(expected); + expect(Utils.clone(dir.media[1].name)).to.be.deep.equal('test image öüóőúéáű-.,.jpg'); + expect(Utils.clone(dir.media[1].metadata)).to.be.deep.equal(expected); }); }); From 0748a3ed5b4ba38812cebf0e8577e5b88d4c7cc9 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 26 Jan 2019 18:28:04 -0500 Subject: [PATCH 12/20] adding performance benchmark --- benchmark/Benchmarks.ts | 6 +++--- benchmark/README.md | 19 ++++++++++++++++++- benchmark/index.ts | 13 ++++++++++--- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/benchmark/Benchmarks.ts b/benchmark/Benchmarks.ts index 58b34e8..6fe0411 100644 --- a/benchmark/Benchmarks.ts +++ b/benchmark/Benchmarks.ts @@ -84,18 +84,18 @@ export class Benchmarks { const startSkip = process.hrtime(); await beforeEach(); const endSkip = process.hrtime(startSkip); - skip += (endSkip[0] * 1000 + endSkip[1] / 1000); + skip += (endSkip[0] * 1000 + endSkip[1] / 1000000); } await fn(); if (afterEach) { const startSkip = process.hrtime(); await afterEach(); const endSkip = process.hrtime(startSkip); - skip += (endSkip[0] * 1000 + endSkip[1] / 1000); + skip += (endSkip[0] * 1000 + endSkip[1] / 1000000); } } const end = process.hrtime(start); - const duration = (end[0] * 1000 + end[1] / 1000) / this.RUNS; + const duration = (end[0] * 1000 + end[1] / 1000000) / this.RUNS; if (!scanned) { return { diff --git a/benchmark/README.md b/benchmark/README.md index 9ace280..e249117 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -2,4 +2,21 @@ These results are created mostly for development, but I'm making them public for curious users. - +## PiGallery2 v1.5.8, 06.01.2019 + +**System**: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz, 16GB Ram, SHDD: 1TB, 5400 rpm +**Gallery**: directories: 0 media: 341, faces: 39 +| action | action details | average time | details | +|:------:|:--------------:|:------------:|:-------:| +| Scanning directory | | 2486.5ms | media: 341, directories:0 | +| Saving directory | | 780.0ms | - | +| Listing Directory | | 31.5ms | media: 341, directories:0 | +| searching | `a` as `directory` | 2.9ms | - | +| searching | `a` as `person` | 7.3ms | media: 39, directories:0 | +| searching | `a` as `keyword` | 30.8ms | media: 339, directories:0 | +| searching | `a` as `position` | 25.7ms | media: 282, directories:0 | +| searching | `a` as `photo` | 2.8ms | - | +| searching | `a` as `video` | 2.6ms | - | +| searching | `a` as `any` | 33.0ms | media: 339, directories:0 | +| instant search | `a` | 6.1ms | media: 10, directories:0 | +| auto complete | `a` | 5.4ms | items: 10 | diff --git a/benchmark/index.ts b/benchmark/index.ts index 98a6048..9a1b975 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -10,7 +10,7 @@ const config: { path: string, system: string } = require(path.join(__dirname, 'c Config.Server.imagesFolder = config.path; const dbPath = path.join(__dirname, 'test.db'); ProjectPath.reset(); -const RUNS = 1; +const RUNS = 50; let resultsText = ''; const printLine = (text: string) => { @@ -25,7 +25,11 @@ const printHeader = async () => { '.' + dt.getFullYear()); printLine('**System**: ' + config.system); const dir = await DiskMangerWorker.scanDirectory('./'); - printLine('**Gallery**: directories:' + dir.directories.length + ' media:' + dir.media.length); + printLine('**Gallery**: directories: ' + + dir.directories.length + + ' media: ' + dir.media.length + + // @ts-ignore + ', faces: ' + dir.media.reduce((p, c) => p + (c.metadata.faces || []).length, 0)); }; @@ -34,6 +38,7 @@ const printTableHeader = () => { printLine('|:------:|:--------------:|:------------:|:-------:|'); }; const printResult = (result: BenchmarkResult, action: string, actionDetails: string = '') => { + console.log('benchmarked: ' + action); let details = '-'; if (result.items) { details = 'items: ' + result.items; @@ -42,10 +47,11 @@ const printResult = (result: BenchmarkResult, action: string, actionDetails: str details = 'media: ' + result.media + ', directories:' + result.directories; } printLine('| ' + action + ' | ' + actionDetails + - ' | ' + (result.duration / 1000).toFixed(2) + 's | ' + details + ' |'); + ' | ' + (result.duration).toFixed(1) + 'ms | ' + details + ' |'); }; const run = async () => { + const start = Date.now(); const bm = new Benchmarks(RUNS, dbPath); // header @@ -64,6 +70,7 @@ const run = async () => { printResult(await bm.bmInstantSearch('a'), 'instant search', '`a`'); printResult(await bm.bmAutocomplete('a'), 'auto complete', '`a`'); console.log(resultsText); + console.log('run for : ' + ((Date.now() - start)).toFixed(1) + 'ms'); }; run(); From 8ed202b53db88e4f460ee3ccf975c7d2e4171cb5 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 26 Jan 2019 23:47:43 -0500 Subject: [PATCH 13/20] fixing tests --- benchmark/README.md | 2 +- benchmark/index.ts | 2 +- gulpfile.js | 195 -------------------------------------------- gulpfile.ts | 179 ++++++++++++++++++++++++++++++++++++++++ package.json | 78 +++++++++--------- 5 files changed, 221 insertions(+), 235 deletions(-) delete mode 100644 gulpfile.js create mode 100644 gulpfile.ts diff --git a/benchmark/README.md b/benchmark/README.md index e249117..27b1c43 100644 --- a/benchmark/README.md +++ b/benchmark/README.md @@ -2,7 +2,7 @@ These results are created mostly for development, but I'm making them public for curious users. -## PiGallery2 v1.5.8, 06.01.2019 +## PiGallery2 v1.5.8, 26.01.2019 **System**: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz, 16GB Ram, SHDD: 1TB, 5400 rpm **Gallery**: directories: 0 media: 341, faces: 39 diff --git a/benchmark/index.ts b/benchmark/index.ts index 9a1b975..b2aaf47 100644 --- a/benchmark/index.ts +++ b/benchmark/index.ts @@ -20,7 +20,7 @@ const printLine = (text: string) => { const printHeader = async () => { const dt = new Date(); printLine('## PiGallery2 v' + require('./../package.json').version + - ', ' + Utils.zeroPrefix(dt.getDay(), 2) + + ', ' + Utils.zeroPrefix(dt.getDate(), 2) + '.' + Utils.zeroPrefix(dt.getMonth() + 1, 2) + '.' + dt.getFullYear()); printLine('**System**: ' + config.system); diff --git a/gulpfile.js b/gulpfile.js deleted file mode 100644 index a1d23df..0000000 --- a/gulpfile.js +++ /dev/null @@ -1,195 +0,0 @@ -var ts = require('gulp-typescript'); -var gulp = require('gulp'); -var zip = require('gulp-zip'); -var fs = require('fs'); -var runSequence = require('run-sequence'); -var jsonModify = require('gulp-json-modify'); -var exec = require('child_process').exec; - -var translationFolder = "translate"; -var tsBackendProject = ts.createProject('tsconfig.json'); -gulp.task('build-backend', function () { - return gulp.src([ - "common/**/*.ts", - "backend/**/*.ts"], {base: "."}) - .pipe(tsBackendProject()) - .js - .pipe(gulp.dest("./release")) - -}); -var createFrontendTask = function (type, script) { - gulp.task(type, function (cb) { - exec(script, function (err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - cb(err); - }); - }); -}; - -gulp.task('build-frontend', function (done) { - var languages = getLanguages().filter(function (l) { - return l !== "en"; - }); - var tasks = []; - createFrontendTask('build-frontend-release default', - "ng build --aot --prod --output-path=./release/dist --no-progress --i18n-locale=en" + - " --i18n-format xlf --i18n-file frontend/" + translationFolder + "/messages.en.xlf" + - " --i18n-missing-translation warning"); - tasks.push('build-frontend-release default'); - for (var i = 0; i < languages.length; i++) { - createFrontendTask('build-frontend-release ' + languages[i], - "ng build --aot --prod --output-path=./release/dist/" + languages[i] + - " --no-progress --i18n-locale=" + languages[i] + - " --i18n-format xlf --i18n-file frontend/" + translationFolder + "/messages." + languages[i] + ".xlf" + - " --i18n-missing-translation warning"); - tasks.push('build-frontend-release ' + languages[i]); - } - tasks.push(function () { - done(); - }); - - runSequence.apply(this, tasks); - -}); - -gulp.task('copy-static', function () { - return gulp.src([ - "backend/model/diagnostics/blank.jpg", - "README.md", - "LICENSE"], {base: "."}) - .pipe(gulp.dest('./release')); -}); - -gulp.task('copy-package', function () { - return gulp.src([ - "package.json"], {base: "."}) - .pipe(jsonModify({ - key: 'devDependencies', - value: {} - })) - .pipe(jsonModify({ - key: 'scripts', - value: {"start": "node ./backend/index.js"} - })) - .pipe(gulp.dest('./release')); -}); - - -gulp.task('zip-release', function () { - return gulp.src(['release/**/*'], {base: "./release"}) - .pipe(zip('pigallery2.zip')) - .pipe(gulp.dest('.')); -}); - -gulp.task('build-release', function (done) { - runSequence('build-frontend', 'build-backend', 'copy-static', 'copy-package', 'zip-release', function () { - done(); - }); -}); - -var getLanguages = function () { - if (!fs.existsSync("./frontend/" + translationFolder)) { - return []; - } - var dirCont = fs.readdirSync("./frontend/" + translationFolder); - var files = dirCont.filter(function (elm) { - return elm.match(/.*\.[a-zA-Z]+\.(xlf)/ig); - }); - return files.map(function (f) { - return f.split(".")[1] - }); -}; - -var simpleBuild = function (isProd) { - return function (done) { - var languages = getLanguages().filter(function (l) { - return l !== "en"; - }); - var tasks = []; - var cmd = "ng build --aot "; - if (isProd) { - cmd += " --prod --no-extract-licenses " - } - createFrontendTask('build-frontend default', cmd + "--output-path=./dist --no-progress --no-progress --i18n-locale en" + - " --i18n-format=xlf --i18n-file=frontend/" + translationFolder + "/messages.en.xlf" + " --i18n-missing-translation warning"); - tasks.push('build-frontend default'); - for (var i = 0; i < languages.length; i++) { - createFrontendTask('build-frontend ' + languages[i], cmd + "--output-path=./dist/" + languages[i] + " --no-progress --i18n-locale " + languages[i] + - " --i18n-format=xlf --i18n-file=frontend/" + translationFolder + "/messages." + languages[i] + ".xlf" + " --i18n-missing-translation warning"); - tasks.push('build-frontend ' + languages[i]); - } - tasks.push(function () { - done(); - }); - - runSequence.apply(this, tasks); - }; -}; - -gulp.task("extract-locale", function (cb) { - console.log("creating source translation file: locale.source.xlf"); - exec('ng xi18n --out-file=./../locale.source.xlf --i18n-format=xlf --i18n-locale=en', {maxBuffer: 1024 * 1024}, function (err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - if (err) { - return cb(err); - } - exec('ngx-extractor -i frontend/**/*.ts -f xlf --out-file locale.source.xlf', function (err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - cb(err); - }); - }); -}); - -var translate = function (list, cb) { - var localsStr = '"[\\"' + list.join('\\",\\"') + '\\"]"'; - exec('xlf-google-translate --source-lang="en" --source-file="./locale.source.xlf" --destination-folder="./frontend/"' + - translationFolder + ' --destination-languages=' + localsStr, function (err, stdout, stderr) { - console.log(stdout); - console.log(stderr); - cb(err); - }); -}; - -gulp.task("update-translation-only", function (cb) { - translate(getLanguages(), cb) -}); - -gulp.task("update-translation", function (done) { - runSequence('extract-locale', 'update-translation-only', function () { - done(); - }); -}); - - -gulp.task("add-translation-only", function (cb) { - var languages = getLanguages(); - var lng = null; - for (var i = 0; i < process.argv.length - 1; i++) { - if (process.argv[i] === "add-translation") { - lng = process.argv[i + 1].replace("--", ""); - } - } - if (lng == null) { - console.error("Error: set language with '--' e.g: npm run add-translation -- --en"); - return cb(); - } - if (languages.indexOf(lng) !== -1) { - console.error("Error: language already exists, can't add. These language(s) already exist(s): " + languages); - return cb(); - } - translate([lng], cb) -}); - - -gulp.task("add-translation", function (done) { - runSequence('extract-locale', 'add-translation-only', function () { - done(); - }); -}); - - -gulp.task('build-dev', simpleBuild(false)); -gulp.task('build-prod', simpleBuild(true)); diff --git a/gulpfile.ts b/gulpfile.ts new file mode 100644 index 0000000..09e1d03 --- /dev/null +++ b/gulpfile.ts @@ -0,0 +1,179 @@ +import * as gulp from 'gulp'; +import * as fs from 'fs'; +import * as zip from 'gulp-zip'; +import * as ts from 'gulp-typescript'; +// @ts-ignore +import * as jsonModify from 'gulp-json-modify'; + + +const exec = require('child_process').exec; + +const translationFolder = 'translate'; +const tsBackendProject = ts.createProject('tsconfig.json'); + +gulp.task('build-backend', function () { + return gulp.src([ + 'common/**/*.ts', + 'backend/**/*.ts'], {base: '.'}) + .pipe(tsBackendProject()) + .js + .pipe(gulp.dest('./release')); + +}); + +const handleError = (cb: (err: any) => void) => { + return (err: any, stdout: string, stderr: string) => { + console.log(stdout); + console.log(stderr); + cb(err); + }; +}; + +const createFrontendTask = (type: string, script: string) => { + gulp.task(type, (cb) => { + exec(script, handleError(cb)); + }); +}; + +const getLanguages = () => { + if (!fs.existsSync('./frontend/' + translationFolder)) { + return []; + } + const dirCont = fs.readdirSync('./frontend/' + translationFolder); + const files: string[] = dirCont.filter((elm) => { + return elm.match(/.*\.[a-zA-Z]+\.(xlf)/ig); + }); + return files.map((f: string) => { + return f.split('.')[1]; + }); +}; + +gulp.task('build-frontend', (() => { + const languages = getLanguages().filter((l) => { + return l !== 'en'; + }); + const tasks = []; + createFrontendTask('build-frontend-release default', + 'ng build --aot --prod --output-path=./release/dist --no-progress --i18n-locale=en' + + ' --i18n-format xlf --i18n-file frontend/' + translationFolder + '/messages.en.xlf' + + ' --i18n-missing-translation warning'); + tasks.push('build-frontend-release default'); + for (let i = 0; i < languages.length; i++) { + createFrontendTask('build-frontend-release ' + languages[i], + 'ng build --aot --prod --output-path=./release/dist/' + languages[i] + + ' --no-progress --i18n-locale=' + languages[i] + + ' --i18n-format xlf --i18n-file frontend/' + translationFolder + '/messages.' + languages[i] + '.xlf' + + ' --i18n-missing-translation warning'); + tasks.push('build-frontend-release ' + languages[i]); + } + return gulp.series(...tasks); +})()); + +gulp.task('copy-static', function () { + return gulp.src([ + 'backend/model/diagnostics/blank.jpg', + 'README.md', + 'LICENSE'], {base: '.'}) + .pipe(gulp.dest('./release')); +}); + +gulp.task('copy-package', function () { + return gulp.src([ + 'package.json'], {base: '.'}) + .pipe(jsonModify({ + key: 'devDependencies', + value: {} + })) + .pipe(jsonModify({ + key: 'scripts', + value: {'start': 'node ./backend/index.js'} + })) + .pipe(gulp.dest('./release')); +}); + + +gulp.task('zip-release', function () { + return gulp.src(['release/**/*'], {base: './release'}) + .pipe(zip('pigallery2.zip')) + .pipe(gulp.dest('.')); +}); + +gulp.task('build-release', gulp.series('build-frontend', 'build-backend', 'copy-static', 'copy-package', 'zip-release')); + + +const simpleBuild = (isProd: boolean) => { + const languages = getLanguages().filter(function (l) { + return l !== 'en'; + }); + const tasks = []; + let cmd = 'ng build --aot '; + if (isProd) { + cmd += ' --prod --no-extract-licenses '; + } + createFrontendTask('build-frontend default', cmd + '--output-path=./dist --no-progress --no-progress --i18n-locale en' + + ' --i18n-format=xlf --i18n-file=frontend/' + translationFolder + '/messages.en.xlf' + ' --i18n-missing-translation warning'); + tasks.push('build-frontend default'); + for (let i = 0; i < languages.length; i++) { + createFrontendTask('build-frontend ' + languages[i], cmd + + '--output-path=./dist/' + languages[i] + + ' --no-progress --i18n-locale ' + languages[i] + + ' --i18n-format=xlf --i18n-file=frontend/' + translationFolder + + '/messages.' + languages[i] + '.xlf' + ' --i18n-missing-translation warning'); + tasks.push('build-frontend ' + languages[i]); + } + return gulp.series(...tasks); +}; + +gulp.task('extract-locale', (cb) => { + console.log('creating source translation file: locale.source.xlf'); + exec('ng xi18n --out-file=./../locale.source.xlf --i18n-format=xlf --i18n-locale=en', + {maxBuffer: 1024 * 1024}, (error: any, stdOut: string, stdErr: string) => { + console.log(stdOut); + console.log(stdErr); + if (error) { + return cb(error); + } + exec('ngx-extractor -i frontend/**/*.ts -f xlf --out-file locale.source.xlf', + handleError(cb)); + }); +}); + +const translate = (list: any[], cb: (err: any) => void) => { + const localsStr = '"[\\"' + list.join('\\",\\"') + '\\"]"'; + exec('xlf-google-translate --source-lang="en" --source-file="./locale.source.xlf" --destination-folder="./frontend/"' + + translationFolder + ' --destination-languages=' + localsStr, + handleError(cb)); +}; + +gulp.task('update-translation-only', function (cb) { + translate(getLanguages(), cb); +}); + +gulp.task('update-translation', gulp.series('extract-locale', 'update-translation-only')); + + +gulp.task('add-translation-only', (cb) => { + const languages = getLanguages(); + let lng = null; + for (let i = 0; i < process.argv.length - 1; i++) { + if (process.argv[i] === 'add-translation') { + lng = process.argv[i + 1].replace('--', ''); + } + } + if (lng == null) { + console.error('Error: set language with \'--\' e.g: npm run add-translation -- --en'); + return cb(); + } + if (languages.indexOf(lng) !== -1) { + console.error('Error: language already exists, can\'t add. These language(s) already exist(s): ' + languages); + return cb(); + } + translate([lng], cb); +}); + + +gulp.task('add-translation', gulp.series('extract-locale', 'add-translation-only')); + + +gulp.task('build-dev', simpleBuild(false)); +gulp.task('build-prod', simpleBuild(true)); diff --git a/package.json b/package.json index 63bc428..fb23df4 100644 --- a/package.json +++ b/package.json @@ -30,58 +30,60 @@ "cookie-parser": "1.4.3", "cookie-session": "2.0.0-beta.3", "ejs": "2.6.1", - "exifreader": "2.5.0", + "exifreader": "2.6.0", "express": "4.16.4", "fluent-ffmpeg": "2.1.2", - "image-size": "0.6.3", + "image-size": "0.7.1", "jdataview": "2.5.0", "jimp": "0.6.0", "locale": "0.1.0", - "reflect-metadata": "0.1.12", - "sqlite3": "4.0.4", + "reflect-metadata": "0.1.13", + "sqlite3": "4.0.6", "ts-exif-parser": "0.1.4", "ts-node-iptc": "1.0.11", "typeconfig": "1.0.7", - "typeorm": "0.2.11", - "winston": "2.4.2", + "typeorm": "0.2.12", + "winston": "2.4.4", "xmldom": "0.1.27" }, "devDependencies": { - "@angular-devkit/build-angular": "0.11.4", - "@angular-devkit/build-optimizer": "0.11.4", - "@angular/animations": "7.1.4", - "@angular/cli": "7.1.4", - "@angular/common": "7.1.4", - "@angular/compiler": "7.1.4", - "@angular/compiler-cli": "7.1.4", - "@angular/core": "7.1.4", - "@angular/forms": "7.1.4", - "@angular/http": "7.1.4", - "@angular/language-service": "7.1.4", - "@angular/platform-browser": "7.1.4", - "@angular/platform-browser-dynamic": "7.1.4", - "@angular/router": "7.1.4", + "@angular-devkit/build-angular": "0.12.3", + "@angular-devkit/build-optimizer": "0.12.3", + "@angular/animations": "7.2.2", + "@angular/cli": "7.2.3", + "@angular/common": "7.2.2", + "@angular/compiler": "7.2.2", + "@angular/compiler-cli": "7.2.2", + "@angular/core": "7.2.2", + "@angular/forms": "7.2.2", + "@angular/http": "7.2.2", + "@angular/language-service": "7.2.2", + "@angular/platform-browser": "7.2.2", + "@angular/platform-browser-dynamic": "7.2.2", + "@angular/router": "7.2.2", "@ngx-translate/i18n-polyfill": "1.0.0", "@types/bcryptjs": "2.4.2", "@types/chai": "4.1.7", "@types/cookie-parser": "1.4.1", "@types/cookie-session": "2.0.36", "@types/ejs": "2.6.1", - "@types/express": "4.16.0", + "@types/express": "4.16.1", "@types/fluent-ffmpeg": "2.1.9", "@types/gm": "1.18.2", - "@types/image-size": "0.0.29", - "@types/jasmine": "3.3.5", + "@types/gulp": "^4.0.5", + "@types/gulp-zip": "^4.0.0", + "@types/image-size": "0.7.0", + "@types/jasmine": "3.3.8", "@types/node": "10.12.18", - "@types/sharp": "0.21.0", - "@types/winston": "2.3.9", + "@types/sharp": "0.21.2", + "@types/winston": "2.4.4", "@yaga/leaflet-ng2": "1.0.0", "bootstrap": "4.1.3", "chai": "4.2.0", "codelyzer": "4.5.0", - "core-js": "2.6.1", + "core-js": "2.6.3", "ejs-loader": "0.3.1", - "gulp": "3.9.1", + "gulp": "4.0.0", "gulp-json-modify": "1.0.2", "gulp-typescript": "5.0.0", "gulp-zip": "4.2.0", @@ -89,8 +91,8 @@ "intl": "1.2.5", "jasmine-core": "3.3.0", "jasmine-spec-reporter": "4.2.1", - "jw-bootstrap-switch-ng2": "2.0.2", - "karma": "3.1.4", + "jw-bootstrap-switch-ng2": "2.0.4", + "karma": "4.0.0", "karma-chrome-launcher": "2.2.0", "karma-cli": "2.0.0", "karma-coverage-istanbul-reporter": "2.0.4", @@ -106,18 +108,18 @@ "ngx-clipboard": "11.1.9", "ngx-toastr": "9.1.1", "open-iconic": "1.1.1", - "protractor": "5.4.1", - "remap-istanbul": "0.12.0", - "rimraf": "2.6.2", + "protractor": "5.4.2", + "remap-istanbul": "0.13.0", + "rimraf": "2.6.3", "run-sequence": "2.2.1", "rxjs": "6.3.3", "rxjs-compat": "^6.3.3", "ts-helpers": "1.1.2", - "ts-node": "7.0.1", - "tslint": "5.12.0", - "typescript": "3.1.6", + "ts-node": "8.0.2", + "tslint": "5.12.1", + "typescript": "3.2.4", "xlf-google-translate": "1.0.0-beta.13", - "zone.js": "0.8.26" + "zone.js": "0.8.29" }, "resolutions": { "natives": "1.1.3" @@ -125,10 +127,10 @@ "optionalDependencies": { "@ffmpeg-installer/ffmpeg": "1.0.17", "@ffprobe-installer/ffprobe": "1.0.9", - "bcrypt": "3.0.2", + "bcrypt": "3.0.3", "gm": "1.23.1", "mysql": "2.16.0", - "sharp": "0.21.1" + "sharp": "0.21.3" }, "engines": { "node": ">= 6.9 <11.0" From d92003eeeed3864a82422f62b9fc679580265eaf Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 27 Jan 2019 14:36:42 -0500 Subject: [PATCH 14/20] improving db scheme. Adding Mysql tests --- .travis.yml | 12 +- backend/middlewares/GalleryMWs.ts | 2 +- backend/model/sql/SQLConnection.ts | 56 ++++++--- backend/model/sql/SearchManager.ts | 16 +-- backend/model/sql/enitites/DirectoryEntity.ts | 20 +++- backend/model/sql/enitites/FaceRegionEntry.ts | 2 +- backend/model/sql/enitites/FileEntity.ts | 2 +- backend/model/sql/enitites/MediaEntity.ts | 23 ++-- backend/model/sql/enitites/PersonEntry.ts | 2 +- backend/model/sql/enitites/SharingEntity.ts | 16 ++- common/DataStructureVersion.ts | 2 +- common/config/public/ConfigClass.ts | 18 ++- frontend/app/gallery/cache.gallery.service.ts | 2 +- .../search/search.gallery.component.ts | 4 +- frontend/app/settings/settings.service.ts | 7 +- test/backend/SQLTestHelper.ts | 107 ++++++++++++++++++ test/backend/integration/model/sql/typeorm.ts | 1 - test/backend/unit/model/sql/GalleryManager.ts | 58 ++++++++++ .../backend/unit/model/sql/IndexingManager.ts | 47 +++----- test/backend/unit/model/sql/SearchManager.ts | 57 +++++----- test/backend/unit/model/sql/SharingManager.ts | 36 ++---- test/backend/unit/model/sql/TestHelper.ts | 2 +- 22 files changed, 342 insertions(+), 150 deletions(-) create mode 100644 test/backend/SQLTestHelper.ts create mode 100644 test/backend/unit/model/sql/GalleryManager.ts diff --git a/.travis.yml b/.travis.yml index 69c0851..2203150 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,15 @@ dist: trusty language: node_js node_js: -- '10' -- '11' - + - '10' + - '11' +env: + - Server-database-mysql-host='localhost' + - Server-database-mysql-username='root' + - Server-database-mysql-password='' + - Server-database-mysql-database='pigallery2_travis' +services: + - mysql addons: chrome: stable before_install: diff --git a/backend/middlewares/GalleryMWs.ts b/backend/middlewares/GalleryMWs.ts index 80f5fe3..61154d1 100644 --- a/backend/middlewares/GalleryMWs.ts +++ b/backend/middlewares/GalleryMWs.ts @@ -222,7 +222,7 @@ export class GalleryMWs { } public static async autocomplete(req: Request, res: Response, next: NextFunction) { - if (Config.Client.Search.autocompleteEnabled === false) { + if (Config.Client.Search.AutoComplete.enabled === false) { return next(); } if (!(req.params.text)) { diff --git a/backend/model/sql/SQLConnection.ts b/backend/model/sql/SQLConnection.ts index 22a8fee..4ae051a 100644 --- a/backend/model/sql/SQLConnection.ts +++ b/backend/model/sql/SQLConnection.ts @@ -17,16 +17,17 @@ import {DataStructureVersion} from '../../../common/DataStructureVersion'; import {FileEntity} from './enitites/FileEntity'; import {FaceRegionEntry} from './enitites/FaceRegionEntry'; import {PersonEntry} from './enitites/PersonEntry'; +import {Utils} from '../../../common/Utils'; export class SQLConnection { + private static connection: Connection = null; + constructor() { } - private static connection: Connection = null; - public static async getConnection(): Promise { if (this.connection == null) { const options: any = this.getDriver(Config.Server.database); @@ -44,8 +45,10 @@ export class SQLConnection { VersionEntity ]; options.synchronize = false; - //options.logging = 'all'; - this.connection = await createConnection(options); + // options.logging = 'all'; + + + this.connection = await this.createConnection(options); await SQLConnection.schemeSync(this.connection); } return this.connection; @@ -72,7 +75,7 @@ export class SQLConnection { ]; options.synchronize = false; // options.logging = "all"; - const conn = await createConnection(options); + const conn = await this.createConnection(options); await SQLConnection.schemeSync(conn); await conn.close(); return true; @@ -92,6 +95,38 @@ export class SQLConnection { } + public static async close() { + try { + if (this.connection != null) { + await this.connection.close(); + this.connection = null; + } + } catch (err) { + console.error(err); + } + } + + private static async createConnection(options: ConnectionOptions) { + if (options.type === 'sqlite') { + return await createConnection(options); + } + try { + return await createConnection(options); + } catch (e) { + if (e.sqlMessage === 'Unknown database \'' + options.database + '\'') { + Logger.debug('creating database: ' + options.database); + const tmpOption = Utils.clone(options); + // @ts-ignore + delete tmpOption.database; + const tmpConn = await createConnection(tmpOption); + await tmpConn.query('CREATE DATABASE IF NOT EXISTS ' + options.database); + await tmpConn.close(); + return await createConnection(options); + } + throw e; + } + } + private static async schemeSync(connection: Connection) { let version = null; try { @@ -145,16 +180,5 @@ export class SQLConnection { return driver; } - public static async close() { - try { - if (this.connection != null) { - await this.connection.close(); - this.connection = null; - } - } catch (err) { - console.error(err); - } - } - } diff --git a/backend/model/sql/SearchManager.ts b/backend/model/sql/SearchManager.ts index f6c06fa..12d7650 100644 --- a/backend/model/sql/SearchManager.ts +++ b/backend/model/sql/SearchManager.ts @@ -9,6 +9,7 @@ import {VideoEntity} from './enitites/VideoEntity'; import {PersonEntry} from './enitites/PersonEntry'; import {FaceRegionEntry} from './enitites/FaceRegionEntry'; import {SelectQueryBuilder} from 'typeorm'; +import {Config} from '../../../common/config/private/Config'; export class SearchManager implements ISearchManager { @@ -40,7 +41,7 @@ export class SearchManager implements ISearchManager { .createQueryBuilder('photo') .select('DISTINCT(photo.metadata.keywords)') .where('photo.metadata.keywords LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .limit(5) + .limit(Config.Client.Search.AutoComplete.maxItemsPerCategory) .getRawMany()) .map(r => >(r.metadataKeywords).split(',')) .forEach(keywords => { @@ -52,7 +53,8 @@ export class SearchManager implements ISearchManager { .createQueryBuilder('person') .select('DISTINCT(person.name)') .where('person.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .limit(5) + .limit(Config.Client.Search.AutoComplete.maxItemsPerCategory) + .orderBy('person.name') .getRawMany()) .map(r => r.name), SearchTypes.person)); @@ -64,7 +66,7 @@ export class SearchManager implements ISearchManager { .orWhere('photo.metadata.positionData.state LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) .orWhere('photo.metadata.positionData.city LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) .groupBy('photo.metadata.positionData.country, photo.metadata.positionData.state, photo.metadata.positionData.city') - .limit(5) + .limit(Config.Client.Search.AutoComplete.maxItemsPerCategory) .getRawMany()) .filter(pm => !!pm) .map(pm => >[pm.city || '', pm.country || '', pm.state || '']) @@ -77,7 +79,7 @@ export class SearchManager implements ISearchManager { .createQueryBuilder('media') .select('DISTINCT(media.name)') .where('media.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .limit(5) + .limit(Config.Client.Search.AutoComplete.maxItemsPerCategory) .getRawMany()) .map(r => r.name), SearchTypes.photo)); @@ -86,7 +88,7 @@ export class SearchManager implements ISearchManager { .createQueryBuilder('media') .select('DISTINCT(media.metadata.caption) as caption') .where('media.metadata.caption LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .limit(5) + .limit(Config.Client.Search.AutoComplete.maxItemsPerCategory) .getRawMany()) .map(r => r.caption), SearchTypes.photo)); @@ -95,7 +97,7 @@ export class SearchManager implements ISearchManager { .createQueryBuilder('media') .select('DISTINCT(media.name)') .where('media.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .limit(5) + .limit(Config.Client.Search.AutoComplete.maxItemsPerCategory) .getRawMany()) .map(r => r.name), SearchTypes.video)); @@ -103,7 +105,7 @@ export class SearchManager implements ISearchManager { .createQueryBuilder('dir') .select('DISTINCT(dir.name)') .where('dir.name LIKE :text COLLATE utf8_general_ci', {text: '%' + text + '%'}) - .limit(5) + .limit(Config.Client.Search.AutoComplete.maxItemsPerCategory) .getRawMany()) .map(r => r.name), SearchTypes.directory)); diff --git a/backend/model/sql/enitites/DirectoryEntity.ts b/backend/model/sql/enitites/DirectoryEntity.ts index e43c633..717b53c 100644 --- a/backend/model/sql/enitites/DirectoryEntity.ts +++ b/backend/model/sql/enitites/DirectoryEntity.ts @@ -1,4 +1,4 @@ -import {Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, Unique, Index} from 'typeorm'; +import {Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, Unique} from 'typeorm'; import {DirectoryDTO} from '../../../../common/entities/DirectoryDTO'; import {MediaEntity} from './MediaEntity'; import {FileEntity} from './FileEntity'; @@ -8,7 +8,7 @@ import {FileEntity} from './FileEntity'; export class DirectoryEntity implements DirectoryDTO { @Index() - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({unsigned: true}) id: number; @Index() @@ -22,18 +22,28 @@ export class DirectoryEntity implements DirectoryDTO { /** * last time the directory was modified (from outside, eg.: a new media was added) */ - @Column('bigint') + @Column('bigint', { + unsigned: true, transformer: { + from: v => parseInt(v, 10), + to: v => v + } + }) public lastModified: number; /** * Last time the directory was fully scanned, not only for a few media to create a preview */ - @Column({type: 'bigint', nullable: true}) + @Column({ + type: 'bigint', nullable: true, unsigned: true, transformer: { + from: v => parseInt(v, 10), + to: v => v + } + }) public lastScanned: number; isPartial?: boolean; - @Column('smallint') + @Column('smallint', {unsigned: true}) mediaCount: number; @Index() diff --git a/backend/model/sql/enitites/FaceRegionEntry.ts b/backend/model/sql/enitites/FaceRegionEntry.ts index 3b569a5..411be7f 100644 --- a/backend/model/sql/enitites/FaceRegionEntry.ts +++ b/backend/model/sql/enitites/FaceRegionEntry.ts @@ -20,7 +20,7 @@ export class FaceRegionBoxEntry implements FaceRegionBox { @Entity() export class FaceRegionEntry { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({unsigned: true}) id: number; @Column(type => FaceRegionBoxEntry) diff --git a/backend/model/sql/enitites/FileEntity.ts b/backend/model/sql/enitites/FileEntity.ts index 0d19efd..b185cfe 100644 --- a/backend/model/sql/enitites/FileEntity.ts +++ b/backend/model/sql/enitites/FileEntity.ts @@ -7,7 +7,7 @@ import {FileDTO} from '../../../../common/entities/FileDTO'; export class FileEntity implements FileDTO { @Index() - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({unsigned: true}) id: number; @Column('text') diff --git a/backend/model/sql/enitites/MediaEntity.ts b/backend/model/sql/enitites/MediaEntity.ts index 026290d..10d3693 100644 --- a/backend/model/sql/enitites/MediaEntity.ts +++ b/backend/model/sql/enitites/MediaEntity.ts @@ -1,4 +1,4 @@ -import {Column, Entity, OneToMany, ManyToOne, PrimaryGeneratedColumn, TableInheritance, Unique, Index} from 'typeorm'; +import {Column, Entity, Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, TableInheritance, Unique} from 'typeorm'; import {DirectoryEntity} from './DirectoryEntity'; import {MediaDimension, MediaDTO, MediaMetadata} from '../../../../common/entities/MediaDTO'; import {OrientationTypes} from 'ts-exif-parser'; @@ -22,10 +22,15 @@ export class MediaMetadataEntity implements MediaMetadata { @Column(type => MediaDimensionEntity) size: MediaDimensionEntity; - @Column('bigint') + @Column('bigint', { + unsigned: true, transformer: { + from: v => parseInt(v, 10), + to: v => v + } + }) creationDate: number; - @Column('int') + @Column('int', {unsigned: true}) fileSize: number; @Column('simple-array') @@ -37,30 +42,30 @@ export class MediaMetadataEntity implements MediaMetadata { @Column(type => PositionMetaDataEntity) positionData: PositionMetaDataEntity; - @Column('tinyint', {default: OrientationTypes.TOP_LEFT}) + @Column('tinyint', {unsigned: true, default: OrientationTypes.TOP_LEFT}) orientation: OrientationTypes; @OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.media) faces: FaceRegionEntry[]; - @Column('int') + @Column('int', {unsigned: true}) bitRate: number; - @Column('bigint') + @Column('int', {unsigned: true}) duration: number; } // TODO: fix inheritance once its working in typeorm @Entity() @Unique(['name', 'directory']) -@TableInheritance({column: {type: 'varchar', name: 'type'}}) +@TableInheritance({column: {type: 'varchar', name: 'type', length: 32}}) export abstract class MediaEntity implements MediaDTO { @Index() - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({unsigned: true}) id: number; - @Column('text') + @Column() name: string; @Index() diff --git a/backend/model/sql/enitites/PersonEntry.ts b/backend/model/sql/enitites/PersonEntry.ts index d539bd5..2826453 100644 --- a/backend/model/sql/enitites/PersonEntry.ts +++ b/backend/model/sql/enitites/PersonEntry.ts @@ -6,7 +6,7 @@ import {FaceRegionEntry} from './FaceRegionEntry'; @Unique(['name']) export class PersonEntry { @Index() - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({unsigned: true}) id: number; @Column() diff --git a/backend/model/sql/enitites/SharingEntity.ts b/backend/model/sql/enitites/SharingEntity.ts index 2031235..7af3d2b 100644 --- a/backend/model/sql/enitites/SharingEntity.ts +++ b/backend/model/sql/enitites/SharingEntity.ts @@ -5,7 +5,7 @@ import {UserDTO} from '../../../../common/entities/UserDTO'; @Entity() export class SharingEntity implements SharingDTO { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn({unsigned: true}) id: number; @Column() @@ -17,10 +17,20 @@ export class SharingEntity implements SharingDTO { @Column({type: 'text', nullable: true}) password: string; - @Column() + @Column('bigint', { + unsigned: true, transformer: { + from: v => parseInt(v, 10), + to: v => v + } + }) expires: number; - @Column() + @Column('bigint', { + unsigned: true, transformer: { + from: v => parseInt(v, 10), + to: v => v + } + }) timeStamp: number; @Column() diff --git a/common/DataStructureVersion.ts b/common/DataStructureVersion.ts index 5611455..950d63a 100644 --- a/common/DataStructureVersion.ts +++ b/common/DataStructureVersion.ts @@ -1 +1 @@ -export const DataStructureVersion = 8; +export const DataStructureVersion = 9; diff --git a/common/config/public/ConfigClass.ts b/common/config/public/ConfigClass.ts index 16761a5..cce0061 100644 --- a/common/config/public/ConfigClass.ts +++ b/common/config/public/ConfigClass.ts @@ -7,14 +7,19 @@ export module ClientConfig { OpenStreetMap, Mapbox, Custom } + export interface AutoCompleteConfig { + enabled: boolean; + maxItemsPerCategory: number; + cacheTimeout: number; + } + export interface SearchConfig { enabled: boolean; instantSearchEnabled: boolean; - autocompleteEnabled: boolean; InstantSearchTimeout: number; - autocompleteCacheTimeout: number; instantSearchCacheTimeout: number; searchCacheTimeout: number; + AutoComplete: AutoCompleteConfig; } export interface SharingConfig { @@ -95,11 +100,14 @@ export class PublicConfigClass { Search: { enabled: true, instantSearchEnabled: true, - autocompleteEnabled: true, InstantSearchTimeout: 3000, - autocompleteCacheTimeout: 1000 * 60 * 60, searchCacheTimeout: 1000 * 60 * 60, - instantSearchCacheTimeout: 1000 * 60 * 60 + instantSearchCacheTimeout: 1000 * 60 * 60, + AutoComplete: { + enabled: true, + cacheTimeout: 1000 * 60 * 60, + maxItemsPerCategory: 5 + } }, Sharing: { enabled: true, diff --git a/frontend/app/gallery/cache.gallery.service.ts b/frontend/app/gallery/cache.gallery.service.ts index 7e39f5e..cf50707 100644 --- a/frontend/app/gallery/cache.gallery.service.ts +++ b/frontend/app/gallery/cache.gallery.service.ts @@ -77,7 +77,7 @@ export class GalleryCacheService { const tmp = localStorage.getItem(key); if (tmp != null) { const value: CacheItem = JSON.parse(tmp); - if (value.timestamp < Date.now() - Config.Client.Search.autocompleteCacheTimeout) { + if (value.timestamp < Date.now() - Config.Client.Search.AutoComplete.cacheTimeout) { localStorage.removeItem(key); return null; } diff --git a/frontend/app/gallery/search/search.gallery.component.ts b/frontend/app/gallery/search/search.gallery.component.ts index ea5d620..cc2645b 100644 --- a/frontend/app/gallery/search/search.gallery.component.ts +++ b/frontend/app/gallery/search/search.gallery.component.ts @@ -54,7 +54,7 @@ export class GallerySearchComponent implements OnDestroy { const searchText = (event.target).value.trim(); - if (Config.Client.Search.autocompleteEnabled && + if (Config.Client.Search.AutoComplete.enabled && this.cache.lastAutocomplete !== searchText) { this.cache.lastAutocomplete = searchText; this.autocomplete(searchText).catch(console.error); @@ -92,7 +92,7 @@ export class GallerySearchComponent implements OnDestroy { } private async autocomplete(searchText: string) { - if (!Config.Client.Search.autocompleteEnabled) { + if (!Config.Client.Search.AutoComplete.enabled) { return; } if (searchText.trim() === '.') { diff --git a/frontend/app/settings/settings.service.ts b/frontend/app/settings/settings.service.ts index 7cecaf1..8a3b37a 100644 --- a/frontend/app/settings/settings.service.ts +++ b/frontend/app/settings/settings.service.ts @@ -15,12 +15,15 @@ export class SettingsService { Client: { Search: { enabled: true, - autocompleteEnabled: true, + AutoComplete: { + enabled: true, + cacheTimeout: 1000 * 60 * 60, + maxItemsPerCategory: 5 + }, instantSearchEnabled: true, InstantSearchTimeout: 0, searchCacheTimeout: 1000 * 60 * 60, instantSearchCacheTimeout: 1000 * 60 * 60, - autocompleteCacheTimeout: 1000 * 60 * 60 }, Thumbnail: { concurrentThumbnailGenerations: null, diff --git a/test/backend/SQLTestHelper.ts b/test/backend/SQLTestHelper.ts new file mode 100644 index 0000000..147ed1a --- /dev/null +++ b/test/backend/SQLTestHelper.ts @@ -0,0 +1,107 @@ +import {Config} from '../../common/config/private/Config'; +import {DatabaseType} from '../../common/config/private/IPrivateConfig'; +import * as fs from 'fs'; +import * as path from 'path'; +import {SQLConnection} from '../../backend/model/sql/SQLConnection'; + +declare let describe: any; +const savedDescribe = describe; + +export class SQLTestHelper { + + static enable = { + sqlite: true, + mysql: true + }; + public static readonly savedDescribe = savedDescribe; + tempDir: string; + dbPath: string; + + constructor(public dbType: DatabaseType) { + this.tempDir = path.resolve(__dirname, './tmp'); + this.dbPath = path.resolve(__dirname, './tmp', 'test.db'); + + } + + static describe(name: string, tests: (helper?: SQLTestHelper) => void) { + savedDescribe(name, async () => { + if (SQLTestHelper.enable.sqlite) { + const helper = new SQLTestHelper(DatabaseType.sqlite); + savedDescribe('sqlite', () => { + return tests(helper); + }); + } + if (SQLTestHelper.enable.mysql) { + const helper = new SQLTestHelper(DatabaseType.mysql); + savedDescribe('mysql', function () { + this.timeout(99999999); + // @ts-ignore + return tests(helper); + }); + } + }); + } + + public async initDB() { + if (this.dbType === DatabaseType.sqlite) { + await this.initSQLite(); + } else { + await this.initMySQL(); + } + } + + + public async clearDB() { + if (this.dbType === DatabaseType.sqlite) { + await this.clearUpSQLite(); + } else { + await this.clearUpMysql(); + } + } + + private async initSQLite() { + await this.resetSQLite(); + + Config.Server.database.type = DatabaseType.sqlite; + Config.Server.database.sqlite.storage = this.dbPath; + } + + private async initMySQL() { + Config.Server.database.type = DatabaseType.mysql; + Config.Server.database.mysql.database = 'pigallery2_test'; + + await this.resetMySQL(); + } + + private async resetSQLite() { + await SQLConnection.close(); + + if (fs.existsSync(this.dbPath)) { + fs.unlinkSync(this.dbPath); + } + if (fs.existsSync(this.tempDir)) { + fs.rmdirSync(this.tempDir); + } + } + + private async resetMySQL() { + Config.Server.database.type = DatabaseType.mysql; + Config.Server.database.mysql.database = 'pigallery2_test'; + const conn = await SQLConnection.getConnection(); + await conn.query('DROP DATABASE IF EXISTS ' + conn.options.database); + await conn.query('CREATE DATABASE IF NOT EXISTS ' + conn.options.database); + await SQLConnection.close(); + } + + private async clearUpMysql() { + Config.Server.database.type = DatabaseType.mysql; + Config.Server.database.mysql.database = 'pigallery2_test'; + const conn = await SQLConnection.getConnection(); + await conn.query('DROP DATABASE IF EXISTS ' + conn.options.database); + await SQLConnection.close(); + } + + private async clearUpSQLite() { + return this.resetSQLite(); + } +} diff --git a/test/backend/integration/model/sql/typeorm.ts b/test/backend/integration/model/sql/typeorm.ts index 679fb1b..c159efc 100644 --- a/test/backend/integration/model/sql/typeorm.ts +++ b/test/backend/integration/model/sql/typeorm.ts @@ -16,7 +16,6 @@ import { PositionMetaDataEntity } from '../../../../../backend/model/sql/enitites/PhotoEntity'; import {MediaDimensionEntity} from '../../../../../backend/model/sql/enitites/MediaEntity'; -import {DataStructureVersion} from '../../../../../common/DataStructureVersion'; import {VersionEntity} from '../../../../../backend/model/sql/enitites/VersionEntity'; describe('Typeorm integration', () => { diff --git a/test/backend/unit/model/sql/GalleryManager.ts b/test/backend/unit/model/sql/GalleryManager.ts new file mode 100644 index 0000000..6774d71 --- /dev/null +++ b/test/backend/unit/model/sql/GalleryManager.ts @@ -0,0 +1,58 @@ +import {expect} from 'chai'; +import {TestHelper} from './TestHelper'; +import {SQLTestHelper} from '../../../SQLTestHelper'; +import {GalleryManager} from '../../../../../backend/model/sql/GalleryManager'; +import {IndexingManager} from '../../../../../backend/model/sql/IndexingManager'; +import {DirectoryDTO} from '../../../../../common/entities/DirectoryDTO'; +import {Utils} from '../../../../../common/Utils'; +import {ObjectManagerRepository} from '../../../../../backend/model/ObjectManagerRepository'; +import {PersonManager} from '../../../../../backend/model/sql/PersonManager'; +import {MediaEntity} from '../../../../../backend/model/sql/enitites/MediaEntity'; + +class IndexingManagerTest extends IndexingManager { + + public async saveToDB(scannedDirectory: DirectoryDTO): Promise { + return super.saveToDB(scannedDirectory); + } +} + +// to help WebStorm to handle the test cases +declare let describe: any; +declare const after: any; +describe = SQLTestHelper.describe; + +describe('GalleryManager', (sqlHelper: SQLTestHelper) => { + + + beforeEach(async () => { + await sqlHelper.initDB(); + ObjectManagerRepository.getInstance().PersonManager = new PersonManager(); + }); + + + after(async () => { + await sqlHelper.clearDB(); + }); + + it('should get random photo', async () => { + const gm = new GalleryManager(); + const im = new IndexingManagerTest(); + + const parent = TestHelper.getRandomizedDirectoryEntry(); + const p1 = TestHelper.getRandomizedPhotoEntry(parent, 'Photo1'); + expect(await gm.getRandomPhoto({})).to.not.exist; + DirectoryDTO.removeReferences(parent); + await im.saveToDB(Utils.clone(parent)); + + delete p1.metadata.faces; + delete p1.directory; + delete p1.id; + const found: MediaEntity = await gm.getRandomPhoto({}); + delete found.metadata.bitRate; + delete found.metadata.duration; + delete found.directory; + delete found.id; + expect(Utils.clone(found)).to.be.deep.equal(Utils.clone(p1)); + }); + +}); diff --git a/test/backend/unit/model/sql/IndexingManager.ts b/test/backend/unit/model/sql/IndexingManager.ts index 349283a..1467e85 100644 --- a/test/backend/unit/model/sql/IndexingManager.ts +++ b/test/backend/unit/model/sql/IndexingManager.ts @@ -1,8 +1,7 @@ import {expect} from 'chai'; import * as fs from 'fs'; -import * as path from 'path'; import {Config} from '../../../../../common/config/private/Config'; -import {DatabaseType, ReIndexingSensitivity} from '../../../../../common/config/private/IPrivateConfig'; +import {ReIndexingSensitivity} from '../../../../../common/config/private/IPrivateConfig'; import {SQLConnection} from '../../../../../backend/model/sql/SQLConnection'; import {GalleryManager} from '../../../../../backend/model/sql/GalleryManager'; import {DirectoryDTO} from '../../../../../common/entities/DirectoryDTO'; @@ -15,6 +14,7 @@ import {FileDTO} from '../../../../../common/entities/FileDTO'; import {IndexingManager} from '../../../../../backend/model/sql/IndexingManager'; import {ObjectManagerRepository} from '../../../../../backend/model/ObjectManagerRepository'; import {PersonManager} from '../../../../../backend/model/sql/PersonManager'; +import {SQLTestHelper} from '../../../SQLTestHelper'; class GalleryManagerTest extends GalleryManager { @@ -41,43 +41,22 @@ class IndexingManagerTest extends IndexingManager { } } -describe('IndexingManager', () => { +// to help WebStorm to handle the test cases +declare let describe: any; +declare const after: any; +describe = SQLTestHelper.describe; +describe('IndexingManager', (sqlHelper: SQLTestHelper) => { - const tempDir = path.join(__dirname, '../../tmp'); - const dbPath = path.join(tempDir, 'test.db'); - - - const setUpSqlDB = async () => { - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath); - } - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir); - } - - Config.Server.database.type = DatabaseType.sqlite; - Config.Server.database.sqlite.storage = dbPath; - ObjectManagerRepository.getInstance().PersonManager = new PersonManager(); - - }; - - const tearDownSqlDB = async () => { - await SQLConnection.close(); - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath); - } - if (fs.existsSync(tempDir)) { - fs.rmdirSync(tempDir); - } - }; beforeEach(async () => { - await setUpSqlDB(); + await sqlHelper.initDB(); + ObjectManagerRepository.getInstance().PersonManager = new PersonManager(); }); - afterEach(async () => { - await tearDownSqlDB(); + + after(async () => { + await sqlHelper.clearDB(); }); const removeIds = (dir: DirectoryDTO) => { @@ -245,7 +224,7 @@ describe('IndexingManager', () => { expect(selected.media.length).to.deep.equal(subDir.media.length); }) as any).timeout(40000); - describe('Test listDirectory', () => { + SQLTestHelper.savedDescribe('Test listDirectory', () => { const statSync = fs.statSync; let dirTime = 0; const indexedTime = { diff --git a/test/backend/unit/model/sql/SearchManager.ts b/test/backend/unit/model/sql/SearchManager.ts index 671d758..a92c362 100644 --- a/test/backend/unit/model/sql/SearchManager.ts +++ b/test/backend/unit/model/sql/SearchManager.ts @@ -1,8 +1,4 @@ import {expect} from 'chai'; -import * as fs from 'fs'; -import * as path from 'path'; -import {Config} from '../../../../../common/config/private/Config'; -import {DatabaseType} from '../../../../../common/config/private/IPrivateConfig'; import {SQLConnection} from '../../../../../backend/model/sql/SQLConnection'; import {PhotoEntity} from '../../../../../backend/model/sql/enitites/PhotoEntity'; import {SearchManager} from '../../../../../backend/model/sql/SearchManager'; @@ -15,12 +11,15 @@ import {VideoEntity} from '../../../../../backend/model/sql/enitites/VideoEntity import {PersonEntry} from '../../../../../backend/model/sql/enitites/PersonEntry'; import {FaceRegionEntry} from '../../../../../backend/model/sql/enitites/FaceRegionEntry'; import {PhotoDTO} from '../../../../../common/entities/PhotoDTO'; +import {SQLTestHelper} from '../../../SQLTestHelper'; +import {Config} from '../../../../../common/config/private/Config'; -describe('SearchManager', () => { +// to help WebStorm to handle the test cases +declare let describe: any; +declare const after: any; +describe = SQLTestHelper.describe; - - const tempDir = path.join(__dirname, '../../tmp'); - const dbPath = path.join(tempDir, 'test.db'); +describe('SearchManager', (sqlHelper: SQLTestHelper) => { const dir = TestHelper.getDirectoryEntry(); const p = TestHelper.getPhotoEntry1(dir); @@ -31,15 +30,7 @@ describe('SearchManager', () => { const v = TestHelper.getVideoEntry1(dir); const setUpSqlDB = async () => { - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath); - } - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir); - } - - Config.Server.database.type = DatabaseType.sqlite; - Config.Server.database.sqlite.storage = dbPath; + await sqlHelper.initDB(); const savePhoto = async (photo: PhotoDTO) => { const savedPhoto = await pr.save(photo); @@ -66,24 +57,15 @@ describe('SearchManager', () => { await SQLConnection.close(); }; - const tearDownSqlDB = async () => { - await SQLConnection.close(); - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath); - } - if (fs.existsSync(tempDir)) { - fs.rmdirSync(tempDir); - } - }; beforeEach(async () => { await setUpSqlDB(); }); - afterEach(async () => { - await tearDownSqlDB(); - }); + after(async () => { + await sqlHelper.clearDB(); + }); it('should get autocomplete', async () => { const sm = new SearchManager(); @@ -103,6 +85,8 @@ describe('SearchManager', () => { new AutoCompleteItem('wars dir', SearchTypes.directory)]); expect((await sm.autocomplete('arch'))).eql([new AutoCompleteItem('Research City', SearchTypes.position)]); + + Config.Client.Search.AutoComplete.maxItemsPerCategory = 99999; expect((await sm.autocomplete('a')).sort(cmp)).eql([ new AutoCompleteItem('Boba Fett', SearchTypes.keyword), new AutoCompleteItem('Boba Fett', SearchTypes.person), @@ -113,6 +97,7 @@ describe('SearchManager', () => { new AutoCompleteItem('Han Solo', SearchTypes.person), new AutoCompleteItem('death star', SearchTypes.keyword), new AutoCompleteItem('Padmé Amidala', SearchTypes.person), + new AutoCompleteItem('Obivan Kenobi', SearchTypes.person), new AutoCompleteItem('Padmé Amidala', SearchTypes.keyword), new AutoCompleteItem('Natalie Portman', SearchTypes.keyword), new AutoCompleteItem('Han Solo\'s dice', SearchTypes.photo), @@ -121,10 +106,24 @@ describe('SearchManager', () => { new AutoCompleteItem('wars dir', SearchTypes.directory), new AutoCompleteItem('Research City', SearchTypes.position)].sort(cmp)); + Config.Client.Search.AutoComplete.maxItemsPerCategory = 1; + expect((await sm.autocomplete('a')).sort(cmp)).eql([ + new AutoCompleteItem('Anakin', SearchTypes.keyword), + new AutoCompleteItem('star wars', SearchTypes.keyword), + new AutoCompleteItem('death star', SearchTypes.keyword), + new AutoCompleteItem('Anakin Skywalker', SearchTypes.person), + new AutoCompleteItem('Han Solo\'s dice', SearchTypes.photo), + new AutoCompleteItem('Kamino', SearchTypes.position), + new AutoCompleteItem('Research City', SearchTypes.position), + new AutoCompleteItem('wars dir', SearchTypes.directory), + new AutoCompleteItem('Boba Fett', SearchTypes.keyword)].sort(cmp)); + Config.Client.Search.AutoComplete.maxItemsPerCategory = 5; + expect((await sm.autocomplete('sw')).sort(cmp)).to.deep.equal([new AutoCompleteItem('sw1', SearchTypes.photo), new AutoCompleteItem('sw2', SearchTypes.photo), new AutoCompleteItem(v.name, SearchTypes.video)].sort(cmp)); expect((await sm.autocomplete(v.name)).sort(cmp)).to.deep.equal([new AutoCompleteItem(v.name, SearchTypes.video)]); + }); diff --git a/test/backend/unit/model/sql/SharingManager.ts b/test/backend/unit/model/sql/SharingManager.ts index d11b72b..c866a4a 100644 --- a/test/backend/unit/model/sql/SharingManager.ts +++ b/test/backend/unit/model/sql/SharingManager.ts @@ -1,32 +1,23 @@ import {expect} from 'chai'; -import * as fs from 'fs'; -import * as path from 'path'; -import {Config} from '../../../../../common/config/private/Config'; -import {DatabaseType} from '../../../../../common/config/private/IPrivateConfig'; import {SQLConnection} from '../../../../../backend/model/sql/SQLConnection'; import {SharingManager} from '../../../../../backend/model/sql/SharingManager'; import {SharingDTO} from '../../../../../common/entities/SharingDTO'; import {UserEntity} from '../../../../../backend/model/sql/enitites/UserEntity'; import {UserDTO, UserRoles} from '../../../../../common/entities/UserDTO'; +import {SQLTestHelper} from '../../../SQLTestHelper'; -describe('SharingManager', () => { +// to help WebStorm to handle the test cases +declare let describe: any; +declare const after: any; +describe = SQLTestHelper.describe; +describe('SharingManager', (sqlHelper: SQLTestHelper) => { - const tempDir = path.join(__dirname, '../../tmp'); - const dbPath = path.join(tempDir, 'test.db'); let creator: UserDTO = null; const setUpSqlDB = async () => { - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath); - } - if (!fs.existsSync(tempDir)) { - fs.mkdirSync(tempDir); - } - - Config.Server.database.type = DatabaseType.sqlite; - Config.Server.database.sqlite.storage = dbPath; + await sqlHelper.initDB(); const conn = await SQLConnection.getConnection(); @@ -41,22 +32,13 @@ describe('SharingManager', () => { await SQLConnection.close(); }; - const teardownUpSqlDB = async () => { - await SQLConnection.close(); - if (fs.existsSync(dbPath)) { - fs.unlinkSync(dbPath); - } - if (fs.existsSync(tempDir)) { - fs.rmdirSync(tempDir); - } - }; beforeEach(async () => { await setUpSqlDB(); }); - afterEach(async () => { - await teardownUpSqlDB(); + after(async () => { + await sqlHelper.clearDB(); }); diff --git a/test/backend/unit/model/sql/TestHelper.ts b/test/backend/unit/model/sql/TestHelper.ts index bc9f685..05c985e 100644 --- a/test/backend/unit/model/sql/TestHelper.ts +++ b/test/backend/unit/model/sql/TestHelper.ts @@ -258,7 +258,7 @@ export class TestHelper { name: rndStr() + '.jpg', directory: dir, metadata: m, - readyThumbnails: null, + readyThumbnails: [], readyIcon: false }; From 3725882945d3cd2443d9b8cf517452fa093d92f5 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 27 Jan 2019 14:39:34 -0500 Subject: [PATCH 15/20] travis update --- .travis.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2203150..057d771 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,10 +4,7 @@ node_js: - '10' - '11' env: - - Server-database-mysql-host='localhost' - - Server-database-mysql-username='root' - - Server-database-mysql-password='' - - Server-database-mysql-database='pigallery2_travis' + - Server-database-mysql-host='localhost' Server-database-mysql-username='root' rver-database-mysql-password='' Server-database-mysql-database='pigallery2_travis' services: - mysql addons: From 27d5bcb12972cf8d4d64cef4ef6432dfffe09343 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 27 Jan 2019 14:53:46 -0500 Subject: [PATCH 16/20] travis update --- .travis.yml | 2 +- gulpfile.ts | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 057d771..74e3462 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ node_js: - '10' - '11' env: - - Server-database-mysql-host='localhost' Server-database-mysql-username='root' rver-database-mysql-password='' Server-database-mysql-database='pigallery2_travis' + - "Server-database-mysql-host"='localhost' "Server-database-mysql-username"='root' "Server-database-mysql-password"='' "Server-database-mysql-database"='pigallery2_travis' services: - mysql addons: diff --git a/gulpfile.ts b/gulpfile.ts index 09e1d03..26398bf 100644 --- a/gulpfile.ts +++ b/gulpfile.ts @@ -113,13 +113,15 @@ const simpleBuild = (isProd: boolean) => { createFrontendTask('build-frontend default', cmd + '--output-path=./dist --no-progress --no-progress --i18n-locale en' + ' --i18n-format=xlf --i18n-file=frontend/' + translationFolder + '/messages.en.xlf' + ' --i18n-missing-translation warning'); tasks.push('build-frontend default'); - for (let i = 0; i < languages.length; i++) { - createFrontendTask('build-frontend ' + languages[i], cmd + - '--output-path=./dist/' + languages[i] + - ' --no-progress --i18n-locale ' + languages[i] + - ' --i18n-format=xlf --i18n-file=frontend/' + translationFolder + - '/messages.' + languages[i] + '.xlf' + ' --i18n-missing-translation warning'); - tasks.push('build-frontend ' + languages[i]); + if (!process.env.CI) { // don't build languages if running in CI + for (let i = 0; i < languages.length; i++) { + createFrontendTask('build-frontend ' + languages[i], cmd + + '--output-path=./dist/' + languages[i] + + ' --no-progress --i18n-locale ' + languages[i] + + ' --i18n-format=xlf --i18n-file=frontend/' + translationFolder + + '/messages.' + languages[i] + '.xlf' + ' --i18n-missing-translation warning'); + tasks.push('build-frontend ' + languages[i]); + } } return gulp.series(...tasks); }; From 2150807d3ffbc0ea0b9e0440cf895c36139bc82f Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 27 Jan 2019 14:56:01 -0500 Subject: [PATCH 17/20] updating travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 74e3462..9e91107 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,7 @@ node_js: - '10' - '11' env: - - "Server-database-mysql-host"='localhost' "Server-database-mysql-username"='root' "Server-database-mysql-password"='' "Server-database-mysql-database"='pigallery2_travis' + - "Server-database-mysql-host"='localhost' "Server-database-mysql-username"='root' "Server-database-mysql-password"='' "Server-database-mysql-database"="pigallery2_travis" services: - mysql addons: From 116410c44d37e28e63b2b9d0f7aebe5558a050f0 Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sun, 27 Jan 2019 14:58:56 -0500 Subject: [PATCH 18/20] triggering travis --- frontend/app/admin/admin.component.html | 3 ++- frontend/app/app.component.ts | 6 ++---- frontend/app/frame/frame.component.html | 10 ++++++---- frontend/app/frame/frame.component.ts | 3 +-- frontend/app/gallery/Media.ts | 1 - frontend/app/gallery/MediaIcon.ts | 1 - frontend/app/model/notification.service.ts | 2 +- frontend/app/model/query.service.ts | 1 - frontend/app/pipes/FileSizePipe.ts | 1 - 9 files changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend/app/admin/admin.component.html b/frontend/app/admin/admin.component.html index dbe3dc1..bf70ef0 100644 --- a/frontend/app/admin/admin.component.html +++ b/frontend/app/admin/admin.component.html @@ -50,7 +50,8 @@ - + `, - + template: `` }) export class AppComponent implements OnInit, OnDestroy { diff --git a/frontend/app/frame/frame.component.html b/frontend/app/frame/frame.component.html index da009bd..9b9492c 100644 --- a/frontend/app/frame/frame.component.html +++ b/frontend/app/frame/frame.component.html @@ -22,13 +22,14 @@
  • -
    +