Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Tran c0de9943e0 fix(server): cannot fetch album with deleted assets 2025-01-25 16:41:32 -06:00
28 changed files with 164 additions and 172 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.43",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.43",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
-4
View File
@@ -1,8 +1,4 @@
[
{
"label": "v1.125.3",
"url": "https://v1.125.3.archive.immich.app"
},
{
"label": "v1.125.2",
"url": "https://v1.125.2.archive.immich.app"
+4 -4
View File
@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.44",
"version": "2.2.43",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -92,7 +92,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.125.3",
"version": "1.125.2",
"description": "",
"main": "index.js",
"type": "module",
+1 -24
View File
@@ -52,10 +52,7 @@ describe('/albums', () => {
user1Albums = await Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [
{ userId: admin.userId, role: AlbumUserRole.Editor },
{ userId: user2.userId, role: AlbumUserRole.Editor },
],
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
@@ -365,26 +362,6 @@ describe('/albums', () => {
shared: true,
});
});
it('should not count trashed assets', async () => {
await utils.deleteAssets(user1.accessToken, [user1Asset2.id]);
const { status, body } = await request(app)
.get(`/albums/${user2Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user2Albums[0],
assets: [],
assetCount: 1,
lastModifiedAssetTimestamp: expect.any(String),
endDate: expect.any(String),
startDate: expect.any(String),
albumUsers: expect.any(Array),
shared: true,
});
});
});
describe('GET /albums/statistics', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.125.3"
version = "1.125.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 179,
"android.injected.version.name" => "1.125.3",
"android.injected.version.code" => 178,
"android.injected.version.name" => "1.125.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.125.3"
version_number: "1.125.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
@@ -28,7 +28,8 @@ class UserCircleAvatar extends ConsumerWidget {
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}';
final textIcon = DefaultTextStyle(
final textIcon = Text(
user.name[0].toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
@@ -36,7 +37,6 @@ class UserCircleAvatar extends ConsumerWidget {
? Colors.black
: Colors.white,
),
child: Text(user.name[0].toUpperCase()),
);
return CircleAvatar(
backgroundColor: user.avatarColor.toColor(),
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.125.3
- API version: 1.125.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.125.3+179
version: 1.125.2+178
environment:
sdk: '>=3.3.0 <4.0.0'
+1 -1
View File
@@ -7454,7 +7454,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.125.3",
"version": "1.125.2",
"contact": {}
},
"tags": [],
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.2",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 1.125.3
* 1.125.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.125.3",
"version": "1.125.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.125.3",
"version": "1.125.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.125.3",
"version": "1.125.2",
"description": "",
"author": "",
"private": true,
+2 -2
View File
@@ -9,8 +9,8 @@ export const IAlbumRepository = 'IAlbumRepository';
export interface AlbumAssetCount {
albumId: string;
assetCount: number;
startDate: Date | null;
endDate: Date | null;
startDate: Date | undefined;
endDate: Date | undefined;
}
export interface AlbumInfoOptions {
+81 -51
View File
@@ -90,7 +90,7 @@ select
(
select
"assets".*,
"exif" as "exifInfo"
to_json("exif") as "exifInfo"
from
"assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
@@ -180,20 +180,19 @@ select
) as "albumUsers"
from
"albums"
inner join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "albums_shared_users_users" as "album_users" on "album_users"."albumsId" = "albums"."id"
where
(
"albums"."ownerId" = $1
or exists (
select
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
and "album_users"."usersId" = $2
(
"albums"."ownerId" = $1
and "album_assets"."assetsId" = $2
)
or (
"album_users"."usersId" = $3
and "album_assets"."assetsId" = $4
)
)
and "album_assets"."assetsId" = $3
and "albums"."deletedAt" is null
order by
"albums"."createdAt" desc,
@@ -201,17 +200,16 @@ order by
-- AlbumRepository.getMetadataForIds
select
"albums"."id" as "albumId",
"albums"."id",
min("assets"."fileCreatedAt") as "startDate",
max("assets"."fileCreatedAt") as "endDate",
count("assets"."id")::int as "assetCount"
count("assets"."id") as "assetCount"
from
"albums"
left join "albums_assets_assets" as "album_assets" on "album_assets"."albumsId" = "albums"."id"
left join "assets" on "assets"."id" = "album_assets"."assetsId"
where
"albums"."id" in ($1)
and "assets"."deletedAt" is null
group by
"albums"."id"
@@ -307,8 +305,8 @@ order by
"albums"."createdAt" desc
-- AlbumRepository.getShared
select
"albums".*,
select distinct
on ("albums"."createdAt") "albums".*,
(
select
coalesce(json_agg(agg), '[]')
@@ -391,26 +389,15 @@ select
) as "sharedLinks"
from
"albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where
(
exists (
select
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
and (
"albums"."ownerId" = $1
or "album_users"."usersId" = $2
)
)
or exists (
select
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
and "shared_links"."userId" = $3
"shared_albums"."usersId" = $1
or "shared_links"."userId" = $2
or (
"albums"."ownerId" = $3
and "shared_albums"."usersId" is not null
)
)
and "albums"."deletedAt" is null
@@ -418,8 +405,48 @@ order by
"albums"."createdAt" desc
-- AlbumRepository.getNotShared
select
"albums".*,
select distinct
on ("albums"."createdAt") "albums".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"album_users".*,
(
select
to_json(obj)
from
(
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt"
from
"users"
where
"users"."id" = "album_users"."usersId"
) as obj
) as "user"
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
) as agg
) as "albumUsers",
(
select
to_json(obj)
@@ -446,26 +473,29 @@ select
where
"users"."id" = "albums"."ownerId"
) as obj
) as "owner"
) as "owner",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
*
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
) as agg
) as "sharedLinks"
from
"albums"
left join "albums_shared_users_users" as "shared_albums" on "shared_albums"."albumsId" = "albums"."id"
left join "shared_links" on "shared_links"."albumId" = "albums"."id"
where
"albums"."ownerId" = $1
and "shared_albums"."usersId" is null
and "shared_links"."userId" is null
and "albums"."deletedAt" is null
and not exists (
select
from
"albums_shared_users_users" as "album_users"
where
"album_users"."albumsId" = "albums"."id"
)
and not exists (
select
from
"shared_links"
where
"shared_links"."albumId" = "albums"."id"
)
order by
"albums"."createdAt" desc
+2 -2
View File
@@ -36,7 +36,7 @@ offset
and "assets"."deletedAt" is null
and "assets"."id" < $6
order by
random()
"assets"."id"
limit
$7
)
@@ -56,7 +56,7 @@ union all
and "assets"."deletedAt" is null
and "assets"."id" > $13
order by
random()
"assets"."id"
limit
$14
)
+31 -38
View File
@@ -59,7 +59,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
.selectFrom('assets')
.selectAll('assets')
.innerJoin('exif', 'assets.id', 'exif.assetId')
.select((eb) => eb.table('exif').as('exifInfo'))
.select((eb) => eb.fn.toJson('exif').as('exifInfo'))
.innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.where('assets.deletedAt', 'is', null)
@@ -93,19 +93,14 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.selectAll('albums')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('albums_shared_users_users as album_users', 'album_users.albumsId', 'albums.id')
.where((eb) =>
eb.or([
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),
),
eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
]),
)
.where('album_assets.assetsId', '=', assetId)
.where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc')
.select(withOwner)
@@ -122,18 +117,25 @@ export class AlbumRepository implements IAlbumRepository {
return [];
}
return this.db
const metadatas = await this.db
.selectFrom('albums')
.leftJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.leftJoin('assets', 'assets.id', 'album_assets.assetsId')
.select('albums.id as albumId')
.select('albums.id')
.select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate'))
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate'))
.select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.as('assetCount'))
.select((eb) => eb.fn.count('assets.id').as('assetCount'))
.where('albums.id', 'in', ids)
.where('assets.deletedAt', 'is', null)
.groupBy('albums.id')
.execute();
return metadatas.map((metadatas) => ({
albumId: metadatas.id,
assetCount: Number(metadatas.assetCount),
startDate: metadatas.startDate ? new Date(metadatas.startDate) : undefined,
endDate: metadatas.endDate ? new Date(metadatas.endDate) : undefined,
}));
}
@GenerateSql({ params: [DummyValue.UUID] })
@@ -158,20 +160,14 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.selectAll('albums')
.distinctOn('albums.createdAt')
.leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
.leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
.where((eb) =>
eb.or([
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),
),
eb('shared_albums.usersId', '=', ownerId),
eb('shared_links.userId', '=', ownerId),
eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]),
]),
)
.where('albums.deletedAt', 'is', null)
@@ -190,21 +186,16 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.selectAll('albums')
.distinctOn('albums.createdAt')
.leftJoin('albums_shared_users_users as shared_albums', 'shared_albums.albumsId', 'albums.id')
.leftJoin('shared_links', 'shared_links.albumId', 'albums.id')
.where('albums.ownerId', '=', ownerId)
.where('shared_albums.usersId', 'is', null)
.where('shared_links.userId', 'is', null)
.where('albums.deletedAt', 'is', null)
.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'))),
)
.select(withAlbumUsers)
.select(withOwner)
.select(withSharedLink)
.orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>;
}
@@ -291,6 +282,7 @@ export class AlbumRepository implements IAlbumRepository {
.selectAll()
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withSharedLink)
.select(withAssets)
.select(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>;
@@ -300,7 +292,7 @@ export class AlbumRepository implements IAlbumRepository {
update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> {
return this.db
.updateTable('albums')
.set(album)
.set({ ...album, updatedAt: new Date() })
.where('id', '=', id)
.returningAll('albums')
.returning(withOwner)
@@ -343,6 +335,7 @@ export class AlbumRepository implements IAlbumRepository {
.select('album_assets.assetsId')
.orderBy('assets.fileCreatedAt', 'desc')
.limit(1),
updatedAt: new Date(),
}))
.where((eb) =>
eb.or([
+2 -8
View File
@@ -72,14 +72,8 @@ export class SearchRepository implements ISearchRepository {
async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> {
const uuid = randomUUID();
const builder = searchAssetBuilder(this.db, options);
const lessThan = builder
.where('assets.id', '<', uuid)
.orderBy(sql`random()`)
.limit(size);
const greaterThan = builder
.where('assets.id', '>', uuid)
.orderBy(sql`random()`)
.limit(size);
const lessThan = builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size);
const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size);
const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
return rows as any as AssetEntity[];
}
+4 -4
View File
@@ -52,8 +52,8 @@ describe(AlbumService.name, () => {
it('gets list of albums for auth user', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
const result = await sut.getAll(authStub.admin, {});
@@ -82,7 +82,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => {
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
const result = await sut.getAll(authStub.admin, { shared: true });
@@ -94,7 +94,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => {
albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
const result = await sut.getAll(authStub.admin, { shared: false });
+11 -5
View File
@@ -55,7 +55,13 @@ export class AlbumService extends BaseService {
const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadata: Record<string, AlbumAssetCount> = {};
for (const metadata of results) {
albumMetadata[metadata.albumId] = metadata;
const { albumId, assetCount, startDate, endDate } = metadata;
albumMetadata[albumId] = {
albumId,
assetCount,
startDate,
endDate,
};
}
return Promise.all(
@@ -64,8 +70,8 @@ export class AlbumService extends BaseService {
return {
...mapAlbumWithoutAssets(album),
sharedLinks: undefined,
startDate: albumMetadata[album.id].startDate ?? undefined,
endDate: albumMetadata[album.id].endDate ?? undefined,
startDate: albumMetadata[album.id].startDate,
endDate: albumMetadata[album.id].endDate,
assetCount: albumMetadata[album.id].assetCount,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
@@ -83,8 +89,8 @@ export class AlbumService extends BaseService {
return {
...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds.startDate ?? undefined,
endDate: albumMetadataForIds.endDate ?? undefined,
startDate: albumMetadataForIds.startDate,
endDate: albumMetadataForIds.endDate,
assetCount: albumMetadataForIds.assetCount,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
+3 -3
View File
@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -75,7 +75,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.125.3",
"version": "1.125.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.125.3",
"version": "1.125.2",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -11,11 +11,7 @@
<section class="min-w-screen flex min-h-dvh items-center justify-center relative">
<div class="absolute -z-10 w-full h-full flex place-items-center place-content-center">
<img
src={immichLogo}
class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10 overflow-hidden"
alt="Immich logo"
/>
<img src={immichLogo} class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10" alt="Immich logo" />
<div
class="w-full h-[99%] absolute left-0 top-0 backdrop-blur-[200px] bg-transparent dark:bg-immich-dark-bg/20"
></div>