diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1396062bee6b1..3465f15671196 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -166,6 +166,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | *SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | *SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | +*SearchApi* | [**searchAlbum**](doc//SearchApi.md#searchalbum) | **GET** /search/album | *SearchApi* | [**searchMetadata**](doc//SearchApi.md#searchmetadata) | **POST** /search/metadata | *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 985029f106d27..d0a6ea33bdb5f 100644 --- a/mobile/openapi/lib/api/search_api.dart +++ b/mobile/openapi/lib/api/search_api.dart @@ -193,6 +193,67 @@ class SearchApi { return null; } + /// Performs an HTTP 'GET /search/album' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [bool] shared: + /// true: only shared albums false: only non-shared own albums undefined: shared and owned albums + Future searchAlbumWithHttpInfo(String name, { bool? shared, }) async { + // ignore: prefer_const_declarations + final path = r'/search/album'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'name', name)); + if (shared != null) { + queryParams.addAll(_queryParams('', 'shared', shared)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] name (required): + /// + /// * [bool] shared: + /// true: only shared albums false: only non-shared own albums undefined: shared and owned albums + Future?> searchAlbum(String name, { bool? shared, }) async { + final response = await searchAlbumWithHttpInfo(name, shared: shared, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'POST /search/metadata' operation and returns the [Response]. /// Parameters: /// diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 27691b16336a4..a46c3f2eef077 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4337,6 +4337,59 @@ ] } }, + "/search/album": { + "get": { + "operationId": "searchAlbum", + "parameters": [ + { + "name": "name", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "shared", + "required": false, + "in": "query", + "description": "true: only shared albums\nfalse: only non-shared own albums\nundefined: shared and owned albums", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/cities": { "get": { "operationId": "getAssetsByCity", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 31d1a7d7ca0d3..96e53a77245f5 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2459,6 +2459,20 @@ export function fixAuditFiles({ fileReportFixDto }: { body: fileReportFixDto }))); } +export function searchAlbum({ name, shared }: { + name: string; + shared?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AlbumResponseDto[]; + }>(`/search/album${QS.query(QS.explode({ + name, + shared + }))}`, { + ...opts + })); +} export function getAssetsByCity(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index 9fdb2746fc1d3..ab42e61bf4b6c 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; +import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; @@ -7,6 +8,7 @@ import { MetadataSearchDto, PlacesResponseDto, RandomSearchDto, + SearchAlbumDto, SearchExploreResponseDto, SearchPeopleDto, SearchPlacesDto, @@ -73,4 +75,10 @@ export class SearchController { // TODO fix open api generation to indicate that results can be nullable return this.service.getSearchSuggestions(auth, dto) as Promise; } + + @Get('album') + @Authenticated() + searchAlbum(@Auth() auth: AuthDto, @Query() dto: SearchAlbumDto): Promise { + return this.service.searchAlbum(auth, dto); + } } diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 5c5dce1a1190a..1d747969f5b5b 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -184,12 +184,24 @@ export class SmartSearchDto extends BaseSearchDto { page?: number; } -export class SearchPlacesDto { +export class SearchDto { @IsString() @IsNotEmpty() name!: string; } +export class SearchPlacesDto extends SearchDto {} + +export class SearchAlbumDto extends SearchDto { + /** + * true: only shared albums + * false: only non-shared own albums + * undefined: shared and owned albums + */ + @ValidateBoolean({ optional: true }) + shared?: boolean; +} + export class SearchPeopleDto { @IsString() @IsNotEmpty() diff --git a/server/src/interfaces/album.interface.ts b/server/src/interfaces/album.interface.ts index 24c64bdc9d2c0..463bc4394984b 100644 --- a/server/src/interfaces/album.interface.ts +++ b/server/src/interfaces/album.interface.ts @@ -29,4 +29,5 @@ export interface IAlbumRepository extends IBulkAsset { update(album: Partial): Promise; delete(id: string): Promise; updateThumbnails(): Promise; + getByName(userId: string, albumName: string, shared?: boolean): Promise; } diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index c4f6fbdd3218b..5e31bfa3e83b7 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -520,3 +520,47 @@ WHERE "album_assets"."albumsId" = "albums"."id" AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId" ) + +-- AlbumRepository.getByName +SELECT + "album"."id" AS "album_id", + "album"."ownerId" AS "album_ownerId", + "album"."albumName" AS "album_albumName", + "album"."description" AS "album_description", + "album"."createdAt" AS "album_createdAt", + "album"."updatedAt" AS "album_updatedAt", + "album"."deletedAt" AS "album_deletedAt", + "album"."albumThumbnailAssetId" AS "album_albumThumbnailAssetId", + "album"."isActivityEnabled" AS "album_isActivityEnabled", + "album"."order" AS "album_order", + "owner"."id" AS "owner_id", + "owner"."name" AS "owner_name", + "owner"."isAdmin" AS "owner_isAdmin", + "owner"."email" AS "owner_email", + "owner"."storageLabel" AS "owner_storageLabel", + "owner"."oauthId" AS "owner_oauthId", + "owner"."profileImagePath" AS "owner_profileImagePath", + "owner"."shouldChangePassword" AS "owner_shouldChangePassword", + "owner"."createdAt" AS "owner_createdAt", + "owner"."deletedAt" AS "owner_deletedAt", + "owner"."status" AS "owner_status", + "owner"."updatedAt" AS "owner_updatedAt", + "owner"."quotaSizeInBytes" AS "owner_quotaSizeInBytes", + "owner"."quotaUsageInBytes" AS "owner_quotaUsageInBytes", + "owner"."profileChangedAt" AS "owner_profileChangedAt" +FROM + "albums" "album" + LEFT JOIN "users" "owner" ON "owner"."id" = "album"."ownerId" + AND ("owner"."deletedAt" IS NULL) + LEFT JOIN "albums_shared_users_users" "album_users" ON "album_users"."albumsId" = "album"."id" +WHERE + ( + ("album_users"."usersId" = $1) + AND ( + LOWER("album"."albumName") LIKE $2 + OR LOWER("album"."albumName") LIKE $3 + ) + ) + AND ("album"."deletedAt" IS NULL) +LIMIT + 1000 diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index f7b4cb44aa976..8eeb07b219348 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -302,4 +302,45 @@ export class AlbumRepository implements IAlbumRepository { return result.affected; } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, true] }) + getByName(userId: string, albumName: string, shared?: boolean): Promise { + let queryBuilder = this.repository + .createQueryBuilder('album') + .leftJoinAndSelect('album.owner', 'owner') + .leftJoin('albums_shared_users_users', 'album_users', 'album_users.albumsId = album.id'); + + const albumSharedOptions = () => { + switch (shared) { + case true: { + return { owner: '(album_users.usersId = :userId)', options: '' }; + } + case false: { + return { + owner: '(album.ownerId = :userId)', + options: 'AND album_users.usersId IS NULL AND shared_links.id IS NULL', + }; + } + case undefined: { + return { owner: '(album.ownerId = :userId OR album_users.usersId = :userId)', options: '' }; + } + } + }; + + if (shared === false) { + queryBuilder = queryBuilder.leftJoin('shared_links', 'shared_links', 'shared_links.albumId = album.id'); + } + + return queryBuilder + .where( + `${albumSharedOptions().owner} AND (LOWER(album.albumName) LIKE :nameStart OR LOWER(album.albumName) LIKE :nameAnywhere) ${albumSharedOptions().options}`, + { + userId, + nameStart: `${albumName.toLowerCase()}%`, + nameAnywhere: `% ${albumName.toLowerCase()}%`, + }, + ) + .limit(1000) + .getMany(); + } } diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 03ffbe97db14e..3602bd53d40ed 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable } from '@nestjs/common'; +import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto'; import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; @@ -6,6 +7,7 @@ import { MetadataSearchDto, PlacesResponseDto, RandomSearchDto, + SearchAlbumDto, SearchPeopleDto, SearchPlacesDto, SearchResponseDto, @@ -16,6 +18,7 @@ import { } from 'src/dtos/search.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { AssetOrder } from 'src/enum'; +import { AlbumAssetCount } from 'src/interfaces/album.interface'; import { SearchExploreItem } from 'src/interfaces/search.interface'; import { BaseService } from 'src/services/base.service'; import { getMyPartnerIds } from 'src/utils/asset.util'; @@ -27,6 +30,35 @@ export class SearchService extends BaseService { return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden }); } + async searchAlbum(auth: AuthDto, dto: SearchAlbumDto): Promise { + const albums = await this.albumRepository.getByName(auth.user.id, dto.name, dto.shared); + const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); + const albumMetadata: Record = {}; + for (const metadata of results) { + const { albumId, assetCount, startDate, endDate } = metadata; + albumMetadata[albumId] = { + albumId, + assetCount, + startDate, + endDate, + }; + } + + return Promise.all( + albums.map(async (album) => { + const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id); + return { + ...mapAlbumWithoutAssets(album), + sharedLinks: undefined, + startDate: albumMetadata[album.id].startDate, + endDate: albumMetadata[album.id].endDate, + assetCount: albumMetadata[album.id].assetCount, + lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt, + }; + }), + ); + } + async searchPlaces(dto: SearchPlacesDto): Promise { const places = await this.searchRepository.searchPlaces(dto.name); return places.map((place) => mapPlaces(place)); diff --git a/server/test/repositories/album.repository.mock.ts b/server/test/repositories/album.repository.mock.ts index dd5c3af6a8d9a..97f86576d54e3 100644 --- a/server/test/repositories/album.repository.mock.ts +++ b/server/test/repositories/album.repository.mock.ts @@ -20,5 +20,6 @@ export const newAlbumRepositoryMock = (): Mocked => { update: vitest.fn(), delete: vitest.fn(), updateThumbnails: vitest.fn(), + getByName: vitest.fn(), }; };