Skip to content

Commit

Permalink
feat: search album by name
Browse files Browse the repository at this point in the history
  • Loading branch information
martabal committed Oct 14, 2024
1 parent 452ce73 commit 296f79e
Show file tree
Hide file tree
Showing 11 changed files with 274 additions and 1 deletion.
1 change: 1 addition & 0 deletions mobile/openapi/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 61 additions & 0 deletions mobile/openapi/lib/api/search_api.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 53 additions & 0 deletions open-api/immich-openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
14 changes: 14 additions & 0 deletions open-api/typescript-sdk/src/fetch-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions server/src/controllers/search.controller.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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';
import {
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchAlbumDto,
SearchExploreResponseDto,
SearchPeopleDto,
SearchPlacesDto,
Expand Down Expand Up @@ -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<string[]>;
}

@Get('album')
@Authenticated()
searchAlbum(@Auth() auth: AuthDto, @Query() dto: SearchAlbumDto): Promise<AlbumResponseDto[]> {
return this.service.searchAlbum(auth, dto);
}
}
14 changes: 13 additions & 1 deletion server/src/dtos/search.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions server/src/interfaces/album.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ export interface IAlbumRepository extends IBulkAsset {
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
delete(id: string): Promise<void>;
updateThumbnails(): Promise<number | undefined>;
getByName(userId: string, albumName: string, shared?: boolean): Promise<AlbumEntity[]>;
}
47 changes: 47 additions & 0 deletions server/src/queries/album.repository.sql
Original file line number Diff line number Diff line change
Expand Up @@ -520,3 +520,50 @@ 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"."ownerId" = $1
OR "album_users"."usersId" = $1
)
AND (
LOWER("album"."albumName") LIKE $2
OR LOWER("album"."albumName") LIKE $3
)
)
AND ("album"."deletedAt" IS NULL)
LIMIT
1000
43 changes: 43 additions & 0 deletions server/src/repositories/album.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,47 @@ export class AlbumRepository implements IAlbumRepository {

return result.affected;
}

@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, undefined] })
getByName(userId: string, albumName: string, shared?: boolean): Promise<AlbumEntity[]> {
const getAlbumSharedOptions = () => {
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: '' };
}
}
};

let queryBuilder = this.repository
.createQueryBuilder('album')
.leftJoinAndSelect('album.owner', 'owner')
.leftJoin('albums_shared_users_users', 'album_users', 'album_users.albumsId = album.id');

const albumSharedOptions = getAlbumSharedOptions();

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();
}
}
32 changes: 32 additions & 0 deletions server/src/services/search.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
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';
import {
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchAlbumDto,
SearchPeopleDto,
SearchPlacesDto,
SearchResponseDto,
Expand All @@ -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';
Expand All @@ -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<AlbumResponseDto[]> {
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<string, AlbumAssetCount> = {};
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<PlacesResponseDto[]> {
const places = await this.searchRepository.searchPlaces(dto.name);
return places.map((place) => mapPlaces(place));
Expand Down
1 change: 1 addition & 0 deletions server/test/repositories/album.repository.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@ export const newAlbumRepositoryMock = (): Mocked<IAlbumRepository> => {
update: vitest.fn(),
delete: vitest.fn(),
updateThumbnails: vitest.fn(),
getByName: vitest.fn(),
};
};

0 comments on commit 296f79e

Please sign in to comment.