diff --git a/server/src/domain/repositories/storage.repository.ts b/server/src/domain/repositories/storage.repository.ts index c55aaf7ecd..bdd23ccabe 100644 --- a/server/src/domain/repositories/storage.repository.ts +++ b/server/src/domain/repositories/storage.repository.ts @@ -42,4 +42,5 @@ export interface IStorageRepository { copyFile(source: string, target: string): Promise; rename(source: string, target: string): Promise; watch(paths: string[], options: WatchOptions): ImmichWatcher; + utimes(filepath: string, atime: Date, mtime: Date): Promise; } diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 6e17ca64e9..67d2bd2226 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -534,6 +534,12 @@ describe(StorageTemplateService.name, () => { .mockResolvedValue({ size: 5000, } as Stats); + when(storageMock.stat) + .calledWith(assetStub.image.originalPath) + .mockResolvedValue({ + atime: new Date(), + mtime: new Date(), + } as Stats); when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(assetStub.image.checksum); await sut.handleMigration(); @@ -542,6 +548,8 @@ describe(StorageTemplateService.name, () => { expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); expect(storageMock.stat).toHaveBeenCalledWith(newPath); + expect(storageMock.stat).toHaveBeenCalledWith(assetStub.image.originalPath); + expect(storageMock.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date)); expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath); expect(storageMock.unlink).toHaveBeenCalledTimes(1); expect(assetMock.save).toHaveBeenCalledWith({ diff --git a/server/src/domain/storage/storage.core.ts b/server/src/domain/storage/storage.core.ts index 9456fd66b1..30a6002be5 100644 --- a/server/src/domain/storage/storage.core.ts +++ b/server/src/domain/storage/storage.core.ts @@ -222,6 +222,9 @@ export class StorageCore { return; } + const { atime, mtime } = await this.repository.stat(move.oldPath); + await this.repository.utimes(newPath, atime, mtime); + try { await this.repository.unlink(move.oldPath); } catch (error: any) { diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 0e5bafa5f0..9f0aa371e8 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -1,4 +1,11 @@ -import { IAssetRepository, IJobRepository, ILibraryRepository, IUserRepository, JobName } from '@app/domain'; +import { + IAssetRepository, + IJobRepository, + ILibraryRepository, + IStorageRepository, + IUserRepository, + JobName, +} from '@app/domain'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { IAccessRepositoryMock, @@ -9,6 +16,7 @@ import { newAssetRepositoryMock, newJobRepositoryMock, newLibraryRepositoryMock, + newStorageRepositoryMock, newUserRepositoryMock, } from '@test'; import { when } from 'jest-when'; @@ -63,6 +71,7 @@ describe('AssetService', () => { let assetMock: jest.Mocked; let jobMock: jest.Mocked; let libraryMock: jest.Mocked; + let storageMock: jest.Mocked; let userMock: jest.Mocked; beforeEach(() => { @@ -81,9 +90,10 @@ describe('AssetService', () => { assetMock = newAssetRepositoryMock(); jobMock = newJobRepositoryMock(); libraryMock = newLibraryRepositoryMock(); + storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); - sut = new AssetService(accessMock, assetRepositoryMockV1, assetMock, jobMock, libraryMock, userMock); + sut = new AssetService(accessMock, assetRepositoryMockV1, assetMock, jobMock, libraryMock, storageMock, userMock); when(assetRepositoryMockV1.get) .calledWith(assetStub.livePhotoStillAsset.id) @@ -113,6 +123,11 @@ describe('AssetService', () => { expect(assetMock.create).toHaveBeenCalled(); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); + expect(storageMock.utimes).toHaveBeenCalledWith( + file.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); }); it('should handle a duplicate', async () => { @@ -167,6 +182,16 @@ describe('AssetService', () => { [{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }], ]); expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, 111); + expect(storageMock.utimes).toHaveBeenCalledWith( + fileStub.livePhotoStill.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); + expect(storageMock.utimes).toHaveBeenCalledWith( + fileStub.livePhotoMotion.originalPath, + expect.any(Date), + new Date(dto.fileModifiedAt), + ); }); }); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index b0ff311c51..f438e55e9c 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -7,6 +7,7 @@ import { IAssetRepository, IJobRepository, ILibraryRepository, + IStorageRepository, IUserRepository, ImmichFileResponse, JobName, @@ -55,6 +56,7 @@ export class AssetService { @Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(ILibraryRepository) private libraryRepository: ILibraryRepository, + @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IUserRepository) private userRepository: IUserRepository, ) { this.access = AccessCore.create(accessRepository); @@ -358,6 +360,10 @@ export class AssetService { isOffline: dto.isOffline ?? false, }); + if (sidecarPath) { + await this.storageRepository.utimes(sidecarPath, new Date(), new Date(dto.fileModifiedAt)); + } + await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt)); await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index 2ae18432b2..fa027ad465 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -12,7 +12,7 @@ import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; import { glob } from 'glob'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; -import fs, { copyFile, readdir, rename, writeFile } from 'node:fs/promises'; +import fs, { copyFile, readdir, rename, utimes, writeFile } from 'node:fs/promises'; import path from 'node:path'; export class FilesystemProvider implements IStorageRepository { @@ -56,6 +56,8 @@ export class FilesystemProvider implements IStorageRepository { copyFile = copyFile; + utimes = utimes; + async checkFileExists(filepath: string, mode = constants.F_OK): Promise { try { await fs.access(filepath, mode); diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index f4dbc0c5b1..9df450f001 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -22,5 +22,6 @@ export const newStorageRepositoryMock = (reset = true): jest.Mocked