mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 15:16:31 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbdbd291ba | |||
| 0ab057f453 |
@@ -10,6 +10,7 @@ import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
|||||||
import { RouteKey } from 'src/enum';
|
import { RouteKey } from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||||
@@ -54,6 +55,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
constructor(
|
constructor(
|
||||||
private reflect: Reflector,
|
private reflect: Reflector,
|
||||||
private assetService: AssetMediaService,
|
private assetService: AssetMediaService,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
private logger: LoggingRepository,
|
private logger: LoggingRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(FileUploadInterceptor.name);
|
this.logger.setContext(FileUploadInterceptor.name);
|
||||||
@@ -125,7 +127,18 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!this.isAssetUploadFile(file)) {
|
if (!this.isAssetUploadFile(file)) {
|
||||||
this.defaultStorage._handleFile(request, file, callback);
|
this.defaultStorage._handleFile(request, file, (error, info) => {
|
||||||
|
if (error) {
|
||||||
|
return callback(error);
|
||||||
|
}
|
||||||
|
// Multer does not sync files to disk after writing.
|
||||||
|
//
|
||||||
|
// TODO: use `flush: true` in multer when available: https://github.com/expressjs/multer/issues/1381
|
||||||
|
this.storageRepository
|
||||||
|
.datasync(info!.path!)
|
||||||
|
.then(() => callback(null, info!))
|
||||||
|
.catch((error) => callback(error));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +149,13 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
hash.destroy();
|
hash.destroy();
|
||||||
callback(error);
|
callback(error);
|
||||||
} else {
|
} else {
|
||||||
callback(null, { ...info, checksum: hash.digest() });
|
this.storageRepository
|
||||||
|
.datasync(info!.path!)
|
||||||
|
.then(() => callback(null, { ...info, checksum: hash.digest() }))
|
||||||
|
.catch((error) => {
|
||||||
|
hash.destroy();
|
||||||
|
callback(error);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { SourceType } from 'src/enum';
|
|||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||||
import { MediaRepository } from 'src/repositories/media.repository';
|
import { MediaRepository } from 'src/repositories/media.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||||
import { automock } from 'test/utils';
|
import { automock } from 'test/utils';
|
||||||
|
|
||||||
@@ -65,8 +66,11 @@ describe(MediaRepository.name, () => {
|
|||||||
let sut: MediaRepository;
|
let sut: MediaRepository;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// eslint-disable-next-line no-sparse-arrays
|
sut = new MediaRepository(
|
||||||
sut = new MediaRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
|
// eslint-disable-next-line no-sparse-arrays
|
||||||
|
automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }),
|
||||||
|
automock(StorageRepository, { args: [{ setContext: () => {} }], strict: false }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('applyEdits (single actions)', () => {
|
describe('applyEdits (single actions)', () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Exif } from 'src/database';
|
|||||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
|
import { Colorspace, LogLevel, RawExtractedFormat } from 'src/enum';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import {
|
import {
|
||||||
DecodeToBufferOptions,
|
DecodeToBufferOptions,
|
||||||
GenerateThumbhashOptions,
|
GenerateThumbhashOptions,
|
||||||
@@ -45,7 +46,10 @@ export type ExtractResult = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaRepository {
|
export class MediaRepository {
|
||||||
constructor(private logger: LoggingRepository) {
|
constructor(
|
||||||
|
private logger: LoggingRepository,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
) {
|
||||||
this.logger.setContext(MediaRepository.name);
|
this.logger.setContext(MediaRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +120,7 @@ export class MediaRepository {
|
|||||||
ignoreMinorErrors: true,
|
ignoreMinorErrors: true,
|
||||||
writeArgs: ['-overwrite_original'],
|
writeArgs: ['-overwrite_original'],
|
||||||
});
|
});
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
this.logger.warn(`Could not write exif data to image: ${error.message}`);
|
||||||
@@ -133,6 +138,7 @@ export class MediaRepository {
|
|||||||
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await this.storageRepository.datasync(target);
|
||||||
return true;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
||||||
@@ -180,6 +186,7 @@ export class MediaRepository {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await decoded.toFile(output);
|
await decoded.toFile(output);
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||||
@@ -274,14 +281,18 @@ export class MediaRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
async transcode(input: string, output: string | Writable, options: TranscodeCommand): Promise<void> {
|
||||||
if (!options.twoPass) {
|
if (!options.twoPass) {
|
||||||
return new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
this.configureFfmpegCall(input, output, options)
|
this.configureFfmpegCall(input, output, options)
|
||||||
.on('error', reject)
|
.on('error', reject)
|
||||||
.on('end', () => resolve())
|
.on('end', () => resolve())
|
||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
|
if (typeof output === 'string') {
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof output !== 'string') {
|
if (typeof output !== 'string') {
|
||||||
@@ -290,7 +301,7 @@ export class MediaRepository {
|
|||||||
|
|
||||||
// two-pass allows for precise control of bitrate at the cost of running twice
|
// two-pass allows for precise control of bitrate at the cost of running twice
|
||||||
// recommended for vp9 for better quality and compression
|
// recommended for vp9 for better quality and compression
|
||||||
return new Promise((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
// first pass output is not saved as only the .log file is needed
|
// first pass output is not saved as only the .log file is needed
|
||||||
this.configureFfmpegCall(input, '/dev/null', options)
|
this.configureFfmpegCall(input, '/dev/null', options)
|
||||||
.addOptions('-pass', '1')
|
.addOptions('-pass', '1')
|
||||||
@@ -310,6 +321,7 @@ export class MediaRepository {
|
|||||||
})
|
})
|
||||||
.run();
|
.run();
|
||||||
});
|
});
|
||||||
|
await this.storageRepository.datasync(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
|
async getImageMetadata(input: string | Buffer): Promise<ImageDimensions & { isTransparent: boolean }> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||||
import geotz from 'geo-tz';
|
import geotz from 'geo-tz';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
|
|
||||||
interface ExifDuration {
|
interface ExifDuration {
|
||||||
@@ -94,7 +95,10 @@ export class MetadataRepository {
|
|||||||
taskTimeoutMillis: 2 * 60 * 1000,
|
taskTimeoutMillis: 2 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor(private logger: LoggingRepository) {
|
constructor(
|
||||||
|
private logger: LoggingRepository,
|
||||||
|
private storageRepository: StorageRepository,
|
||||||
|
) {
|
||||||
this.logger.setContext(MetadataRepository.name);
|
this.logger.setContext(MetadataRepository.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,6 +125,7 @@ export class MetadataRepository {
|
|||||||
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.exiftool.write(path, tags);
|
await this.exiftool.write(path, tags);
|
||||||
|
await this.storageRepository.datasync(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
this.logger.warn(`Error writing exif data (${path}): ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,8 +50,18 @@ export class StorageRepository {
|
|||||||
return fs.readdir(folder);
|
return fs.readdir(folder);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyFile(source: string, target: string) {
|
async copyFile(source: string, target: string) {
|
||||||
return fs.copyFile(source, target);
|
await fs.copyFile(source, target);
|
||||||
|
await this.datasync(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
async datasync(filepath: string) {
|
||||||
|
const handle = await fs.open(filepath, 'r');
|
||||||
|
try {
|
||||||
|
await handle.datasync();
|
||||||
|
} finally {
|
||||||
|
await handle.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stat(filepath: string) {
|
stat(filepath: string) {
|
||||||
@@ -59,19 +69,19 @@ export class StorageRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createFile(filepath: string, buffer: Buffer) {
|
createFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'wx' });
|
return fs.writeFile(filepath, buffer, { flag: 'wx', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
createWriteStream(filepath: string): Writable {
|
createWriteStream(filepath: string): Writable {
|
||||||
return createWriteStream(filepath, { flags: 'w' });
|
return createWriteStream(filepath, { flags: 'w', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
createOrOverwriteFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'w' });
|
return fs.writeFile(filepath, buffer, { flag: 'w', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
overwriteFile(filepath: string, buffer: Buffer) {
|
overwriteFile(filepath: string, buffer: Buffer) {
|
||||||
return fs.writeFile(filepath, buffer, { flag: 'r+' });
|
return fs.writeFile(filepath, buffer, { flag: 'r+', flush: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
rename(source: string, target: string) {
|
rename(source: string, target: string) {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
|||||||
walk: vitest.fn().mockImplementation(async function* () {}),
|
walk: vitest.fn().mockImplementation(async function* () {}),
|
||||||
rename: vitest.fn(),
|
rename: vitest.fn(),
|
||||||
copyFile: vitest.fn(),
|
copyFile: vitest.fn(),
|
||||||
|
datasync: vitest.fn(),
|
||||||
utimes: vitest.fn(),
|
utimes: vitest.fn(),
|
||||||
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
|
watch: vitest.fn().mockImplementation(makeMockWatcher({})),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user