feat(server)!: search via typesense (#1778)

* build: add typesense to docker

* feat(server): typesense search

* feat(web): search

* fix(web): show api error response message

* chore: search tests

* chore: regenerate open api

* fix: disable typesense on e2e

* fix: number properties for open api (dart)

* fix: e2e test

* fix: change lat/lng from floats to typesense geopoint

* dev: Add smartInfo relation to findAssetById to be able to query against it

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-03-02 21:47:08 -05:00 committed by GitHub
parent 1cc184ed10
commit 0aaeab124d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
87 changed files with 2216 additions and 77 deletions

View file

@ -17,3 +17,5 @@ ENABLE_MAPBOX=false
# WEB
MAPBOX_KEY=
VITE_SERVER_ENDPOINT=http://localhost:2283/api
TYPESENSE_ENABLED=false

View file

@ -23,6 +23,7 @@ services:
depends_on:
- redis
- database
- typesense
immich-machine-learning:
container_name: immich_machine_learning
@ -64,6 +65,7 @@ services:
depends_on:
- database
- immich-server
- typesense
immich-web:
container_name: immich_web
@ -89,6 +91,15 @@ services:
depends_on:
- immich-server
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.0
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
volumes:
- tsdata:/data
redis:
container_name: immich_redis
image: redis:6.2
@ -129,3 +140,4 @@ services:
volumes:
pgdata:
model-cache:
tsdata:

View file

@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
immich-server-test:
@ -9,7 +9,7 @@ services:
target: builder
command: npm run test:e2e
expose:
- '3000'
- "3000"
volumes:
- ../server:/usr/src/app
- /usr/src/app/node_modules
@ -17,6 +17,7 @@ services:
- .env.test
environment:
- NODE_ENV=development
- TYPESENSE_ENABLED=false
depends_on:
- immich-redis-test
- immich-database-test

View file

@ -4,7 +4,7 @@ services:
immich-server:
container_name: immich_server
image: altran1502/immich-server:release
entrypoint: [ "/bin/sh", "./start-server.sh" ]
entrypoint: ["/bin/sh", "./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@ -14,12 +14,13 @@ services:
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
container_name: immich_microservices
image: altran1502/immich-server:release
entrypoint: [ "/bin/sh", "./start-microservices.sh" ]
entrypoint: ["/bin/sh", "./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
@ -29,6 +30,7 @@ services:
depends_on:
- redis
- database
- typesense
restart: always
immich-machine-learning:
@ -46,11 +48,20 @@ services:
immich-web:
container_name: immich_web
image: altran1502/immich-web:release
entrypoint: [ "/bin/sh", "./entrypoint.sh" ]
entrypoint: ["/bin/sh", "./entrypoint.sh"]
env_file:
- .env
restart: always
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.0
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
volumes:
- tsdata:/data
redis:
container_name: immich_redis
image: redis:6.2
@ -88,3 +99,4 @@ services:
volumes:
pgdata:
model-cache:
tsdata:

View file

@ -30,6 +30,13 @@ REDIS_HOSTNAME=immich_redis
UPLOAD_LOCATION=absolute_location_on_your_machine_where_you_want_to_store_the_backup
###################################################################################
# Typesense
###################################################################################
TYPESENSE_API_KEY=some-random-text
# TYPESENSE_ENABLED=false
###################################################################################
# Reverse Geocoding
#
@ -76,4 +83,4 @@ IMMICH_MACHINE_LEARNING_URL=http://immich-machine-learning:3003
# Examples: http://localhost:3001, http://immich-api.example.com, etc
####################################################################################
#IMMICH_API_URL_EXTERNAL=http://localhost:3001
#IMMICH_API_URL_EXTERNAL=http://localhost:3001

View file

@ -61,7 +61,14 @@ doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/RemoveAssetsDto.md
doc/SearchAlbumResponseDto.md
doc/SearchApi.md
doc/SearchAssetDto.md
doc/SearchAssetResponseDto.md
doc/SearchConfigResponseDto.md
doc/SearchFacetCountResponseDto.md
doc/SearchFacetResponseDto.md
doc/SearchResponseDto.md
doc/ServerInfoApi.md
doc/ServerInfoResponseDto.md
doc/ServerPingResponse.md
@ -103,6 +110,7 @@ lib/api/authentication_api.dart
lib/api/device_info_api.dart
lib/api/job_api.dart
lib/api/o_auth_api.dart
lib/api/search_api.dart
lib/api/server_info_api.dart
lib/api/share_api.dart
lib/api/system_config_api.dart
@ -167,7 +175,13 @@ lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
lib/model/remove_assets_dto.dart
lib/model/search_album_response_dto.dart
lib/model/search_asset_dto.dart
lib/model/search_asset_response_dto.dart
lib/model/search_config_response_dto.dart
lib/model/search_facet_count_response_dto.dart
lib/model/search_facet_response_dto.dart
lib/model/search_response_dto.dart
lib/model/server_info_response_dto.dart
lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
@ -254,7 +268,14 @@ test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/remove_assets_dto_test.dart
test/search_album_response_dto_test.dart
test/search_api_test.dart
test/search_asset_dto_test.dart
test/search_asset_response_dto_test.dart
test/search_config_response_dto_test.dart
test/search_facet_count_response_dto_test.dart
test/search_facet_response_dto_test.dart
test/search_response_dto_test.dart
test/server_info_api_test.dart
test/server_info_response_dto_test.dart
test/server_ping_response_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SearchApi.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SearchResponseDto.md generated Normal file

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/api/search_api.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/test/search_api_test.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -2,7 +2,7 @@ import { AlbumService } from './album.service';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra';
import { AlbumResponseDto, ICryptoRepository, mapUser } from '@app/domain';
import { AlbumResponseDto, ICryptoRepository, IJobRepository, JobName, mapUser } from '@app/domain';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
import { IAlbumRepository } from './album-repository';
import { DownloadService } from '../../modules/download/download.service';
@ -10,6 +10,7 @@ import { ISharedLinkRepository } from '@app/domain';
import {
assetEntityStub,
newCryptoRepositoryMock,
newJobRepositoryMock,
newSharedLinkRepositoryMock,
userEntityStub,
} from '@app/domain/../test';
@ -20,6 +21,7 @@ describe('Album service', () => {
let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
@ -139,12 +141,14 @@ describe('Album service', () => {
};
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new AlbumService(
albumRepositoryMock,
sharedLinkRepositoryMock,
downloadServiceMock as DownloadService,
cryptoMock,
jobMock,
);
});
@ -158,6 +162,7 @@ describe('Album service', () => {
expect(result.id).toEqual(albumEntity.id);
expect(result.albumName).toEqual(albumEntity.albumName);
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } });
});
it('gets list of albums for auth user', async () => {
@ -291,9 +296,8 @@ describe('Album service', () => {
const updatedAlbumName = 'new album name';
const updatedAlbumThumbnailAssetId = '69d2f917-0b31-48d8-9d7d-673b523f1aac';
albumRepositoryMock.get.mockImplementation(() => Promise.resolve<AlbumEntity>(albumEntity));
albumRepositoryMock.updateAlbum.mockImplementation(() =>
Promise.resolve<AlbumEntity>({ ...albumEntity, albumName: updatedAlbumName }),
);
const updatedAlbum = { ...albumEntity, albumName: updatedAlbumName };
albumRepositoryMock.updateAlbum.mockResolvedValue(updatedAlbum);
const result = await sut.updateAlbumInfo(
authUser,
@ -311,6 +315,7 @@ describe('Album service', () => {
albumName: updatedAlbumName,
albumThumbnailAssetId: updatedAlbumThumbnailAssetId,
});
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } });
});
it('prevents updating a not owned album (shared with auth user)', async () => {

View file

@ -6,7 +6,7 @@ import { AddUsersDto } from './dto/add-users.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
import { UpdateAlbumDto } from './dto/update-album.dto';
import { GetAlbumsDto } from './dto/get-albums.dto';
import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
import { AlbumResponseDto, IJobRepository, JobName, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
import { IAlbumRepository } from './album-repository';
import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ -27,6 +27,7 @@ export class AlbumService {
@Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
private downloadService: DownloadService,
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
}
@ -56,6 +57,7 @@ export class AlbumService {
async create(authUser: AuthUserDto, createAlbumDto: CreateAlbumDto): Promise<AlbumResponseDto> {
const albumEntity = await this.albumRepository.create(authUser.id, createAlbumDto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: albumEntity } });
return mapAlbum(albumEntity);
}
@ -105,6 +107,7 @@ export class AlbumService {
}
await this.albumRepository.delete(album);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { id: albumId } });
}
async removeUserFromAlbum(authUser: AuthUserDto, albumId: string, userId: string | 'me'): Promise<void> {
@ -171,6 +174,9 @@ export class AlbumService {
}
const updatedAlbum = await this.albumRepository.updateAlbum(album, updateAlbumDto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { album: updatedAlbum } });
return mapAlbum(updatedAlbum);
}

View file

@ -252,7 +252,7 @@ export class AssetRepository implements IAssetRepository {
where: {
id: assetId,
},
relations: ['exifInfo', 'tags', 'sharedLinks'],
relations: ['exifInfo', 'tags', 'sharedLinks', 'smartInfo'],
});
}

View file

@ -445,6 +445,8 @@ describe('AssetService', () => {
]);
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset1' } }],
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { id: 'asset2' } }],
[
{
name: JobName.DELETE_FILES,

View file

@ -170,6 +170,8 @@ export class AssetService {
const updatedAsset = await this._assetRepository.update(authUser.id, asset, dto);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { asset: updatedAsset } });
return mapAsset(updatedAsset);
}
@ -425,6 +427,7 @@ export class AssetService {
try {
await this._assetRepository.remove(asset);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { id } });
result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath);

View file

@ -1,5 +1,5 @@
import { immichAppConfig } from '@app/common/config';
import { Module } from '@nestjs/common';
import { Module, OnModuleInit } from '@nestjs/common';
import { AssetModule } from './api-v1/asset/asset.module';
import { ConfigModule } from '@nestjs/config';
import { ServerInfoModule } from './api-v1/server-info/server-info.module';
@ -9,13 +9,14 @@ import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
import { JobModule } from './api-v1/job/job.module';
import { TagModule } from './api-v1/tag/tag.module';
import { DomainModule } from '@app/domain';
import { DomainModule, SearchService } from '@app/domain';
import { InfraModule } from '@app/infra';
import {
APIKeyController,
AuthController,
DeviceInfoController,
OAuthController,
SearchController,
ShareController,
SystemConfigController,
UserController,
@ -46,16 +47,21 @@ import { AuthGuard } from './middlewares/auth.guard';
TagModule,
],
controllers: [
//
AppController,
APIKeyController,
AuthController,
DeviceInfoController,
OAuthController,
SearchController,
ShareController,
SystemConfigController,
UserController,
],
providers: [{ provide: APP_GUARD, useExisting: AuthGuard }, AuthGuard],
})
export class AppModule {}
export class AppModule implements OnModuleInit {
constructor(private searchService: SearchService) {}
async onModuleInit() {
await this.searchService.bootstrap();
}
}

View file

@ -2,6 +2,7 @@ export * from './api-key.controller';
export * from './auth.controller';
export * from './device-info.controller';
export * from './oauth.controller';
export * from './search.controller';
export * from './share.controller';
export * from './system-config.controller';
export * from './user.controller';

View file

@ -0,0 +1,27 @@
import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain';
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { GetAuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
@ApiTags('Search')
@Authenticated()
@Controller('search')
export class SearchController {
constructor(private readonly searchService: SearchService) {}
@Authenticated()
@Get()
async search(
@GetAuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: SearchDto,
): Promise<SearchResponseDto> {
return this.searchService.search(authUser, dto);
}
@Authenticated()
@Get('config')
getSearchConfig(): SearchConfigResponseDto {
return this.searchService.getConfig();
}
}

View file

@ -11,7 +11,7 @@ import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
import { json } from 'body-parser';
import { patchOpenAPI } from './utils/patch-open-api.util';
import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
import { IMMICH_ACCESS_COOKIE } from '@app/domain';
import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
const logger = new Logger('ImmichServer');
@ -73,6 +73,9 @@ async function bootstrap() {
);
});
const searchService = app.get(SearchService);
logger.warn(`Machine learning is ${MACHINE_LEARNING_ENABLED ? 'enabled' : 'disabled'}`);
logger.warn(`Search is ${searchService.isEnabled() ? 'enabled' : 'disabled'}`);
}
bootstrap();

View file

@ -7,6 +7,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import {
BackgroundTaskProcessor,
MachineLearningProcessor,
SearchIndexProcessor,
StorageTemplateMigrationProcessor,
ThumbnailGeneratorProcessor,
} from './processors';
@ -26,6 +27,7 @@ import { VideoTranscodeProcessor } from './processors/video-transcode.processor'
MachineLearningProcessor,
StorageTemplateMigrationProcessor,
BackgroundTaskProcessor,
SearchIndexProcessor,
],
})
export class MicroservicesModule {}

View file

@ -1,12 +1,15 @@
import {
AssetService,
IAlbumJob,
IAssetJob,
IAssetUploadedJob,
IDeleteFilesJob,
IDeleteJob,
IUserDeletionJob,
JobName,
MediaService,
QueueName,
SearchService,
SmartInfoService,
StorageService,
StorageTemplateService,
@ -61,6 +64,41 @@ export class MachineLearningProcessor {
}
}
@Processor(QueueName.SEARCH)
export class SearchIndexProcessor {
constructor(private searchService: SearchService) {}
@Process(JobName.SEARCH_INDEX_ALBUMS)
async onIndexAlbums() {
await this.searchService.handleIndexAlbums();
}
@Process(JobName.SEARCH_INDEX_ASSETS)
async onIndexAssets() {
await this.searchService.handleIndexAssets();
}
@Process(JobName.SEARCH_INDEX_ALBUM)
async onIndexAlbum(job: Job<IAlbumJob>) {
await this.searchService.handleIndexAlbum(job.data);
}
@Process(JobName.SEARCH_INDEX_ASSET)
async onIndexAsset(job: Job<IAssetJob>) {
await this.searchService.handleIndexAsset(job.data);
}
@Process(JobName.SEARCH_REMOVE_ALBUM)
async onRemoveAlbum(job: Job<IDeleteJob>) {
await this.searchService.handleRemoveAlbum(job.data);
}
@Process(JobName.SEARCH_REMOVE_ASSET)
async onRemoveAsset(job: Job<IDeleteJob>) {
await this.searchService.handleRemoveAsset(job.data);
}
}
@Processor(QueueName.STORAGE_TEMPLATE_MIGRATION)
export class StorageTemplateMigrationProcessor {
constructor(private storageTemplateService: StorageTemplateService) {}

View file

@ -1,18 +1,26 @@
import {
AssetCore,
IAssetRepository,
IAssetUploadedJob,
IReverseGeocodingJob,
ISearchRepository,
JobName,
QueueName,
} from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra';
import { IReverseGeocodingJob, IAssetUploadedJob, QueueName, JobName, IAssetRepository } from '@app/domain';
import { Process, Processor } from '@nestjs/bull';
import { Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Job } from 'bull';
import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
import ffmpeg from 'fluent-ffmpeg';
import { getName } from 'i18n-iso-countries';
import geocoder, { InitOptions } from 'local-reverse-geocoder';
import fs from 'node:fs';
import path from 'path';
import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository';
import geocoder, { InitOptions } from 'local-reverse-geocoder';
import { getName } from 'i18n-iso-countries';
import fs from 'node:fs';
import { ExifDateTime, exiftool, Tags } from 'exiftool-vendored';
interface ImmichTags extends Tags {
ContentIdentifier?: string;
@ -71,13 +79,19 @@ export type GeoData = {
export class MetadataExtractionProcessor {
private logger = new Logger(MetadataExtractionProcessor.name);
private isGeocodeInitialized = false;
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(ISearchRepository) searchRepository: ISearchRepository,
@InjectRepository(ExifEntity)
private exifRepository: Repository<ExifEntity>,
configService: ConfigService,
) {
this.assetCore = new AssetCore(assetRepository, searchRepository);
if (!configService.get('DISABLE_REVERSE_GEOCODING')) {
this.logger.log('Initializing Reverse Geocoding');
geocoderInit({
@ -175,20 +189,11 @@ export class MetadataExtractionProcessor {
newExif.longitude = exifData?.GPSLongitude || null;
newExif.livePhotoCID = exifData?.MediaGroupUUID || null;
await this.assetRepository.save({
id: asset.id,
fileCreatedAt: fileCreatedAt?.toISOString(),
});
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {
const motionAsset = await this.assetRepository.findLivePhotoMatch(
newExif.livePhotoCID,
asset.id,
AssetType.VIDEO,
);
const motionAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.VIDEO);
if (motionAsset) {
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
await this.assetCore.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
await this.assetCore.save({ id: motionAsset.id, isVisible: false });
}
}
@ -226,6 +231,7 @@ export class MetadataExtractionProcessor {
}
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
await this.assetCore.save({ id: asset.id, fileCreatedAt: fileCreatedAt?.toISOString() });
} catch (error: any) {
this.logger.error(`Error extracting EXIF ${error}`, error?.stack);
}
@ -292,14 +298,10 @@ export class MetadataExtractionProcessor {
newExif.livePhotoCID = exifData?.ContentIdentifier || null;
if (newExif.livePhotoCID) {
const photoAsset = await this.assetRepository.findLivePhotoMatch(
newExif.livePhotoCID,
asset.id,
AssetType.IMAGE,
);
const photoAsset = await this.assetCore.findLivePhotoMatch(newExif.livePhotoCID, asset.id, AssetType.IMAGE);
if (photoAsset) {
await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
await this.assetRepository.save({ id: asset.id, isVisible: false });
await this.assetCore.save({ id: photoAsset.id, livePhotoVideoId: asset.id });
await this.assetCore.save({ id: asset.id, isVisible: false });
}
}
@ -355,7 +357,7 @@ export class MetadataExtractionProcessor {
}
await this.exifRepository.upsert(newExif, { conflictPaths: ['assetId'] });
await this.assetRepository.save({ id: asset.id, duration: durationString, fileCreatedAt });
await this.assetCore.save({ id: asset.id, duration: durationString, fileCreatedAt });
} catch (err) {
``;
// do nothing

View file

@ -544,6 +544,171 @@
]
}
},
"/search": {
"get": {
"operationId": "search",
"description": "",
"parameters": [
{
"name": "query",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"enum": [
"IMAGE",
"VIDEO",
"AUDIO",
"OTHER"
],
"type": "string"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "exifInfo.city",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.state",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.country",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.make",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "exifInfo.model",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "smartInfo.objects",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "smartInfo.tags",
"required": false,
"in": "query",
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchResponseDto"
}
}
}
}
},
"tags": [
"Search"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/search/config": {
"get": {
"operationId": "getSearchConfig",
"description": "",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SearchConfigResponseDto"
}
}
}
}
},
"tags": [
"Search"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"bearer": []
},
{
"cookie": []
}
]
}
},
"/share": {
"get": {
"operationId": "getAllSharedLinks",
@ -3554,13 +3719,6 @@
"url"
]
},
"SharedLinkType": {
"type": "string",
"enum": [
"ALBUM",
"INDIVIDUAL"
]
},
"AssetTypeEnum": {
"type": "string",
"enum": [
@ -3871,6 +4029,130 @@
"owner"
]
},
"SearchFacetCountResponseDto": {
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"value": {
"type": "string"
}
},
"required": [
"count",
"value"
]
},
"SearchFacetResponseDto": {
"type": "object",
"properties": {
"fieldName": {
"type": "string"
},
"counts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SearchFacetCountResponseDto"
}
}
},
"required": [
"fieldName",
"counts"
]
},
"SearchAlbumResponseDto": {
"type": "object",
"properties": {
"total": {
"type": "integer"
},
"count": {
"type": "integer"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AlbumResponseDto"
}
},
"facets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SearchFacetResponseDto"
}
}
},
"required": [
"total",
"count",
"items",
"facets"
]
},
"SearchAssetResponseDto": {
"type": "object",
"properties": {
"total": {
"type": "integer"
},
"count": {
"type": "integer"
},
"items": {
"type": "array",
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
}
},
"facets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SearchFacetResponseDto"
}
}
},
"required": [
"total",
"count",
"items",
"facets"
]
},
"SearchResponseDto": {
"type": "object",
"properties": {
"albums": {
"$ref": "#/components/schemas/SearchAlbumResponseDto"
},
"assets": {
"$ref": "#/components/schemas/SearchAssetResponseDto"
}
},
"required": [
"albums",
"assets"
]
},
"SearchConfigResponseDto": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
}
},
"required": [
"enabled"
]
},
"SharedLinkType": {
"type": "string",
"enum": [
"ALBUM",
"INDIVIDUAL"
]
},
"SharedLinkResponseDto": {
"type": "object",
"properties": {

View file

@ -16,6 +16,11 @@ export const immichAppConfig: ConfigModuleOptions = {
DB_PASSWORD: WHEN_DB_URL_SET,
DB_DATABASE_NAME: WHEN_DB_URL_SET,
DB_URL: Joi.string().optional(),
TYPESENSE_API_KEY: Joi.when('TYPESENSE_ENABLED', {
is: 'false',
then: Joi.string().optional(),
otherwise: Joi.string().required(),
}),
DISABLE_REVERSE_GEOCODING: Joi.boolean().optional().valid(true, false).default(false),
REVERSE_GEOCODING_PRECISION: Joi.number().optional().valid(0, 1, 2, 3).default(3),
LOG_LEVEL: Joi.string().optional().valid('simple', 'verbose', 'debug', 'log', 'warn', 'error').default('log'),

View file

@ -1,5 +1,9 @@
import { AlbumEntity } from '@app/infra/db/entities';
export const IAlbumRepository = 'IAlbumRepository';
export interface IAlbumRepository {
deleteAll(userId: string): Promise<void>;
getAll(): Promise<AlbumEntity[]>;
save(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
}

View file

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

View file

@ -1,10 +1,14 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
export interface AssetSearchOptions {
isVisible?: boolean;
}
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
deleteAll(ownerId: string): Promise<void>;
getAll(): Promise<AssetEntity[]>;
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null>;
}

View file

@ -1,19 +1,25 @@
import { AssetEntity, AssetType } from '@app/infra/db/entities';
import { newJobRepositoryMock } from '../../test';
import { AssetService } from '../asset';
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();
});
beforeEach(async () => {
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new AssetService(jobMock);
searchMock = newSearchRepositoryMock();
sut = new AssetService(assetMock, jobMock, searchMock);
});
describe(`handle asset upload`, () => {
@ -42,4 +48,15 @@ describe(AssetService.name, () => {
]);
});
});
describe('save', () => {
it('should save an asset', async () => {
assetMock.save.mockResolvedValue(assetEntityStub.image);
await sut.save(assetEntityStub.image);
expect(assetMock.save).toHaveBeenCalledWith(assetEntityStub.image);
expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
});
});
});

View file

@ -1,9 +1,20 @@
import { AssetType } from '@app/infra/db/entities';
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';
export class AssetService {
constructor(@Inject(IJobRepository) private jobRepository: IJobRepository) {}
private assetCore: AssetCore;
constructor(
@Inject(IAssetRepository) assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) searchRepository: ISearchRepository,
) {
this.assetCore = new AssetCore(assetRepository, searchRepository);
}
async handleAssetUpload(data: IAssetUploadedJob) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data });
@ -15,4 +26,8 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.EXIF_EXTRACTION, data });
}
}
save(asset: Partial<AssetEntity>) {
return this.assetCore.save(asset);
}
}

View file

@ -1,3 +1,4 @@
export * from './asset.core';
export * from './asset.repository';
export * from './asset.service';
export * from './response-dto';

View file

@ -5,6 +5,7 @@ import { AuthService } from './auth';
import { DeviceInfoService } from './device-info';
import { MediaService } from './media';
import { OAuthService } from './oauth';
import { SearchService } from './search';
import { ShareService } from './share';
import { SmartInfoService } from './smart-info';
import { StorageService } from './storage';
@ -25,6 +26,7 @@ const providers: Provider[] = [
SystemConfigService,
UserService,
ShareService,
SearchService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [SystemConfigService],

View file

@ -9,6 +9,7 @@ export * from './domain.module';
export * from './job';
export * from './media';
export * from './oauth';
export * from './search';
export * from './share';
export * from './smart-info';
export * from './storage';

View file

@ -5,6 +5,7 @@ export enum QueueName {
MACHINE_LEARNING = 'machine-learning-queue',
BACKGROUND_TASK = 'background-task',
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration-queue',
SEARCH = 'search-queue',
}
export enum JobName {
@ -22,4 +23,10 @@ export enum JobName {
OBJECT_DETECTION = 'detect-object',
IMAGE_TAGGING = 'tag-image',
DELETE_FILES = 'delete-files',
SEARCH_INDEX_ASSETS = 'search-index-assets',
SEARCH_INDEX_ASSET = 'search-index-asset',
SEARCH_INDEX_ALBUMS = 'search-index-albums',
SEARCH_INDEX_ALBUM = 'search-index-album',
SEARCH_REMOVE_ALBUM = 'search-remove-album',
SEARCH_REMOVE_ASSET = 'search-remove-asset',
}

View file

@ -1,4 +1,8 @@
import { AssetEntity, UserEntity } from '@app/infra/db/entities';
import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/db/entities';
export interface IAlbumJob {
album: AlbumEntity;
}
export interface IAssetJob {
asset: AssetEntity;
@ -9,6 +13,10 @@ export interface IAssetUploadedJob {
fileName: string;
}
export interface IDeleteJob {
id: string;
}
export interface IDeleteFilesJob {
files: Array<string | null | undefined>;
}

View file

@ -1,5 +1,13 @@
import { JobName, QueueName } from './job.constants';
import { IAssetJob, IAssetUploadedJob, IDeleteFilesJob, IReverseGeocodingJob, IUserDeletionJob } from './job.interface';
import {
IAlbumJob,
IAssetJob,
IAssetUploadedJob,
IDeleteFilesJob,
IDeleteJob,
IReverseGeocodingJob,
IUserDeletionJob,
} from './job.interface';
export interface JobCounts {
active: number;
@ -23,7 +31,13 @@ export type JobItem =
| { name: JobName.EXTRACT_VIDEO_METADATA; data: IAssetUploadedJob }
| { name: JobName.OBJECT_DETECTION; data: IAssetJob }
| { name: JobName.IMAGE_TAGGING; data: IAssetJob }
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob };
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
| { name: JobName.SEARCH_INDEX_ASSETS }
| { name: JobName.SEARCH_INDEX_ASSET; data: IAssetJob }
| { name: JobName.SEARCH_INDEX_ALBUMS }
| { name: JobName.SEARCH_INDEX_ALBUM; data: IAlbumJob }
| { name: JobName.SEARCH_REMOVE_ASSET; data: IDeleteJob }
| { name: JobName.SEARCH_REMOVE_ALBUM; data: IDeleteJob };
export const IJobRepository = 'IJobRepository';

View file

@ -0,0 +1 @@
export * from './search.dto';

View file

@ -0,0 +1,57 @@
import { AssetType } from '@app/infra/db/entities';
import { Transform } from 'class-transformer';
import { IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { toBoolean } from '../../../../../apps/immich/src/utils/transform.util';
export class SearchDto {
@IsString()
@IsNotEmpty()
@IsOptional()
query?: string;
@IsEnum(AssetType)
@IsOptional()
type?: AssetType;
@IsBoolean()
@IsOptional()
@Transform(toBoolean)
isFavorite?: boolean;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.city'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.state'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.country'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.make'?: string;
@IsString()
@IsNotEmpty()
@IsOptional()
'exifInfo.model'?: string;
@IsString({ each: true })
@IsArray()
@IsOptional()
@Transform(({ value }) => value.split(','))
'smartInfo.objects'?: string[];
@IsString({ each: true })
@IsArray()
@IsOptional()
@Transform(({ value }) => value.split(','))
'smartInfo.tags'?: string[];
}

View file

@ -0,0 +1,4 @@
export * from './dto';
export * from './response-dto';
export * from './search.repository';
export * from './search.service';

View file

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

View file

@ -0,0 +1,3 @@
export class SearchConfigResponseDto {
enabled!: boolean;
}

View file

@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { AlbumResponseDto } from '../../album';
import { AssetResponseDto } from '../../asset';
class SearchFacetCountResponseDto {
@ApiProperty({ type: 'integer' })
count!: number;
value!: string;
}
class SearchFacetResponseDto {
fieldName!: string;
counts!: SearchFacetCountResponseDto[];
}
class SearchAlbumResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
@ApiProperty({ type: 'integer' })
count!: number;
items!: AlbumResponseDto[];
facets!: SearchFacetResponseDto[];
}
class SearchAssetResponseDto {
@ApiProperty({ type: 'integer' })
total!: number;
@ApiProperty({ type: 'integer' })
count!: number;
items!: AssetResponseDto[];
facets!: SearchFacetResponseDto[];
}
export class SearchResponseDto {
albums!: SearchAlbumResponseDto;
assets!: SearchAssetResponseDto;
}

View file

@ -0,0 +1,60 @@
import { AlbumEntity, AssetEntity, AssetType } from '@app/infra/db/entities';
export enum SearchCollection {
ASSETS = 'assets',
ALBUMS = 'albums',
}
export interface SearchFilter {
id?: string;
userId: string;
type?: AssetType;
isFavorite?: boolean;
city?: string;
state?: string;
country?: string;
make?: string;
model?: string;
objects?: string[];
tags?: string[];
}
export interface SearchResult<T> {
/** total matches */
total: number;
/** collection size */
count: number;
/** current page */
page: number;
/** items for page */
items: T[];
facets: SearchFacet[];
}
export interface SearchFacet {
fieldName: string;
counts: Array<{
count: number;
value: string;
}>;
}
export type SearchCollectionIndexStatus = Record<SearchCollection, boolean>;
export const ISearchRepository = 'ISearchRepository';
export interface ISearchRepository {
setup(): Promise<void>;
checkMigrationStatus(): Promise<SearchCollectionIndexStatus>;
index(collection: SearchCollection.ASSETS, item: AssetEntity): Promise<void>;
index(collection: SearchCollection.ALBUMS, item: AlbumEntity): Promise<void>;
delete(collection: SearchCollection, id: string): Promise<void>;
import(collection: SearchCollection.ASSETS, items: AssetEntity[], done: boolean): Promise<void>;
import(collection: SearchCollection.ALBUMS, items: AlbumEntity[], done: boolean): Promise<void>;
search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise<SearchResult<AssetEntity>>;
search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise<SearchResult<AlbumEntity>>;
}

View file

@ -0,0 +1,317 @@
import { BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { plainToInstance } from 'class-transformer';
import {
albumStub,
assetEntityStub,
authStub,
newAlbumRepositoryMock,
newAssetRepositoryMock,
newJobRepositoryMock,
newSearchRepositoryMock,
} from '../../test';
import { IAlbumRepository } from '../album/album.repository';
import { IAssetRepository } from '../asset/asset.repository';
import { JobName } from '../job';
import { IJobRepository } from '../job/job.repository';
import { SearchDto } from './dto';
import { ISearchRepository } from './search.repository';
import { SearchService } from './search.service';
describe(SearchService.name, () => {
let sut: SearchService;
let albumMock: jest.Mocked<IAlbumRepository>;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let searchMock: jest.Mocked<ISearchRepository>;
let configMock: jest.Mocked<ConfigService>;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
searchMock = newSearchRepositoryMock();
configMock = { get: jest.fn() } as unknown as jest.Mocked<ConfigService>;
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('request dto', () => {
it('should convert smartInfo.tags to a string list', () => {
const instance = plainToInstance(SearchDto, { 'smartInfo.tags': 'a,b,c' });
expect(instance['smartInfo.tags']).toEqual(['a', 'b', 'c']);
});
it('should handle empty smartInfo.tags', () => {
const instance = plainToInstance(SearchDto, {});
expect(instance['smartInfo.tags']).toBeUndefined();
});
it('should convert smartInfo.objects to a string list', () => {
const instance = plainToInstance(SearchDto, { 'smartInfo.objects': 'a,b,c' });
expect(instance['smartInfo.objects']).toEqual(['a', 'b', 'c']);
});
it('should handle empty smartInfo.objects', () => {
const instance = plainToInstance(SearchDto, {});
expect(instance['smartInfo.objects']).toBeUndefined();
});
});
describe('isEnabled', () => {
it('should be enabled by default', () => {
expect(sut.isEnabled()).toBe(true);
});
it('should be disabled via an env variable', () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
expect(sut.isEnabled()).toBe(false);
});
});
describe('getConfig', () => {
it('should return the config', () => {
expect(sut.getConfig()).toEqual({ enabled: true });
});
it('should return the config when search is disabled', () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
expect(sut.getConfig()).toEqual({ enabled: false });
});
});
describe(`bootstrap`, () => {
it('should skip when search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.bootstrap();
expect(searchMock.setup).not.toHaveBeenCalled();
expect(searchMock.checkMigrationStatus).not.toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should skip schema migration if not needed', async () => {
searchMock.checkMigrationStatus.mockResolvedValue({ assets: false, albums: false });
await sut.bootstrap();
expect(searchMock.setup).toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should do schema migration if needed', async () => {
searchMock.checkMigrationStatus.mockResolvedValue({ assets: true, albums: true });
await sut.bootstrap();
expect(searchMock.setup).toHaveBeenCalled();
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_INDEX_ASSETS }],
[{ name: JobName.SEARCH_INDEX_ALBUMS }],
]);
});
});
describe('search', () => {
it('should throw an error is search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await expect(sut.search(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException);
expect(searchMock.search).not.toHaveBeenCalled();
});
it('should search assets and albums', async () => {
searchMock.search.mockResolvedValue({
total: 0,
count: 0,
page: 1,
items: [],
facets: [],
});
await expect(sut.search(authStub.admin, {})).resolves.toEqual({
albums: {
total: 0,
count: 0,
page: 1,
items: [],
facets: [],
},
assets: {
total: 0,
count: 0,
page: 1,
items: [],
facets: [],
},
});
expect(searchMock.search.mock.calls).toEqual([
['assets', '*', { userId: authStub.admin.id }],
['albums', '*', { userId: authStub.admin.id }],
]);
});
});
describe('handleIndexAssets', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAssets();
expect(searchMock.import).not.toHaveBeenCalled();
});
it('should index all the assets', async () => {
assetMock.getAll.mockResolvedValue([]);
await sut.handleIndexAssets();
expect(searchMock.import).toHaveBeenCalledWith('assets', [], true);
});
it('should log an error', async () => {
assetMock.getAll.mockResolvedValue([]);
searchMock.import.mockRejectedValue(new Error('import failed'));
await sut.handleIndexAssets();
});
});
describe('handleIndexAsset', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAsset({ asset: assetEntityStub.image });
expect(searchMock.index).not.toHaveBeenCalled();
});
it('should index the asset', async () => {
await sut.handleIndexAsset({ asset: assetEntityStub.image });
expect(searchMock.index).toHaveBeenCalledWith('assets', assetEntityStub.image);
});
it('should log an error', async () => {
searchMock.index.mockRejectedValue(new Error('index failed'));
await sut.handleIndexAsset({ asset: assetEntityStub.image });
expect(searchMock.index).toHaveBeenCalled();
});
});
describe('handleIndexAlbums', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAlbums();
expect(searchMock.import).not.toHaveBeenCalled();
});
it('should index all the albums', async () => {
albumMock.getAll.mockResolvedValue([]);
await sut.handleIndexAlbums();
expect(searchMock.import).toHaveBeenCalledWith('albums', [], true);
});
it('should log an error', async () => {
albumMock.getAll.mockResolvedValue([]);
searchMock.import.mockRejectedValue(new Error('import failed'));
await sut.handleIndexAlbums();
});
});
describe('handleIndexAlbum', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleIndexAlbum({ album: albumStub.empty });
expect(searchMock.index).not.toHaveBeenCalled();
});
it('should index the album', async () => {
await sut.handleIndexAlbum({ album: albumStub.empty });
expect(searchMock.index).toHaveBeenCalledWith('albums', albumStub.empty);
});
it('should log an error', async () => {
searchMock.index.mockRejectedValue(new Error('index failed'));
await sut.handleIndexAlbum({ album: albumStub.empty });
expect(searchMock.index).toHaveBeenCalled();
});
});
describe('handleRemoveAlbum', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleRemoveAlbum({ id: 'album1' });
expect(searchMock.delete).not.toHaveBeenCalled();
});
it('should remove the album', async () => {
await sut.handleRemoveAlbum({ id: 'album1' });
expect(searchMock.delete).toHaveBeenCalledWith('albums', 'album1');
});
it('should log an error', async () => {
searchMock.delete.mockRejectedValue(new Error('remove failed'));
await sut.handleRemoveAlbum({ id: 'album1' });
expect(searchMock.delete).toHaveBeenCalled();
});
});
describe('handleRemoveAsset', () => {
it('should skip if search is disabled', async () => {
configMock.get.mockReturnValue('false');
sut = new SearchService(albumMock, assetMock, jobMock, searchMock, configMock);
await sut.handleRemoveAsset({ id: 'asset1`' });
expect(searchMock.delete).not.toHaveBeenCalled();
});
it('should remove the asset', async () => {
await sut.handleRemoveAsset({ id: 'asset1' });
expect(searchMock.delete).toHaveBeenCalledWith('assets', 'asset1');
});
it('should log an error', async () => {
searchMock.delete.mockRejectedValue(new Error('remove failed'));
await sut.handleRemoveAsset({ id: 'asset1' });
expect(searchMock.delete).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,154 @@
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { IAlbumRepository } from '../album/album.repository';
import { IAssetRepository } from '../asset/asset.repository';
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';
@Injectable()
export class SearchService {
private logger = new Logger(SearchService.name);
private enabled: boolean;
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
configService: ConfigService,
) {
this.enabled = configService.get('TYPESENSE_ENABLED') !== 'false';
}
isEnabled() {
return this.enabled;
}
getConfig(): SearchConfigResponseDto {
return {
enabled: this.enabled,
};
}
async bootstrap() {
if (!this.enabled) {
return;
}
this.logger.log('Running bootstrap');
await this.searchRepository.setup();
const migrationStatus = await this.searchRepository.checkMigrationStatus();
if (migrationStatus[SearchCollection.ASSETS]) {
this.logger.debug('Queueing job to re-index all assets');
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSETS });
}
if (migrationStatus[SearchCollection.ALBUMS]) {
this.logger.debug('Queueing job to re-index all albums');
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUMS });
}
}
async search(authUser: AuthUserDto, dto: SearchDto): Promise<SearchResponseDto> {
if (!this.enabled) {
throw new BadRequestException('Search is disabled');
}
const query = dto.query || '*';
return {
assets: (await this.searchRepository.search(SearchCollection.ASSETS, query, {
userId: authUser.id,
...dto,
})) as any,
albums: (await this.searchRepository.search(SearchCollection.ALBUMS, query, {
userId: authUser.id,
...dto,
})) as any,
};
}
async handleIndexAssets() {
if (!this.enabled) {
return;
}
try {
this.logger.debug(`Running indexAssets`);
// TODO: do this in batches based on searchIndexVersion
const assets = await this.assetRepository.getAll({ isVisible: true });
this.logger.log(`Indexing ${assets.length} assets`);
await this.searchRepository.import(SearchCollection.ASSETS, assets, true);
} catch (error: any) {
this.logger.error(`Unable to index all assets`, error?.stack);
}
}
async handleIndexAsset(data: IAssetJob) {
if (!this.enabled) {
return;
}
const { asset } = data;
try {
await this.searchRepository.index(SearchCollection.ASSETS, asset);
} catch (error: any) {
this.logger.error(`Unable to index asset: ${asset.id}`, error?.stack);
}
}
async handleIndexAlbums() {
if (!this.enabled) {
return;
}
try {
const albums = await this.albumRepository.getAll();
this.logger.log(`Indexing ${albums.length} albums`);
await this.searchRepository.import(SearchCollection.ALBUMS, albums, true);
} catch (error: any) {
this.logger.error(`Unable to index all albums`, error?.stack);
}
}
async handleIndexAlbum(data: IAlbumJob) {
if (!this.enabled) {
return;
}
const { album } = data;
try {
await this.searchRepository.index(SearchCollection.ALBUMS, album);
} catch (error: any) {
this.logger.error(`Unable to index album: ${album.id}`, error?.stack);
}
}
async handleRemoveAlbum(data: IDeleteJob) {
await this.handleRemove(SearchCollection.ALBUMS, data);
}
async handleRemoveAsset(data: IDeleteJob) {
await this.handleRemove(SearchCollection.ASSETS, data);
}
private async handleRemove(collection: SearchCollection, data: IDeleteJob) {
if (!this.enabled) {
return;
}
const { id } = data;
try {
await this.searchRepository.delete(collection, id);
} catch (error: any) {
this.logger.error(`Unable to remove ${collection}: ${id}`, error?.stack);
}
}
}

View file

@ -3,5 +3,7 @@ import { IAlbumRepository } from '../src';
export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
return {
deleteAll: jest.fn(),
getAll: jest.fn(),
save: jest.fn(),
};
};

View file

@ -1,4 +1,5 @@
import {
AlbumEntity,
APIKeyEntity,
AssetEntity,
AssetType,
@ -155,6 +156,21 @@ export const assetEntityStub = {
} as AssetEntity),
};
export const albumStub = {
empty: Object.freeze<AlbumEntity>({
id: 'album-1',
albumName: 'Empty album',
ownerId: authStub.admin.id,
owner: userEntityStub.admin,
assets: [],
albumThumbnailAssetId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
sharedLinks: [],
sharedUsers: [],
}),
};
const assetInfo: ExifResponseDto = {
make: 'camera-make',
model: 'camera-model',

View file

@ -6,6 +6,7 @@ export * from './device-info.repository.mock';
export * from './fixtures';
export * from './job.repository.mock';
export * from './machine-learning.repository.mock';
export * from './search.repository.mock';
export * from './shared-link.repository.mock';
export * from './smart-info.repository.mock';
export * from './storage.repository.mock';

View file

@ -0,0 +1,12 @@
import { ISearchRepository } from '../src';
export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
return {
setup: jest.fn(),
checkMigrationStatus: jest.fn(),
index: jest.fn(),
import: jest.fn(),
search: jest.fn(),
delete: jest.fn(),
};
};

View file

@ -11,4 +11,13 @@ export class AlbumRepository implements IAlbumRepository {
async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ ownerId: userId });
}
getAll(): Promise<AlbumEntity[]> {
return this.repository.find();
}
async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({ where: { id } });
}
}

View file

@ -1,4 +1,4 @@
import { IAssetRepository } from '@app/domain';
import { AssetSearchOptions, IAssetRepository } from '@app/domain';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
@ -12,13 +12,32 @@ export class AssetRepository implements IAssetRepository {
await this.repository.delete({ ownerId });
}
async getAll(): Promise<AssetEntity[]> {
return this.repository.find({ relations: { exifInfo: true } });
getAll(options?: AssetSearchOptions | undefined): Promise<AssetEntity[]> {
options = options || {};
return this.repository.find({
where: {
isVisible: options.isVisible,
},
relations: {
exifInfo: true,
smartInfo: true,
tags: true,
},
});
}
async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
const { id } = await this.repository.save(asset);
return this.repository.findOneOrFail({ where: { id } });
return this.repository.findOneOrFail({
where: { id },
relations: {
exifInfo: true,
owner: true,
smartInfo: true,
tags: true,
},
});
}
findLivePhotoMatch(livePhotoCID: string, otherAssetId: string, type: AssetType): Promise<AssetEntity | null> {

View file

@ -8,6 +8,7 @@ import {
IKeyRepository,
IMachineLearningRepository,
IMediaRepository,
ISearchRepository,
ISharedLinkRepository,
ISmartInfoRepository,
IStorageRepository,
@ -45,6 +46,7 @@ import {
import { JobRepository } from './job';
import { MachineLearningRepository } from './machine-learning';
import { MediaRepository } from './media';
import { TypesenseRepository } from './search';
import { FilesystemProvider } from './storage';
const providers: Provider[] = [
@ -52,12 +54,12 @@ const providers: Provider[] = [
{ provide: IAssetRepository, useClass: AssetRepository },
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: ICryptoRepository, useClass: CryptoRepository },
{ provide: IDeviceInfoRepository, useClass: DeviceInfoRepository },
{ provide: IKeyRepository, useClass: APIKeyRepository },
{ provide: IJobRepository, useClass: JobRepository },
{ provide: IMachineLearningRepository, useClass: MachineLearningRepository },
{ provide: IMediaRepository, useClass: MediaRepository },
{ provide: ISearchRepository, useClass: TypesenseRepository },
{ provide: ISharedLinkRepository, useClass: SharedLinkRepository },
{ provide: ISmartInfoRepository, useClass: SmartInfoRepository },
{ provide: IStorageRepository, useClass: FilesystemProvider },

View file

@ -13,6 +13,7 @@ export class JobRepository implements IJobRepository {
@InjectQueue(QueueName.STORAGE_TEMPLATE_MIGRATION) private storageTemplateMigration: Queue,
@InjectQueue(QueueName.THUMBNAIL_GENERATION) private thumbnail: Queue,
@InjectQueue(QueueName.VIDEO_CONVERSION) private videoTranscode: Queue<IAssetJob>,
@InjectQueue(QueueName.SEARCH) private searchIndex: Queue,
) {}
async isActive(name: QueueName): Promise<boolean> {
@ -70,6 +71,18 @@ export class JobRepository implements IJobRepository {
await this.videoTranscode.add(item.name, item.data);
break;
case JobName.SEARCH_INDEX_ASSETS:
case JobName.SEARCH_INDEX_ALBUMS:
await this.searchIndex.add(item.name);
break;
case JobName.SEARCH_INDEX_ASSET:
case JobName.SEARCH_INDEX_ALBUM:
case JobName.SEARCH_REMOVE_ALBUM:
case JobName.SEARCH_REMOVE_ASSET:
await this.searchIndex.add(item.name, item.data);
break;
default:
// TODO inject remaining queues and map job to queue
this.logger.error('Invalid job', item);

View file

@ -0,0 +1 @@
export * from './typesense.repository';

View file

@ -0,0 +1,13 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const albumSchemaVersion = 1;
export const albumSchema: CollectionCreateSchema = {
name: `albums-v${albumSchemaVersion}`,
fields: [
{ name: 'ownerId', type: 'string', facet: false },
{ name: 'albumName', type: 'string', facet: false, sort: true },
{ name: 'createdAt', type: 'string', facet: false, sort: true },
{ name: 'updatedAt', type: 'string', facet: false, sort: true },
],
default_sorting_field: 'createdAt',
};

View file

@ -0,0 +1,37 @@
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
export const assetSchemaVersion = 1;
export const assetSchema: CollectionCreateSchema = {
name: `assets-v${assetSchemaVersion}`,
fields: [
// asset
{ name: 'ownerId', type: 'string', facet: false },
{ name: 'type', type: 'string', facet: true },
{ name: 'originalPath', type: 'string', facet: false },
{ name: 'createdAt', type: 'string', facet: false, sort: true },
{ name: 'updatedAt', type: 'string', facet: false, sort: true },
{ name: 'fileCreatedAt', type: 'string', facet: false, sort: true },
{ name: 'fileModifiedAt', type: 'string', facet: false, sort: true },
{ name: 'isFavorite', type: 'bool', facet: true },
// { name: 'checksum', type: 'string', facet: true },
// { name: 'tags', type: 'string[]', facet: true, optional: true },
// exif
{ name: 'exifInfo.city', type: 'string', facet: true, optional: true },
{ name: 'exifInfo.country', type: 'string', facet: true, optional: true },
{ 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 },
// smart info
{ name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true },
{ name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true },
],
token_separators: ['.'],
enable_nested_fields: true,
default_sorting_field: 'fileCreatedAt',
};

View file

@ -0,0 +1,325 @@
import {
ISearchRepository,
SearchCollection,
SearchCollectionIndexStatus,
SearchFilter,
SearchResult,
} from '@app/domain';
import { Injectable, Logger } from '@nestjs/common';
import _, { Dictionary } from 'lodash';
import { Client } from 'typesense';
import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections';
import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents';
import { AlbumEntity, AssetEntity } from '../db';
import { albumSchema } from './schemas/album.schema';
import { assetSchema } from './schemas/asset.schema';
interface GeoAssetEntity extends AssetEntity {
geo?: [number, number];
}
function removeNil<T extends Dictionary<any>>(item: T): Partial<T> {
_.forOwn(item, (value, key) => {
if (_.isNil(value) || (_.isObject(value) && !_.isDate(value) && _.isEmpty(removeNil(value)))) {
delete item[key];
}
});
return item;
}
const schemaMap: Record<SearchCollection, CollectionCreateSchema> = {
[SearchCollection.ASSETS]: assetSchema,
[SearchCollection.ALBUMS]: albumSchema,
};
const schemas = Object.entries(schemaMap) as [SearchCollection, CollectionCreateSchema][];
interface SearchUpdateQueue<T = any> {
upsert: T[];
delete: string[];
}
@Injectable()
export class TypesenseRepository implements ISearchRepository {
private logger = new Logger(TypesenseRepository.name);
private queue: Record<SearchCollection, SearchUpdateQueue> = {
[SearchCollection.ASSETS]: {
upsert: [],
delete: [],
},
[SearchCollection.ALBUMS]: {
upsert: [],
delete: [],
},
};
private _client: Client | null = null;
private get client(): Client {
if (!this._client) {
throw new Error('Typesense client not available (no apiKey was provided)');
}
return this._client;
}
constructor() {
const apiKey = process.env.TYPESENSE_API_KEY;
if (!apiKey) {
return;
}
this._client = new Client({
nodes: [
{
host: process.env.TYPESENSE_HOST || 'typesense',
port: Number(process.env.TYPESENSE_PORT) || 8108,
protocol: process.env.TYPESENSE_PROTOCOL || 'http',
},
],
apiKey,
numRetries: 3,
connectionTimeoutSeconds: 10,
});
setInterval(() => this.flush(), 5_000);
}
async setup(): Promise<void> {
// upsert collections
for (const [collectionName, schema] of schemas) {
const collection = await this.client
.collections(schema.name)
.retrieve()
.catch(() => null);
if (!collection) {
this.logger.log(`Creating schema: ${collectionName}/${schema.name}`);
await this.client.collections().create(schema);
} else {
this.logger.log(`Schema up to date: ${collectionName}/${schema.name}`);
}
}
}
async checkMigrationStatus(): Promise<SearchCollectionIndexStatus> {
const migrationMap: SearchCollectionIndexStatus = {
[SearchCollection.ASSETS]: false,
[SearchCollection.ALBUMS]: false,
};
// check if alias is using the current schema
const { aliases } = await this.client.aliases().retrieve();
this.logger.log(`Alias mapping: ${JSON.stringify(aliases)}`);
for (const [aliasName, schema] of schemas) {
const match = aliases.find((alias) => alias.name === aliasName);
if (!match || match.collection_name !== schema.name) {
migrationMap[aliasName] = true;
}
}
this.logger.log(`Collections needing migration: ${JSON.stringify(migrationMap)}`);
return migrationMap;
}
async index(collection: SearchCollection, item: AssetEntity | AlbumEntity, immediate?: boolean): Promise<void> {
const schema = schemaMap[collection];
if (collection === SearchCollection.ASSETS) {
item = this.patchAsset(item as AssetEntity);
}
if (immediate) {
await this.client.collections(schema.name).documents().upsert(item);
return;
}
this.queue[collection].upsert.push(item);
}
async delete(collection: SearchCollection, id: string, immediate?: boolean): Promise<void> {
const schema = schemaMap[collection];
if (immediate) {
await this.client.collections(schema.name).documents().delete(id);
return;
}
this.queue[collection].delete.push(id);
}
async import(collection: SearchCollection, items: AssetEntity[] | AlbumEntity[], done: boolean): Promise<void> {
try {
const schema = schemaMap[collection];
const _items = items.map((item) => {
if (collection === SearchCollection.ASSETS) {
item = this.patchAsset(item as AssetEntity);
}
// null values are invalid for typesense documents
return removeNil(item);
});
if (_items.length > 0) {
await this.client
.collections(schema.name)
.documents()
.import(_items, { action: 'upsert', dirty_values: 'coerce_or_drop' });
}
if (done) {
await this.updateAlias(collection);
}
} catch (error: any) {
this.handleError(error);
}
}
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) {
const alias = await this.client.aliases(collection).retrieve();
const { userId } = filters;
const _filters = [`ownerId:${userId}`];
if (filters.id) {
_filters.push(`id:=${filters.id}`);
}
if (collection === SearchCollection.ASSETS) {
for (const item of schemaMap[collection].fields || []) {
let value = filters[item.name as keyof SearchFilter];
if (Array.isArray(value)) {
value = `[${value.join(',')}]`;
}
if (item.facet && value !== undefined) {
_filters.push(`${item.name}:${value}`);
}
}
this.logger.debug(`Searching query='${query}', filters='${JSON.stringify(_filters)}'`);
const results = await this.client
.collections<AssetEntity>(alias.collection_name)
.documents()
.search({
q: query,
query_by: [
'exifInfo.imageName',
'exifInfo.country',
'exifInfo.state',
'exifInfo.city',
'exifInfo.description',
'smartInfo.tags',
'smartInfo.objects',
].join(','),
filter_by: _filters.join(' && '),
per_page: 250,
facet_by: (assetSchema.fields || [])
.filter((field) => field.facet)
.map((field) => field.name)
.join(','),
});
return this.asResponse(results);
}
if (collection === SearchCollection.ALBUMS) {
const results = await this.client
.collections<AlbumEntity>(alias.collection_name)
.documents()
.search({
q: query,
query_by: 'albumName',
filter_by: _filters.join(','),
});
return this.asResponse(results);
}
throw new Error(`Invalid collection: ${collection}`);
}
private asResponse<T extends DocumentSchema>(results: SearchResponse<T>): SearchResult<T> {
return {
page: results.page,
total: results.found,
count: results.out_of,
items: (results.hits || []).map((hit) => hit.document),
facets: (results.facet_counts || []).map((facet) => ({
counts: facet.counts.map((item) => ({ count: item.count, value: item.value })),
fieldName: facet.field_name as string,
})),
};
}
private async flush() {
for (const [collection, schema] of schemas) {
if (this.queue[collection].upsert.length > 0) {
try {
const items = this.queue[collection].upsert.map((item) => removeNil(item));
this.logger.debug(`Flushing ${items.length} ${collection} upserts to typesense`);
await this.client
.collections(schema.name)
.documents()
.import(items, { action: 'upsert', dirty_values: 'coerce_or_drop' });
this.queue[collection].upsert = [];
} catch (error) {
this.handleError(error);
}
}
if (this.queue[collection].delete.length > 0) {
try {
const items = this.queue[collection].delete;
this.logger.debug(`Flushing ${items.length} ${collection} deletes to typesense`);
await this.client
.collections(schema.name)
.documents()
.delete({ filter_by: `id: [${items.join(',')}]` });
this.queue[collection].delete = [];
} catch (error) {
this.handleError(error);
}
}
}
}
private handleError(error: any): never {
this.logger.error('Unable to index documents');
const results = error.importResults || [];
for (const result of results) {
try {
result.document = JSON.parse(result.document);
} catch {}
}
this.logger.verbose(JSON.stringify(results, null, 2));
throw error;
}
private async updateAlias(collection: SearchCollection) {
const schema = schemaMap[collection];
const alias = await this.client
.aliases(collection)
.retrieve()
.catch(() => null);
// update alias to current collection
this.logger.log(`Using new schema: ${alias?.collection_name || '(unset)'} => ${schema.name}`);
await this.client.aliases().upsert(collection, { collection_name: schema.name });
// delete previous collection
if (alias && alias.collection_name !== schema.name) {
this.logger.log(`Deleting old schema: ${alias.collection_name}`);
await this.client.collections(alias.collection_name).delete();
}
}
private patchAsset(asset: AssetEntity): GeoAssetEntity {
const lat = asset.exifInfo?.latitude;
const lng = asset.exifInfo?.longitude;
if (lat && lng && lat !== 0 && lng !== 0) {
return { ...asset, geo: [lat, lng] };
}
return asset;
}
}

View file

@ -6,9 +6,10 @@
"packages": {
"": {
"name": "immich",
"version": "1.49.0",
"version": "1.50.1",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
"@nestjs/bull": "^0.6.2",
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
@ -46,7 +47,8 @@
"rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3",
"sharp": "^0.28.0",
"typeorm": "^0.3.11"
"typeorm": "^0.3.11",
"typesense": "^1.5.2"
},
"bin": {
"immich": "bin/cli.sh"
@ -765,6 +767,17 @@
"@babel/core": "^7.0.0-0"
}
},
"node_modules/@babel/runtime": {
"version": "7.20.13",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz",
"integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==",
"dependencies": {
"regenerator-runtime": "^0.13.11"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz",
@ -8104,6 +8117,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/loglevel": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz",
"integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -9498,6 +9523,11 @@
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"node_modules/regexpp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@ -11106,6 +11136,18 @@
"node": ">=4.2.0"
}
},
"node_modules/typesense": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz",
"integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==",
"dependencies": {
"axios": "^0.26.0",
"loglevel": "^1.8.0"
},
"peerDependencies": {
"@babel/runtime": "^7.17.2"
}
},
"node_modules/uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
@ -12115,6 +12157,14 @@
"@babel/helper-plugin-utils": "^7.16.7"
}
},
"@babel/runtime": {
"version": "7.20.13",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.13.tgz",
"integrity": "sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA==",
"requires": {
"regenerator-runtime": "^0.13.11"
}
},
"@babel/template": {
"version": "7.16.7",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz",
@ -17808,6 +17858,11 @@
"is-unicode-supported": "^0.1.0"
}
},
"loglevel": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.8.1.tgz",
"integrity": "sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg=="
},
"lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
@ -18862,6 +18917,11 @@
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
},
"regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
"regexpp": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
@ -19962,6 +20022,15 @@
"integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==",
"devOptional": true
},
"typesense": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/typesense/-/typesense-1.5.2.tgz",
"integrity": "sha512-ysARFw+4z3AdSViOACqf7K9TXoP2wAXd5p5uSGTdXW14UYjcEzpV/S/EhMoiC6YdZyrnbDdNsxgWbf+AWJ9Udw==",
"requires": {
"axios": "^0.26.0",
"loglevel": "^1.8.0"
}
},
"uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",

View file

@ -39,6 +39,7 @@
"api:generate": "bash ./bin/generate-open-api.sh"
},
"dependencies": {
"@babel/runtime": "^7.20.13",
"@nestjs/bull": "^0.6.2",
"@nestjs/common": "^9.2.1",
"@nestjs/config": "^2.2.0",
@ -76,7 +77,8 @@
"rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3",
"sharp": "^0.28.0",
"typeorm": "^0.3.11"
"typeorm": "^0.3.11",
"typesense": "^1.5.2"
},
"devDependencies": {
"@nestjs/cli": "^9.1.8",

View file

@ -8,6 +8,7 @@ import {
DeviceInfoApi,
JobApi,
OAuthApi,
SearchApi,
ServerInfoApi,
ShareApi,
SystemConfigApi,
@ -21,6 +22,7 @@ export class ImmichApi {
public authenticationApi: AuthenticationApi;
public oauthApi: OAuthApi;
public deviceInfoApi: DeviceInfoApi;
public searchApi: SearchApi;
public serverInfoApi: ServerInfoApi;
public jobApi: JobApi;
public keyApi: APIKeyApi;
@ -41,6 +43,7 @@ export class ImmichApi {
this.serverInfoApi = new ServerInfoApi(this.config);
this.jobApi = new JobApi(this.config);
this.keyApi = new APIKeyApi(this.config);
this.searchApi = new SearchApi(this.config);
this.systemConfigApi = new SystemConfigApi(this.config);
this.shareApi = new ShareApi(this.config);
}

View file

@ -1451,6 +1451,37 @@ export interface RemoveAssetsDto {
*/
'assetIds': Array<string>;
}
/**
*
* @export
* @interface SearchAlbumResponseDto
*/
export interface SearchAlbumResponseDto {
/**
*
* @type {number}
* @memberof SearchAlbumResponseDto
*/
'total': number;
/**
*
* @type {number}
* @memberof SearchAlbumResponseDto
*/
'count': number;
/**
*
* @type {Array<AlbumResponseDto>}
* @memberof SearchAlbumResponseDto
*/
'items': Array<AlbumResponseDto>;
/**
*
* @type {Array<SearchFacetResponseDto>}
* @memberof SearchAlbumResponseDto
*/
'facets': Array<SearchFacetResponseDto>;
}
/**
*
* @export
@ -1464,6 +1495,107 @@ export interface SearchAssetDto {
*/
'searchTerm': string;
}
/**
*
* @export
* @interface SearchAssetResponseDto
*/
export interface SearchAssetResponseDto {
/**
*
* @type {number}
* @memberof SearchAssetResponseDto
*/
'total': number;
/**
*
* @type {number}
* @memberof SearchAssetResponseDto
*/
'count': number;
/**
*
* @type {Array<AssetResponseDto>}
* @memberof SearchAssetResponseDto
*/
'items': Array<AssetResponseDto>;
/**
*
* @type {Array<SearchFacetResponseDto>}
* @memberof SearchAssetResponseDto
*/
'facets': Array<SearchFacetResponseDto>;
}
/**
*
* @export
* @interface SearchConfigResponseDto
*/
export interface SearchConfigResponseDto {
/**
*
* @type {boolean}
* @memberof SearchConfigResponseDto
*/
'enabled': boolean;
}
/**
*
* @export
* @interface SearchFacetCountResponseDto
*/
export interface SearchFacetCountResponseDto {
/**
*
* @type {number}
* @memberof SearchFacetCountResponseDto
*/
'count': number;
/**
*
* @type {string}
* @memberof SearchFacetCountResponseDto
*/
'value': string;
}
/**
*
* @export
* @interface SearchFacetResponseDto
*/
export interface SearchFacetResponseDto {
/**
*
* @type {string}
* @memberof SearchFacetResponseDto
*/
'fieldName': string;
/**
*
* @type {Array<SearchFacetCountResponseDto>}
* @memberof SearchFacetResponseDto
*/
'counts': Array<SearchFacetCountResponseDto>;
}
/**
*
* @export
* @interface SearchResponseDto
*/
export interface SearchResponseDto {
/**
*
* @type {SearchAlbumResponseDto}
* @memberof SearchResponseDto
*/
'albums': SearchAlbumResponseDto;
/**
*
* @type {SearchAssetResponseDto}
* @memberof SearchResponseDto
*/
'assets': SearchAssetResponseDto;
}
/**
*
* @export
@ -6485,6 +6617,248 @@ export class OAuthApi extends BaseAPI {
}
/**
* SearchApi - axios parameter creator
* @export
*/
export const SearchApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSearchConfig: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/search/config`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
// authentication cookie required
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} [query]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/search`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
// authentication cookie required
if (query !== undefined) {
localVarQueryParameter['query'] = query;
}
if (type !== undefined) {
localVarQueryParameter['type'] = type;
}
if (isFavorite !== undefined) {
localVarQueryParameter['isFavorite'] = isFavorite;
}
if (exifInfoCity !== undefined) {
localVarQueryParameter['exifInfo.city'] = exifInfoCity;
}
if (exifInfoState !== undefined) {
localVarQueryParameter['exifInfo.state'] = exifInfoState;
}
if (exifInfoCountry !== undefined) {
localVarQueryParameter['exifInfo.country'] = exifInfoCountry;
}
if (exifInfoMake !== undefined) {
localVarQueryParameter['exifInfo.make'] = exifInfoMake;
}
if (exifInfoModel !== undefined) {
localVarQueryParameter['exifInfo.model'] = exifInfoModel;
}
if (smartInfoObjects) {
localVarQueryParameter['smartInfo.objects'] = smartInfoObjects;
}
if (smartInfoTags) {
localVarQueryParameter['smartInfo.tags'] = smartInfoTags;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* SearchApi - functional programming interface
* @export
*/
export const SearchApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getSearchConfig(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchConfigResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getSearchConfig(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} [query]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SearchResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* SearchApi - factory interface
* @export
*/
export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = SearchApiFp(configuration)
return {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSearchConfig(options?: any): AxiosPromise<SearchConfigResponseDto> {
return localVarFp.getSearchConfig(options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} [query]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: any): AxiosPromise<SearchResponseDto> {
return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath));
},
};
};
/**
* SearchApi - object-oriented interface
* @export
* @class SearchApi
* @extends {BaseAPI}
*/
export class SearchApi extends BaseAPI {
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public getSearchConfig(options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).getSearchConfig(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {string} [query]
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [isFavorite]
* @param {string} [exifInfoCity]
* @param {string} [exifInfoState]
* @param {string} [exifInfoCountry]
* @param {string} [exifInfoMake]
* @param {string} [exifInfoModel]
* @param {Array<string>} [smartInfoObjects]
* @param {Array<string>} [smartInfoTags]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SearchApi
*/
public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array<string>, smartInfoTags?: Array<string>, options?: AxiosRequestConfig) {
return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* ServerInfoApi - axios parameter creator
* @export

2
web/src/app.d.ts vendored
View file

@ -13,7 +13,7 @@ declare namespace App {
interface Error {
message: string;
stack?: string;
code?: string;
code?: string | number;
}
}

View file

@ -1,5 +1,5 @@
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { AxiosError } from 'axios';
import { AxiosError, AxiosResponse } from 'axios';
import { env } from '$env/dynamic/public';
import { ImmichApi } from './api/api';
@ -34,11 +34,24 @@ export const handle = (async ({ event, resolve }) => {
return res;
}) satisfies Handle;
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
export const handleError: HandleServerError = async ({ error }) => {
const httpError = error as AxiosError;
const response = httpError?.response as AxiosResponse<{
message: string;
statusCode: number;
error: string;
}>;
let code = response?.data?.statusCode || response?.status || httpError.code || '500';
if (response) {
code += ` - ${response.data?.error || response.statusText}`;
}
return {
message: httpError?.message || 'Hmm, not sure about that. Check the logs or open a ticket?',
stack: httpError?.stack,
code: httpError.code || '500'
message: response?.data?.message || httpError?.message || DEFAULT_MESSAGE,
code,
stack: httpError?.stack
};
};

View file

@ -22,7 +22,7 @@
$: {
if (assets.length < 6) {
thumbnailSize = Math.floor(viewWidth / assets.length - assets.length);
thumbnailSize = Math.min(320, Math.floor(viewWidth / assets.length - assets.length));
} else {
if (viewWidth > 600) thumbnailSize = Math.floor(viewWidth / 6 - 6);
else if (viewWidth > 400) thumbnailSize = Math.floor(viewWidth / 4 - 6);

View file

@ -11,6 +11,7 @@
import ImmichLogo from '../immich-logo.svelte';
export let user: UserResponseDto;
export let shouldShowUploadButton = true;
export let term = '';
let shouldShowAccountInfo = false;
@ -35,6 +36,10 @@
goto(data.redirectUri || '/auth/login?autoLaunch=0');
};
const onSearch = () => {
goto(`/search?q=${term}`);
};
</script>
<section
@ -52,12 +57,16 @@
IMMICH
</h1>
</a>
<div class="flex-1 ml-24">
<form class="flex-1 ml-24" autocomplete="off" on:submit|preventDefault={onSearch}>
<input
type="text"
name="search"
class="w-[50%] rounded-3xl bg-gray-200 dark:bg-immich-dark-gray px-8 py-4"
placeholder="Search - Coming soon"
placeholder="Search"
required
bind:value={term}
/>
</div>
</form>
<section class="flex gap-4 place-items-center">
<ThemeButton />

View file

@ -0,0 +1,26 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load = (async ({ locals, parent, url }) => {
const { user } = await parent();
if (!user) {
throw redirect(302, '/auth/login');
}
const term = url.searchParams.get('q') || undefined;
const { data: results } = await locals.api.searchApi.search(
term,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ params: url.searchParams }
);
return { user, term, results };
}) satisfies PageServerLoad;

View file

@ -0,0 +1,27 @@
<script lang="ts">
import { page } from '$app/stores';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte';
import type { PageData } from './$types';
export let data: PageData;
const term = $page.url.searchParams.get('q') || '';
</script>
<section>
<NavigationBar {term} user={data.user} shouldShowUploadButton={false} />
</section>
<section class="relative pt-[72px] h-screen bg-immich-bg dark:bg-immich-dark-bg">
<section class="overflow-y-auto relative immich-scrollbar">
<section
id="search-content"
class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg"
>
{#if data.results?.assets?.items}
<GalleryViewer assets={data.results.assets.items} />
{/if}
</section>
</section>
</section>

View file

@ -68,7 +68,7 @@
<div class="p-4 max-h-[75vh] min-h-[300px] overflow-y-auto immich-scrollbar pb-4 gap-4">
<div class="flex flex-col w-full gap-2">
<p class="text-red-500">{$page.error?.message} - {$page.error?.code}</p>
<p class="text-red-500">{$page.error?.message} ({$page.error?.code})</p>
{#if $page.error?.stack}
<label for="stacktrace">Stacktrace</label>
<pre id="stacktrace" class="text-xs">{$page.error?.stack || 'No stack'}</pre>