1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat(server): restore modified at timestamp after upload, preserve when copying (#7010)

This commit is contained in:
Trevor Jex 2024-02-11 21:40:34 -07:00 committed by GitHub
parent 0c4df216d7
commit d7437d31d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 49 additions and 3 deletions

View File

@ -42,4 +42,5 @@ export interface IStorageRepository {
copyFile(source: string, target: string): Promise<void>; copyFile(source: string, target: string): Promise<void>;
rename(source: string, target: string): Promise<void>; rename(source: string, target: string): Promise<void>;
watch(paths: string[], options: WatchOptions): ImmichWatcher; watch(paths: string[], options: WatchOptions): ImmichWatcher;
utimes(filepath: string, atime: Date, mtime: Date): Promise<void>;
} }

View File

@ -534,6 +534,12 @@ describe(StorageTemplateService.name, () => {
.mockResolvedValue({ .mockResolvedValue({
size: 5000, size: 5000,
} as Stats); } 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); when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(assetStub.image.checksum);
await sut.handleMigration(); await sut.handleMigration();
@ -542,6 +548,8 @@ describe(StorageTemplateService.name, () => {
expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath); expect(storageMock.rename).toHaveBeenCalledWith('/original/path.jpg', newPath);
expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath); expect(storageMock.copyFile).toHaveBeenCalledWith('/original/path.jpg', newPath);
expect(storageMock.stat).toHaveBeenCalledWith(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).toHaveBeenCalledWith(assetStub.image.originalPath);
expect(storageMock.unlink).toHaveBeenCalledTimes(1); expect(storageMock.unlink).toHaveBeenCalledTimes(1);
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({

View File

@ -222,6 +222,9 @@ export class StorageCore {
return; return;
} }
const { atime, mtime } = await this.repository.stat(move.oldPath);
await this.repository.utimes(newPath, atime, mtime);
try { try {
await this.repository.unlink(move.oldPath); await this.repository.unlink(move.oldPath);
} catch (error: any) { } catch (error: any) {

View File

@ -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 { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { import {
IAccessRepositoryMock, IAccessRepositoryMock,
@ -9,6 +16,7 @@ import {
newAssetRepositoryMock, newAssetRepositoryMock,
newJobRepositoryMock, newJobRepositoryMock,
newLibraryRepositoryMock, newLibraryRepositoryMock,
newStorageRepositoryMock,
newUserRepositoryMock, newUserRepositoryMock,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
@ -63,6 +71,7 @@ describe('AssetService', () => {
let assetMock: jest.Mocked<IAssetRepository>; let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>; let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>; let libraryMock: jest.Mocked<ILibraryRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>; let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => { beforeEach(() => {
@ -81,9 +90,10 @@ describe('AssetService', () => {
assetMock = newAssetRepositoryMock(); assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock(); jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock(); libraryMock = newLibraryRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
sut = new AssetService(accessMock, assetRepositoryMockV1, assetMock, jobMock, libraryMock, userMock); sut = new AssetService(accessMock, assetRepositoryMockV1, assetMock, jobMock, libraryMock, storageMock, userMock);
when(assetRepositoryMockV1.get) when(assetRepositoryMockV1.get)
.calledWith(assetStub.livePhotoStillAsset.id) .calledWith(assetStub.livePhotoStillAsset.id)
@ -113,6 +123,11 @@ describe('AssetService', () => {
expect(assetMock.create).toHaveBeenCalled(); expect(assetMock.create).toHaveBeenCalled();
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size); 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 () => { it('should handle a duplicate', async () => {
@ -167,6 +182,16 @@ describe('AssetService', () => {
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }], [{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }],
]); ]);
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, 111); 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),
);
}); });
}); });

View File

@ -7,6 +7,7 @@ import {
IAssetRepository, IAssetRepository,
IJobRepository, IJobRepository,
ILibraryRepository, ILibraryRepository,
IStorageRepository,
IUserRepository, IUserRepository,
ImmichFileResponse, ImmichFileResponse,
JobName, JobName,
@ -55,6 +56,7 @@ export class AssetService {
@Inject(IAssetRepository) private assetRepository: IAssetRepository, @Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository, @Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private libraryRepository: ILibraryRepository, @Inject(ILibraryRepository) private libraryRepository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
) { ) {
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
@ -358,6 +360,10 @@ export class AssetService {
isOffline: dto.isOffline ?? false, 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.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });

View File

@ -12,7 +12,7 @@ import archiver from 'archiver';
import chokidar, { WatchOptions } from 'chokidar'; import chokidar, { WatchOptions } from 'chokidar';
import { glob } from 'glob'; import { glob } from 'glob';
import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; 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'; import path from 'node:path';
export class FilesystemProvider implements IStorageRepository { export class FilesystemProvider implements IStorageRepository {
@ -56,6 +56,8 @@ export class FilesystemProvider implements IStorageRepository {
copyFile = copyFile; copyFile = copyFile;
utimes = utimes;
async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> { async checkFileExists(filepath: string, mode = constants.F_OK): Promise<boolean> {
try { try {
await fs.access(filepath, mode); await fs.access(filepath, mode);

View File

@ -22,5 +22,6 @@ export const newStorageRepositoryMock = (reset = true): jest.Mocked<IStorageRepo
rename: jest.fn(), rename: jest.fn(),
copyFile: jest.fn(), copyFile: jest.fn(),
watch: jest.fn(), watch: jest.fn(),
utimes: jest.fn(),
}; };
}; };