mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 06:31:58 +00:00
refactor(server): media paths
This commit is contained in:
parent
8daa8073ae
commit
fa30120bc4
17 changed files with 145 additions and 142 deletions
|
@ -3,7 +3,7 @@ import { dirname, join, resolve } from 'node:path';
|
|||
import { APP_MEDIA_LOCATION } from 'src/constants';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { PersonEntity } from 'src/entities/person.entity';
|
||||
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
|
||||
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType } from 'src/enum';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
|
@ -15,9 +15,6 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
|
|||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
|
||||
export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS));
|
||||
export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO));
|
||||
|
||||
export interface MoveRequest {
|
||||
entityId: string;
|
||||
pathType: PathType;
|
||||
|
@ -72,42 +69,6 @@ export class StorageCore {
|
|||
return instance;
|
||||
}
|
||||
|
||||
static reset() {
|
||||
instance = null;
|
||||
}
|
||||
|
||||
static getFolderLocation(folder: StorageFolder, userId: string) {
|
||||
return join(StorageCore.getBaseFolder(folder), userId);
|
||||
}
|
||||
|
||||
static getLibraryFolder(user: { storageLabel: string | null; id: string }) {
|
||||
return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
|
||||
}
|
||||
|
||||
static getBaseFolder(folder: StorageFolder) {
|
||||
return join(APP_MEDIA_LOCATION, folder);
|
||||
}
|
||||
|
||||
static getPersonThumbnailPath(person: PersonEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) {
|
||||
return StorageCore.getNestedPath(StorageFolder.THUMBNAILS, asset.ownerId, `${asset.id}-${type}.${format}`);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: AssetEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
|
||||
static getAndroidMotionPath(asset: AssetEntity, uuid: string) {
|
||||
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${uuid}-MP.mp4`);
|
||||
}
|
||||
|
||||
static isAndroidMotionPath(originalPath: string) {
|
||||
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO));
|
||||
}
|
||||
|
||||
static isImmichPath(path: string) {
|
||||
const resolvedPath = resolve(path);
|
||||
const resolvedAppMediaLocation = resolve(APP_MEDIA_LOCATION);
|
||||
|
@ -118,10 +79,6 @@ export class StorageCore {
|
|||
return normalizedPath.startsWith(normalizedAppMediaLocation);
|
||||
}
|
||||
|
||||
static isGeneratedAsset(path: string) {
|
||||
return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR);
|
||||
}
|
||||
|
||||
async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) {
|
||||
const { id: entityId, files } = asset;
|
||||
const { thumbnailFile, previewFile } = getAssetFiles(files);
|
||||
|
@ -144,6 +101,7 @@ export class StorageCore {
|
|||
}
|
||||
|
||||
async movePersonFile(person: PersonEntity, pathType: PersonPathType) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
const { id: entityId, thumbnailPath } = person;
|
||||
switch (pathType) {
|
||||
case PersonPathType.FACE: {
|
||||
|
@ -151,7 +109,7 @@ export class StorageCore {
|
|||
entityId,
|
||||
pathType,
|
||||
oldPath: thumbnailPath,
|
||||
newPath: StorageCore.getPersonThumbnailPath(person),
|
||||
newPath: buildPath({ mediaPaths, personThumbnail: person }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -275,10 +233,6 @@ export class StorageCore {
|
|||
this.storageRepository.mkdirSync(dirname(input));
|
||||
}
|
||||
|
||||
removeEmptyDirs(folder: StorageFolder) {
|
||||
return this.storageRepository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
switch (pathType) {
|
||||
case AssetPathType.ORIGINAL: {
|
||||
|
@ -302,14 +256,6 @@ export class StorageCore {
|
|||
}
|
||||
}
|
||||
|
||||
static getNestedFolder(folder: StorageFolder, ownerId: string, filename: string): string {
|
||||
return join(StorageCore.getFolderLocation(folder, ownerId), filename.slice(0, 2), filename.slice(2, 4));
|
||||
}
|
||||
|
||||
static getNestedPath(folder: StorageFolder, ownerId: string, filename: string): string {
|
||||
return join(this.getNestedFolder(folder, ownerId, filename), filename);
|
||||
}
|
||||
|
||||
static getTempPathInDir(dir: string): string {
|
||||
return join(dir, `${randomUUID()}.tmp`);
|
||||
}
|
||||
|
|
|
@ -153,14 +153,6 @@ export enum SharedLinkType {
|
|||
INDIVIDUAL = 'INDIVIDUAL',
|
||||
}
|
||||
|
||||
export enum StorageFolder {
|
||||
ENCODED_VIDEO = 'encoded-video',
|
||||
LIBRARY = 'library',
|
||||
UPLOAD = 'upload',
|
||||
PROFILE = 'profile',
|
||||
THUMBNAILS = 'thumbs',
|
||||
}
|
||||
|
||||
export enum SystemMetadataKey {
|
||||
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
|
||||
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
|
||||
|
|
|
@ -3,6 +3,14 @@ import { VectorExtension } from 'src/interfaces/database.interface';
|
|||
|
||||
export const IConfigRepository = 'IConfigRepository';
|
||||
|
||||
export type MediaPaths = {
|
||||
uploads: string;
|
||||
library: string;
|
||||
profile: string;
|
||||
thumbnails: string;
|
||||
encodedVideos: string;
|
||||
};
|
||||
|
||||
export interface EnvData {
|
||||
port: number;
|
||||
environment: ImmichEnvironment;
|
||||
|
@ -41,6 +49,8 @@ export interface EnvData {
|
|||
server: string;
|
||||
};
|
||||
|
||||
mediaPaths: MediaPaths;
|
||||
|
||||
resourcePaths: {
|
||||
lockFile: string;
|
||||
geodata: {
|
||||
|
|
|
@ -44,6 +44,9 @@ export class ConfigRepository implements IConfigRepository {
|
|||
const environment = process.env.IMMICH_ENV as ImmichEnvironment;
|
||||
const isProd = environment === ImmichEnvironment.PRODUCTION;
|
||||
const buildFolder = process.env.IMMICH_BUILD_DATA || '/build';
|
||||
// TODO change default to /data or similar
|
||||
const uploadFolder = process.env.IMMICH_MEDIA_LOCATION || './upload';
|
||||
|
||||
const folders = {
|
||||
geodata: join(buildFolder, 'geodata'),
|
||||
web: join(buildFolder, 'www'),
|
||||
|
@ -86,6 +89,14 @@ export class ConfigRepository implements IConfigRepository {
|
|||
|
||||
licensePublicKey: isProd ? productionKeys : stagingKeys,
|
||||
|
||||
mediaPaths: {
|
||||
uploads: join(uploadFolder, 'upload'),
|
||||
library: join(uploadFolder, 'library'),
|
||||
profile: join(uploadFolder, 'profile'),
|
||||
thumbnails: join(uploadFolder, 'thumbs'),
|
||||
encodedVideos: join(uploadFolder, 'encoded-video'),
|
||||
},
|
||||
|
||||
resourcePaths: {
|
||||
lockFile: join(buildFolder, 'build-lock.json'),
|
||||
geodata: {
|
||||
|
|
|
@ -12,15 +12,7 @@ import {
|
|||
PathEntityType,
|
||||
} from 'src/dtos/audit.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
DatabaseAction,
|
||||
Permission,
|
||||
PersonPathType,
|
||||
StorageFolder,
|
||||
UserPathType,
|
||||
} from 'src/enum';
|
||||
import { AssetFileType, AssetPathType, DatabaseAction, Permission, PersonPathType, UserPathType } from 'src/enum';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
|
@ -119,19 +111,16 @@ export class AuditService extends BaseService {
|
|||
|
||||
async getFileReport() {
|
||||
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(this.fullPath(filename));
|
||||
const crawl = async (folder: StorageFolder) =>
|
||||
new Set(
|
||||
await this.storageRepository.crawl({
|
||||
includeHidden: true,
|
||||
pathsToCrawl: [StorageCore.getBaseFolder(folder)],
|
||||
}),
|
||||
);
|
||||
const crawl = async (folder: string) =>
|
||||
new Set(await this.storageRepository.crawl({ includeHidden: true, pathsToCrawl: [folder] }));
|
||||
|
||||
const uploadFiles = await crawl(StorageFolder.UPLOAD);
|
||||
const libraryFiles = await crawl(StorageFolder.LIBRARY);
|
||||
const thumbFiles = await crawl(StorageFolder.THUMBNAILS);
|
||||
const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO);
|
||||
const profileFiles = await crawl(StorageFolder.PROFILE);
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
|
||||
const uploadFiles = await crawl(mediaPaths.uploads);
|
||||
const libraryFiles = await crawl(mediaPaths.library);
|
||||
const thumbFiles = await crawl(mediaPaths.thumbnails);
|
||||
const videoFiles = await crawl(mediaPaths.encodedVideos);
|
||||
const profileFiles = await crawl(mediaPaths.profile);
|
||||
const allFiles = new Set<string>();
|
||||
for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) {
|
||||
for (const item of list) {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { Inject } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetPathType } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IActivityRepository } from 'src/interfaces/activity.interface';
|
||||
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
|
||||
|
@ -41,6 +43,11 @@ import { IViewRepository } from 'src/interfaces/view.interface';
|
|||
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
|
||||
import { getConfig, updateConfig } from 'src/utils/config';
|
||||
|
||||
type PathRequest = { id: string; ownerId: string };
|
||||
type ThumbnailPathRequest = PathRequest & { image: SystemConfig['image'] };
|
||||
|
||||
const asNestedPath = (filename: string) => join(filename.slice(0, 2), filename.slice(2, 4), filename);
|
||||
|
||||
export class BaseService {
|
||||
protected storageCore: StorageCore;
|
||||
|
||||
|
@ -119,4 +126,36 @@ export class BaseService {
|
|||
checkAccess(request: AccessRequest) {
|
||||
return checkAccess(this.accessRepository, request);
|
||||
}
|
||||
|
||||
buildAssetPreviewPath({ image, id, ownerId }: ThumbnailPathRequest) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
const filename = `${id}-${AssetPathType.PREVIEW}.${image.preview.format}`;
|
||||
return join(mediaPaths.thumbnails, ownerId, asNestedPath(filename));
|
||||
}
|
||||
|
||||
buildAssetThumbnailPath({ image, id, ownerId }: ThumbnailPathRequest) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
const filename = `${id}-${AssetPathType.THUMBNAIL}.${image.thumbnail.format}`;
|
||||
return join(mediaPaths.thumbnails, ownerId, asNestedPath(filename));
|
||||
}
|
||||
|
||||
buildPersonThumbnailPath({ id, ownerId }: PathRequest) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
return join(mediaPaths.thumbnails, ownerId, asNestedPath(`${id}.jpeg`));
|
||||
}
|
||||
|
||||
buildEncodedVideoPath({ id, ownerId }: PathRequest) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
return join(mediaPaths.encodedVideos, ownerId, asNestedPath(`${id}.mp4`));
|
||||
}
|
||||
|
||||
buildAndroidMotionPath({ id, ownerId }: PathRequest) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
return join(mediaPaths.encodedVideos, ownerId, asNestedPath(`${id}-MP.mp4`));
|
||||
}
|
||||
|
||||
buildLibraryFolder({ id, storageLabel }: { id: string; storageLabel: string | null }) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
return join(mediaPaths.library, storageLabel || id);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { parse } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
|
@ -9,6 +8,7 @@ import { Permission } from 'src/enum';
|
|||
import { ImmichReadStream } from 'src/interfaces/storage.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
import { isAndroidMotionPath } from 'src/utils/file';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
import { getPreferences } from 'src/utils/preferences';
|
||||
|
||||
|
@ -20,6 +20,7 @@ export class DownloadService extends BaseService {
|
|||
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
|
||||
|
||||
const preferences = getPreferences(auth.user);
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
|
||||
const assetPagination = await this.getDownloadAssets(auth, dto);
|
||||
for await (const assets of assetPagination) {
|
||||
|
@ -29,7 +30,7 @@ export class DownloadService extends BaseService {
|
|||
const motionAssets = await this.assetRepository.getByIds(motionIds, { exifInfo: true });
|
||||
for (const motionAsset of motionAssets) {
|
||||
if (
|
||||
!StorageCore.isAndroidMotionPath(motionAsset.originalPath) ||
|
||||
!isAndroidMotionPath(mediaPaths, motionAsset.originalPath) ||
|
||||
preferences.download.includeEmbeddedVideos
|
||||
) {
|
||||
assets.push(motionAsset);
|
||||
|
|
|
@ -10,7 +10,6 @@ import {
|
|||
AudioCodec,
|
||||
Colorspace,
|
||||
LogLevel,
|
||||
StorageFolder,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
TranscodeTarget,
|
||||
|
@ -97,8 +96,9 @@ export class MediaService extends BaseService {
|
|||
|
||||
const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION);
|
||||
if (active === 1 && waiting === 0) {
|
||||
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
|
||||
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
await this.storageRepository.removeEmptyDirs(mediaPaths.thumbnails);
|
||||
await this.storageRepository.removeEmptyDirs(mediaPaths.encodedVideos);
|
||||
}
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
|
@ -196,8 +196,9 @@ export class MediaService extends BaseService {
|
|||
|
||||
private async generateImageThumbnails(asset: AssetEntity) {
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||
|
||||
const previewPath = this.buildAssetPreviewPath({ image, id: asset.id, ownerId: asset.ownerId });
|
||||
const thumbnailPath = this.buildAssetThumbnailPath({ image, id: asset.id, ownerId: asset.ownerId });
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
const shouldExtract = image.extractEmbedded && mimeTypes.isRaw(asset.originalPath);
|
||||
|
@ -230,8 +231,8 @@ export class MediaService extends BaseService {
|
|||
|
||||
private async generateVideoThumbnails(asset: AssetEntity) {
|
||||
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const previewPath = StorageCore.getImagePath(asset, AssetPathType.PREVIEW, image.preview.format);
|
||||
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.THUMBNAIL, image.thumbnail.format);
|
||||
const thumbnailPath = this.buildAssetThumbnailPath({ image, id: asset.id, ownerId: asset.ownerId });
|
||||
const previewPath = this.buildAssetPreviewPath({ image, id: asset.id, ownerId: asset.ownerId });
|
||||
this.storageCore.ensureFolders(previewPath);
|
||||
|
||||
const { audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||
|
@ -282,7 +283,7 @@ export class MediaService extends BaseService {
|
|||
}
|
||||
|
||||
const input = asset.originalPath;
|
||||
const output = StorageCore.getEncodedVideoPath(asset);
|
||||
const output = this.buildEncodedVideoPath({ id, ownerId: asset.ownerId });
|
||||
this.storageCore.ensureFolders(output);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
|
@ -559,7 +558,7 @@ export class PersonService extends BaseService {
|
|||
|
||||
const { width, height, inputPath } = await this.getInputDimensions(asset, { width: oldWidth, height: oldHeight });
|
||||
|
||||
const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
|
||||
const thumbnailPath = this.buildPersonThumbnailPath(person);
|
||||
this.storageCore.ensureFolders(thumbnailPath);
|
||||
|
||||
const thumbnailOptions = {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import {
|
||||
|
@ -13,7 +12,7 @@ import {
|
|||
ServerStorageResponseDto,
|
||||
UsageByUserDto,
|
||||
} from 'src/dtos/server.dto';
|
||||
import { StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { UserStatsQueryResponse } from 'src/interfaces/user.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { asHumanReadable } from 'src/utils/bytes';
|
||||
|
@ -49,8 +48,8 @@ export class ServerService extends BaseService {
|
|||
}
|
||||
|
||||
async getStorage(): Promise<ServerStorageResponseDto> {
|
||||
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
const diskInfo = await this.storageRepository.checkDiskUsage(mediaPaths.library);
|
||||
|
||||
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
|
||||
|
||||
|
|
|
@ -12,15 +12,14 @@ import {
|
|||
supportedWeekTokens,
|
||||
supportedYearTokens,
|
||||
} from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
|
||||
import { AssetPathType, AssetType } from 'src/enum';
|
||||
import { DatabaseLock } from 'src/interfaces/database.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getLivePhotoMotionFilename } from 'src/utils/file';
|
||||
import { getLivePhotoMotionFilename, isAndroidMotionPath } from 'src/utils/file';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
export interface MoveAssetMetadata {
|
||||
|
@ -132,8 +131,8 @@ export class StorageTemplateService extends BaseService {
|
|||
}
|
||||
|
||||
this.logger.debug('Cleaning up empty directories...');
|
||||
const libraryFolder = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
await this.storageRepository.removeEmptyDirs(libraryFolder);
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
await this.storageRepository.removeEmptyDirs(mediaPaths.library);
|
||||
|
||||
this.logger.log('Finished storage template migration');
|
||||
|
||||
|
@ -141,7 +140,8 @@ export class StorageTemplateService extends BaseService {
|
|||
}
|
||||
|
||||
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
|
||||
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
if (asset.isExternal || isAndroidMotionPath(mediaPaths, asset.originalPath)) {
|
||||
// External assets are not affected by storage template
|
||||
// TODO: shouldn't this only apply to external assets?
|
||||
return;
|
||||
|
@ -186,7 +186,7 @@ export class StorageTemplateService extends BaseService {
|
|||
const source = asset.originalPath;
|
||||
const extension = path.extname(source).split('.').pop() as string;
|
||||
const sanitized = sanitize(path.basename(filename, `.${extension}`));
|
||||
const rootPath = StorageCore.getLibraryFolder({ id: asset.ownerId, storageLabel });
|
||||
const rootPath = this.buildLibraryFolder({ id: asset.ownerId, storageLabel });
|
||||
|
||||
let albumName = null;
|
||||
if (this.template.needsAlbum) {
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { DatabaseLock } from 'src/interfaces/database.interface';
|
||||
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
@ -10,6 +9,8 @@ import { BaseService } from 'src/services/base.service';
|
|||
export class ImmichStartupError extends Error {}
|
||||
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
|
||||
|
||||
type MountPaths = { folderPath: string; internalPath: string; externalPath: string };
|
||||
|
||||
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
|
||||
|
||||
@Injectable()
|
||||
|
@ -17,6 +18,14 @@ export class StorageService extends BaseService {
|
|||
@OnEvent({ name: 'app.bootstrap' })
|
||||
async onBootstrap() {
|
||||
const envData = this.configRepository.getEnv();
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
const folders = [
|
||||
mediaPaths.encodedVideos,
|
||||
mediaPaths.library,
|
||||
mediaPaths.profile,
|
||||
mediaPaths.thumbnails,
|
||||
mediaPaths.uploads,
|
||||
];
|
||||
|
||||
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
||||
const flags = (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
|
||||
|
@ -26,14 +35,18 @@ export class StorageService extends BaseService {
|
|||
|
||||
try {
|
||||
// check each folder exists and is writable
|
||||
for (const folder of Object.values(StorageFolder)) {
|
||||
for (const folder of folders) {
|
||||
const internalPath = join(folder, '.immich');
|
||||
const externalPath = `<UPLOAD_LOCATION>/${folder.split('/').pop()}/.immich`;
|
||||
const paths = { internalPath, externalPath, folderPath: folder };
|
||||
|
||||
if (!enabled) {
|
||||
this.logger.log(`Writing initial mount file for the ${folder} folder`);
|
||||
await this.createMountFile(folder);
|
||||
await this.createMountFile(paths);
|
||||
}
|
||||
|
||||
await this.verifyReadAccess(folder);
|
||||
await this.verifyWriteAccess(folder);
|
||||
await this.verifyReadAccess(paths);
|
||||
await this.verifyWriteAccess(paths);
|
||||
}
|
||||
|
||||
if (!flags.mountFiles) {
|
||||
|
@ -73,8 +86,7 @@ export class StorageService extends BaseService {
|
|||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
private async verifyReadAccess(folder: StorageFolder) {
|
||||
const { internalPath, externalPath } = this.getMountFilePaths(folder);
|
||||
private async verifyReadAccess({ internalPath, externalPath }: MountPaths) {
|
||||
try {
|
||||
await this.storageRepository.readFile(internalPath);
|
||||
} catch (error) {
|
||||
|
@ -83,8 +95,7 @@ export class StorageService extends BaseService {
|
|||
}
|
||||
}
|
||||
|
||||
private async createMountFile(folder: StorageFolder) {
|
||||
const { folderPath, internalPath, externalPath } = this.getMountFilePaths(folder);
|
||||
private async createMountFile({ folderPath, internalPath, externalPath }: MountPaths) {
|
||||
try {
|
||||
this.storageRepository.mkdirSync(folderPath);
|
||||
await this.storageRepository.createFile(internalPath, Buffer.from(`${Date.now()}`));
|
||||
|
@ -98,8 +109,7 @@ export class StorageService extends BaseService {
|
|||
}
|
||||
}
|
||||
|
||||
private async verifyWriteAccess(folder: StorageFolder) {
|
||||
const { internalPath, externalPath } = this.getMountFilePaths(folder);
|
||||
private async verifyWriteAccess({ internalPath, externalPath }: MountPaths) {
|
||||
try {
|
||||
await this.storageRepository.overwriteFile(internalPath, Buffer.from(`${Date.now()}`));
|
||||
} catch (error) {
|
||||
|
@ -107,12 +117,4 @@ export class StorageService extends BaseService {
|
|||
throw new ImmichStartupError(`Failed to write "${externalPath} - ${docsMessage}"`);
|
||||
}
|
||||
}
|
||||
|
||||
private getMountFilePaths(folder: StorageFolder) {
|
||||
const folderPath = StorageCore.getBaseFolder(folder);
|
||||
const internalPath = join(folderPath, '.immich');
|
||||
const externalPath = `<UPLOAD_LOCATION>/${folder}/.immich`;
|
||||
|
||||
return { folderPath, internalPath, externalPath };
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { join } from 'node:path';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
|
||||
|
@ -9,7 +9,7 @@ import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
|||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||
import { CacheControl, UserMetadataKey } from 'src/enum';
|
||||
import { IEntityJob, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { UserFindOptions } from 'src/interfaces/user.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
|
@ -196,14 +196,20 @@ export class UserService extends BaseService {
|
|||
|
||||
this.logger.log(`Deleting user: ${user.id}`);
|
||||
|
||||
const { mediaPaths } = this.configRepository.getEnv();
|
||||
|
||||
const folders = [
|
||||
StorageCore.getLibraryFolder(user),
|
||||
StorageCore.getFolderLocation(StorageFolder.UPLOAD, user.id),
|
||||
StorageCore.getFolderLocation(StorageFolder.PROFILE, user.id),
|
||||
StorageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id),
|
||||
StorageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id),
|
||||
join(mediaPaths.library, user.id),
|
||||
join(mediaPaths.uploads, user.id),
|
||||
join(mediaPaths.profile, user.id),
|
||||
join(mediaPaths.thumbnails, user.id),
|
||||
join(mediaPaths.encodedVideos, user.id),
|
||||
];
|
||||
|
||||
if (user.storageLabel) {
|
||||
folders.push(join(mediaPaths.library, user.storageLabel));
|
||||
}
|
||||
|
||||
for (const folder of folders) {
|
||||
this.logger.warn(`Removing user from filesystem: ${folder}`);
|
||||
await this.storageRepository.unlinkDir(folder, { recursive: true, force: true });
|
||||
|
|
|
@ -4,6 +4,7 @@ import { access, constants } from 'node:fs/promises';
|
|||
import { basename, extname, isAbsolute } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
import { CacheControl } from 'src/enum';
|
||||
import { MediaPaths } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ImmichReadStream } from 'src/interfaces/storage.interface';
|
||||
import { isConnectionAborted } from 'src/utils/misc';
|
||||
|
@ -20,6 +21,9 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string
|
|||
return getFileNameWithoutExtension(stillName) + extname(motionName);
|
||||
}
|
||||
|
||||
export const isAndroidMotionPath = (mediaPaths: MediaPaths, filePath: string) =>
|
||||
filePath.startsWith(mediaPaths.encodedVideos);
|
||||
|
||||
export class ImmichFileResponse {
|
||||
public readonly path!: string;
|
||||
public readonly contentType!: string;
|
||||
|
|
2
server/test/fixtures/library.stub.ts
vendored
2
server/test/fixtures/library.stub.ts
vendored
|
@ -83,7 +83,7 @@ export const libraryStub = {
|
|||
assets: [],
|
||||
owner: userStub.admin,
|
||||
ownerId: 'user-id',
|
||||
importPaths: [join(THUMBNAIL_DIR, 'library'), '/xyz', join(APP_MEDIA_LOCATION, 'library')],
|
||||
importPaths: ['upload/thumbs/library', '/xyz', 'upload/library'],
|
||||
createdAt: new Date('2023-01-01'),
|
||||
updatedAt: new Date('2023-01-01'),
|
||||
refreshedAt: null,
|
||||
|
|
|
@ -25,6 +25,14 @@ const envData: EnvData = {
|
|||
server: 'server-public-key',
|
||||
},
|
||||
|
||||
mediaPaths: {
|
||||
uploads: 'upload/upload',
|
||||
library: 'upload/library',
|
||||
profile: 'upload/profile',
|
||||
thumbnails: 'upload/thumbs',
|
||||
encodedVideos: 'upload/encoded-video',
|
||||
},
|
||||
|
||||
resourcePaths: {
|
||||
lockFile: 'build-lock.json',
|
||||
geodata: {
|
||||
|
|
|
@ -39,11 +39,7 @@ export const makeMockWatcher =
|
|||
return () => Promise.resolve();
|
||||
};
|
||||
|
||||
export const newStorageRepositoryMock = (reset = true): Mocked<IStorageRepository> => {
|
||||
if (reset) {
|
||||
StorageCore.reset();
|
||||
}
|
||||
|
||||
export const newStorageRepositoryMock = (): Mocked<IStorageRepository> => {
|
||||
return {
|
||||
createZipStream: vitest.fn(),
|
||||
createReadStream: vitest.fn(),
|
||||
|
|
Loading…
Reference in a new issue