2024-04-19 11:50:13 -04:00
|
|
|
import { randomUUID } from 'node:crypto';
|
2023-10-14 13:12:59 -04:00
|
|
|
import { dirname, join, resolve } from 'node:path';
|
2024-05-14 14:43:49 -04:00
|
|
|
import { ImageFormat } from 'src/config';
|
2024-03-20 22:15:09 -05:00
|
|
|
import { APP_MEDIA_LOCATION } from 'src/constants';
|
2024-03-20 21:20:38 +01:00
|
|
|
import { SystemConfigCore } from 'src/cores/system-config.core';
|
2024-03-20 16:02:51 -05:00
|
|
|
import { AssetEntity } from 'src/entities/asset.entity';
|
|
|
|
|
import { AssetPathType, PathType, PersonPathType } from 'src/entities/move.entity';
|
|
|
|
|
import { PersonEntity } from 'src/entities/person.entity';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
|
|
|
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
2024-04-17 03:00:31 +05:30
|
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
2024-03-21 12:59:49 +01:00
|
|
|
import { IMoveRepository } from 'src/interfaces/move.interface';
|
|
|
|
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
|
|
|
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
2024-05-15 18:58:23 -04:00
|
|
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
2023-03-25 10:50:57 -04:00
|
|
|
|
|
|
|
|
export enum StorageFolder {
|
|
|
|
|
ENCODED_VIDEO = 'encoded-video',
|
|
|
|
|
LIBRARY = 'library',
|
|
|
|
|
UPLOAD = 'upload',
|
|
|
|
|
PROFILE = 'profile',
|
|
|
|
|
THUMBNAILS = 'thumbs',
|
|
|
|
|
}
|
|
|
|
|
|
2024-03-15 18:01:58 -04:00
|
|
|
export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS));
|
|
|
|
|
export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO));
|
|
|
|
|
|
2023-10-11 04:14:44 +02:00
|
|
|
export interface MoveRequest {
|
|
|
|
|
entityId: string;
|
|
|
|
|
pathType: PathType;
|
|
|
|
|
oldPath: string | null;
|
|
|
|
|
newPath: string;
|
2023-12-29 18:41:33 +00:00
|
|
|
assetInfo?: {
|
|
|
|
|
sizeInBytes: number;
|
|
|
|
|
checksum: Buffer;
|
|
|
|
|
};
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
export type GeneratedImageType = AssetPathType.PREVIEW | AssetPathType.THUMBNAIL;
|
|
|
|
|
export type GeneratedAssetType = GeneratedImageType | AssetPathType.ENCODED_VIDEO;
|
2023-10-11 04:14:44 +02:00
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
let instance: StorageCore | null;
|
|
|
|
|
|
2023-03-25 10:50:57 -04:00
|
|
|
export class StorageCore {
|
2023-12-29 18:41:33 +00:00
|
|
|
private configCore;
|
2023-10-23 17:52:21 +02:00
|
|
|
private constructor(
|
2023-10-11 04:14:44 +02:00
|
|
|
private assetRepository: IAssetRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
private cryptoRepository: ICryptoRepository,
|
2023-10-11 04:14:44 +02:00
|
|
|
private moveRepository: IMoveRepository,
|
|
|
|
|
private personRepository: IPersonRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
private storageRepository: IStorageRepository,
|
2024-05-15 18:58:23 -04:00
|
|
|
systemMetadataRepository: ISystemMetadataRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
private logger: ILoggerRepository,
|
2023-12-29 18:41:33 +00:00
|
|
|
) {
|
2024-05-15 18:58:23 -04:00
|
|
|
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
2023-12-29 18:41:33 +00:00
|
|
|
}
|
2023-09-25 17:07:21 +02:00
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
static create(
|
|
|
|
|
assetRepository: IAssetRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
cryptoRepository: ICryptoRepository,
|
2023-10-23 17:52:21 +02:00
|
|
|
moveRepository: IMoveRepository,
|
|
|
|
|
personRepository: IPersonRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
storageRepository: IStorageRepository,
|
2024-05-15 18:58:23 -04:00
|
|
|
systemMetadataRepository: ISystemMetadataRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
logger: ILoggerRepository,
|
2023-10-23 17:52:21 +02:00
|
|
|
) {
|
|
|
|
|
if (!instance) {
|
2023-12-29 18:41:33 +00:00
|
|
|
instance = new StorageCore(
|
|
|
|
|
assetRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
cryptoRepository,
|
2023-12-29 18:41:33 +00:00
|
|
|
moveRepository,
|
|
|
|
|
personRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
storageRepository,
|
2024-05-15 18:58:23 -04:00
|
|
|
systemMetadataRepository,
|
2024-04-17 03:00:31 +05:30
|
|
|
logger,
|
2023-12-29 18:41:33 +00:00
|
|
|
);
|
2023-10-23 17:52:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static reset() {
|
|
|
|
|
instance = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static getFolderLocation(folder: StorageFolder, userId: string) {
|
2023-10-14 13:12:59 -04:00
|
|
|
return join(StorageCore.getBaseFolder(folder), userId);
|
2023-03-25 10:50:57 -04:00
|
|
|
}
|
2023-05-21 23:18:10 -04:00
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
static getLibraryFolder(user: { storageLabel: string | null; id: string }) {
|
2023-10-14 13:12:59 -04:00
|
|
|
return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
|
2023-05-24 22:05:31 -04:00
|
|
|
}
|
|
|
|
|
|
2023-10-14 13:12:59 -04:00
|
|
|
static getBaseFolder(folder: StorageFolder) {
|
2023-05-24 22:05:31 -04:00
|
|
|
return join(APP_MEDIA_LOCATION, folder);
|
2023-05-21 23:18:10 -04:00
|
|
|
}
|
2023-09-25 17:07:21 +02:00
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
static getPersonThumbnailPath(person: PersonEntity) {
|
|
|
|
|
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
static getImagePath(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) {
|
|
|
|
|
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`);
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
static getEncodedVideoPath(asset: AssetEntity) {
|
|
|
|
|
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
|
2024-01-22 10:04:45 -08:00
|
|
|
static getAndroidMotionPath(asset: AssetEntity, uuid: string) {
|
|
|
|
|
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${uuid}-MP.mp4`);
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
|
2023-10-23 17:52:21 +02:00
|
|
|
static isAndroidMotionPath(originalPath: string) {
|
2023-10-14 13:12:59 -04:00
|
|
|
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static isImmichPath(path: string) {
|
2024-03-31 16:47:03 +02:00
|
|
|
const resolvedPath = resolve(path);
|
|
|
|
|
const resolvedAppMediaLocation = resolve(APP_MEDIA_LOCATION);
|
|
|
|
|
const normalizedPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
|
|
|
|
|
const normalizedAppMediaLocation = resolvedAppMediaLocation.endsWith('/')
|
|
|
|
|
? resolvedAppMediaLocation
|
|
|
|
|
: resolvedAppMediaLocation + '/';
|
|
|
|
|
return normalizedPath.startsWith(normalizedAppMediaLocation);
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
|
2024-03-15 18:01:58 -04:00
|
|
|
static isGeneratedAsset(path: string) {
|
|
|
|
|
return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR);
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-02 00:56:56 -04:00
|
|
|
async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) {
|
|
|
|
|
const { id: entityId, previewPath, thumbnailPath } = asset;
|
|
|
|
|
return this.moveFile({
|
|
|
|
|
entityId,
|
|
|
|
|
pathType,
|
|
|
|
|
oldPath: pathType === AssetPathType.PREVIEW ? previewPath : thumbnailPath,
|
|
|
|
|
newPath: StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, format),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async moveAssetVideo(asset: AssetEntity) {
|
|
|
|
|
return this.moveFile({
|
|
|
|
|
entityId: asset.id,
|
|
|
|
|
pathType: AssetPathType.ENCODED_VIDEO,
|
|
|
|
|
oldPath: asset.encodedVideoPath,
|
|
|
|
|
newPath: StorageCore.getEncodedVideoPath(asset),
|
|
|
|
|
});
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
|
|
|
|
|
const { id: entityId, thumbnailPath } = person;
|
|
|
|
|
switch (pathType) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case PersonPathType.FACE: {
|
2023-10-11 04:14:44 +02:00
|
|
|
await this.moveFile({
|
|
|
|
|
entityId,
|
|
|
|
|
pathType,
|
|
|
|
|
oldPath: thumbnailPath,
|
2023-10-23 17:52:21 +02:00
|
|
|
newPath: StorageCore.getPersonThumbnailPath(person),
|
2023-10-11 04:14:44 +02:00
|
|
|
});
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async moveFile(request: MoveRequest) {
|
2023-12-29 18:41:33 +00:00
|
|
|
const { entityId, pathType, oldPath, newPath, assetInfo } = request;
|
2023-10-11 04:14:44 +02:00
|
|
|
if (!oldPath || oldPath === newPath) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.ensureFolders(newPath);
|
|
|
|
|
|
|
|
|
|
let move = await this.moveRepository.getByEntity(entityId, pathType);
|
|
|
|
|
if (move) {
|
|
|
|
|
this.logger.log(`Attempting to finish incomplete move: ${move.oldPath} => ${move.newPath}`);
|
2024-04-17 03:00:31 +05:30
|
|
|
const oldPathExists = await this.storageRepository.checkFileExists(move.oldPath);
|
|
|
|
|
const newPathExists = await this.storageRepository.checkFileExists(move.newPath);
|
2024-02-02 04:18:00 +01:00
|
|
|
const newPathCheck = newPathExists ? move.newPath : null;
|
|
|
|
|
const actualPath = oldPathExists ? move.oldPath : newPathCheck;
|
2023-10-11 04:14:44 +02:00
|
|
|
if (!actualPath) {
|
|
|
|
|
this.logger.warn('Unable to complete move. File does not exist at either location.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-29 18:41:33 +00:00
|
|
|
const fileAtNewLocation = actualPath === move.newPath;
|
|
|
|
|
this.logger.log(`Found file at ${fileAtNewLocation ? 'new' : 'old'} location`);
|
|
|
|
|
|
2024-02-02 04:18:00 +01:00
|
|
|
if (
|
|
|
|
|
fileAtNewLocation &&
|
|
|
|
|
!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, move.newPath, assetInfo))
|
|
|
|
|
) {
|
|
|
|
|
this.logger.fatal(
|
|
|
|
|
`Skipping move as file verification failed, old file is missing and new file is different to what was expected`,
|
|
|
|
|
);
|
|
|
|
|
return;
|
2023-12-29 18:41:33 +00:00
|
|
|
}
|
2023-10-11 04:14:44 +02:00
|
|
|
|
|
|
|
|
move = await this.moveRepository.update({ id: move.id, oldPath: actualPath, newPath });
|
|
|
|
|
} else {
|
|
|
|
|
move = await this.moveRepository.create({ entityId, pathType, oldPath, newPath });
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-29 18:41:33 +00:00
|
|
|
if (pathType === AssetPathType.ORIGINAL && !assetInfo) {
|
|
|
|
|
this.logger.warn(`Unable to complete move. Missing asset info for ${entityId}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-11 04:14:44 +02:00
|
|
|
if (move.oldPath !== newPath) {
|
2023-12-29 18:41:33 +00:00
|
|
|
try {
|
|
|
|
|
this.logger.debug(`Attempting to rename file: ${move.oldPath} => ${newPath}`);
|
2024-04-17 03:00:31 +05:30
|
|
|
await this.storageRepository.rename(move.oldPath, newPath);
|
2024-02-02 04:18:00 +01:00
|
|
|
} catch (error: any) {
|
|
|
|
|
if (error.code !== 'EXDEV') {
|
2023-12-29 18:41:33 +00:00
|
|
|
this.logger.warn(
|
2024-02-02 04:18:00 +01:00
|
|
|
`Unable to complete move. Error renaming file with code ${error.code} and message: ${error.message}`,
|
2023-12-29 18:41:33 +00:00
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
this.logger.debug(`Unable to rename file. Falling back to copy, verify and delete`);
|
2024-04-17 03:00:31 +05:30
|
|
|
await this.storageRepository.copyFile(move.oldPath, newPath);
|
2023-12-29 18:41:33 +00:00
|
|
|
|
|
|
|
|
if (!(await this.verifyNewPathContentsMatchesExpected(move.oldPath, newPath, assetInfo))) {
|
|
|
|
|
this.logger.warn(`Skipping move due to file size mismatch`);
|
2024-04-17 03:00:31 +05:30
|
|
|
await this.storageRepository.unlink(newPath);
|
2023-12-29 18:41:33 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-17 03:00:31 +05:30
|
|
|
const { atime, mtime } = await this.storageRepository.stat(move.oldPath);
|
|
|
|
|
await this.storageRepository.utimes(newPath, atime, mtime);
|
2024-02-11 21:40:34 -07:00
|
|
|
|
2023-12-29 18:41:33 +00:00
|
|
|
try {
|
2024-04-17 03:00:31 +05:30
|
|
|
await this.storageRepository.unlink(move.oldPath);
|
2024-02-02 04:18:00 +01:00
|
|
|
} catch (error: any) {
|
|
|
|
|
this.logger.warn(`Unable to delete old file, it will now no longer be tracked by Immich: ${error.message}`);
|
2023-12-29 18:41:33 +00:00
|
|
|
}
|
|
|
|
|
}
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
2023-12-29 18:41:33 +00:00
|
|
|
|
2023-10-11 04:14:44 +02:00
|
|
|
await this.savePath(pathType, entityId, newPath);
|
|
|
|
|
await this.moveRepository.delete(move);
|
|
|
|
|
}
|
|
|
|
|
|
2023-12-29 18:41:33 +00:00
|
|
|
private async verifyNewPathContentsMatchesExpected(
|
|
|
|
|
oldPath: string,
|
|
|
|
|
newPath: string,
|
|
|
|
|
assetInfo?: { sizeInBytes: number; checksum: Buffer },
|
|
|
|
|
) {
|
2024-04-17 03:00:31 +05:30
|
|
|
const oldStat = await this.storageRepository.stat(oldPath);
|
|
|
|
|
const newStat = await this.storageRepository.stat(newPath);
|
2024-02-02 04:18:00 +01:00
|
|
|
const oldPathSize = assetInfo ? assetInfo.sizeInBytes : oldStat.size;
|
|
|
|
|
const newPathSize = newStat.size;
|
2023-12-29 18:41:33 +00:00
|
|
|
this.logger.debug(`File size check: ${newPathSize} === ${oldPathSize}`);
|
|
|
|
|
if (newPathSize !== oldPathSize) {
|
|
|
|
|
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
2024-06-12 07:07:35 -04:00
|
|
|
const config = await this.configCore.getConfig({ withCache: true });
|
2024-02-02 04:18:00 +01:00
|
|
|
if (assetInfo && config.storageTemplate.hashVerificationEnabled) {
|
2023-12-29 18:41:33 +00:00
|
|
|
const { checksum } = assetInfo;
|
|
|
|
|
const newChecksum = await this.cryptoRepository.hashFile(newPath);
|
|
|
|
|
if (!newChecksum.equals(checksum)) {
|
|
|
|
|
this.logger.warn(
|
|
|
|
|
`Unable to complete move. File checksum mismatch: ${newChecksum.toString('base64')} !== ${checksum.toString(
|
|
|
|
|
'base64',
|
|
|
|
|
)}`,
|
|
|
|
|
);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
this.logger.debug(`File checksum check: ${newChecksum.toString('base64')} === ${checksum.toString('base64')}`);
|
|
|
|
|
}
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2023-10-11 04:14:44 +02:00
|
|
|
ensureFolders(input: string) {
|
2024-04-17 03:00:31 +05:30
|
|
|
this.storageRepository.mkdirSync(dirname(input));
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
removeEmptyDirs(folder: StorageFolder) {
|
2024-04-17 03:00:31 +05:30
|
|
|
return this.storageRepository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
2023-09-25 17:07:21 +02:00
|
|
|
}
|
2023-10-11 04:14:44 +02:00
|
|
|
|
|
|
|
|
private savePath(pathType: PathType, id: string, newPath: string) {
|
|
|
|
|
switch (pathType) {
|
2024-02-02 04:18:00 +01:00
|
|
|
case AssetPathType.ORIGINAL: {
|
2024-03-19 22:42:10 -04:00
|
|
|
return this.assetRepository.update({ id, originalPath: newPath });
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2024-04-02 00:56:56 -04:00
|
|
|
case AssetPathType.PREVIEW: {
|
|
|
|
|
return this.assetRepository.update({ id, previewPath: newPath });
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2024-04-02 00:56:56 -04:00
|
|
|
case AssetPathType.THUMBNAIL: {
|
|
|
|
|
return this.assetRepository.update({ id, thumbnailPath: newPath });
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case AssetPathType.ENCODED_VIDEO: {
|
2024-03-19 22:42:10 -04:00
|
|
|
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case AssetPathType.SIDECAR: {
|
2024-03-19 22:42:10 -04:00
|
|
|
return this.assetRepository.update({ id, sidecarPath: newPath });
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
|
|
|
|
case PersonPathType.FACE: {
|
2023-10-11 04:14:44 +02:00
|
|
|
return this.personRepository.update({ id, thumbnailPath: newPath });
|
2024-02-02 04:18:00 +01:00
|
|
|
}
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-01-04 20:45:16 +00:00
|
|
|
static getNestedFolder(folder: StorageFolder, ownerId: string, filename: string): string {
|
2024-02-02 04:18:00 +01:00
|
|
|
return join(StorageCore.getFolderLocation(folder, ownerId), filename.slice(0, 2), filename.slice(2, 4));
|
2024-01-04 20:45:16 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
|
|
|
|
|
return join(this.getNestedFolder(folder, ownerId, filename), filename);
|
2023-10-11 04:14:44 +02:00
|
|
|
}
|
2024-04-19 11:50:13 -04:00
|
|
|
|
|
|
|
|
static getTempPathInDir(dir: string): string {
|
|
|
|
|
return join(dir, `${randomUUID()}.tmp`);
|
|
|
|
|
}
|
2023-03-25 10:50:57 -04:00
|
|
|
}
|