mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './search-config-response.dto';
|
||||
export * from './search-explore.response.dto';
|
||||
export * from './search-response.dto';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { AssetResponseDto } from '../../asset';
|
||||
|
||||
class SearchExploreItem {
|
||||
value!: string;
|
||||
data!: AssetResponseDto;
|
||||
}
|
||||
|
||||
export class SearchExploreResponseDto {
|
||||
fieldName!: string;
|
||||
items!: SearchExploreItem[];
|
||||
}
|
||||
@@ -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>[]>;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
|
||||
import: jest.fn(),
|
||||
search: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
explore: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(',');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user