From 0445c499e812085a539befdf3de0e15aa36b63bc Mon Sep 17 00:00:00 2001 From: "Patrik J. Braun" Date: Sat, 26 Jan 2019 18:03:40 -0500 Subject: [PATCH] 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); }); });