2023-02-25 09:12:03 -05:00
|
|
|
import { Injectable } from '@nestjs/common';
|
2025-04-18 23:10:34 +02:00
|
|
|
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
2025-01-21 11:24:48 -06:00
|
|
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
|
|
|
|
import { InjectKysely } from 'nestjs-kysely';
|
2025-04-18 23:10:34 +02:00
|
|
|
import { columns, Exif } from 'src/database';
|
2024-07-05 14:58:34 -04:00
|
|
|
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
2025-01-21 11:24:48 -06:00
|
|
|
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
|
2025-06-30 13:19:16 -04:00
|
|
|
import { DB } from 'src/schema';
|
|
|
|
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
2025-06-02 10:33:08 -04:00
|
|
|
import { withDefaultVisibility } from 'src/utils/database';
|
2025-02-11 14:08:13 -05:00
|
|
|
|
|
|
|
|
export interface AlbumAssetCount {
|
|
|
|
|
albumId: string;
|
|
|
|
|
assetCount: number;
|
|
|
|
|
startDate: Date | null;
|
|
|
|
|
endDate: Date | null;
|
2025-04-01 00:28:41 +13:00
|
|
|
lastModifiedAssetTimestamp: Date | null;
|
2025-02-11 14:08:13 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AlbumInfoOptions {
|
|
|
|
|
withAssets: boolean;
|
|
|
|
|
}
|
2025-01-21 11:24:48 -06:00
|
|
|
|
|
|
|
|
const withOwner = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
2025-04-18 23:10:34 +02:00
|
|
|
return jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'albums.ownerId'))
|
|
|
|
|
.$notNull()
|
|
|
|
|
.as('owner');
|
2025-01-21 11:24:48 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const withAlbumUsers = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
|
|
|
|
return jsonArrayFrom(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('albums_shared_users_users as album_users')
|
2025-04-14 20:34:06 +02:00
|
|
|
.select('album_users.role')
|
2025-01-21 11:24:48 -06:00
|
|
|
.select((eb) =>
|
2025-04-18 23:10:34 +02:00
|
|
|
jsonObjectFrom(eb.selectFrom('users').select(columns.user).whereRef('users.id', '=', 'album_users.usersId'))
|
|
|
|
|
.$notNull()
|
|
|
|
|
.as('user'),
|
2025-01-21 11:24:48 -06:00
|
|
|
)
|
|
|
|
|
.whereRef('album_users.albumsId', '=', 'albums.id'),
|
2025-04-18 23:10:34 +02:00
|
|
|
)
|
|
|
|
|
.$notNull()
|
|
|
|
|
.as('albumUsers');
|
2025-01-21 11:24:48 -06:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const withSharedLink = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
|
|
|
|
return jsonArrayFrom(eb.selectFrom('shared_links').selectAll().whereRef('shared_links.albumId', '=', 'albums.id')).as(
|
|
|
|
|
'sharedLinks',
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
|
|
|
|
|
return eb
|
|
|
|
|
.selectFrom((eb) =>
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('assets')
|
|
|
|
|
.selectAll('assets')
|
2025-03-26 21:24:22 -05:00
|
|
|
.leftJoin('exif', 'assets.id', 'exif.assetId')
|
2025-04-18 23:10:34 +02:00
|
|
|
.select((eb) => eb.table('exif').$castTo<Exif>().as('exifInfo'))
|
2025-01-21 11:24:48 -06:00
|
|
|
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
|
|
|
|
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
|
2025-01-22 15:17:42 -05:00
|
|
|
.where('assets.deletedAt', 'is', null)
|
2025-06-02 10:33:08 -04:00
|
|
|
.$call(withDefaultVisibility)
|
2025-01-21 11:24:48 -06:00
|
|
|
.orderBy('assets.fileCreatedAt', 'desc')
|
|
|
|
|
.as('asset'),
|
|
|
|
|
)
|
|
|
|
|
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
|
|
|
|
|
.as('assets');
|
2024-04-25 06:19:49 +02:00
|
|
|
};
|
|
|
|
|
|
2023-02-25 09:12:03 -05:00
|
|
|
@Injectable()
|
2025-02-11 14:08:13 -05:00
|
|
|
export class AlbumRepository {
|
2025-01-22 15:17:42 -05:00
|
|
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
2023-08-01 21:29:14 -04:00
|
|
|
|
2025-01-22 15:17:42 -05:00
|
|
|
@GenerateSql({ params: [DummyValue.UUID, { withAssets: true }] })
|
2025-04-18 23:10:34 +02:00
|
|
|
async getById(id: string, options: AlbumInfoOptions) {
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db
|
|
|
|
|
.selectFrom('albums')
|
|
|
|
|
.selectAll('albums')
|
|
|
|
|
.where('albums.id', '=', id)
|
|
|
|
|
.where('albums.deletedAt', 'is', null)
|
|
|
|
|
.select(withOwner)
|
|
|
|
|
.select(withAlbumUsers)
|
|
|
|
|
.select(withSharedLink)
|
|
|
|
|
.$if(options.withAssets, (eb) => eb.select(withAssets))
|
2025-04-18 23:10:34 +02:00
|
|
|
.$narrowType<{ assets: NotNull }>()
|
|
|
|
|
.executeTakeFirst();
|
2023-08-01 21:29:14 -04:00
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
|
2023-11-30 10:10:30 -05:00
|
|
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
|
2025-04-18 23:10:34 +02:00
|
|
|
async getByAssetId(ownerId: string, assetId: string) {
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db
|
|
|
|
|
.selectFrom('albums')
|
|
|
|
|
.selectAll('albums')
|
2025-01-25 23:37:19 -05:00
|
|
|
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
|
2025-01-21 11:24:48 -06:00
|
|
|
.where((eb) =>
|
|
|
|
|
eb.or([
|
2025-01-25 23:37:19 -05:00
|
|
|
eb('albums.ownerId', '=', ownerId),
|
|
|
|
|
eb.exists(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('albums_shared_users_users as album_users')
|
|
|
|
|
.whereRef('album_users.albumsId', '=', 'albums.id')
|
|
|
|
|
.where('album_users.usersId', '=', ownerId),
|
|
|
|
|
),
|
2025-01-21 11:24:48 -06:00
|
|
|
]),
|
|
|
|
|
)
|
2025-01-25 23:37:19 -05:00
|
|
|
.where('album_assets.assetsId', '=', assetId)
|
2025-01-21 11:24:48 -06:00
|
|
|
.where('albums.deletedAt', 'is', null)
|
|
|
|
|
.orderBy('albums.createdAt', 'desc')
|
|
|
|
|
.select(withOwner)
|
|
|
|
|
.select(withAlbumUsers)
|
|
|
|
|
.orderBy('albums.createdAt', 'desc')
|
2025-04-18 23:10:34 +02:00
|
|
|
.execute();
|
2023-03-26 04:46:48 +02:00
|
|
|
}
|
|
|
|
|
|
2023-11-30 10:10:30 -05:00
|
|
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
2024-01-06 20:36:12 -05:00
|
|
|
@ChunkedArray()
|
2023-11-26 16:23:43 +01:00
|
|
|
async getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]> {
|
2023-11-21 10:07:49 -06:00
|
|
|
// Guard against running invalid query when ids list is empty.
|
2024-02-02 04:18:00 +01:00
|
|
|
if (ids.length === 0) {
|
2023-11-21 10:07:49 -06:00
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2025-04-01 00:28:41 +13:00
|
|
|
return (
|
|
|
|
|
this.db
|
|
|
|
|
.selectFrom('assets')
|
2025-06-02 10:33:08 -04:00
|
|
|
.$call(withDefaultVisibility)
|
2025-04-01 00:28:41 +13:00
|
|
|
.innerJoin('albums_assets_assets as album_assets', 'album_assets.assetsId', 'assets.id')
|
|
|
|
|
.select('album_assets.albumsId as albumId')
|
|
|
|
|
.select((eb) => eb.fn.min(sql<Date>`("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('startDate'))
|
|
|
|
|
.select((eb) => eb.fn.max(sql<Date>`("assets"."localDateTime" AT TIME ZONE 'UTC'::text)::date`).as('endDate'))
|
|
|
|
|
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
|
|
|
|
.select((eb) => eb.fn.max('assets.updatedAt').as('lastModifiedAssetTimestamp'))
|
|
|
|
|
.select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.as('assetCount'))
|
|
|
|
|
.where('album_assets.albumsId', 'in', ids)
|
|
|
|
|
.where('assets.deletedAt', 'is', null)
|
|
|
|
|
.groupBy('album_assets.albumsId')
|
|
|
|
|
.execute()
|
|
|
|
|
);
|
2023-11-21 10:07:49 -06:00
|
|
|
}
|
|
|
|
|
|
2023-11-30 10:10:30 -05:00
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
2025-04-18 23:10:34 +02:00
|
|
|
async getOwned(ownerId: string) {
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db
|
|
|
|
|
.selectFrom('albums')
|
|
|
|
|
.selectAll('albums')
|
|
|
|
|
.select(withOwner)
|
|
|
|
|
.select(withAlbumUsers)
|
|
|
|
|
.select(withSharedLink)
|
|
|
|
|
.where('albums.ownerId', '=', ownerId)
|
|
|
|
|
.where('albums.deletedAt', 'is', null)
|
|
|
|
|
.orderBy('albums.createdAt', 'desc')
|
2025-04-18 23:10:34 +02:00
|
|
|
.execute();
|
2023-03-26 04:46:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get albums shared with and shared by owner.
|
|
|
|
|
*/
|
2023-11-30 10:10:30 -05:00
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
2025-04-18 23:10:34 +02:00
|
|
|
async getShared(ownerId: string) {
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db
|
|
|
|
|
.selectFrom('albums')
|
|
|
|
|
.selectAll('albums')
|
|
|
|
|
.where((eb) =>
|
|
|
|
|
eb.or([
|
2025-01-25 23:37:19 -05:00
|
|
|
eb.exists(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('albums_shared_users_users as album_users')
|
|
|
|
|
.whereRef('album_users.albumsId', '=', 'albums.id')
|
|
|
|
|
.where((eb) => eb.or([eb('albums.ownerId', '=', ownerId), eb('album_users.usersId', '=', ownerId)])),
|
|
|
|
|
),
|
|
|
|
|
eb.exists(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('shared_links')
|
|
|
|
|
.whereRef('shared_links.albumId', '=', 'albums.id')
|
|
|
|
|
.where('shared_links.userId', '=', ownerId),
|
|
|
|
|
),
|
2025-01-21 11:24:48 -06:00
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.where('albums.deletedAt', 'is', null)
|
|
|
|
|
.select(withAlbumUsers)
|
|
|
|
|
.select(withOwner)
|
|
|
|
|
.select(withSharedLink)
|
|
|
|
|
.orderBy('albums.createdAt', 'desc')
|
2025-04-18 23:10:34 +02:00
|
|
|
.execute();
|
2023-03-26 04:46:48 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get albums of owner that are _not_ shared
|
|
|
|
|
*/
|
2023-11-30 10:10:30 -05:00
|
|
|
@GenerateSql({ params: [DummyValue.UUID] })
|
2025-04-18 23:10:34 +02:00
|
|
|
async getNotShared(ownerId: string) {
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db
|
|
|
|
|
.selectFrom('albums')
|
|
|
|
|
.selectAll('albums')
|
|
|
|
|
.where('albums.ownerId', '=', ownerId)
|
|
|
|
|
.where('albums.deletedAt', 'is', null)
|
2025-01-25 23:37:19 -05:00
|
|
|
.where((eb) =>
|
|
|
|
|
eb.not(
|
|
|
|
|
eb.exists(
|
|
|
|
|
eb
|
|
|
|
|
.selectFrom('albums_shared_users_users as album_users')
|
|
|
|
|
.whereRef('album_users.albumsId', '=', 'albums.id'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
.where((eb) =>
|
|
|
|
|
eb.not(eb.exists(eb.selectFrom('shared_links').whereRef('shared_links.albumId', '=', 'albums.id'))),
|
|
|
|
|
)
|
2025-01-21 11:24:48 -06:00
|
|
|
.select(withOwner)
|
|
|
|
|
.orderBy('albums.createdAt', 'desc')
|
2025-04-18 23:10:34 +02:00
|
|
|
.execute();
|
2023-03-26 04:46:48 +02:00
|
|
|
}
|
|
|
|
|
|
2023-09-18 17:56:50 +02:00
|
|
|
async restoreAll(userId: string): Promise<void> {
|
2025-01-21 11:24:48 -06:00
|
|
|
await this.db.updateTable('albums').set({ deletedAt: null }).where('ownerId', '=', userId).execute();
|
2023-09-18 17:56:50 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async softDeleteAll(userId: string): Promise<void> {
|
2025-01-21 11:24:48 -06:00
|
|
|
await this.db.updateTable('albums').set({ deletedAt: new Date() }).where('ownerId', '=', userId).execute();
|
2023-09-18 17:56:50 +02:00
|
|
|
}
|
|
|
|
|
|
2023-02-25 09:12:03 -05:00
|
|
|
async deleteAll(userId: string): Promise<void> {
|
2025-01-21 11:24:48 -06:00
|
|
|
await this.db.deleteFrom('albums').where('ownerId', '=', userId).execute();
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|
2023-03-02 21:47:08 -05:00
|
|
|
|
2025-05-15 09:35:21 -06:00
|
|
|
@GenerateSql({ params: [[DummyValue.UUID]] })
|
|
|
|
|
@Chunked()
|
|
|
|
|
async removeAssetsFromAll(assetIds: string[]): Promise<void> {
|
|
|
|
|
await this.db.deleteFrom('albums_assets_assets').where('albums_assets_assets.assetsId', 'in', assetIds).execute();
|
2023-10-18 11:56:00 -04:00
|
|
|
}
|
|
|
|
|
|
2024-01-06 20:36:12 -05:00
|
|
|
@Chunked({ paramIndex: 1 })
|
2024-03-29 12:56:16 +01:00
|
|
|
async removeAssetIds(albumId: string, assetIds: string[]): Promise<void> {
|
2024-07-05 14:58:34 -04:00
|
|
|
if (assetIds.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-21 11:24:48 -06:00
|
|
|
await this.db
|
|
|
|
|
.deleteFrom('albums_assets_assets')
|
|
|
|
|
.where('albums_assets_assets.albumsId', '=', albumId)
|
|
|
|
|
.where('albums_assets_assets.assetsId', 'in', assetIds)
|
2023-08-15 21:34:57 -04:00
|
|
|
.execute();
|
|
|
|
|
}
|
|
|
|
|
|
fix(server): Check album asset membership in bulk (#4603)
Add `AlbumRepository` method to retrieve an album's asset ids, with an
optional parameter to only filter by the provided asset ids. With this,
we can now check asset membership using a single query.
When adding or removing assets to an album, checking whether each asset
is already present in the album now requires a single query, instead of
one query per asset.
Related to #4539 performance improvements.
Before:
```
// Asset membership and permissions check (2 queries per asset)
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","b666ae6c-afa8-4d6f-a1ad-7091a0659320"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["b666ae6c-afa8-4d6f-a1ad-7091a0659320","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","c656ab1c-7775-4ff7-b56f-01308c072a76"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["c656ab1c-7775-4ff7-b56f-01308c072a76","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
```
After:
```
// Asset membership check (1 query for all assets)
immich_server | query: SELECT "albums_assets"."assetsId" AS "assetId" FROM "albums_assets_assets" "albums_assets" WHERE "albums_assets"."albumsId" = $1 AND "albums_assets"."assetsId" IN ($2, $3, $4) -- PARAMETERS: ["ca870d76-6311-4e89-bf9a-f5b51ea2452c","b666ae6c-afa8-4d6f-a1ad-7091a0659320","c656ab1c-7775-4ff7-b56f-01308c072a76","cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9"]
// Permissions check (1 query per asset)
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["b666ae6c-afa8-4d6f-a1ad-7091a0659320","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["c656ab1c-7775-4ff7-b56f-01308c072a76","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
```
2023-10-23 09:02:27 -04:00
|
|
|
/**
|
|
|
|
|
* Get asset IDs for the given album ID.
|
|
|
|
|
*
|
|
|
|
|
* @param albumId Album ID to get asset IDs for.
|
|
|
|
|
* @param assetIds Optional list of asset IDs to filter on.
|
|
|
|
|
* @returns Set of Asset IDs for the given album ID.
|
|
|
|
|
*/
|
2024-07-05 14:58:34 -04:00
|
|
|
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] })
|
|
|
|
|
@ChunkedSet({ paramIndex: 1 })
|
|
|
|
|
async getAssetIds(albumId: string, assetIds: string[]): Promise<Set<string>> {
|
|
|
|
|
if (assetIds.length === 0) {
|
|
|
|
|
return new Set();
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db
|
|
|
|
|
.selectFrom('albums_assets_assets')
|
|
|
|
|
.selectAll()
|
|
|
|
|
.where('albums_assets_assets.albumsId', '=', albumId)
|
|
|
|
|
.where('albums_assets_assets.assetsId', 'in', assetIds)
|
|
|
|
|
.execute()
|
|
|
|
|
.then((results) => new Set(results.map(({ assetsId }) => assetsId)));
|
fix(server): Check album asset membership in bulk (#4603)
Add `AlbumRepository` method to retrieve an album's asset ids, with an
optional parameter to only filter by the provided asset ids. With this,
we can now check asset membership using a single query.
When adding or removing assets to an album, checking whether each asset
is already present in the album now requires a single query, instead of
one query per asset.
Related to #4539 performance improvements.
Before:
```
// Asset membership and permissions check (2 queries per asset)
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","b666ae6c-afa8-4d6f-a1ad-7091a0659320"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["b666ae6c-afa8-4d6f-a1ad-7091a0659320","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","c656ab1c-7775-4ff7-b56f-01308c072a76"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["c656ab1c-7775-4ff7-b56f-01308c072a76","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_assets_assets" "AlbumEntity_AlbumEntity__AlbumEntity_assets" ON "AlbumEntity_AlbumEntity__AlbumEntity_assets"."albumsId"="AlbumEntity"."id" LEFT JOIN "assets" "AlbumEntity__AlbumEntity_assets" ON "AlbumEntity__AlbumEntity_assets"."id"="AlbumEntity_AlbumEntity__AlbumEntity_assets"."assetsId" AND ("AlbumEntity__AlbumEntity_assets"."deletedAt" IS NULL) WHERE ( ("AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_assets"."id" = $2) ) AND ( "AlbumEntity"."deletedAt" IS NULL )) LIMIT 1 -- PARAMETERS: ["3fdf0e58-a1c7-4efe-8288-06e4c3f38df9","cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
```
After:
```
// Asset membership check (1 query for all assets)
immich_server | query: SELECT "albums_assets"."assetsId" AS "assetId" FROM "albums_assets_assets" "albums_assets" WHERE "albums_assets"."albumsId" = $1 AND "albums_assets"."assetsId" IN ($2, $3, $4) -- PARAMETERS: ["ca870d76-6311-4e89-bf9a-f5b51ea2452c","b666ae6c-afa8-4d6f-a1ad-7091a0659320","c656ab1c-7775-4ff7-b56f-01308c072a76","cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9"]
// Permissions check (1 query per asset)
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["b666ae6c-afa8-4d6f-a1ad-7091a0659320","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["c656ab1c-7775-4ff7-b56f-01308c072a76","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
immich_server | query: SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (SELECT 1 FROM "assets" "AssetEntity" WHERE ("AssetEntity"."id" = $1 AND "AssetEntity"."ownerId" = $2)) LIMIT 1 -- PARAMETERS: ["cf82adb2-1fcc-4f9e-9013-8fc03cc8d3a9","6bc60cf1-bd18-4501-a1c2-120b51276fda"]
```
2023-10-23 09:02:27 -04:00
|
|
|
}
|
|
|
|
|
|
2024-03-29 12:56:16 +01:00
|
|
|
async addAssetIds(albumId: string, assetIds: string[]): Promise<void> {
|
2025-01-21 11:24:48 -06:00
|
|
|
await this.addAssets(this.db, albumId, assetIds);
|
2023-10-18 11:56:00 -04:00
|
|
|
}
|
|
|
|
|
|
2025-06-30 13:19:16 -04:00
|
|
|
create(album: Insertable<AlbumTable>, assetIds: string[], albumUsers: AlbumUserCreateDto[]) {
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db.transaction().execute(async (tx) => {
|
|
|
|
|
const newAlbum = await tx.insertInto('albums').values(album).returning('albums.id').executeTakeFirst();
|
|
|
|
|
|
|
|
|
|
if (!newAlbum) {
|
|
|
|
|
throw new Error('Failed to create album');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (assetIds.length > 0) {
|
|
|
|
|
await this.addAssets(tx, newAlbum.id, assetIds);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (albumUsers.length > 0) {
|
|
|
|
|
await tx
|
|
|
|
|
.insertInto('albums_shared_users_users')
|
|
|
|
|
.values(
|
|
|
|
|
albumUsers.map((albumUser) => ({ albumsId: newAlbum.id, usersId: albumUser.userId, role: albumUser.role })),
|
|
|
|
|
)
|
|
|
|
|
.execute();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return tx
|
|
|
|
|
.selectFrom('albums')
|
|
|
|
|
.selectAll()
|
|
|
|
|
.where('id', '=', newAlbum.id)
|
|
|
|
|
.select(withOwner)
|
|
|
|
|
.select(withAssets)
|
|
|
|
|
.select(withAlbumUsers)
|
2025-04-18 23:10:34 +02:00
|
|
|
.$narrowType<{ assets: NotNull }>()
|
|
|
|
|
.executeTakeFirstOrThrow();
|
2024-07-17 07:43:35 -04:00
|
|
|
});
|
2023-05-25 15:37:19 -04:00
|
|
|
}
|
|
|
|
|
|
2025-06-30 13:19:16 -04:00
|
|
|
update(id: string, album: Updateable<AlbumTable>) {
|
2025-01-21 11:24:48 -06:00
|
|
|
return this.db
|
|
|
|
|
.updateTable('albums')
|
2025-01-25 23:37:19 -05:00
|
|
|
.set(album)
|
2025-01-21 11:24:48 -06:00
|
|
|
.where('id', '=', id)
|
|
|
|
|
.returningAll('albums')
|
|
|
|
|
.returning(withOwner)
|
|
|
|
|
.returning(withSharedLink)
|
|
|
|
|
.returning(withAlbumUsers)
|
2025-04-18 23:10:34 +02:00
|
|
|
.executeTakeFirstOrThrow();
|
2023-05-24 22:10:45 -04:00
|
|
|
}
|
|
|
|
|
|
2024-07-17 07:43:35 -04:00
|
|
|
async delete(id: string): Promise<void> {
|
2025-01-21 11:24:48 -06:00
|
|
|
await this.db.deleteFrom('albums').where('id', '=', id).execute();
|
2024-07-17 07:43:35 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Chunked({ paramIndex: 2, chunkSize: 30_000 })
|
2025-01-21 11:24:48 -06:00
|
|
|
private async addAssets(db: Kysely<DB>, albumId: string, assetIds: string[]): Promise<void> {
|
2024-07-17 07:43:35 -04:00
|
|
|
if (assetIds.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-01-21 11:24:48 -06:00
|
|
|
await db
|
|
|
|
|
.insertInto('albums_assets_assets')
|
2024-07-17 07:43:35 -04:00
|
|
|
.values(assetIds.map((assetId) => ({ albumsId: albumId, assetsId: assetId })))
|
|
|
|
|
.execute();
|
2023-05-26 09:04:09 -04:00
|
|
|
}
|
|
|
|
|
|
2023-08-01 21:29:14 -04:00
|
|
|
/**
|
|
|
|
|
* Makes sure all thumbnails for albums are updated by:
|
|
|
|
|
* - Removing thumbnails from albums without assets
|
|
|
|
|
* - Removing references of thumbnails to assets outside the album
|
|
|
|
|
* - Setting a thumbnail when none is set and the album contains assets
|
|
|
|
|
*
|
|
|
|
|
* @returns Amount of updated album thumbnails or undefined when unknown
|
|
|
|
|
*/
|
|
|
|
|
async updateThumbnails(): Promise<number | undefined> {
|
|
|
|
|
// Subquery for getting a new thumbnail.
|
|
|
|
|
|
2025-01-21 11:24:48 -06:00
|
|
|
const result = await this.db
|
|
|
|
|
.updateTable('albums')
|
|
|
|
|
.set((eb) => ({
|
|
|
|
|
albumThumbnailAssetId: this.updateThumbnailBuilder(eb)
|
|
|
|
|
.select('album_assets.assetsId')
|
|
|
|
|
.orderBy('assets.fileCreatedAt', 'desc')
|
|
|
|
|
.limit(1),
|
|
|
|
|
}))
|
|
|
|
|
.where((eb) =>
|
|
|
|
|
eb.or([
|
|
|
|
|
eb.and([
|
|
|
|
|
eb('albumThumbnailAssetId', 'is', null),
|
|
|
|
|
eb.exists(this.updateThumbnailBuilder(eb).select(sql`1`.as('1'))), // Has assets
|
|
|
|
|
]),
|
|
|
|
|
eb.and([
|
|
|
|
|
eb('albumThumbnailAssetId', 'is not', null),
|
|
|
|
|
eb.not(
|
|
|
|
|
eb.exists(
|
|
|
|
|
this.updateThumbnailBuilder(eb)
|
|
|
|
|
.select(sql`1`.as('1'))
|
|
|
|
|
.whereRef('albums.albumThumbnailAssetId', '=', 'album_assets.assetsId'), // Has invalid assets
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
]),
|
|
|
|
|
]),
|
|
|
|
|
)
|
|
|
|
|
.execute();
|
|
|
|
|
|
|
|
|
|
return Number(result[0].numUpdatedRows);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateThumbnailBuilder(eb: ExpressionBuilder<DB, 'albums'>) {
|
|
|
|
|
return eb
|
|
|
|
|
.selectFrom('albums_assets_assets as album_assets')
|
|
|
|
|
.innerJoin('assets', (join) =>
|
|
|
|
|
join.onRef('album_assets.assetsId', '=', 'assets.id').on('assets.deletedAt', 'is', null),
|
|
|
|
|
)
|
|
|
|
|
.whereRef('album_assets.albumsId', '=', 'albums.id');
|
2023-08-01 21:29:14 -04:00
|
|
|
}
|
2023-02-25 09:12:03 -05:00
|
|
|
}
|