feat(web,server): explore (#1926)

* feat: explore

* chore: generate open api

* styling explore page

* styling no result page

* style overlay

* style: bluring text on thumbnail card for readability

* explore page tweaks

* fix(web): search urls

* feat(web): use objects for things

* feat(server): filter by motion, sort by createdAt

* More styling

* better navigation

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
This commit is contained in:
Jason Rasmussen
2023-03-05 15:44:31 -05:00
committed by GitHub
parent 1f631eafce
commit 2ca560ebf8
35 changed files with 1079 additions and 63 deletions

View File

@@ -1,21 +1,21 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { ISearchRepository, SearchCollection } from '../search/search.repository';
import { IJobRepository, JobName } from '../job';
import { AssetSearchOptions, IAssetRepository } from './asset.repository';
export class AssetCore {
constructor(private repository: IAssetRepository, private searchRepository: ISearchRepository) {}
constructor(private assetRepository: IAssetRepository, private jobRepository: IJobRepository) {}
getAll(options: AssetSearchOptions) {
return this.repository.getAll(options);
return this.assetRepository.getAll(options);
}
async save(asset: Partial<AssetEntity>) {
const _asset = await this.repository.save(asset);
await this.searchRepository.index(SearchCollection.ASSETS, _asset);
const _asset = await this.assetRepository.save(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: _asset } });
return _asset;
}
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {
return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type);
}
}

View File

@@ -1,15 +1,12 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import { newSearchRepositoryMock } from '../../test/search.repository.mock';
import { AssetService, IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
import { ISearchRepository } from '../search';
describe(AssetService.name, () => {
let sut: AssetService;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let searchMock: jest.Mocked<ISearchRepository>;
it('should work', () => {
expect(sut).toBeDefined();
@@ -18,8 +15,7 @@ describe(AssetService.name, () => {
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
searchMock = newSearchRepositoryMock();
sut = new AssetService(assetMock, jobMock, searchMock);
sut = new AssetService(assetMock, jobMock);
});
describe(`handle asset upload`, () => {
@@ -56,7 +52,10 @@ describe(AssetService.name, () => {
await sut.save(assetEntityStub.image);
expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ASSET,
data: { asset: assetEntityStub.image },
});
});
});
});

View File

@@ -1,7 +1,6 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { Inject } from '@nestjs/common';
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
import { ISearchRepository } from '../search';
import { AssetCore } from './asset.core';
import { IAssetRepository } from './asset.repository';
@@ -11,9 +10,8 @@ export class AssetService {
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) searchRepository: ISearchRepository,
) {
this.assetCore = new AssetCore(assetRepository, searchRepository);
this.assetCore = new AssetCore(assetRepository, jobRepository);
}
async handleAssetUpload(data: IAssetUploadedJob) {

View File

@@ -54,4 +54,14 @@ export class SearchDto {
@IsOptional()
@Transform(({ value }) => value.split(','))
'smartInfo.tags'?: string[];
@IsBoolean()
@IsOptional()
@Transform(toBoolean)
recent?: boolean;
@IsBoolean()
@IsOptional()
@Transform(toBoolean)
motion?: boolean;
}

View File

@@ -1,2 +1,3 @@
export * from './search-config-response.dto';
export * from './search-explore.response.dto';
export * from './search-response.dto';

View File

@@ -0,0 +1,11 @@
import { AssetResponseDto } from '../../asset';
class SearchExploreItem {
value!: string;
data!: AssetResponseDto;
}
export class SearchExploreResponseDto {
fieldName!: string;
items!: SearchExploreItem[];
}

View File

@@ -17,6 +17,8 @@ export interface SearchFilter {
model?: string;
objects?: string[];
tags?: string[];
recent?: boolean;
motion?: boolean;
}
export interface SearchResult<T> {
@@ -39,6 +41,14 @@ export interface SearchFacet {
}>;
}
export interface SearchExploreItem<T> {
fieldName: string;
items: Array<{
value: string;
data: T;
}>;
}
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
export const ISearchRepository = 'ISearchRepository';
@@ -57,4 +67,6 @@ export interface ISearchRepository {
search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]>;
}

View File

@@ -1,3 +1,4 @@
import { AssetEntity } from '@app/infra/db/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IAlbumRepository } from '../album/album.repository';
@@ -6,7 +7,7 @@ import { AuthUserDto } from '../auth';
import { IAlbumJob, IAssetJob, IDeleteJob, IJobRepository, JobName } from '../job';
import { SearchDto } from './dto';
import { SearchConfigResponseDto, SearchResponseDto } from './response-dto';
import { ISearchRepository, SearchCollection } from './search.repository';
import { ISearchRepository, SearchCollection, SearchExploreItem } from './search.repository';
@Injectable()
export class SearchService {
@@ -52,10 +53,13 @@ export class SearchService {
}
}
async getExploreData(authUser: AuthUserDto): Promise<SearchExploreItem<AssetEntity>[]> {
this.assertEnabled();
return this.searchRepository.explore(authUser.id);
}
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
if (!this.enabled) {
throw new BadRequestException('Search is disabled');
}
this.assertEnabled();
const query = dto.query || '*';
@@ -83,6 +87,7 @@ export class SearchService {
this.logger.log(`Indexing ${assets.length} assets`);
await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
this.logger.debug('Finished re-indexing all assets');
} catch (error: any) {
this.logger.error(`Unable to index all assets`, error?.stack);
}
@@ -94,6 +99,9 @@ export class SearchService {
}
const { asset } = data;
if (!asset.isVisible) {
return;
}
try {
await this.searchRepository.index(SearchCollection.ASSETS, asset);
@@ -111,6 +119,7 @@ export class SearchService {
const albums = await this.albumRepository.getAll();
this.logger.log(`Indexing ${albums.length} albums`);
await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
this.logger.debug('Finished re-indexing all albums');
} catch (error: any) {
this.logger.error(`Unable to index all albums`, error?.stack);
}
@@ -151,4 +160,10 @@ export class SearchService {
this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
}
}
private assertEnabled() {
if (!this.enabled) {
throw new BadRequestException('Search is disabled');
}
}
}

View File

@@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
import: jest.fn(),
search: jest.fn(),
delete: jest.fn(),
explore: jest.fn(),
};
};

View File

@@ -1,6 +1,6 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const assetSchemaVersion = 1;
export const assetSchemaVersion = 2;
export const assetSchema: CollectionCreateSchema = {
name: `assets-v${assetSchemaVersion}`,
fields: [
@@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = {
{ name: 'exifInfo.state', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.description', type: 'string', facet: false, optional: true },
{ name: 'exifInfo.imageName', type: 'string', facet: false, optional: true },
{ name: 'geo', type: 'geopoint', facet: false, optional: true },
{ name: 'exifInfo.make', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.model', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.orientation', type: 'string', optional: true },
@@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = {
// smart info
{ name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
{ name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
// computed
{ name: 'geo', type: 'geopoint', facet: false, optional: true },
{ name: 'motion', type: 'bool', facet: true },
],
token_separators: ['.'],
enable_nested_fields: true,

View File

@@ -2,11 +2,13 @@ import {
ISearchRepository,
SearchCollection,
SearchCollectionIndexStatus,
SearchExploreItem,
SearchFilter,
SearchResult,
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import _, { Dictionary } from 'lodash';
import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs';
import { Client } from 'typesense';
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
@@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db';
import { albumSchema } from './schemas/album.schema';
import { assetSchema } from './schemas/asset.schema';
interface GeoAssetEntity extends AssetEntity {
interface CustomAssetEntity extends AssetEntity {
geo?: [number, number];
motion?: boolean;
}
function removeNil<T extends Dictionary<any>>(item: T): Partial<T> {
@@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository {
}
async setup(): Promise<void> {
const collections = await this.client.collections().retrieve();
for (const collection of collections) {
this.logger.debug(`${collection.name} => ${collection.num_documents}`);
// await this.client.collections(collection.name).delete();
}
// upsert collections
for (const [collectionName, schema] of schemas) {
const collection = await this.client
@@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository {
}
}
async explore(userId: string): Promise<SearchExploreItem<AssetEntity>[]> {
const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve();
const common = {
q: '*',
filter_by: `ownerId:${userId}`,
per_page: 100,
};
const asset$ = this.client.collections<AssetEntity>(alias.collection_name).documents();
const { facet_counts: facets } = await asset$.search({
...common,
query_by: 'exifInfo.imageName',
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
max_facet_values: 50,
});
return firstValueFrom(
from(facets || []).pipe(
mergeMap(
(facet) =>
from(facet.counts).pipe(
mergeMap(
(count) =>
from(
asset$.search({
...common,
query_by: 'exifInfo.imageName',
filter_by: `${facet.field_name}:${count.value}`,
}),
).pipe(
map((result) => ({
value: count.value,
data: result.hits?.[0]?.document as AssetEntity,
})),
filter((item) => !!item.data),
),
5,
),
toArray(),
map((items) => ({
fieldName: facet.field_name as string,
items,
})),
),
3,
),
toArray(),
),
);
}
search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise<SearchResult<AssetEntity>>;
search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise<SearchResult<AlbumEntity>>;
async search(collection: SearchCollection, query: string, filters: SearchFilter) {
@@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository {
].join(','),
filter_by: _filters.join(' && '),
per_page: 250,
facet_by: (assetSchema.fields || [])
.filter((field) => field.facet)
.map((field) => field.name)
.join(','),
sort_by: filters.recent ? 'createdAt:desc' : undefined,
facet_by: this.getFacetFieldNames(SearchCollection.ASSETS),
});
return this.asResponse(results);
@@ -313,13 +373,24 @@ export class TypesenseRepository implements ISearchRepository {
}
}
private patchAsset(asset: AssetEntity): GeoAssetEntity {
private patchAsset(asset: AssetEntity): CustomAssetEntity {
let custom = asset as CustomAssetEntity;
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng && lat !== 0 && lng !== 0) {
return { ...asset, geo: [lat, lng] };
custom = { ...custom, geo: [lat, lng] };
}
return asset;
custom = { ...custom, motion: !!asset.livePhotoVideoId };
return custom;
}
private getFacetFieldNames(collection: SearchCollection) {
return (schemaMap[collection].fields || [])
.filter((field) => field.facet)
.map((field) => field.name)
.join(',');
}
}