diff --git a/e2e/src/api/specs/person.e2e-spec.ts b/e2e/src/api/specs/person.e2e-spec.ts index 6e7eba74ba..1826002af6 100644 --- a/e2e/src/api/specs/person.e2e-spec.ts +++ b/e2e/src/api/specs/person.e2e-spec.ts @@ -5,22 +5,6 @@ import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; -const invalidBirthday = [ - { - birthDate: 'false', - response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'], - }, - { - birthDate: '123567', - response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'], - }, - { - birthDate: 123_567, - response: ['birthDate must be a string in the format yyyy-MM-dd', 'Birth date cannot be in the future'], - }, - { birthDate: '9999-01-01', response: ['Birth date cannot be in the future'] }, -]; - describe('/people', () => { let admin: LoginResponseDto; let visiblePerson: PersonResponseDto; @@ -58,14 +42,6 @@ describe('/people', () => { describe('GET /people', () => { beforeEach(async () => {}); - - it('should require authentication', async () => { - const { status, body } = await request(app).get('/people'); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should return all people (including hidden)', async () => { const { status, body } = await request(app) .get('/people') @@ -117,13 +93,6 @@ describe('/people', () => { }); describe('GET /people/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/people/${uuidDto.notFound}`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should throw error if person with id does not exist', async () => { const { status, body } = await request(app) .get(`/people/${uuidDto.notFound}`) @@ -144,13 +113,6 @@ describe('/people', () => { }); describe('GET /people/:id/statistics', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get(`/people/${multipleAssetsPerson.id}/statistics`); - - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should throw error if person with id does not exist', async () => { const { status, body } = await request(app) .get(`/people/${uuidDto.notFound}/statistics`) @@ -171,23 +133,6 @@ describe('/people', () => { }); describe('POST /people', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/people`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - for (const { birthDate, response } of invalidBirthday) { - it(`should not accept an invalid birth date [${birthDate}]`, async () => { - const { status, body } = await request(app) - .post(`/people`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ birthDate }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(response)); - }); - } - it('should create a person', async () => { const { status, body } = await request(app) .post(`/people`) @@ -223,39 +168,6 @@ describe('/people', () => { }); describe('PUT /people/:id', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).put(`/people/${uuidDto.notFound}`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - for (const { key, type } of [ - { key: 'name', type: 'string' }, - { key: 'featureFaceAssetId', type: 'string' }, - { key: 'isHidden', type: 'boolean value' }, - { key: 'isFavorite', type: 'boolean value' }, - ]) { - it(`should not allow null ${key}`, async () => { - const { status, body } = await request(app) - .put(`/people/${visiblePerson.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ [key]: null }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest([`${key} must be a ${type}`])); - }); - } - - for (const { birthDate, response } of invalidBirthday) { - it(`should not accept an invalid birth date [${birthDate}]`, async () => { - const { status, body } = await request(app) - .put(`/people/${visiblePerson.id}`) - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ birthDate }); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(response)); - }); - } - it('should update a date of birth', async () => { const { status, body } = await request(app) .put(`/people/${visiblePerson.id}`) @@ -312,12 +224,6 @@ describe('/people', () => { }); describe('POST /people/:id/merge', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).post(`/people/${uuidDto.notFound}/merge`); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - it('should not supporting merging a person into themselves', async () => { const { status, body } = await request(app) .post(`/people/${visiblePerson.id}/merge`) diff --git a/i18n/en.json b/i18n/en.json index 578fe9a115..b9331df5db 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -601,6 +601,7 @@ "cannot_undo_this_action": "You cannot undo this action!", "cannot_update_the_description": "Cannot update the description", "change_date": "Change date", + "change_description": "Change description", "change_display_order": "Change display order", "change_expiration_time": "Change expiration time", "change_location": "Change location", @@ -794,6 +795,8 @@ "edit_avatar": "Edit avatar", "edit_date": "Edit date", "edit_date_and_time": "Edit date and time", + "edit_description": "Edit description", + "edit_description_prompt": "Please select a new description:", "edit_exclusion_pattern": "Edit exclusion pattern", "edit_faces": "Edit faces", "edit_import_path": "Edit import path", @@ -882,6 +885,7 @@ "unable_to_archive_unarchive": "Unable to {archived, select, true {archive} other {unarchive}}", "unable_to_change_album_user_role": "Unable to change the album user's role", "unable_to_change_date": "Unable to change date", + "unable_to_change_description": "Unable to change description", "unable_to_change_favorite": "Unable to change favorite for asset", "unable_to_change_location": "Unable to change location", "unable_to_change_password": "Unable to change password", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index 270e743291..a462b5e696 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -8,10 +8,13 @@ FROM builder-cpu AS builder-cuda FROM builder-cpu AS builder-armnn +# renovate: datasource=github-releases depName=ARM-software/armnn +ARG ARMNN_VERSION="v24.05" + ENV ARMNN_PATH=/opt/armnn COPY ann /opt/ann RUN mkdir /opt/armnn && \ - curl -SL "https://github.com/ARM-software/armnn/releases/download/v24.05/ArmNN-linux-aarch64.tar.gz" | tar -zx -C /opt/armnn && \ + curl -SL "https://github.com/ARM-software/armnn/releases/download/${ARMNN_VERSION}/ArmNN-linux-aarch64.tar.gz" | tar -zx -C /opt/armnn && \ cd /opt/ann && \ sh build.sh @@ -21,6 +24,8 @@ FROM builder-cpu AS builder-rknn # TODO: find a way to reduce the image size FROM rocm/dev-ubuntu-22.04:6.3.4-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm +# renovate: datasource=github-releases depName=Microsoft/onnxruntime +ARG ONNXRUNTIME_VERSION="v1.20.1" WORKDIR /code RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv @@ -32,7 +37,7 @@ RUN wget -nv https://github.com/Kitware/CMake/releases/download/v3.30.1/cmake-3. ENV PATH=/code/cmake-3.30.1-linux-x86_64/bin:${PATH} -RUN git clone --single-branch --branch v1.20.1 --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime +RUN git clone --single-branch --branch "${ONNXRUNTIME_VERSION}" --recursive "https://github.com/Microsoft/onnxruntime" onnxruntime WORKDIR /code/onnxruntime # Fix for multi-threading based on comments in https://github.com/microsoft/onnxruntime/pull/19567 # TODO: find a way to fix this without disabling algo caching @@ -42,7 +47,7 @@ RUN git apply /tmp/*.patch RUN /bin/sh ./dockerfiles/scripts/install_common_deps.sh # Note: the `parallel` setting uses a substantial amount of RAM RUN ./build.sh --allow_running_as_root --config Release --build_wheel --update --build --parallel 17 --cmake_extra_defines\ - ONNXRUNTIME_VERSION=1.20.1 --skip_tests --use_rocm --rocm_home=/opt/rocm + ONNXRUNTIME_VERSION="${ONNXRUNTIME_VERSION}" --skip_tests --use_rocm --rocm_home=/opt/rocm RUN mv /code/onnxruntime/build/Linux/Release/dist/*.whl /opt/ FROM builder-${DEVICE} AS builder @@ -74,6 +79,7 @@ RUN apt-get update && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \ wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \ wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \ + # TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file wget -nv https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \ dpkg -i *.deb && \ rm *.deb && \ @@ -118,9 +124,12 @@ COPY --from=builder-armnn \ FROM prod-cpu AS prod-rknn +# renovate: datasource=github-tags depName=airockchip/rknn-toolkit2 +ARG RKNN_TOOLKIT_VERSION="v2.3.0" + ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 -ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/v2.3.0/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so /usr/lib/ +ADD --checksum=sha256:73993ed4b440460825f21611731564503cc1d5a0c123746477da6cd574f34885 "https://github.com/airockchip/rknn-toolkit2/raw/refs/tags/${RKNN_TOOLKIT_VERSION}/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so" /usr/lib/ FROM prod-${DEVICE} AS prod diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 39d7cd996f..571badf029 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -14,6 +14,7 @@ class AssetBulkUpdateDto { /// Returns a new [AssetBulkUpdateDto] instance. AssetBulkUpdateDto({ this.dateTimeOriginal, + this.description, this.duplicateId, this.ids = const [], this.isFavorite, @@ -31,6 +32,14 @@ class AssetBulkUpdateDto { /// String? dateTimeOriginal; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? description; + String? duplicateId; List ids; @@ -80,6 +89,7 @@ class AssetBulkUpdateDto { @override bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && other.dateTimeOriginal == dateTimeOriginal && + other.description == description && other.duplicateId == duplicateId && _deepEquality.equals(other.ids, ids) && other.isFavorite == isFavorite && @@ -92,6 +102,7 @@ class AssetBulkUpdateDto { int get hashCode => // ignore: unnecessary_parenthesis (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + + (description == null ? 0 : description!.hashCode) + (duplicateId == null ? 0 : duplicateId!.hashCode) + (ids.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + @@ -101,7 +112,7 @@ class AssetBulkUpdateDto { (visibility == null ? 0 : visibility!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, visibility=$visibility]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, description=$description, duplicateId=$duplicateId, ids=$ids, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, visibility=$visibility]'; Map toJson() { final json = {}; @@ -110,6 +121,11 @@ class AssetBulkUpdateDto { } else { // json[r'dateTimeOriginal'] = null; } + if (this.description != null) { + json[r'description'] = this.description; + } else { + // json[r'description'] = null; + } if (this.duplicateId != null) { json[r'duplicateId'] = this.duplicateId; } else { @@ -154,6 +170,7 @@ class AssetBulkUpdateDto { return AssetBulkUpdateDto( dateTimeOriginal: mapValueOfType(json, r'dateTimeOriginal'), + description: mapValueOfType(json, r'description'), duplicateId: mapValueOfType(json, r'duplicateId'), ids: json[r'ids'] is Iterable ? (json[r'ids'] as Iterable).cast().toList(growable: false) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 89bdfef45e..5de3987367 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -8605,6 +8605,9 @@ "dateTimeOriginal": { "type": "string" }, + "description": { + "type": "string" + }, "duplicateId": { "nullable": true, "type": "string" @@ -11075,6 +11078,7 @@ }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", + "format": "uuid", "type": "string" }, "id": { @@ -11280,6 +11284,7 @@ }, "featureFaceAssetId": { "description": "Asset is used to get the feature face thumbnail.", + "format": "uuid", "type": "string" }, "isFavorite": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 1d3a04da44..c293b2aa6c 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -431,6 +431,7 @@ export type AssetMediaResponseDto = { }; export type AssetBulkUpdateDto = { dateTimeOriginal?: string; + description?: string; duplicateId?: string | null; ids: string[]; isFavorite?: boolean; diff --git a/server/src/controllers/person.controller.spec.ts b/server/src/controllers/person.controller.spec.ts new file mode 100644 index 0000000000..0366829336 --- /dev/null +++ b/server/src/controllers/person.controller.spec.ts @@ -0,0 +1,172 @@ +import { PersonController } from 'src/controllers/person.controller'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PersonService } from 'src/services/person.service'; +import request from 'supertest'; +import { errorDto } from 'test/medium/responses'; +import { factory } from 'test/small.factory'; +import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; + +describe(PersonController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(PersonService); + + beforeAll(async () => { + ctx = await controllerSetup(PersonController, [ + { provide: PersonService, useValue: service }, + { provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) }, + ]); + return () => ctx.close(); + }); + + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + describe('GET /people', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get('/people'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it(`should require closestPersonId to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/people`) + .query({ closestPersonId: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + + it(`should require closestAssetId to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .get(`/people`) + .query({ closestAssetId: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')])); + }); + }); + + describe('POST /people', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post('/people'); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should map an empty birthDate to null', async () => { + await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' }); + expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null }); + }); + }); + + describe('GET /people/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('PUT /people/:id', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + + it('should require a valid uuid', async () => { + const { status, body } = await request(ctx.getHttpServer()).put(`/people/123`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')])); + }); + + it(`should not allow a null name`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .post(`/people`) + .send({ name: null }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['name must be a string'])); + }); + + it(`should require featureFaceAssetId to be a uuid`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ featureFaceAssetId: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['featureFaceAssetId must be a UUID'])); + }); + + it(`should require isFavorite to be a boolean`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ isFavorite: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['isFavorite must be a boolean value'])); + }); + + it(`should require isHidden to be a boolean`, async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ isHidden: 'invalid' }) + .set('Authorization', `Bearer token`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['isHidden must be a boolean value'])); + }); + + it('should map an empty birthDate to null', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).put(`/people/${id}`).send({ birthDate: '' }); + expect(service.update).toHaveBeenCalledWith(undefined, id, { birthDate: null }); + }); + + it('should not accept an invalid birth date (false)', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ birthDate: false }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest([ + 'birthDate must be a string in the format yyyy-MM-dd', + 'Birth date cannot be in the future', + ]), + ); + }); + + it('should not accept an invalid birth date (number)', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ birthDate: 123_456 }); + expect(status).toBe(400); + expect(body).toEqual( + errorDto.badRequest([ + 'birthDate must be a string in the format yyyy-MM-dd', + 'Birth date cannot be in the future', + ]), + ); + }); + + it('should not accept a birth date in the future)', async () => { + const { status, body } = await request(ctx.getHttpServer()) + .put(`/people/${factory.uuid()}`) + .send({ birthDate: '9999-01-01' }); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['Birth date cannot be in the future'])); + }); + }); + + describe('POST /people/:id/merge', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).post(`/people/${factory.uuid()}/merge`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); + + describe('GET /people/:id/statistics', () => { + it('should be an authenticated route', async () => { + await request(ctx.getHttpServer()).get(`/people/${factory.uuid()}/statistics`); + expect(ctx.authenticate).toHaveBeenCalled(); + }); + }); +}); diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index e98dd6a002..3440042eda 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -27,7 +27,9 @@ export class PersonController { constructor( private service: PersonService, private logger: LoggingRepository, - ) {} + ) { + this.logger.setContext(PersonController.name); + } @Get() @Authenticated({ permission: Permission.PERSON_READ }) diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 0789633878..940cfbf9cc 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -54,6 +54,10 @@ export class UpdateAssetBase { @Max(5) @Min(-1) rating?: number; + + @Optional() + @IsString() + description?: string; } export class AssetBulkUpdateDto extends UpdateAssetBase { @@ -65,10 +69,6 @@ export class AssetBulkUpdateDto extends UpdateAssetBase { } export class UpdateAssetDto extends UpdateAssetBase { - @Optional() - @IsString() - description?: string; - @ValidateUUID({ optional: true, nullable: true }) livePhotoVideoId?: string | null; } diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 90490715ef..c59ab905bd 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -33,7 +33,7 @@ export class PersonCreateDto { @ApiProperty({ format: 'date' }) @MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' }) @IsDateStringFormat('yyyy-MM-dd') - @Optional({ nullable: true }) + @Optional({ nullable: true, emptyToNull: true }) birthDate?: Date | null; /** @@ -54,8 +54,7 @@ export class PersonUpdateDto extends PersonCreateDto { /** * Asset is used to get the feature face thumbnail. */ - @Optional() - @IsString() + @ValidateUUID({ optional: true }) featureFaceAssetId?: string; } diff --git a/server/src/enum.ts b/server/src/enum.ts index a4d2d21274..e49f1636a0 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -567,6 +567,7 @@ export enum DatabaseLock { Library = 1337, GetSystemConfig = 69, BackupDatabase = 42, + MemoryCreation = 777, } export enum SyncRequestType { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 556641fdb0..bc73ff6410 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -108,13 +108,21 @@ export class AssetService extends BaseService { } async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise { - const { ids, dateTimeOriginal, latitude, longitude, ...options } = dto; + const { ids, description, dateTimeOriginal, latitude, longitude, ...options } = dto; await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); - if (dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined) { - await this.assetRepository.updateAllExif(ids, { dateTimeOriginal, latitude, longitude }); + if ( + description !== undefined || + dateTimeOriginal !== undefined || + latitude !== undefined || + longitude !== undefined + ) { + await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude }); await this.jobRepository.queueAll( - ids.map((id) => ({ name: JobName.SIDECAR_WRITE, data: { id, dateTimeOriginal, latitude, longitude } })), + ids.map((id) => ({ + name: JobName.SIDECAR_WRITE, + data: { id, description, dateTimeOriginal, latitude, longitude }, + })), ); } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 3d3d10540b..1ccd311790 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -4,9 +4,8 @@ import { OnJob } from 'src/decorators'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto'; -import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; +import { DatabaseLock, JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; -import { OnThisDayData } from 'src/types'; import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util'; const DAYS = 3; @@ -16,55 +15,61 @@ export class MemoryService extends BaseService { @OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK }) async onMemoriesCreate() { const users = await this.userRepository.getList({ withDeleted: false }); - const userMap: Record = {}; - for (const user of users) { - const partnerIds = await getMyPartnerIds({ - userId: user.id, - repository: this.partnerRepository, - timelineEnabled: true, - }); - userMap[user.id] = [user.id, ...partnerIds]; - } + const usersIds = await Promise.all( + users.map((user) => + getMyPartnerIds({ + userId: user.id, + repository: this.partnerRepository, + timelineEnabled: true, + }), + ), + ); - const start = DateTime.utc().startOf('day').minus({ days: DAYS }); + await this.databaseRepository.withLock(DatabaseLock.MemoryCreation, async () => { + const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); + const start = DateTime.utc().startOf('day').minus({ days: DAYS }); + const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; - const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); - const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; - - // generate a memory +/- X days from today - for (let i = 0; i <= DAYS * 2; i++) { - const target = start.plus({ days: i }); - if (lastOnThisDayDate >= target) { - continue; - } - - const showAt = target.startOf('day').toISO(); - const hideAt = target.endOf('day').toISO(); - - for (const [userId, userIds] of Object.entries(userMap)) { - const memories = await this.assetRepository.getByDayOfYear(userIds, target); - - for (const { year, assets } of memories) { - const data: OnThisDayData = { year }; - await this.memoryRepository.create( - { - ownerId: userId, - type: MemoryType.ON_THIS_DAY, - data, - memoryAt: target.set({ year }).toISO(), - showAt, - hideAt, - }, - new Set(assets.map(({ id }) => id)), - ); + // generate a memory +/- X days from today + for (let i = 0; i <= DAYS * 2; i++) { + const target = start.plus({ days: i }); + if (lastOnThisDayDate >= target) { + continue; } - } - await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { - ...state, - lastOnThisDayDate: target.toISO(), - }); - } + try { + await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target))); + } catch (error) { + this.logger.error(`Failed to create memories for ${target.toISO()}`, error); + } + // update system metadata even when there is an error to minimize the chance of duplicates + await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, { + ...state, + lastOnThisDayDate: target.toISO(), + }); + } + }); + } + + private async createOnThisDayMemories(ownerId: string, userIds: string[], target: DateTime) { + const showAt = target.startOf('day').toISO(); + const hideAt = target.endOf('day').toISO(); + const memories = await this.assetRepository.getByDayOfYear([ownerId, ...userIds], target); + await Promise.all( + memories.map(({ year, assets }) => + this.memoryRepository.create( + { + ownerId, + type: MemoryType.ON_THIS_DAY, + data: { year }, + memoryAt: target.set({ year }).toISO()!, + showAt, + hideAt, + }, + new Set(assets.map(({ id }) => id)), + ), + ), + ); } @OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK }) diff --git a/server/test/medium/specs/services/memory.service.spec.ts b/server/test/medium/specs/services/memory.service.spec.ts index 445434d60a..8489e6bcc9 100644 --- a/server/test/medium/specs/services/memory.service.spec.ts +++ b/server/test/medium/specs/services/memory.service.spec.ts @@ -15,6 +15,7 @@ describe(MemoryService.name, () => { database: db || defaultDatabase, repos: { asset: 'real', + database: 'real', memory: 'real', user: 'real', systemMetadata: 'real', diff --git a/web/src/lib/components/elements/checkbox.svelte b/web/src/lib/components/elements/checkbox.svelte deleted file mode 100644 index 4595c06bfb..0000000000 --- a/web/src/lib/components/elements/checkbox.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -
- - -
diff --git a/web/src/lib/components/elements/slider.svelte b/web/src/lib/components/elements/slider.svelte deleted file mode 100644 index 5c80eb2a9e..0000000000 --- a/web/src/lib/components/elements/slider.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - - - - diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 09218d3c47..468fbe6d41 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -8,6 +8,7 @@ import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; + import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -323,6 +324,7 @@ + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} diff --git a/web/src/lib/components/photos-page/actions/change-description-action.svelte b/web/src/lib/components/photos-page/actions/change-description-action.svelte new file mode 100644 index 0000000000..129d327fb9 --- /dev/null +++ b/web/src/lib/components/photos-page/actions/change-description-action.svelte @@ -0,0 +1,37 @@ + + +{#if menuItem} + handleUpdateDescription()} /> +{/if} diff --git a/web/src/lib/components/photos-page/delete-asset-dialog.svelte b/web/src/lib/components/photos-page/delete-asset-dialog.svelte index 336a0fd78a..4862e072b8 100644 --- a/web/src/lib/components/photos-page/delete-asset-dialog.svelte +++ b/web/src/lib/components/photos-page/delete-asset-dialog.svelte @@ -1,8 +1,8 @@
diff --git a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte index 06fa3c5bdf..afb8054920 100644 --- a/web/src/lib/components/shared-components/search-bar/search-display-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-display-section.svelte @@ -1,13 +1,14 @@ onClose()}> @@ -54,31 +70,32 @@ { - $slideshowNavigation = handleToggle(option, navigationOptions) || $slideshowNavigation; + tempSlideshowNavigation = handleToggle(option, navigationOptions) || tempSlideshowNavigation; }} /> { - $slideshowLook = handleToggle(option, lookOptions) || $slideshowLook; + tempSlideshowLook = handleToggle(option, lookOptions) || tempSlideshowLook; }} /> - - + +
{#snippet stickyBottom()} - + + {/snippet} diff --git a/web/src/lib/components/user-settings-page/user-api-key-list.svelte b/web/src/lib/components/user-settings-page/user-api-key-list.svelte index 6aebab282c..ccc1bdfe92 100644 --- a/web/src/lib/components/user-settings-page/user-api-key-list.svelte +++ b/web/src/lib/components/user-settings-page/user-api-key-list.svelte @@ -18,6 +18,7 @@ import { fade } from 'svelte/transition'; import { handleError } from '../../utils/handle-error'; import { notificationController, NotificationType } from '../shared-components/notification/notification'; + import { dateFormats } from '$lib/constants'; interface Props { keys: ApiKeyResponseDto[]; @@ -25,12 +26,6 @@ let { keys = $bindable() }: Props = $props(); - const format: Intl.DateTimeFormatOptions = { - month: 'short', - day: 'numeric', - year: 'numeric', - }; - async function refreshKeys() { keys = await getApiKeys(); } @@ -130,7 +125,7 @@ > {key.name} {new Date(key.createdAt).toLocaleDateString($locale, format)} + >{new Date(key.createdAt).toLocaleDateString($locale, dateFormats.settings)} {$t('purchase_activated_time', { - values: { date: new Date(serverPurchaseInfo.activatedAt) }, + values: { + date: new Date(serverPurchaseInfo.activatedAt).toLocaleString($locale, dateFormats.settings), + }, })}

{:else} @@ -161,7 +165,9 @@ {#if $user.license?.activatedAt}

{$t('purchase_activated_time', { - values: { date: new Date($user.license?.activatedAt) }, + values: { + date: new Date($user.license?.activatedAt).toLocaleString($locale, dateFormats.settings), + }, })}

{/if} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index 167c976eeb..fdb18b3978 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -72,6 +72,11 @@ export const dateFormats = { day: 'numeric', year: 'numeric', }, + settings: { + month: 'short', + day: 'numeric', + year: 'numeric', + }, }; export enum QueryParameter { diff --git a/web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte b/web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte new file mode 100644 index 0000000000..4d5a81f5fa --- /dev/null +++ b/web/src/lib/modals/AssetUpdateDecriptionConfirmModal.svelte @@ -0,0 +1,29 @@ + + + (confirmed ? onClose(description) : onClose())} +> + {#snippet promptSnippet()} +
+
+ + +
+
+ {/snippet} +
diff --git a/web/src/lib/modals/ConfirmModal.svelte b/web/src/lib/modals/ConfirmModal.svelte index 9726a1d9cf..327d13c355 100644 --- a/web/src/lib/modals/ConfirmModal.svelte +++ b/web/src/lib/modals/ConfirmModal.svelte @@ -30,7 +30,7 @@ }; - onClose(false)} {size} class="bg-light text-dark"> + onClose(false)} {size}> {#if promptSnippet}{@render promptSnippet()}{:else}

{prompt}

diff --git a/web/src/lib/modals/PasswordResetSuccessModal.svelte b/web/src/lib/modals/PasswordResetSuccessModal.svelte index 74e035b93b..9f8dc9d668 100644 --- a/web/src/lib/modals/PasswordResetSuccessModal.svelte +++ b/web/src/lib/modals/PasswordResetSuccessModal.svelte @@ -12,13 +12,7 @@ const { onClose, newPassword }: Props = $props(); - onClose()} - size="small" - class="bg-light text-dark" -> + onClose()} size="small">
{$t('admin.user_password_has_been_reset')} diff --git a/web/src/lib/modals/PersonEditBirthDateModal.svelte b/web/src/lib/modals/PersonEditBirthDateModal.svelte index 52d23f4075..d79b716364 100644 --- a/web/src/lib/modals/PersonEditBirthDateModal.svelte +++ b/web/src/lib/modals/PersonEditBirthDateModal.svelte @@ -24,7 +24,7 @@ try { const updatedPerson = await updatePerson({ id: person.id, - personUpdateDto: { birthDate: birthDate.length > 0 ? birthDate : null }, + personUpdateDto: { birthDate }, }); notificationController.show({ message: $t('date_of_birth_saved'), type: NotificationType.Info }); @@ -53,6 +53,13 @@ bind:value={birthDate} max={todayFormatted} /> + {#if person.birthDate} +
+ +
+ {/if}
@@ -62,8 +69,8 @@ - diff --git a/web/src/lib/modals/SearchFilterModal.svelte b/web/src/lib/modals/SearchFilterModal.svelte index 7de6f9984a..4ee3934e9e 100644 --- a/web/src/lib/modals/SearchFilterModal.svelte +++ b/web/src/lib/modals/SearchFilterModal.svelte @@ -84,8 +84,8 @@ }, display: { isArchive: searchQuery.visibility === AssetVisibility.Archive, - isFavorite: searchQuery.isFavorite, - isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined, + isFavorite: searchQuery.isFavorite ?? false, + isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false, }, mediaType: searchQuery.type === AssetTypeEnum.Image @@ -105,7 +105,11 @@ location: {}, camera: {}, date: {}, - display: {}, + display: { + isArchive: false, + isFavorite: false, + isNotInAlbum: false, + }, mediaType: MediaType.All, rating: undefined, }; diff --git a/web/src/lib/modals/UserCreateModal.svelte b/web/src/lib/modals/UserCreateModal.svelte index 34e498ce1c..f40a709215 100644 --- a/web/src/lib/modals/UserCreateModal.svelte +++ b/web/src/lib/modals/UserCreateModal.svelte @@ -81,7 +81,7 @@ }; - +
{#if error} diff --git a/web/src/lib/modals/UserDeleteConfirmModal.svelte b/web/src/lib/modals/UserDeleteConfirmModal.svelte index 294cafa035..7f7cea4e32 100644 --- a/web/src/lib/modals/UserDeleteConfirmModal.svelte +++ b/web/src/lib/modals/UserDeleteConfirmModal.svelte @@ -1,10 +1,10 @@ - +
diff --git a/web/src/lib/modals/UserRestoreConfirmModal.svelte b/web/src/lib/modals/UserRestoreConfirmModal.svelte index c8fde89f36..5cf9c1c91b 100644 --- a/web/src/lib/modals/UserRestoreConfirmModal.svelte +++ b/web/src/lib/modals/UserRestoreConfirmModal.svelte @@ -23,7 +23,7 @@ }; - +

diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 088d3dae97..e46ad0fc77 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -13,6 +13,7 @@ import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; + import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -478,6 +479,7 @@ {#if assetInteraction.isAllUserOwned} + {#if assetInteraction.selectedAssets.length === 1} + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 50dc8f8166..ea726d783a 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -11,6 +11,7 @@ import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte'; import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; + import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -328,6 +329,7 @@ return; } + person = updatedPerson; people = people.map((person: PersonResponseDto) => { if (person.id === updatedPerson.id) { return updatedPerson; @@ -514,6 +516,7 @@ onClick={handleReassignAssets} /> + {/if} + assetStore.removeAssets(assetIds)} /> {#if $preferences.tags.enabled} diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index d35e2697c1..813683244e 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -9,6 +9,7 @@ import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte'; import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte'; import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte'; + import ChangeDescription from '$lib/components/photos-page/actions/change-description-action.svelte'; import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte'; import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte'; import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte'; @@ -249,57 +250,6 @@ -

- {#if assetInteraction.selectionActive} -
- cancelMultiselect(assetInteraction)} - > - - - - - - - { - for (const id of ids) { - const asset = searchResultAssets.find((asset) => asset.id === id); - if (asset) { - asset.isFavorite = isFavorite; - } - } - }} - /> - - - - - - - {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} - - {/if} - -
- -
-
-
- {:else} -
- goto(previousRoute)} backIcon={mdiArrowLeft}> -
-
- -
-
-
- {/if} -
- {#if terms}
{/if}
+ +
+ {#if assetInteraction.selectionActive} +
+ cancelMultiselect(assetInteraction)} + > + + + + + + + { + for (const id of ids) { + const asset = searchResultAssets.find((asset) => asset.id === id); + if (asset) { + asset.isFavorite = isFavorite; + } + } + }} + /> + + + + + + + + {#if $preferences.tags.enabled && assetInteraction.isAllUserOwned} + + {/if} + +
+ +
+
+
+ {:else} +
+ goto(previousRoute)} backIcon={mdiArrowLeft}> +
+
+ +
+
+
+ {/if} +