diff --git a/docs/docs/features/search.md b/docs/docs/features/search.md
index 2c5fa03a5..0805007e5 100644
--- a/docs/docs/features/search.md
+++ b/docs/docs/features/search.md
@@ -6,6 +6,8 @@ Smart search is powered by the [pgvecto.rs](https://github.com/tensorchord/pgvec
Metadata search (prefixed with `m:`) can search specifically by text without the use of a model.
+Archived photos are not included in search results by default. To include them, add the query parameter `withArchived=true` to the url.
+
Some search examples:
diff --git a/mobile/openapi/doc/SearchApi.md b/mobile/openapi/doc/SearchApi.md
index d70cfd75e..524ce03f5 100644
--- a/mobile/openapi/doc/SearchApi.md
+++ b/mobile/openapi/doc/SearchApi.md
@@ -66,7 +66,7 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **search**
-> SearchResponseDto search(q, query, clip, type, recent, motion)
+> SearchResponseDto search(q, query, clip, type, recent, motion, withArchived)
@@ -95,9 +95,10 @@ final clip = true; // bool |
final type = type_example; // String |
final recent = true; // bool |
final motion = true; // bool |
+final withArchived = true; // bool |
try {
- final result = api_instance.search(q, query, clip, type, recent, motion);
+ final result = api_instance.search(q, query, clip, type, recent, motion, withArchived);
print(result);
} catch (e) {
print('Exception when calling SearchApi->search: $e\n');
@@ -114,6 +115,7 @@ Name | Type | Description | Notes
**type** | **String**| | [optional]
**recent** | **bool**| | [optional]
**motion** | **bool**| | [optional]
+ **withArchived** | **bool**| | [optional]
### Return type
diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart
index c1b6a51f8..5dd8dc0c7 100644
--- a/mobile/openapi/lib/api/search_api.dart
+++ b/mobile/openapi/lib/api/search_api.dart
@@ -74,7 +74,9 @@ class SearchApi {
/// * [bool] recent:
///
/// * [bool] motion:
- Future searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async {
+ ///
+ /// * [bool] withArchived:
+ Future searchWithHttpInfo({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, bool? withArchived, }) async {
// ignore: prefer_const_declarations
final path = r'/search';
@@ -103,6 +105,9 @@ class SearchApi {
if (motion != null) {
queryParams.addAll(_queryParams('', 'motion', motion));
}
+ if (withArchived != null) {
+ queryParams.addAll(_queryParams('', 'withArchived', withArchived));
+ }
const contentTypes = [];
@@ -131,8 +136,10 @@ class SearchApi {
/// * [bool] recent:
///
/// * [bool] motion:
- Future search({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, }) async {
- final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, recent: recent, motion: motion, );
+ ///
+ /// * [bool] withArchived:
+ Future search({ String? q, String? query, bool? clip, String? type, bool? recent, bool? motion, bool? withArchived, }) async {
+ final response = await searchWithHttpInfo( q: q, query: query, clip: clip, type: type, recent: recent, motion: motion, withArchived: withArchived, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
diff --git a/mobile/openapi/test/search_api_test.dart b/mobile/openapi/test/search_api_test.dart
index 09788ee44..edbbfa45b 100644
--- a/mobile/openapi/test/search_api_test.dart
+++ b/mobile/openapi/test/search_api_test.dart
@@ -22,7 +22,7 @@ void main() {
// TODO
});
- //Future search({ String q, String query, bool clip, String type, bool recent, bool motion }) async
+ //Future search({ String q, String query, bool clip, String type, bool recent, bool motion, bool withArchived }) async
test('test search', () async {
// TODO
});
diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json
index 61f2d26c9..9ed206cfd 100644
--- a/open-api/immich-openapi-specs.json
+++ b/open-api/immich-openapi-specs.json
@@ -4576,6 +4576,14 @@
"schema": {
"type": "boolean"
}
+ },
+ {
+ "name": "withArchived",
+ "required": false,
+ "in": "query",
+ "schema": {
+ "type": "boolean"
+ }
}
],
"responses": {
diff --git a/open-api/typescript-sdk/client/api.ts b/open-api/typescript-sdk/client/api.ts
index f6e1e9532..9cb8d4ffb 100644
--- a/open-api/typescript-sdk/client/api.ts
+++ b/open-api/typescript-sdk/client/api.ts
@@ -14638,10 +14638,11 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [recent]
* @param {boolean} [motion]
+ * @param {boolean} [withArchived]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
- search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options: AxiosRequestConfig = {}): Promise => {
+ search: async (q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, withArchived?: 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);
@@ -14687,6 +14688,10 @@ export const SearchApiAxiosParamCreator = function (configuration?: Configuratio
localVarQueryParameter['motion'] = motion;
}
+ if (withArchived !== undefined) {
+ localVarQueryParameter['withArchived'] = withArchived;
+ }
+
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -14775,11 +14780,12 @@ export const SearchApiFp = function(configuration?: Configuration) {
* @param {'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER'} [type]
* @param {boolean} [recent]
* @param {boolean} [motion]
+ * @param {boolean} [withArchived]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
- async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
- const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, recent, motion, options);
+ async search(q?: string, query?: string, clip?: boolean, type?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER', recent?: boolean, motion?: boolean, withArchived?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {
+ const localVarAxiosArgs = await localVarAxiosParamCreator.search(q, query, clip, type, recent, motion, withArchived, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -14818,7 +14824,7 @@ export const SearchApiFactory = function (configuration?: Configuration, basePat
* @throws {RequiredError}
*/
search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig): AxiosPromise {
- return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(axios, basePath));
+ return localVarFp.search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, requestParameters.withArchived, options).then((request) => request(axios, basePath));
},
/**
*
@@ -14879,6 +14885,13 @@ export interface SearchApiSearchRequest {
* @memberof SearchApiSearch
*/
readonly motion?: boolean
+
+ /**
+ *
+ * @type {boolean}
+ * @memberof SearchApiSearch
+ */
+ readonly withArchived?: boolean
}
/**
@@ -14927,7 +14940,7 @@ export class SearchApi extends BaseAPI {
* @memberof SearchApi
*/
public search(requestParameters: SearchApiSearchRequest = {}, options?: AxiosRequestConfig) {
- return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, options).then((request) => request(this.axios, this.basePath));
+ return SearchApiFp(this.configuration).search(requestParameters.q, requestParameters.query, requestParameters.clip, requestParameters.type, requestParameters.recent, requestParameters.motion, requestParameters.withArchived, options).then((request) => request(this.axios, this.basePath));
}
/**
diff --git a/server/src/domain/repositories/smart-info.repository.ts b/server/src/domain/repositories/smart-info.repository.ts
index c35ec1d84..81c68e049 100644
--- a/server/src/domain/repositories/smart-info.repository.ts
+++ b/server/src/domain/repositories/smart-info.repository.ts
@@ -9,6 +9,7 @@ export interface EmbeddingSearch {
embedding: Embedding;
numResults: number;
maxDistance?: number;
+ withArchived?: boolean;
}
export interface ISmartInfoRepository {
diff --git a/server/src/domain/search/dto/search.dto.ts b/server/src/domain/search/dto/search.dto.ts
index 3a77bd4b7..acfb91f0c 100644
--- a/server/src/domain/search/dto/search.dto.ts
+++ b/server/src/domain/search/dto/search.dto.ts
@@ -32,6 +32,11 @@ export class SearchDto {
@Optional()
@Transform(toBoolean)
motion?: boolean;
+
+ @IsBoolean()
+ @Optional()
+ @Transform(toBoolean)
+ withArchived?: boolean;
}
export class SearchPeopleDto {
diff --git a/server/src/domain/search/search.service.spec.ts b/server/src/domain/search/search.service.spec.ts
index 9541d8f1d..7b182b9d3 100644
--- a/server/src/domain/search/search.service.spec.ts
+++ b/server/src/domain/search/search.service.spec.ts
@@ -114,6 +114,39 @@ describe(SearchService.name, () => {
expect(smartInfoMock.searchCLIP).not.toHaveBeenCalled();
});
+ it('should search archived photos if `withArchived` option is true', async () => {
+ const dto: SearchDto = { q: 'test query', clip: true, withArchived: true };
+ const embedding = [1, 2, 3];
+ smartInfoMock.searchCLIP.mockResolvedValueOnce([assetStub.image]);
+ machineMock.encodeText.mockResolvedValueOnce(embedding);
+ partnerMock.getAll.mockResolvedValueOnce([]);
+ const expectedResponse = {
+ albums: {
+ total: 0,
+ count: 0,
+ items: [],
+ facets: [],
+ },
+ assets: {
+ total: 1,
+ count: 1,
+ items: [mapAsset(assetStub.image)],
+ facets: [],
+ },
+ };
+
+ const result = await sut.search(authStub.user1, dto);
+
+ expect(result).toEqual(expectedResponse);
+ expect(smartInfoMock.searchCLIP).toHaveBeenCalledWith({
+ userIds: [authStub.user1.user.id],
+ embedding,
+ numResults: 100,
+ withArchived: true,
+ });
+ expect(assetMock.searchMetadata).not.toHaveBeenCalled();
+ });
+
it('should search by CLIP if `clip` option is true', async () => {
const dto: SearchDto = { q: 'test query', clip: true };
const embedding = [1, 2, 3];
@@ -142,6 +175,7 @@ describe(SearchService.name, () => {
userIds: [authStub.user1.user.id],
embedding,
numResults: 100,
+ withArchived: false,
});
expect(assetMock.searchMetadata).not.toHaveBeenCalled();
});
diff --git a/server/src/domain/search/search.service.ts b/server/src/domain/search/search.service.ts
index ef5a42fe4..2bd65daab 100644
--- a/server/src/domain/search/search.service.ts
+++ b/server/src/domain/search/search.service.ts
@@ -67,6 +67,7 @@ export class SearchService {
}
const strategy = dto.clip ? SearchStrategy.CLIP : SearchStrategy.TEXT;
const userIds = await this.getUserIdsToSearch(auth);
+ const withArchived = dto.withArchived || false;
let assets: AssetEntity[] = [];
@@ -77,7 +78,12 @@ export class SearchService {
{ text: query },
machineLearning.clip,
);
- assets = await this.smartInfoRepository.searchCLIP({ userIds: userIds, embedding, numResults: 100 });
+ assets = await this.smartInfoRepository.searchCLIP({
+ userIds: userIds,
+ embedding,
+ numResults: 100,
+ withArchived,
+ });
break;
case SearchStrategy.TEXT:
assets = await this.assetRepository.searchMetadata(query, userIds, { numResults: 250 });
diff --git a/server/src/infra/repositories/smart-info.repository.ts b/server/src/infra/repositories/smart-info.repository.ts
index ae8bea2d0..37d1766ea 100644
--- a/server/src/infra/repositories/smart-info.repository.ts
+++ b/server/src/infra/repositories/smart-info.repository.ts
@@ -43,7 +43,7 @@ export class SmartInfoRepository implements ISmartInfoRepository {
@GenerateSql({
params: [{ userIds: [DummyValue.UUID], embedding: Array.from({ length: 512 }, Math.random), numResults: 100 }],
})
- async searchCLIP({ userIds, embedding, numResults }: EmbeddingSearch): Promise {
+ async searchCLIP({ userIds, embedding, numResults, withArchived }: EmbeddingSearch): Promise {
if (!isValidInteger(numResults, { min: 1 })) {
throw new Error(`Invalid value for 'numResults': ${numResults}`);
}
@@ -52,12 +52,18 @@ export class SmartInfoRepository implements ISmartInfoRepository {
await this.assetRepository.manager.transaction(async (manager) => {
await manager.query(`SET LOCAL vectors.k = '${numResults}'`);
await manager.query(`SET LOCAL vectors.enable_prefilter = on`);
- results = await manager
+
+ const query = manager
.createQueryBuilder(AssetEntity, 'a')
.innerJoin('a.smartSearch', 's')
.where('a.ownerId IN (:...userIds )')
- .andWhere('a.isVisible = true')
- .andWhere('a.isArchived = false')
+ .andWhere('a.isVisible = true');
+
+ if (!withArchived) {
+ query.andWhere('a.isArchived = false');
+ }
+
+ results = await query
.andWhere('a.fileCreatedAt < NOW()')
.leftJoinAndSelect('a.exifInfo', 'e')
.orderBy('s.embedding <=> :embedding')