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(),
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user