0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-11 01:18:24 -05:00

Merge branch 'main' of https://github.com/immich-app/immich into feat/nullable-dates

This commit is contained in:
Jonathan Jogenfors 2025-01-27 23:40:24 +01:00
commit 41800d6278
36 changed files with 223 additions and 146 deletions

6
cli/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.43",
"version": "2.2.46",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.43",
"version": "2.2.46",
"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.2",
"version": "1.125.5",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.2.43",
"version": "2.2.46",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View file

@ -1,4 +1,16 @@
[
{
"label": "v1.125.5",
"url": "https://v1.125.5.archive.immich.app"
},
{
"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",
"url": "https://v1.125.2.archive.immich.app"

8
e2e/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.125.2",
"version": "1.125.5",
"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.43",
"version": "2.2.46",
"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.2",
"version": "1.125.5",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View file

@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.125.2",
"version": "1.125.5",
"description": "",
"main": "index.js",
"type": "module",

View file

@ -22,79 +22,92 @@ const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
const user4DeletedAsset = 'user4DeletedAsset';
const user4Empty = 'user4Empty';
describe('/albums', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset1: AssetMediaResponseDto;
let user1Asset2: AssetMediaResponseDto;
let user4Asset1: AssetMediaResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
let deletedAssetAlbum: AlbumResponseDto;
let user3: LoginResponseDto; // deleted
let user4: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
[user1, user2, user3] = await Promise.all([
[user1, user2, user3, user4] = await Promise.all([
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]);
[user1Asset1, user1Asset2] = await Promise.all([
[user1Asset1, user1Asset2, user4Asset1] = await Promise.all([
utils.createAsset(user1.accessToken, { isFavorite: true }),
utils.createAsset(user1.accessToken),
utils.createAsset(user1.accessToken),
]);
user1Albums = await Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Editor }],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedViewerUser,
albumUsers: [{ userId: user2.userId, role: AlbumUserRole.Viewer }],
assetIds: [user1Asset1.id],
[user1Albums, user2Albums, deletedAssetAlbum] = await Promise.all([
Promise.all([
utils.createAlbum(user1.accessToken, {
albumName: user1SharedEditorUser,
albumUsers: [
{ userId: admin.userId, role: AlbumUserRole.Editor },
{ userId: user2.userId, role: AlbumUserRole.Editor },
],
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1SharedLink,
assetIds: [user1Asset1.id],
}),
utils.createAlbum(user1.accessToken, {
albumName: user1NotShared,
assetIds: [user1Asset1.id, user1Asset2.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([
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
utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Album,
@ -107,7 +120,11 @@ describe('/albums', () => {
}),
]);
await deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
[user2Albums[0]] = await Promise.all([
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', () => {
@ -284,6 +301,25 @@ describe('/albums', () => {
expect(status).toBe(200);
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', () => {
@ -362,6 +398,26 @@ 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', () => {

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.125.2"
version = "1.125.5"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View file

@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 178,
"android.injected.version.name" => "1.125.2",
"android.injected.version.code" => 181,
"android.injected.version.name" => "1.125.5",
}
)
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')

View file

@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 189;
CURRENT_PROJECT_VERSION = 190;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View file

@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.125.1</string>
<string>1.125.2</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>189</string>
<string>190</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release"
lane :release do
increment_version_number(
version_number: "1.125.2"
version_number: "1.125.5"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View file

@ -277,7 +277,6 @@ class SearchPage extends HookConsumerWidget {
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
locale: context.locale,
);
if (date == null) {

View file

@ -28,8 +28,7 @@ class UserCircleAvatar extends ConsumerWidget {
final profileImageUrl =
'${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}';
final textIcon = Text(
user.name[0].toUpperCase(),
final textIcon = DefaultTextStyle(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
@ -37,6 +36,7 @@ class UserCircleAvatar extends ConsumerWidget {
? Colors.black
: Colors.white,
),
child: Text(user.name[0].toUpperCase()),
);
return CircleAvatar(
backgroundColor: user.avatarColor.toColor(),

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.2
- API version: 1.125.5
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.125.2+178
version: 1.125.5+181
environment:
sdk: '>=3.3.0 <4.0.0'

View file

@ -7454,7 +7454,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.125.2",
"version": "1.125.5",
"contact": {}
},
"tags": [],

View file

@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View file

@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.125.2",
"version": "1.125.5",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View file

@ -1,6 +1,6 @@
/**
* Immich
* 1.125.2
* 1.125.5
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/

View file

@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.0",

View file

@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.125.2",
"version": "1.125.5",
"description": "",
"author": "",
"private": true,

View file

@ -9,8 +9,8 @@ export const IAlbumRepository = 'IAlbumRepository';
export interface AlbumAssetCount {
albumId: string;
assetCount: number;
startDate: Date | undefined;
endDate: Date | undefined;
startDate: Date | null;
endDate: Date | null;
}
export interface AlbumInfoOptions {

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.fn.toJson('exif').as('exifInfo'))
.select((eb) => eb.table('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,14 +93,19 @@ export class AlbumRepository implements IAlbumRepository {
return this.db
.selectFrom('albums')
.selectAll('albums')
.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')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.where((eb) =>
eb.or([
eb.and([eb('albums.ownerId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
eb.and([eb('album_users.usersId', '=', ownerId), eb('album_assets.assetsId', '=', assetId)]),
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),
),
]),
)
.where('album_assets.assetsId', '=', assetId)
.where('albums.deletedAt', 'is', null)
.orderBy('albums.createdAt', 'desc')
.select(withOwner)
@ -117,24 +122,18 @@ export class AlbumRepository implements IAlbumRepository {
return [];
}
const metadatas = await this.db
return 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')
.innerJoin('albums_assets_assets as album_assets', 'album_assets.albumsId', 'albums.id')
.innerJoin('assets', 'assets.id', 'album_assets.assetsId')
.select('albums.id as albumId')
.select((eb) => eb.fn.min('assets.fileCreatedAt').as('startDate'))
.select((eb) => eb.fn.max('assets.fileCreatedAt').as('endDate'))
.select((eb) => eb.fn.count('assets.id').as('assetCount'))
.select((eb) => sql<number>`${eb.fn.count('assets.id')}::int`.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] })
@ -159,14 +158,20 @@ 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('shared_albums.usersId', '=', ownerId),
eb('shared_links.userId', '=', ownerId),
eb.and([eb('albums.ownerId', '=', ownerId), eb('shared_albums.usersId', 'is not', null)]),
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),
),
]),
)
.where('albums.deletedAt', 'is', null)
@ -185,16 +190,21 @@ 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)
.select(withAlbumUsers)
.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(withOwner)
.select(withSharedLink)
.orderBy('albums.createdAt', 'desc')
.execute() as unknown as Promise<AlbumEntity[]>;
}
@ -281,7 +291,6 @@ export class AlbumRepository implements IAlbumRepository {
.selectAll()
.where('id', '=', newAlbum.id)
.select(withOwner)
.select(withSharedLink)
.select(withAssets)
.select(withAlbumUsers)
.executeTakeFirst() as unknown as Promise<AlbumEntity>;
@ -291,7 +300,7 @@ export class AlbumRepository implements IAlbumRepository {
update(id: string, album: Updateable<Albums>): Promise<AlbumEntity> {
return this.db
.updateTable('albums')
.set({ ...album, updatedAt: new Date() })
.set(album)
.where('id', '=', id)
.returningAll('albums')
.returning(withOwner)
@ -334,7 +343,6 @@ export class AlbumRepository implements IAlbumRepository {
.select('album_assets.assetsId')
.orderBy('assets.fileCreatedAt', 'desc')
.limit(1),
updatedAt: new Date(),
}))
.where((eb) =>
eb.or([

View file

@ -100,7 +100,6 @@ export class PersonRepository implements IPersonRepository {
.$if(!!options.personId, (qb) => qb.where('asset_faces.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_faces.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.$if(!!options.assetId, (qb) => qb.where('asset_faces.assetId', '=', options.assetId!))
.stream() as AsyncIterableIterator<AssetFaceEntity>;
}
@ -109,7 +108,7 @@ export class PersonRepository implements IPersonRepository {
.selectFrom('person')
.selectAll('person')
.$if(!!options.ownerId, (qb) => qb.where('person.ownerId', '=', options.ownerId!))
.$if(!!options.thumbnailPath, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.thumbnailPath !== undefined, (qb) => qb.where('person.thumbnailPath', '=', options.thumbnailPath!))
.$if(options.faceAssetId === null, (qb) => qb.where('person.faceAssetId', 'is', null))
.$if(!!options.faceAssetId, (qb) => qb.where('person.faceAssetId', '=', options.faceAssetId!))
.$if(options.isHidden !== undefined, (qb) => qb.where('person.isHidden', '=', options.isHidden!))

View file

@ -72,8 +72,14 @@ 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('assets.id').limit(size);
const greaterThan = builder.where('assets.id', '>', uuid).orderBy('assets.id').limit(size);
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 { rows } = await sql`${lessThan} union all ${greaterThan} limit ${size}`.execute(this.db);
return rows as any as AssetEntity[];
}

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: undefined, endDate: undefined },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]);
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: undefined, endDate: undefined },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: null, endDate: null },
]);
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: undefined, endDate: undefined },
{ albumId: albumStub.empty.id, assetCount: 0, startDate: null, endDate: null },
]);
const result = await sut.getAll(authStub.admin, { shared: false });

View file

@ -55,13 +55,7 @@ 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) {
const { albumId, assetCount, startDate, endDate } = metadata;
albumMetadata[albumId] = {
albumId,
assetCount,
startDate,
endDate,
};
albumMetadata[metadata.albumId] = metadata;
}
return Promise.all(
@ -70,9 +64,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbumWithoutAssets(album),
sharedLinks: undefined,
startDate: albumMetadata[album.id].startDate,
endDate: albumMetadata[album.id].endDate,
assetCount: albumMetadata[album.id].assetCount,
startDate: albumMetadata[album.id]?.startDate ?? undefined,
endDate: albumMetadata[album.id]?.endDate ?? undefined,
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}),
@ -89,9 +83,9 @@ export class AlbumService extends BaseService {
return {
...mapAlbum(album, withAssets, auth),
startDate: albumMetadataForIds.startDate,
endDate: albumMetadataForIds.endDate,
assetCount: albumMetadataForIds.assetCount,
startDate: albumMetadataForIds?.startDate ?? undefined,
endDate: albumMetadataForIds?.endDate ?? undefined,
assetCount: albumMetadataForIds?.assetCount ?? 0,
lastModifiedAssetTimestamp: lastModifiedAsset?.updatedAt,
};
}

View file

@ -194,7 +194,7 @@ export class MediaService extends BaseService {
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
}
if (asset.thumbhash != generated.thumbhash) {
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, generated.thumbhash) !== 0) {
await this.assetRepository.update({ id: asset.id, thumbhash: generated.thumbhash });
}

View file

@ -4,7 +4,7 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<IMediaRepository> => {
return {
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockImplementation(() => Promise.resolve()),
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
extract: vitest.fn().mockResolvedValue(false),
probe: vitest.fn(),

6
web/package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.125.2",
"version": "1.125.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.125.2",
"version": "1.125.5",
"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.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View file

@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.125.2",
"version": "1.125.5",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",

View file

@ -11,7 +11,11 @@
<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" alt="Immich logo" />
<img
src={immichLogo}
class="max-w-screen-md mx-auto h-full mb-2 antialiased -z-10 overflow-hidden"
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>

View file

@ -15,7 +15,7 @@
</script>
<ul class="list-none ml-2">
{#each Object.entries(items) as [path, tree]}
{#each Object.entries(items).sort() as [path, tree]}
{@const value = normalizeTreePath(`${parent}/${path}`)}
{@const key = value + getColor(value)}
{#key key}

View file

@ -24,7 +24,6 @@ class FoldersStore {
const uniquePaths = await getUniqueOriginalPaths();
this.uniquePaths.push(...uniquePaths);
this.uniquePaths.sort();
}
bustAssetCache() {

View file

@ -44,7 +44,7 @@
let pathSegments = $derived(data.path ? data.path.split('/') : []);
let tree = $derived(buildTree(foldersStore.uniquePaths));
let currentPath = $derived($page.url.searchParams.get(QueryParameter.PATH) || '');
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree));
let currentTreeItems = $derived(currentPath ? data.currentFolders : Object.keys(tree).sort());
const assetInteraction = new AssetInteraction();

View file

@ -34,7 +34,7 @@ export const load = (async ({ params, url }) => {
return {
asset,
path,
currentFolders: Object.keys(tree || {}),
currentFolders: Object.keys(tree || {}).sort(),
pathAssets,
meta: {
title: $t('folders'),