diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 7a37ef8886..f01b4603b8 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -66,6 +66,8 @@ doc/SearchApi.md doc/SearchAssetDto.md doc/SearchAssetResponseDto.md doc/SearchConfigResponseDto.md +doc/SearchExploreItem.md +doc/SearchExploreResponseDto.md doc/SearchFacetCountResponseDto.md doc/SearchFacetResponseDto.md doc/SearchResponseDto.md @@ -179,6 +181,8 @@ 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_explore_item.dart +lib/model/search_explore_response_dto.dart lib/model/search_facet_count_response_dto.dart lib/model/search_facet_response_dto.dart lib/model/search_response_dto.dart @@ -273,6 +277,8 @@ 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_explore_item_test.dart +test/search_explore_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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 4717cf9704..98eabb4abc 100644 Binary files a/mobile/openapi/README.md and b/mobile/openapi/README.md differ diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md index ebf8c884e6..8faafbfabe 100644 Binary files a/mobile/openapi/doc/SearchApi.md and b/mobile/openapi/doc/SearchApi.md differ diff --git a/mobile/openapi/doc/SearchExploreItem.md b/mobile/openapi/doc/SearchExploreItem.md new file mode 100644 index 0000000000..75eaabd8b1 Binary files /dev/null and b/mobile/openapi/doc/SearchExploreItem.md differ diff --git a/mobile/openapi/doc/SearchExploreResponseDto.md b/mobile/openapi/doc/SearchExploreResponseDto.md new file mode 100644 index 0000000000..0185b3651b Binary files /dev/null and b/mobile/openapi/doc/SearchExploreResponseDto.md differ diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 3f0c9efe45..d70ad04993 100644 Binary files a/mobile/openapi/lib/api.dart and b/mobile/openapi/lib/api.dart differ diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 652270ed9b..6e7560b311 100644 Binary files a/mobile/openapi/lib/api/search_api.dart and b/mobile/openapi/lib/api/search_api.dart differ diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 676e09dd6f..3b2399f23e 100644 Binary files a/mobile/openapi/lib/api_client.dart and b/mobile/openapi/lib/api_client.dart differ diff --git a/mobile/openapi/lib/model/search_explore_item.dart b/mobile/openapi/lib/model/search_explore_item.dart new file mode 100644 index 0000000000..f7529496c5 Binary files /dev/null and b/mobile/openapi/lib/model/search_explore_item.dart differ diff --git a/mobile/openapi/lib/model/search_explore_response_dto.dart b/mobile/openapi/lib/model/search_explore_response_dto.dart new file mode 100644 index 0000000000..812aceecf7 Binary files /dev/null and b/mobile/openapi/lib/model/search_explore_response_dto.dart differ diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart index 6286e048a2..8136969c91 100644 Binary files a/mobile/openapi/test/search_api_test.dart and b/mobile/openapi/test/search_api_test.dart differ diff --git a/mobile/openapi/test/search_explore_item_test.dart b/mobile/openapi/test/search_explore_item_test.dart new file mode 100644 index 0000000000..d4fae1dbff Binary files /dev/null and b/mobile/openapi/test/search_explore_item_test.dart differ diff --git a/mobile/openapi/test/search_explore_response_dto_test.dart b/mobile/openapi/test/search_explore_response_dto_test.dart new file mode 100644 index 0000000000..ccc82a0d75 Binary files /dev/null and b/mobile/openapi/test/search_explore_response_dto_test.dart differ diff --git a/server/apps/immich/src/controllers/search.controller.ts b/server/apps/immich/src/controllers/search.controller.ts index 7f67927cf4..2c2248c3fc 100644 --- a/server/apps/immich/src/controllers/search.controller.ts +++ b/server/apps/immich/src/controllers/search.controller.ts @@ -1,4 +1,11 @@ -import { AuthUserDto, SearchConfigResponseDto, SearchDto, SearchResponseDto, SearchService } from '@app/domain'; +import { + AuthUserDto, + SearchConfigResponseDto, + SearchDto, + SearchExploreResponseDto, + 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'; @@ -10,7 +17,6 @@ import { Authenticated } from '../decorators/authenticated.decorator'; export class SearchController { constructor(private readonly searchService: SearchService) {} - @Authenticated() @Get() async search( @GetAuthUser() authUser: AuthUserDto, @@ -19,9 +25,13 @@ export class SearchController { return this.searchService.search(authUser, dto); } - @Authenticated() @Get('config') getSearchConfig(): SearchConfigResponseDto { return this.searchService.getConfig(); } + + @Get('explore') + getExploreData(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.searchService.getExploreData(authUser) as Promise; + } } diff --git a/server/apps/microservices/src/processors/metadata-extraction.processor.ts b/server/apps/microservices/src/processors/metadata-extraction.processor.ts index 7fb1fc9da5..5bc8c4e786 100644 --- a/server/apps/microservices/src/processors/metadata-extraction.processor.ts +++ b/server/apps/microservices/src/processors/metadata-extraction.processor.ts @@ -2,8 +2,8 @@ import { AssetCore, IAssetRepository, IAssetUploadedJob, + IJobRepository, IReverseGeocodingJob, - ISearchRepository, JobName, QueueName, } from '@app/domain'; @@ -86,14 +86,14 @@ export class MetadataExtractionProcessor { constructor( @Inject(IAssetRepository) assetRepository: IAssetRepository, - @Inject(ISearchRepository) searchRepository: ISearchRepository, + @Inject(IJobRepository) jobRepository: IJobRepository, @InjectRepository(ExifEntity) private exifRepository: Repository, configService: ConfigService, ) { - this.assetCore = new AssetCore(assetRepository, searchRepository); + this.assetCore = new AssetCore(assetRepository, jobRepository); if (!configService.get('DISABLE_REVERSE_GEOCODING')) { this.logger.log('Initializing Reverse Geocoding'); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index fdf2ac31ca..2c21d6214a 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -640,6 +640,22 @@ "type": "string" } } + }, + { + "name": "recent", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "motion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -658,12 +674,6 @@ "Search" ], "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, { "bearer": [] }, @@ -699,7 +709,34 @@ }, { "cookie": [] - }, + } + ] + } + }, + "/search/explore": { + "get": { + "operationId": "getExploreData", + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchExploreResponseDto" + } + } + } + } + } + }, + "tags": [ + "Search" + ], + "security": [ { "bearer": [] }, @@ -4149,6 +4186,39 @@ "enabled" ] }, + "SearchExploreItem": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/AssetResponseDto" + } + }, + "required": [ + "value", + "data" + ] + }, + "SearchExploreResponseDto": { + "type": "object", + "properties": { + "fieldName": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SearchExploreItem" + } + } + }, + "required": [ + "fieldName", + "items" + ] + }, "SharedLinkType": { "type": "string", "enum": [ diff --git a/server/libs/domain/src/asset/asset.core.ts b/server/libs/domain/src/asset/asset.core.ts index e923f29d95..46b4231ff4 100644 --- a/server/libs/domain/src/asset/asset.core.ts +++ b/server/libs/domain/src/asset/asset.core.ts @@ -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) { - 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 { - return this.repository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); + return this.assetRepository.findLivePhotoMatch(livePhotoCID, otherAssetId, type); } } diff --git a/server/libs/domain/src/asset/asset.service.spec.ts b/server/libs/domain/src/asset/asset.service.spec.ts index bff4efa20c..536a0c148c 100644 --- a/server/libs/domain/src/asset/asset.service.spec.ts +++ b/server/libs/domain/src/asset/asset.service.spec.ts @@ -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; let jobMock: jest.Mocked; - let searchMock: jest.Mocked; 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 }, + }); }); }); }); diff --git a/server/libs/domain/src/asset/asset.service.ts b/server/libs/domain/src/asset/asset.service.ts index 06e8c7aa96..22d6b4dc48 100644 --- a/server/libs/domain/src/asset/asset.service.ts +++ b/server/libs/domain/src/asset/asset.service.ts @@ -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) { diff --git a/server/libs/domain/src/search/dto/search.dto.ts b/server/libs/domain/src/search/dto/search.dto.ts index c080ff5eac..1610e2e713 100644 --- a/server/libs/domain/src/search/dto/search.dto.ts +++ b/server/libs/domain/src/search/dto/search.dto.ts @@ -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; } diff --git a/server/libs/domain/src/search/response-dto/index.ts b/server/libs/domain/src/search/response-dto/index.ts index e55378686d..e74cc29b37 100644 --- a/server/libs/domain/src/search/response-dto/index.ts +++ b/server/libs/domain/src/search/response-dto/index.ts @@ -1,2 +1,3 @@ export * from './search-config-response.dto'; +export * from './search-explore.response.dto'; export * from './search-response.dto'; diff --git a/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts b/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts new file mode 100644 index 0000000000..37398d9dec --- /dev/null +++ b/server/libs/domain/src/search/response-dto/search-explore.response.dto.ts @@ -0,0 +1,11 @@ +import { AssetResponseDto } from '../../asset'; + +class SearchExploreItem { + value!: string; + data!: AssetResponseDto; +} + +export class SearchExploreResponseDto { + fieldName!: string; + items!: SearchExploreItem[]; +} diff --git a/server/libs/domain/src/search/search.repository.ts b/server/libs/domain/src/search/search.repository.ts index f288578502..4508b14514 100644 --- a/server/libs/domain/src/search/search.repository.ts +++ b/server/libs/domain/src/search/search.repository.ts @@ -17,6 +17,8 @@ export interface SearchFilter { model?: string; objects?: string[]; tags?: string[]; + recent?: boolean; + motion?: boolean; } export interface SearchResult { @@ -39,6 +41,14 @@ export interface SearchFacet { }>; } +export interface SearchExploreItem { + fieldName: string; + items: Array<{ + value: string; + data: T; + }>; +} + export type SearchCollectionIndexStatus = Record; export const ISearchRepository = 'ISearchRepository'; @@ -57,4 +67,6 @@ export interface ISearchRepository { search(collection: SearchCollection.ASSETS, query: string, filters: SearchFilter): Promise>; search(collection: SearchCollection.ALBUMS, query: string, filters: SearchFilter): Promise>; + + explore(userId: string): Promise[]>; } diff --git a/server/libs/domain/src/search/search.service.ts b/server/libs/domain/src/search/search.service.ts index 322644167b..f350e19b45 100644 --- a/server/libs/domain/src/search/search.service.ts +++ b/server/libs/domain/src/search/search.service.ts @@ -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[]> { + this.assertEnabled(); + return this.searchRepository.explore(authUser.id); + } + async search(authUser: AuthUserDto, dto: SearchDto): Promise { - 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'); + } + } } diff --git a/server/libs/domain/test/search.repository.mock.ts b/server/libs/domain/test/search.repository.mock.ts index b1918f3933..0ba2dd4f9c 100644 --- a/server/libs/domain/test/search.repository.mock.ts +++ b/server/libs/domain/test/search.repository.mock.ts @@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked => { import: jest.fn(), search: jest.fn(), delete: jest.fn(), + explore: jest.fn(), }; }; diff --git a/server/libs/infra/src/search/schemas/asset.schema.ts b/server/libs/infra/src/search/schemas/asset.schema.ts index 962f4e9b2a..d379048c97 100644 --- a/server/libs/infra/src/search/schemas/asset.schema.ts +++ b/server/libs/infra/src/search/schemas/asset.schema.ts @@ -1,6 +1,6 @@ import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; -export const assetSchemaVersion = 1; +export const assetSchemaVersion = 2; export const assetSchema: CollectionCreateSchema = { name: `assets-v${assetSchemaVersion}`, fields: [ @@ -22,7 +22,6 @@ export const assetSchema: CollectionCreateSchema = { { name: 'exifInfo.state', type: 'string', facet: true, optional: true }, { name: 'exifInfo.description', type: 'string', facet: false, optional: true }, { name: 'exifInfo.imageName', type: 'string', facet: false, optional: true }, - { name: 'geo', type: 'geopoint', facet: false, optional: true }, { name: 'exifInfo.make', type: 'string', facet: true, optional: true }, { name: 'exifInfo.model', type: 'string', facet: true, optional: true }, { name: 'exifInfo.orientation', type: 'string', optional: true }, @@ -30,6 +29,10 @@ export const assetSchema: CollectionCreateSchema = { // smart info { name: 'smartInfo.objects', type: 'string[]', facet: true, optional: true }, { name: 'smartInfo.tags', type: 'string[]', facet: true, optional: true }, + + // computed + { name: 'geo', type: 'geopoint', facet: false, optional: true }, + { name: 'motion', type: 'bool', facet: true }, ], token_separators: ['.'], enable_nested_fields: true, diff --git a/server/libs/infra/src/search/typesense.repository.ts b/server/libs/infra/src/search/typesense.repository.ts index b24da06546..a656d4b24e 100644 --- a/server/libs/infra/src/search/typesense.repository.ts +++ b/server/libs/infra/src/search/typesense.repository.ts @@ -2,11 +2,13 @@ import { ISearchRepository, SearchCollection, SearchCollectionIndexStatus, + SearchExploreItem, SearchFilter, SearchResult, } from '@app/domain'; import { Injectable, Logger } from '@nestjs/common'; import _, { Dictionary } from 'lodash'; +import { filter, firstValueFrom, from, map, mergeMap, toArray } from 'rxjs'; import { Client } from 'typesense'; import { CollectionCreateSchema } from 'typesense/lib/Typesense/Collections'; import { DocumentSchema, SearchResponse } from 'typesense/lib/Typesense/Documents'; @@ -14,8 +16,9 @@ import { AlbumEntity, AssetEntity } from '../db'; import { albumSchema } from './schemas/album.schema'; import { assetSchema } from './schemas/asset.schema'; -interface GeoAssetEntity extends AssetEntity { +interface CustomAssetEntity extends AssetEntity { geo?: [number, number]; + motion?: boolean; } function removeNil>(item: T): Partial { @@ -85,6 +88,12 @@ export class TypesenseRepository implements ISearchRepository { } async setup(): Promise { + const collections = await this.client.collections().retrieve(); + for (const collection of collections) { + this.logger.debug(`${collection.name} => ${collection.num_documents}`); + // await this.client.collections(collection.name).delete(); + } + // upsert collections for (const [collectionName, schema] of schemas) { const collection = await this.client @@ -172,6 +181,59 @@ export class TypesenseRepository implements ISearchRepository { } } + async explore(userId: string): Promise[]> { + const alias = await this.client.aliases(SearchCollection.ASSETS).retrieve(); + + const common = { + q: '*', + filter_by: `ownerId:${userId}`, + per_page: 100, + }; + + const asset$ = this.client.collections(alias.collection_name).documents(); + + const { facet_counts: facets } = await asset$.search({ + ...common, + query_by: 'exifInfo.imageName', + facet_by: this.getFacetFieldNames(SearchCollection.ASSETS), + max_facet_values: 50, + }); + + return firstValueFrom( + from(facets || []).pipe( + mergeMap( + (facet) => + from(facet.counts).pipe( + mergeMap( + (count) => + from( + asset$.search({ + ...common, + query_by: 'exifInfo.imageName', + filter_by: `${facet.field_name}:${count.value}`, + }), + ).pipe( + map((result) => ({ + value: count.value, + data: result.hits?.[0]?.document as AssetEntity, + })), + filter((item) => !!item.data), + ), + 5, + ), + toArray(), + map((items) => ({ + fieldName: facet.field_name as string, + items, + })), + ), + 3, + ), + toArray(), + ), + ); + } + search(collection: SearchCollection.ASSETS, query: string, filter: SearchFilter): Promise>; search(collection: SearchCollection.ALBUMS, query: string, filter: SearchFilter): Promise>; async search(collection: SearchCollection, query: string, filters: SearchFilter) { @@ -213,10 +275,8 @@ export class TypesenseRepository implements ISearchRepository { ].join(','), filter_by: _filters.join(' && '), per_page: 250, - facet_by: (assetSchema.fields || []) - .filter((field) => field.facet) - .map((field) => field.name) - .join(','), + sort_by: filters.recent ? 'createdAt:desc' : undefined, + facet_by: this.getFacetFieldNames(SearchCollection.ASSETS), }); return this.asResponse(results); @@ -313,13 +373,24 @@ export class TypesenseRepository implements ISearchRepository { } } - private patchAsset(asset: AssetEntity): GeoAssetEntity { + private patchAsset(asset: AssetEntity): CustomAssetEntity { + let custom = asset as CustomAssetEntity; + const lat = asset.exifInfo?.latitude; const lng = asset.exifInfo?.longitude; if (lat && lng && lat !== 0 && lng !== 0) { - return { ...asset, geo: [lat, lng] }; + custom = { ...custom, geo: [lat, lng] }; } - return asset; + custom = { ...custom, motion: !!asset.livePhotoVideoId }; + + return custom; + } + + private getFacetFieldNames(collection: SearchCollection) { + return (schemaMap[collection].fields || []) + .filter((field) => field.facet) + .map((field) => field.name) + .join(','); } } diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 586edb6df6..69a66a5679 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -1539,6 +1539,44 @@ export interface SearchConfigResponseDto { */ 'enabled': boolean; } +/** + * + * @export + * @interface SearchExploreItem + */ +export interface SearchExploreItem { + /** + * + * @type {string} + * @memberof SearchExploreItem + */ + 'value': string; + /** + * + * @type {AssetResponseDto} + * @memberof SearchExploreItem + */ + 'data': AssetResponseDto; +} +/** + * + * @export + * @interface SearchExploreResponseDto + */ +export interface SearchExploreResponseDto { + /** + * + * @type {string} + * @memberof SearchExploreResponseDto + */ + 'fieldName': string; + /** + * + * @type {Array} + * @memberof SearchExploreResponseDto + */ + 'items': Array; +} /** * * @export @@ -6629,6 +6667,41 @@ export class OAuthApi extends BaseAPI { */ export const SearchApiAxiosParamCreator = function (configuration?: Configuration) { return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExploreData: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/search/explore`; + // 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 {*} [options] Override http request option. @@ -6676,10 +6749,12 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @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, smartInfoTags?: Array, options: AxiosRequestConfig = {}): Promise => { + search: async (query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => { const localVarPath = `/search`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -6738,6 +6813,14 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio localVarQueryParameter['smartInfo.tags'] = smartInfoTags; } + if (recent !== undefined) { + localVarQueryParameter['recent'] = recent; + } + + if (motion !== undefined) { + localVarQueryParameter['motion'] = motion; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -6759,6 +6842,15 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio export const SearchApiFp = function(configuration?: Configuration) { const localVarAxiosParamCreator = SearchApiAxiosParamCreator(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getExploreData(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getExploreData(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {*} [options] Override http request option. @@ -6780,11 +6872,13 @@ export const SearchApiFp = function(configuration?: Configuration) { * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @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, smartInfoTags?: Array, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options); + async search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, } @@ -6797,6 +6891,14 @@ export const SearchApiFp = function(configuration?: Configuration) { export const SearchApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { const localVarFp = SearchApiFp(configuration) return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getExploreData(options?: any): AxiosPromise> { + return localVarFp.getExploreData(options).then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -6817,11 +6919,13 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @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, smartInfoTags?: Array, options?: any): AxiosPromise { - return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, options).then((request) => request(axios, basePath)); + search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: any): AxiosPromise { + return localVarFp.search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(axios, basePath)); }, }; }; @@ -6833,6 +6937,16 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat * @extends {BaseAPI} */ export class SearchApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SearchApi + */ + public getExploreData(options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).getExploreData(options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {*} [options] Override http request option. @@ -6855,12 +6969,14 @@ export class SearchApi extends BaseAPI { * @param {string} [exifInfoModel] * @param {Array} [smartInfoObjects] * @param {Array} [smartInfoTags] + * @param {boolean} [recent] + * @param {boolean} [motion] * @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, smartInfoTags?: Array, 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)); + public search(query?: string, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', isFavorite?: boolean, exifInfoCity?: string, exifInfoState?: string, exifInfoCountry?: string, exifInfoMake?: string, exifInfoModel?: string, smartInfoObjects?: Array, smartInfoTags?: Array, recent?: boolean, motion?: boolean, options?: AxiosRequestConfig) { + return SearchApiFp(this.configuration).search(query, type, isFavorite, exifInfoCity, exifInfoState, exifInfoCountry, exifInfoMake, exifInfoModel, smartInfoObjects, smartInfoTags, recent, motion, options).then((request) => request(this.axios, this.basePath)); } } diff --git a/web/src/lib/components/shared-components/immich-thumbnail.svelte b/web/src/lib/components/shared-components/immich-thumbnail.svelte index f2cb9b4ea5..5ef9ab6270 100644 --- a/web/src/lib/components/shared-components/immich-thumbnail.svelte +++ b/web/src/lib/components/shared-components/immich-thumbnail.svelte @@ -19,6 +19,7 @@ export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let selected = false; export let disabled = false; + export let readonly = false; export let publicSharedKey = ''; export let isRoundedCorner = false; @@ -56,6 +57,7 @@ }; const parseVideoDuration = (duration: string) => { + duration = duration || '0:00:00.00000'; const timePart = duration.split(':'); const hours = timePart[0]; const minutes = timePart[1]; @@ -118,7 +120,7 @@ } else if (disabled) { return 'border-[20px] border-gray-300'; } else if (isRoundedCorner) { - return 'rounded-[20px]'; + return 'rounded-lg'; } else { return ''; } @@ -157,7 +159,7 @@ on:click={thumbnailClickedHandler} on:keydown={thumbnailClickedHandler} > - {#if mouseOver || selected || disabled} + {#if (mouseOver || selected || disabled) && !readonly}
+ + + { + const { user } = await parent(); + if (!user) { + throw redirect(302, '/auth/login'); + } + + const { data: items } = await locals.api.searchApi.getExploreData(); + + return { user, items }; +}) satisfies PageServerLoad; diff --git a/web/src/routes/(user)/explore/+page.svelte b/web/src/routes/(user)/explore/+page.svelte new file mode 100644 index 0000000000..c9cf4a48b9 --- /dev/null +++ b/web/src/routes/(user)/explore/+page.svelte @@ -0,0 +1,173 @@ + + +
+ +
+ +
+ + +
+
+ +
+
+

Explore

+
+
+ +
+
+
+ +
+ {#if places.length > 0} + + {/if} + + {#if things.length > 0} +
+
+

Things

+
+
+ {#each things as item} + +
+ +
+ + {item.value} + +
+ {/each} +
+
+ {/if} + +
+ +
+ +
+

CATEGORIES

+ +
+
+
+
+
+
diff --git a/web/src/routes/(user)/search/+page.server.ts b/web/src/routes/(user)/search/+page.server.ts index 26eefac329..1abb5294dc 100644 --- a/web/src/routes/(user)/search/+page.server.ts +++ b/web/src/routes/(user)/search/+page.server.ts @@ -8,7 +8,6 @@ export const load = (async ({ locals, parent, url }) => { } const term = url.searchParams.get('q') || undefined; - const { data: results } = await locals.api.searchApi.search( term, undefined, @@ -20,6 +19,8 @@ export const load = (async ({ locals, parent, url }) => { undefined, undefined, undefined, + undefined, + undefined, { params: url.searchParams } ); return { user, term, results }; diff --git a/web/src/routes/(user)/search/+page.svelte b/web/src/routes/(user)/search/+page.svelte index 6bcf8f9568..8f38109516 100644 --- a/web/src/routes/(user)/search/+page.svelte +++ b/web/src/routes/(user)/search/+page.svelte @@ -1,16 +1,34 @@
- + goto(goBackRoute)} backIcon={ArrowLeft}> + +

+ Search + {#if term} + - {term} + {/if} +

+
+
@@ -19,8 +37,16 @@ id="search-content" class="relative pt-8 pl-4 mb-12 bg-immich-bg dark:bg-immich-dark-bg" > - {#if data.results?.assets?.items} + {#if data.results?.assets?.items.length != 0} + {:else} +
+
+ +

No results

+

Try a synonym or more general keyword

+
+
{/if}