mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 14:41:59 +00:00
feat(server): restore modified at timestamp after upload, preserve when copying (#7010)
This commit is contained in:
parent
0c4df216d7
commit
d7437d31d1
7 changed files with 49 additions and 3 deletions
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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' } });
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue