1
0
mirror of https://github.com/xuthus83/pigallery2.git synced 2025-01-14 14:43:17 +08:00

Add person count search and sorting support.

Note it will trigger a DB drop as the scheme changed. #683
This commit is contained in:
Patrik J. Braun 2023-08-01 22:57:36 +02:00
parent 1974323b61
commit 0e460d07af
17 changed files with 305 additions and 105 deletions

View File

@ -367,6 +367,7 @@ export class IndexingManager {
),
];
}
(media[i].metadata as PhotoMetadataEntity).personsLength = (media[i].metadata as PhotoMetadataEntity)?.persons?.length || 0;
if (mediaItem == null) {

View File

@ -13,9 +13,9 @@ import {
DatePatternFrequency,
DatePatternSearch,
DistanceSearch,
FromDateSearch,
FromDateSearch, MaxPersonCountSearch,
MaxRatingSearch,
MaxResolutionSearch,
MaxResolutionSearch, MinPersonCountSearch,
MinRatingSearch,
MinResolutionSearch,
OrientationSearch,
@ -379,6 +379,12 @@ export class SearchManager {
case SortingMethods.ascName:
query.addOrderBy('media.name', 'ASC');
break;
case SortingMethods.descPersonCount:
query.addOrderBy('media.metadata.personsLength', 'DESC');
break;
case SortingMethods.ascPersonCount:
query.addOrderBy('media.metadata.personsLength', 'ASC');
break;
case SortingMethods.random:
if (Config.Database.type === DatabaseType.mysql) {
query.groupBy('RAND(), media.id');
@ -639,6 +645,52 @@ export class SearchManager {
return q;
});
case SearchQueryTypes.min_person_count:
if (directoryOnly) {
throw new Error('not supported in directoryOnly mode');
}
return new Brackets((q): unknown => {
if (typeof (query as MinPersonCountSearch).value === 'undefined') {
throw new Error(
'Invalid search query: Person count Query should contain minvalue'
);
}
const relation = (query as TextSearch).negate ? '<' : '>=';
const textParam: { [key: string]: unknown } = {};
textParam['min' + queryId] = (query as MinPersonCountSearch).value;
q.where(
`media.metadata.personsLength ${relation} :min${queryId}`,
textParam
);
return q;
});
case SearchQueryTypes.max_person_count:
if (directoryOnly) {
throw new Error('not supported in directoryOnly mode');
}
return new Brackets((q): unknown => {
if (typeof (query as MaxPersonCountSearch).value === 'undefined') {
throw new Error(
'Invalid search query: Person count Query should contain max value'
);
}
const relation = (query as TextSearch).negate ? '>' : '<=';
if (typeof (query as MaxRatingSearch).value !== 'undefined') {
const textParam: { [key: string]: unknown } = {};
textParam['max' + queryId] = (query as MaxPersonCountSearch).value;
q.where(
`media.metadata.personsLength ${relation} :max${queryId}`,
textParam
);
}
return q;
});
case SearchQueryTypes.min_resolution:
if (directoryOnly) {
throw new Error('not supported in directoryOnly mode');

View File

@ -1,26 +1,9 @@
import {
Column,
Entity,
Index,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
TableInheritance,
Unique,
} 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 {MediaDimension, MediaDTO, MediaMetadata,} from '../../../../common/entities/MediaDTO';
import {PersonJunctionTable} from './PersonJunctionTable';
import {columnCharsetCS} from './EntityUtils';
import {
CameraMetadata, FaceRegion,
GPSMetadata,
PositionMetaData,
} from '../../../../common/entities/PhotoDTO';
import {CameraMetadata, FaceRegion, GPSMetadata, PositionMetaData,} from '../../../../common/entities/PhotoDTO';
export class MediaDimensionEntity implements MediaDimension {
@Column('int')
@ -147,7 +130,8 @@ export class MediaMetadataEntity implements MediaMetadata {
type: 'simple-json',
nullable: true,
charset: columnCharsetCS.charset,
collation: columnCharsetCS.collation})
collation: columnCharsetCS.collation
})
faces: FaceRegion[];
/**
@ -162,6 +146,18 @@ export class MediaMetadataEntity implements MediaMetadata {
})
persons: string[];
/**
* Caches the list of persons' length. Only used for searching
*/
@Column({
type: 'tinyint',
select: false,
nullable: false,
default: 0
})
personsLength: number;
@Column('int', {unsigned: true})
bitRate: number;

View File

@ -35,7 +35,7 @@ export class TopPickSendJob extends Job<{
type: 'sort-array',
name: backendTexts.sortBy.name,
description: backendTexts.sortBy.description,
defaultValue: [SortingMethods.descRating],
defaultValue: [SortingMethods.descRating, SortingMethods.descPersonCount],
}, {
id: 'pickAmount',
type: 'number',

View File

@ -1,4 +1,4 @@
/**
* This version indicates that the sql/entities/*Entity.ts files got changed and the db needs to be recreated
*/
export const DataStructureVersion = 30;
export const DataStructureVersion = 31;

View File

@ -13,6 +13,8 @@ export const PG2ConfMap = {
'.order_descending_rating.pg2conf': SortingMethods.descRating,
'.order_ascending_rating.pg2conf': SortingMethods.ascRating,
'.order_random.pg2conf': SortingMethods.random,
'.order_descending_person_count.pg2conf': SortingMethods.descPersonCount,
'.order_ascending_person_count.pg2conf': SortingMethods.descPersonCount,
},
};

View File

@ -4,10 +4,6 @@ import {
DatePatternSearch,
DistanceSearch,
FromDateSearch,
MaxRatingSearch,
MaxResolutionSearch,
MinRatingSearch,
MinResolutionSearch,
NegatableSearchQuery,
OrientationSearch,
ORSearchQuery,
@ -41,6 +37,8 @@ export interface QueryKeywords {
minResolution: string;
maxRating: string;
minRating: string;
maxPersonCount: string;
minPersonCount: string;
NSomeOf: string;
someOf: string;
or: string;
@ -65,8 +63,10 @@ export const defaultQueryKeywords: QueryKeywords = {
to: 'before',
maxRating: 'max-rating',
maxResolution: 'max-resolution',
minRating: 'min-rating',
maxPersonCount: 'max-persons',
minPersonCount: 'min-persons',
maxResolution: 'max-resolution',
minResolution: 'min-resolution',
kmFrom: 'km-from',
@ -303,38 +303,28 @@ export class SearchQueryParser {
} as ToDateSearch;
}
if (kwStartsWith(str, this.keywords.minRating)) {
const addValueRangeParser = (matcher: string, type: SearchQueryTypes): RangeSearch | undefined => {
if (kwStartsWith(str, matcher)) {
return {
type: SearchQueryTypes.min_rating,
type: type,
value: parseInt(str.substring(str.indexOf(':') + 1), 10),
...(str.startsWith(this.keywords.minRating + '!:') && {negate: true}), // only add if the value is true
} as MinRatingSearch;
...(str.startsWith(matcher + '!:') && {negate: true}), // only add if the value is true
} as RangeSearch;
}
if (kwStartsWith(str, this.keywords.maxRating)) {
return {
type: SearchQueryTypes.max_rating,
value: parseInt(str.substring(str.indexOf(':') + 1), 10),
...(str.startsWith(this.keywords.maxRating + '!:') && {negate: true}), // only add if the value is true
} as MaxRatingSearch;
}
if (kwStartsWith(str, this.keywords.minResolution)) {
return {
type: SearchQueryTypes.min_resolution,
value: parseInt(str.substring(str.indexOf(':') + 1), 10),
...(str.startsWith(this.keywords.minResolution + '!:') && {
negate: true,
}), // only add if the value is true
} as MinResolutionSearch;
}
if (kwStartsWith(str, this.keywords.maxResolution)) {
return {
type: SearchQueryTypes.max_resolution,
value: parseInt(str.substring(str.indexOf(':') + 1), 10),
...(str.startsWith(this.keywords.maxResolution + '!:') && {
negate: true,
}), // only add if the value is true
} as MaxResolutionSearch;
};
const range = addValueRangeParser(this.keywords.minRating, SearchQueryTypes.min_rating) ||
addValueRangeParser(this.keywords.maxRating, SearchQueryTypes.max_rating) ||
addValueRangeParser(this.keywords.minResolution, SearchQueryTypes.min_resolution) ||
addValueRangeParser(this.keywords.maxResolution, SearchQueryTypes.max_resolution) ||
addValueRangeParser(this.keywords.minPersonCount, SearchQueryTypes.min_person_count) ||
addValueRangeParser(this.keywords.maxPersonCount, SearchQueryTypes.max_person_count);
if (range) {
return range;
}
if (new RegExp('^\\d*-' + this.keywords.kmFrom + '!?:').test(str)) {
let from = str.slice(
new RegExp('^\\d*-' + this.keywords.kmFrom + '!?:').exec(str)[0].length
@ -528,6 +518,22 @@ export class SearchQueryParser {
? ''
: (query as RangeSearch).value)
);
case SearchQueryTypes.min_person_count:
return (
this.keywords.minPersonCount +
colon +
(isNaN((query as RangeSearch).value)
? ''
: (query as RangeSearch).value)
);
case SearchQueryTypes.max_person_count:
return (
this.keywords.maxPersonCount +
colon +
(isNaN((query as RangeSearch).value)
? ''
: (query as RangeSearch).value)
);
case SearchQueryTypes.min_resolution:
return (
this.keywords.minResolution +

View File

@ -898,6 +898,7 @@ export class ServerPreviewConfig {
Sorting: SortingMethods[] = [
SortingMethods.descRating,
SortingMethods.descDate,
SortingMethods.descPersonCount,
];
}

View File

@ -14,10 +14,14 @@ export enum SearchQueryTypes {
max_rating,
min_resolution,
max_resolution,
min_person_count,
max_person_count,
distance,
distance = 50,
orientation,
date_pattern,
date_pattern = 60,
// TEXT search types
any_text = 100,
@ -211,6 +215,17 @@ export interface MaxRatingSearch extends RangeSearch {
value: number;
}
export interface MinPersonCountSearch extends RangeSearch {
type: SearchQueryTypes.min_person_count;
value: number;
}
export interface MaxPersonCountSearch extends RangeSearch {
type: SearchQueryTypes.max_person_count;
value: number;
}
export interface MinResolutionSearch extends RangeSearch {
type: SearchQueryTypes.min_resolution;
value: number; // in megapixels

View File

@ -1,9 +1,15 @@
/**
* Order of these enums determines the order in the UI.
* Keep spaces between the values, so new value can be added in between without changing the existing ones
*/
export enum SortingMethods {
ascName = 1,
descName,
ascDate,
descDate,
ascRating,
descRating,
random,
ascName = 10,
descName = 11,
ascDate = 20,
descDate = 21,
ascRating = 30,
descRating = 31,
ascPersonCount = 40,
descPersonCount = 41,
random = 100 // let's keep random as the last in the UI
}

View File

@ -9,6 +9,10 @@ export class IconizeSortingMethod implements PipeTransform {
return '<span class="oi oi-sort-ascending"></span><span class="oi oi-star text-bold"></span>';
case SortingMethods.descRating:
return '<span class="oi oi-sort-descending"></span><span class="oi oi-star text-bold"></span>';
case SortingMethods.ascPersonCount:
return '<span class="oi oi-sort-ascending"></span><span class="oi oi-person text-bold"></span>';
case SortingMethods.descPersonCount:
return '<span class="oi oi-sort-descending"></span><span class="oi oi-person text-bold"></span>';
case SortingMethods.ascName:
return '<span class="oi oi-sort-ascending"></span><strong>A</strong>';
case SortingMethods.descName:

View File

@ -43,6 +43,8 @@ EnumTranslations[SortingMethods[SortingMethods.ascName]] = $localize`ascending n
EnumTranslations[SortingMethods[SortingMethods.descRating]] = $localize`descending rating`;
EnumTranslations[SortingMethods[SortingMethods.ascRating]] = $localize`ascending rating`;
EnumTranslations[SortingMethods[SortingMethods.random]] = $localize`random`;
EnumTranslations[SortingMethods[SortingMethods.ascPersonCount]] = $localize`ascending persons`;
EnumTranslations[SortingMethods[SortingMethods.descPersonCount]] = $localize`descending persons`;
EnumTranslations[NavigationLinkTypes[NavigationLinkTypes.url]] = $localize`Url`;

View File

@ -179,6 +179,18 @@ export class GallerySortingService {
(b.metadata.rating || 0) - (a.metadata.rating || 0)
);
break;
case SortingMethods.ascPersonCount:
c.media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(a.metadata?.faces?.length || 0) - (b.metadata?.faces?.length || 0)
);
break;
case SortingMethods.descPersonCount:
c.media.sort(
(a: PhotoDTO, b: PhotoDTO) =>
(b.metadata?.faces?.length || 0) - (a.metadata?.faces?.length || 0)
);
break;
case SortingMethods.random:
this.rndService.setSeed(c.media.length);
c.media

View File

@ -29,6 +29,8 @@ export class AutoCompleteService {
k !== this.searchQueryParserService.keywords.NSomeOf &&
k !== this.searchQueryParserService.keywords.minRating &&
k !== this.searchQueryParserService.keywords.maxRating &&
k !== this.searchQueryParserService.keywords.minPersonCount &&
k !== this.searchQueryParserService.keywords.maxPersonCount &&
k !== this.searchQueryParserService.keywords.every_week &&
k !== this.searchQueryParserService.keywords.every_month &&
k !== this.searchQueryParserService.keywords.every_year &&
@ -91,6 +93,11 @@ export class AutoCompleteService {
this.noACKeywordsMap[this.searchQueryParserService.keywords.maxRating]
= SearchQueryTypes.max_rating;
this.noACKeywordsMap[this.searchQueryParserService.keywords.minPersonCount]
= SearchQueryTypes.min_person_count;
this.noACKeywordsMap[this.searchQueryParserService.keywords.maxPersonCount]
= SearchQueryTypes.max_person_count;
this.noACKeywordsMap[this.searchQueryParserService.keywords.minResolution]
= SearchQueryTypes.min_resolution;
this.noACKeywordsMap[this.searchQueryParserService.keywords.maxResolution]
@ -321,11 +328,13 @@ export class AutoCompleteService {
}
}
const addRangeAutoComp = (minStr: string, maxStr: string, minRange: number, maxRange: number) => {
// only showing rating recommendations of the full query is typed
const mrKey = this.searchQueryParserService.keywords.minRating + ':';
const mxrKey = this.searchQueryParserService.keywords.maxRating + ':';
const mrKey = minStr + ':';
const mxrKey = maxStr + ':';
if (text.current.toLowerCase().startsWith(mrKey)) {
for (let i = 1; i <= 5; ++i) {
for (let i = minRange; i <= maxRange; ++i) {
ret.push(generateMatch(mrKey + i));
}
} else if (mrKey.startsWith(text.current.toLowerCase())) {
@ -334,12 +343,18 @@ export class AutoCompleteService {
if (text.current.toLowerCase().startsWith(mxrKey)) {
for (let i = 1; i <= 5; ++i) {
for (let i = minRange; i <= maxRange; ++i) {
ret.push(generateMatch(mxrKey + i));
}
} else if (mxrKey.startsWith(text.current.toLowerCase())) {
ret.push(generateMatch(mxrKey));
}
};
addRangeAutoComp(this.searchQueryParserService.keywords.minRating,
this.searchQueryParserService.keywords.maxRating, 1, 5);
addRangeAutoComp(this.searchQueryParserService.keywords.minPersonCount,
this.searchQueryParserService.keywords.maxPersonCount, 0, 9);
// Date patterns
if (new RegExp('^' +

View File

@ -16,8 +16,10 @@ export class SearchQueryParserService {
to: 'before',
landscape: 'landscape',
maxRating: 'max-rating',
maxResolution: 'max-resolution',
minRating: 'min-rating',
minPersonCount: 'min-persons',
maxPersonCount: 'max-persons',
maxResolution: 'max-resolution',
minResolution: 'min-resolution',
orientation: 'orientation',

View File

@ -9,8 +9,10 @@ import {
DatePatternSearch,
DistanceSearch,
FromDateSearch,
MaxPersonCountSearch,
MaxRatingSearch,
MaxResolutionSearch,
MinPersonCountSearch,
MinRatingSearch,
MinResolutionSearch,
OrientationSearch,
@ -1094,6 +1096,83 @@ describe('SearchManager', (sqlHelper: DBTestHelper) => {
});
it('should search person count', async () => {
const sm = new SearchManager();
let query: MinPersonCountSearch | MaxPersonCountSearch = {value: 0, type: SearchQueryTypes.max_person_count} as MaxPersonCountSearch;
expect(Utils.clone(await sm.search(query)))
.to.deep.equalInAnyOrder(removeDir({
searchQuery: query,
directories: [],
media: [pFaceLess, v],
metaFile: [],
resultOverflow: false
} as SearchResultDTO));
query = ({value: 20, type: SearchQueryTypes.max_person_count} as MaxPersonCountSearch);
expect(Utils.clone(await sm.search(query)))
.to.deep.equalInAnyOrder(removeDir({
searchQuery: query,
directories: [],
media: [p, p2, pFaceLess, p4, v],
metaFile: [],
resultOverflow: false
} as SearchResultDTO));
query = ({value: 20, negate: true, type: SearchQueryTypes.max_person_count} as MaxPersonCountSearch);
expect(Utils.clone(await sm.search(query)))
.to.deep.equalInAnyOrder(removeDir({
searchQuery: query,
directories: [],
media: [],
metaFile: [],
resultOverflow: false
} as SearchResultDTO));
query = ({value: 4, type: SearchQueryTypes.max_person_count} as MaxPersonCountSearch);
expect(Utils.clone(await sm.search(query)))
.to.deep.equalInAnyOrder(removeDir({
searchQuery: query,
directories: [],
media: [p2, p4, pFaceLess, v],
metaFile: [],
resultOverflow: false
} as SearchResultDTO));
query = ({value: 2, type: SearchQueryTypes.min_person_count} as MinPersonCountSearch);
expect(Utils.clone(await sm.search(query)))
.to.deep.equalInAnyOrder(removeDir({
searchQuery: query,
directories: [],
media: [p, p2, p4],
metaFile: [],
resultOverflow: false
} as SearchResultDTO));
query = ({value: 6, type: SearchQueryTypes.min_person_count} as MinPersonCountSearch);
expect(Utils.clone(await sm.search(query)))
.to.deep.equalInAnyOrder(removeDir({
searchQuery: query,
directories: [],
media: [p],
metaFile: [],
resultOverflow: false
} as SearchResultDTO));
query = ({value: 2, negate: true, type: SearchQueryTypes.min_person_count} as MinPersonCountSearch);
expect(Utils.clone(await sm.search(query)))
.to.deep.equalInAnyOrder(removeDir({
searchQuery: query,
directories: [],
media: [v, pFaceLess],
metaFile: [],
resultOverflow: false
} as SearchResultDTO));
});
it('should search resolution', async () => {
const sm = new SearchManager();

View File

@ -5,8 +5,9 @@ import {
DatePatternSearch,
DistanceSearch,
FromDateSearch,
MaxPersonCountSearch,
MaxRatingSearch,
MaxResolutionSearch,
MaxResolutionSearch, MinPersonCountSearch,
MinRatingSearch,
MinResolutionSearch,
OrientationSearch,
@ -98,6 +99,12 @@ describe('SearchQueryParser', () => {
check({type: SearchQueryTypes.min_rating, value: 10, negate: true} as MinRatingSearch);
check({type: SearchQueryTypes.max_rating, value: 1, negate: true} as MaxRatingSearch);
});
it('Person count search', () => {
check({type: SearchQueryTypes.min_person_count, value: 10} as MinPersonCountSearch);
check({type: SearchQueryTypes.max_person_count, value: 1} as MaxPersonCountSearch);
check({type: SearchQueryTypes.min_person_count, value: 10, negate: true} as MinPersonCountSearch);
check({type: SearchQueryTypes.max_person_count, value: 1, negate: true} as MaxPersonCountSearch);
});
it('Resolution search', () => {
check({type: SearchQueryTypes.min_resolution, value: 10} as MinResolutionSearch);
check({type: SearchQueryTypes.max_resolution, value: 5} as MaxResolutionSearch);