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
34 changed files with 226 additions and 270 deletions
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.45", "version": "2.2.43",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.45", "version": "2.2.43",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
@@ -52,7 +52,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.4", "version": "1.125.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.45", "version": "2.2.43",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
-8
View File
@@ -1,12 +1,4 @@
[ [
{
"label": "v1.125.4",
"url": "https://v1.125.4.archive.immich.app"
},
{
"label": "v1.125.3",
"url": "https://v1.125.3.archive.immich.app"
},
{ {
"label": "v1.125.2", "label": "v1.125.2",
"url": "https://v1.125.2.archive.immich.app" "url": "https://v1.125.2.archive.immich.app"
+4 -4
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.125.4", "version": "1.125.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.125.4", "version": "1.125.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -45,7 +45,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.45", "version": "2.2.43",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -92,7 +92,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.4", "version": "1.125.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.125.4", "version": "1.125.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
+45 -101
View File
@@ -22,92 +22,79 @@ const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser'; const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink'; const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared'; const user2NotShared = 'user2NotShared';
const user4DeletedAsset = 'user4DeletedAsset';
const user4Empty = 'user4Empty';
describe('/albums', () => { describe('/albums', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user1: LoginResponseDto; let user1: LoginResponseDto;
let user1Asset1: AssetMediaResponseDto; let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetMediaResponseDto; let user1Asset2: AssetMediaResponseDto;
let user4Asset1: AssetMediaResponseDto;
let user1Albums: AlbumResponseDto[]; let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto; let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[]; let user2Albums: AlbumResponseDto[];
let deletedAssetAlbum: AlbumResponseDto;
let user3: LoginResponseDto; // deleted let user3: LoginResponseDto; // deleted
let user4: LoginResponseDto;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
[user1, user2, user3, user4] = await Promise.all([ [user1, user2, user3] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3), utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]); ]);
[user1Asset1, user1Asset2, user4Asset1] = await Promise.all([ [user1Asset1, user1Asset2] = await Promise.all([
utils.createAsset(user1.accessToken, { isFavorite: true }), utils.createAsset(user1.accessToken, { isFavorite: true }),
utils.createAsset(user1.accessToken), utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]); ]);
[user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([ user1Albums = await Promise.all([
Promise.all([ utils.createAlbum(user1.accessToken, {
utils.createAlbum(user1.accessToken, { albumName: user1SharedEditorUser,
albumName: user1SharedEditorUser, albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
albumUsers: [ assetIds: [user1Asset1.id],
{ userId: admin.userId, role: AlbumUserRole.Editor }, }),
{ userId: user2.userId, role: AlbumUserRole.Editor }, utils.createAlbum(user1.accessToken, {
], albumName: user1SharedLink,
assetIds: [user1Asset1.id], assetIds: [user1Asset1.id],
}), }),
utils.createAlbum(user1.accessToken, { utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink, albumName: user1NotShared,
assetIds: [user1Asset1.id], assetIds: [user1Asset1.id, user1Asset2.id],
}), }),
utils.createAlbum(user1.accessToken, { utils.createAlbum(user1.accessToken, {
albumName: user1NotShared, albumName: user1SharedViewerUser,
assetIds: [user1Asset1.id, user1Asset2.id], albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
}), assetIds: [user1Asset1.id],
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
}),
]),
Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]),
utils.createAlbum(user4.accessToken, { albumName: user4DeletedAsset }),
utils.createAlbum(user4.accessToken, { albumName: user4Empty }),
utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
}), }),
]); ]);
user2Albums = await Promise.all([
utils.createAlbum(user2.accessToken, {
albumName: user2SharedUser,
albumUsers: [
{ userId: user1.userId, role: AlbumUserRole.Editor },
{ userId: user3.userId, role: AlbumUserRole.Editor },
],
}),
utils.createAlbum(user2.accessToken, { albumName: user2SharedLink }),
utils.createAlbum(user2.accessToken, { albumName: user2NotShared }),
]);
await utils.createAlbum(user3.accessToken, {
albumName: 'Deleted',
albumUsers: [{ userId: user1.userId, role: AlbumUserRole.Editor }],
});
await addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
);
user2Albums[0] = await getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) });
await Promise.all([ await Promise.all([
addAssetsToAlbum(
{ id: user2Albums[0].id, bulkIdsDto: { ids: [user1Asset1.id, user1Asset2.id] } },
{ headers: asBearerAuth(user1.accessToken) },
),
addAssetsToAlbum(
{ id: deletedAssetAlbum.id, bulkIdsDto: { ids: [user4Asset1.id] } },
{ headers: asBearerAuth(user4.accessToken) },
),
// add shared link to user1SharedLink album // add shared link to user1SharedLink album
utils.createSharedLink(user1.accessToken, { utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album, type: SharedLinkType.Album,
@@ -120,11 +107,7 @@ describe('/albums', () => {
}), }),
]); ]);
[user2Albums[0]] = await Promise.all([ await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),
utils.deleteAssets(user1.accessToken, [user4Asset1.id]),
]);
}); });
describe('GET /albums', () => { describe('GET /albums', () => {
@@ -301,25 +284,6 @@ describe('/albums', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body).toHaveLength(5); expect(body).toHaveLength(5);
}); });
it('should return empty albums and albums where all assets are deleted', async () => {
const { status, body } = await request(app).get('/albums').set('Authorization', `Bearer ${user4.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({
ownerId: user4.userId,
albumName: user4DeletedAsset,
shared: false,
}),
expect.objectContaining({
ownerId: user4.userId,
albumName: user4Empty,
shared: false,
}),
]),
);
});
}); });
describe('GET /albums/:id', () => { describe('GET /albums/:id', () => {
@@ -398,26 +362,6 @@ describe('/albums', () => {
shared: true, 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', () => { describe('GET /albums/statistics', () => {
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "machine-learning" name = "machine-learning"
version = "1.125.4" version = "1.125.2"
description = "" description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"] authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md" readme = "README.md"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 180, "android.injected.version.code" => 178,
"android.injected.version.name" => "1.125.4", "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') 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')
+6 -6
View File
@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 190; CURRENT_PROJECT_VERSION = 189;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 190; CURRENT_PROJECT_VERSION = 189;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 190; CURRENT_PROJECT_VERSION = 189;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 190; CURRENT_PROJECT_VERSION = 189;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 190; CURRENT_PROJECT_VERSION = 189;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 190; CURRENT_PROJECT_VERSION = 189;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
+2 -2
View File
@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.125.2</string> <string>1.125.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>190</string> <string>189</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.125.4" version_number: "1.125.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,
@@ -28,7 +28,8 @@ class UserCircleAvatar extends ConsumerWidget {
final profileImageUrl = final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}'; '${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( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 12, fontSize: 12,
@@ -36,7 +37,6 @@ class UserCircleAvatar extends ConsumerWidget {
? Colors.black ? Colors.black
: Colors.white, : Colors.white,
), ),
child: Text(user.name[0].toUpperCase()),
); );
return CircleAvatar( return CircleAvatar(
backgroundColor: user.avatarColor.toColor(), 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: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.125.4 - API version: 1.125.2
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - 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 description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.125.4+180 version: 1.125.2+178
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
+1 -1
View File
@@ -7454,7 +7454,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.125.4", "version": "1.125.2",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.4", "version": "1.125.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.4", "version": "1.125.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.4", "version": "1.125.2",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.125.4 * 1.125.2
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich", "name": "immich",
"version": "1.125.4", "version": "1.125.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich", "name": "immich",
"version": "1.125.4", "version": "1.125.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@nestjs/bullmq": "^11.0.0", "@nestjs/bullmq": "^11.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.125.4", "version": "1.125.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
+2 -2
View File
@@ -9,8 +9,8 @@ export const IAlbumRepository = 'IAlbumRepository';
export interface AlbumAssetCount { export interface AlbumAssetCount {
albumId: string; albumId: string;
assetCount: number; assetCount: number;
startDate: Date | null; startDate: Date | undefined;
endDate: Date | null; endDate: Date | undefined;
} }
export interface AlbumInfoOptions { export interface AlbumInfoOptions {
+83 -53
View File
@@ -90,7 +90,7 @@ select
( (
select select
"assets".*, "assets".*,
"exif" as "exifInfo" to_json("exif") as "exifInfo"
from from
"assets" "assets"
inner join "exif" on "assets"."id" = "exif"."assetId" inner join "exif" on "assets"."id" = "exif"."assetId"
@@ -180,20 +180,19 @@ select
) as "albumUsers" ) as "albumUsers"
from from
"albums" "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 where
( (
"albums"."ownerId" = $1 (
or exists ( "albums"."ownerId" = $1
select and "album_assets"."assetsId" = $2
from )
"albums_shared_users_users" as "album_users" or (
where "album_users"."usersId" = $3
"album_users"."albumsId" = "albums"."id" and "album_assets"."assetsId" = $4
and "album_users"."usersId" = $2
) )
) )
and "album_assets"."assetsId" = $3
and "albums"."deletedAt" is null and "albums"."deletedAt" is null
order by order by
"albums"."createdAt" desc, "albums"."createdAt" desc,
@@ -201,17 +200,16 @@ order by
-- AlbumRepository.getMetadataForIds -- AlbumRepository.getMetadataForIds
select select
"albums"."id" as "albumId", "albums"."id",
min("assets"."fileCreatedAt") as "startDate", min("assets"."fileCreatedAt") as "startDate",
max("assets"."fileCreatedAt") as "endDate", max("assets"."fileCreatedAt") as "endDate",
count("assets"."id")::int as "assetCount" count("assets"."id") as "assetCount"
from from
"albums" "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"
inner join "assets" on "assets"."id" = "album_assets"."assetsId" left join "assets" on "assets"."id" = "album_assets"."assetsId"
where where
"albums"."id" in ($1) "albums"."id" in ($1)
and "assets"."deletedAt" is null
group by group by
"albums"."id" "albums"."id"
@@ -307,8 +305,8 @@ order by
"albums"."createdAt" desc "albums"."createdAt" desc
-- AlbumRepository.getShared -- AlbumRepository.getShared
select select distinct
"albums".*, on ("albums"."createdAt") "albums".*,
( (
select select
coalesce(json_agg(agg), '[]') coalesce(json_agg(agg), '[]')
@@ -391,26 +389,15 @@ select
) as "sharedLinks" ) as "sharedLinks"
from from
"albums" "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 where
( (
exists ( "shared_albums"."usersId" = $1
select or "shared_links"."userId" = $2
from or (
"albums_shared_users_users" as "album_users" "albums"."ownerId" = $3
where and "shared_albums"."usersId" is not null
"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
) )
) )
and "albums"."deletedAt" is null and "albums"."deletedAt" is null
@@ -418,8 +405,48 @@ order by
"albums"."createdAt" desc "albums"."createdAt" desc
-- AlbumRepository.getNotShared -- AlbumRepository.getNotShared
select select distinct
"albums".*, 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 select
to_json(obj) to_json(obj)
@@ -446,26 +473,29 @@ select
where where
"users"."id" = "albums"."ownerId" "users"."id" = "albums"."ownerId"
) as obj ) 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 from
"albums" "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 where
"albums"."ownerId" = $1 "albums"."ownerId" = $1
and "shared_albums"."usersId" is null
and "shared_links"."userId" is null
and "albums"."deletedAt" 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 order by
"albums"."createdAt" desc "albums"."createdAt" desc
+2 -2
View File
@@ -36,7 +36,7 @@ offset
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."id" < $6 and "assets"."id" < $6
order by order by
random() "assets"."id"
limit limit
$7 $7
) )
@@ -56,7 +56,7 @@ union all
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "assets"."id" > $13 and "assets"."id" > $13
order by order by
random() "assets"."id"
limit limit
$14 $14
) )
+33 -40
View File
@@ -59,7 +59,7 @@ const withAssets = (eb: ExpressionBuilder<DB, 'albums'>) => {
.selectFrom('assets') .selectFrom('assets')
.selectAll('assets') .selectAll('assets')
.innerJoin('exif', 'assets.id', 'exif.assetId') .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') .innerJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
.whereRef('albums_assets_assets.albumsId', '=', 'albums.id') .whereRef('albums_assets_assets.albumsId', '=', 'albums.id')
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
@@ -93,19 +93,14 @@ export class AlbumRepository implements IAlbumRepository {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('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) => .where((eb) =>
eb.or([ eb.or([
eb('albums.ownerId', '=', ownerId), eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
eb.exists( eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
eb
.selectFrom('albums_shared_users_users as album_users')
.whereRef('album_users.albumsId', '=', 'albums.id')
.where('album_users.usersId', '=', ownerId),
),
]), ]),
) )
.where('album_assets.assetsId', '=', assetId)
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.select(withOwner) .select(withOwner)
@@ -122,18 +117,25 @@ export class AlbumRepository implements IAlbumRepository {
return []; return [];
} }
return this.db const metadatas = await this.db
.selectFrom('albums') .selectFrom('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')
.innerJoin('assets', 'assets.id', 'album_assets.assetsId') .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.min('assets.fileCreatedAt').as('startDate'))
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate')) .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('albums.id', 'in', ids)
.where('assets.deletedAt', 'is', null) .where('assets.deletedAt', 'is', null)
.groupBy('albums.id') .groupBy('albums.id')
.execute(); .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] }) @GenerateSql({ params: [DummyValue.UUID] })
@@ -158,20 +160,14 @@ export class AlbumRepository implements IAlbumRepository {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('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) => .where((eb) =>
eb.or([ eb.or([
eb.exists( eb('shared_albums.usersId', '=', ownerId),
eb eb('shared_links.userId', '=', ownerId),
.selectFrom('albums_shared_users_users as album_users') eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]),
.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),
),
]), ]),
) )
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
@@ -190,21 +186,16 @@ export class AlbumRepository implements IAlbumRepository {
return this.db return this.db
.selectFrom('albums') .selectFrom('albums')
.selectAll('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('albums.ownerId', '=', ownerId)
.where('shared_albums.usersId', 'is', null)
.where('shared_links.userId', 'is', null)
.where('albums.deletedAt', 'is', null) .where('albums.deletedAt', 'is', null)
.where((eb) => .select(withAlbumUsers)
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(withOwner) .select(withOwner)
.select(withSharedLink)
.orderBy('albums.createdAt', 'desc') .orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>; .execute() as unknown as Promise<AlbumEntity[]>;
} }
@@ -291,6 +282,7 @@ export class AlbumRepository implements IAlbumRepository {
.selectAll() .selectAll()
.where('id', '=', newAlbum.id) .where('id', '=', newAlbum.id)
.select(withOwner) .select(withOwner)
.select(withSharedLink)
.select(withAssets) .select(withAssets)
.select(withAlbumUsers) .select(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>; .executeTakeFirst() as unknown as Promise<AlbumEntity>;
@@ -300,7 +292,7 @@ export class AlbumRepository implements IAlbumRepository {
update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> { update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> {
return this.db return this.db
.updateTable('albums') .updateTable('albums')
.set(album) .set({ ...album, updatedAt: new Date() })
.where('id', '=', id) .where('id', '=', id)
.returningAll('albums') .returningAll('albums')
.returning(withOwner) .returning(withOwner)
@@ -343,6 +335,7 @@ export class AlbumRepository implements IAlbumRepository {
.select('album_assets.assetsId') .select('album_assets.assetsId')
.orderBy('assets.fileCreatedAt', 'desc') .orderBy('assets.fileCreatedAt', 'desc')
.limit(1), .limit(1),
updatedAt: new Date(),
})) }))
.where((eb) => .where((eb) =>
eb.or([ eb.or([
+2 -8
View File
@@ -72,14 +72,8 @@ export class SearchRepository implements ISearchRepository {
async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> { async searchRandom(size: number, options: AssetSearchOptions): Promise<AssetEntity[]> {
const uuid = randomUUID(); const uuid = randomUUID();
const builder = searchAssetBuilder(this.db, options); const builder = searchAssetBuilder(this.db, options);
const lessThan = builder const lessThan = builder.where('assets.id', '<', uuid).orderBy('assets.id').limit(size);
.where('assets.id', '<', uuid) const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size);
.orderBy(sql`random()`)
.limit(size);
const greaterThan = builder
.where('assets.id', '>', uuid)
.orderBy(sql`random()`)
.limit(size);
const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db); const { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
return rows as any as AssetEntity[]; 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 () => { it('gets list of albums for auth user', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]); albumMock.getOwned.mockResolvedValue([albumStub.empty, albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([ albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null }, { albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ 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, {}); const result = await sut.getAll(authStub.admin, {});
@@ -82,7 +82,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => { it('gets list of albums that are shared', async () => {
albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]); albumMock.getShared.mockResolvedValue([albumStub.sharedWithUser]);
albumMock.getMetadataForIds.mockResolvedValue([ 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 }); 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 () => { it('gets list of albums that are NOT shared', async () => {
albumMock.getNotShared.mockResolvedValue([albumStub.empty]); albumMock.getNotShared.mockResolvedValue([albumStub.empty]);
albumMock.getMetadataForIds.mockResolvedValue([ 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 }); const result = await sut.getAll(authStub.admin, { shared: false });
+13 -7
View File
@@ -55,7 +55,13 @@ export class AlbumService extends BaseService {
const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id)); const results = await this.albumRepository.getMetadataForIds(albums.map((album) => album.id));
const albumMetadata: Record<string, AlbumAssetCount> = {}; const albumMetadata: Record<string, AlbumAssetCount> = {};
for (const metadata of results) { for (const metadata of results) {
albumMetadata[metadata.albumId] = metadata; const { albumId, assetCount, startDate, endDate } = metadata;
albumMetadata[albumId] = {
albumId,
assetCount,
startDate,
endDate,
};
} }
return Promise.all( return Promise.all(
@@ -64,9 +70,9 @@ export class AlbumService extends BaseService {
return { return {
...mapAlbumWithoutAssets(album), ...mapAlbumWithoutAssets(album),
sharedLinks: undefined, sharedLinks: undefined,
startDate: albumMetadata[album.id]?.startDate ?? undefined, startDate: albumMetadata[album.id].startDate,
endDate: albumMetadata[album.id]?.endDate ?? undefined, endDate: albumMetadata[album.id].endDate,
assetCount: albumMetadata[album.id]?.assetCount ?? 0, assetCount: albumMetadata[album.id].assetCount,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
}; };
}), }),
@@ -83,9 +89,9 @@ export class AlbumService extends BaseService {
return { return {
...mapAlbum(album, withAssets, auth), ...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds?.startDate ?? undefined, startDate: albumMetadataForIds.startDate,
endDate: albumMetadataForIds?.endDate ?? undefined, endDate: albumMetadataForIds.endDate,
assetCount: albumMetadataForIds?.assetCount ?? 0, assetCount: albumMetadataForIds.assetCount,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt, lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
}; };
} }
+3 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.125.4", "version": "1.125.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-web", "name": "immich-web",
"version": "1.125.4", "version": "1.125.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8", "@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -75,7 +75,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.125.4", "version": "1.125.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.125.4", "version": "1.125.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000", "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"> <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"> <div class="absolute -z-10 w-full h-full flex place-items-center place-content-center">
<img <img src={immichLogo} class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10" alt="Immich logo" />
src={immichLogo}
class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10 overflow-hidden"
alt="Immich logo"
/>
<div <div
class="w-full h-[99%] absolute left-0 top-0 backdrop-blur-[200px] bg-transparent dark:bg-immich-dark-bg/20" class="w-full h-[99%] absolute left-0 top-0 backdrop-blur-[200px] bg-transparent dark:bg-immich-dark-bg/20"
></div> ></div>
@@ -15,7 +15,7 @@
</script> </script>
<ul class="list-none ml-2"> <ul class="list-none ml-2">
{#each Object.entries(items).sort() as [path, tree]} {#each Object.entries(items) as [path, tree]}
{@const value = normalizeTreePath(`${parent}/${path}`)} {@const value = normalizeTreePath(`${parent}/${path}`)}
{@const key = value + getColor(value)} {@const key = value + getColor(value)}
{#key key} {#key key}
+1
View File
@@ -24,6 +24,7 @@ class FoldersStore {
const uniquePaths = await getUniqueOriginalPaths(); const uniquePaths = await getUniqueOriginalPaths();
this.uniquePaths.push(...uniquePaths); this.uniquePaths.push(...uniquePaths);
this.uniquePaths.sort();
} }
bustAssetCache() { bustAssetCache() {
@@ -44,7 +44,7 @@
let pathSegments = $derived(data.path ? data.path.split('/') : []); let pathSegments = $derived(data.path ? data.path.split('/') : []);
let tree = $derived(buildTree(foldersStore.uniquePaths)); let tree = $derived(buildTree(foldersStore.uniquePaths));
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || ''); let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort()); let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
@@ -34,7 +34,7 @@ export const load = (async ({ params, url }) => {
return { return {
asset, asset,
path, path,
currentFolders: Object.keys(tree || {}).sort(), currentFolders: Object.keys(tree || {}),
pathAssets, pathAssets,
meta: { meta: {
title: $t('folders'), title: $t('folders'),