feat: change default media location to /data (#20367)

* feat!: change default media location to /data

* feat: dynamically detect media location
This commit is contained in:
Jason Rasmussen
2025-07-29 16:58:50 -04:00
committed by GitHub
parent 4cae15f28d
commit 58521c9efb
39 changed files with 316 additions and 209 deletions
@@ -61,7 +61,7 @@ export class ChangeMediaLocationCommand extends CommandRunner {
immich-server:
...
volumes:
- \${UPLOAD_LOCATION}:/usr/src/app/upload
- \${UPLOAD_LOCATION}:/data
...
)`;
-2
View File
@@ -47,8 +47,6 @@ export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || '/usr/src/app/upload';
export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000);
export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number(
process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000,
+4 -1
View File
@@ -2,7 +2,6 @@ import { StorageCore } from 'src/cores/storage.core';
import { vitest } from 'vitest';
vitest.mock('src/constants', () => ({
APP_MEDIA_LOCATION: '/photos',
ADDED_IN_PREFIX: 'This property was added in ',
DEPRECATED_IN_PREFIX: 'This property was deprecated in ',
IWorker: 'IWorker',
@@ -10,6 +9,10 @@ vitest.mock('src/constants', () => ({
describe('StorageCore', () => {
describe('isImmichPath', () => {
beforeAll(() => {
StorageCore.setMediaLocation('/photos');
});
it('should return true for APP_MEDIA_LOCATION path', () => {
const immichPath = '/photos';
expect(StorageCore.isImmichPath(immichPath)).toBe(true);
+16 -3
View File
@@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { StorageAsset } from 'src/database';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
@@ -32,6 +31,8 @@ export type ThumbnailPathEntity = { id: string; ownerId: string };
let instance: StorageCore | null;
let mediaLocation: string | undefined;
export class StorageCore {
private constructor(
private assetRepository: AssetRepository,
@@ -74,6 +75,18 @@ export class StorageCore {
instance = null;
}
static getMediaLocation(): string {
if (mediaLocation === undefined) {
throw new Error('Media location is not set.');
}
return mediaLocation;
}
static setMediaLocation(location: string) {
mediaLocation = location;
}
static getFolderLocation(folder: StorageFolder, userId: string) {
return join(StorageCore.getBaseFolder(folder), userId);
}
@@ -83,7 +96,7 @@ export class StorageCore {
}
static getBaseFolder(folder: StorageFolder) {
return join(APP_MEDIA_LOCATION, folder);
return join(StorageCore.getMediaLocation(), folder);
}
static getPersonThumbnailPath(person: ThumbnailPathEntity) {
@@ -108,7 +121,7 @@ export class StorageCore {
static isImmichPath(path: string) {
const resolvedPath = resolve(path);
const resolvedAppMediaLocation = resolve(APP_MEDIA_LOCATION);
const resolvedAppMediaLocation = StorageCore.getMediaLocation();
const normalizedPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
const normalizedAppMediaLocation = resolvedAppMediaLocation.endsWith('/')
? resolvedAppMediaLocation
+2
View File
@@ -475,6 +475,8 @@ export enum DatabaseExtension {
export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access
DatabaseService = -200,
// Detect and configure the media location before jobs are queued which may use it
StorageService = -195,
// Other services may need to queue jobs on bootstrap.
JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap
@@ -97,6 +97,7 @@ export interface EnvData {
storage: {
ignoreMountCheckErrors: boolean;
mediaLocation?: string;
};
workers: ImmichWorker[];
@@ -307,6 +308,7 @@ const getEnv = (): EnvData => {
storage: {
ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS,
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
},
telemetry: {
@@ -162,6 +162,10 @@ export class StorageRepository {
}
}
existsSync(filepath: string) {
return existsSync(filepath);
}
async checkDiskUsage(folder: string): Promise<DiskUsage> {
const stats = await fs.statfs(folder);
return {
@@ -29,7 +29,7 @@ const uploadFile = {
file: {
uuid: 'random-uuid',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg',
originalPath: '/data/library/admin/image.jpeg',
originalName: 'image.jpeg',
size: 1000,
},
@@ -42,7 +42,7 @@ const uploadFile = {
uuid: 'random-uuid',
mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`,
originalPath: `/data/admin/${filename}`,
originalName: filename,
size: 1000,
},
@@ -294,16 +294,16 @@ describe(AssetMediaService.name, () => {
it('should return profile for profile uploads', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
expect.stringContaining('upload/profile/admin_id'),
expect.stringContaining('/data/profile/admin_id'),
);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/profile/admin_id'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/profile/admin_id'));
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
expect.stringContaining('upload/upload/admin_id/ra/nd'),
expect.stringContaining('/data/upload/admin_id/ra/nd'),
);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/upload/admin_id/ra/nd'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/upload/admin_id/ra/nd'));
});
});
@@ -907,14 +907,14 @@ describe(AssetMediaService.name, () => {
size: 1000,
uuid: 'random-uuid',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/upload/user-id/ra/nd/random-uuid.jpg',
originalPath: '/data/upload/user-id/ra/nd/random-uuid.jpg',
} as unknown as Express.Multer.File;
await sut.onUploadError(request, file);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [expect.stringContaining('upload/upload/user-id/ra/nd/random-uuid.jpg')] },
data: { files: [expect.stringContaining('/data/upload/user-id/ra/nd/random-uuid.jpg')] },
});
});
});
+1 -1
View File
@@ -843,7 +843,7 @@ describe(AuthService.name, () => {
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
profileImagePath: expect.stringContaining(`upload/profile/${user.id}/${fileId}.jpg`),
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
profileChangedAt: expect.any(Date),
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
+10 -11
View File
@@ -1,5 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { DownloadResponseDto } from 'src/dtos/download.dto';
import { DownloadService } from 'src/services/download.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -49,7 +48,7 @@ describe(DownloadService.name, () => {
expect(archiveMock.addFile).toHaveBeenCalledTimes(1);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(
1,
expect.stringContaining('upload/library/IMG_123.jpg'),
expect.stringContaining('/data/library/IMG_123.jpg'),
'IMG_123.jpg',
);
});
@@ -75,8 +74,8 @@ describe(DownloadService.name, () => {
expect(mocks.logger.warn).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
});
it('should download an archive', async () => {
@@ -98,8 +97,8 @@ describe(DownloadService.name, () => {
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
});
it('should handle duplicate file names', async () => {
@@ -121,8 +120,8 @@ describe(DownloadService.name, () => {
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_123.jpg', 'IMG_123+1.jpg');
});
it('should be deterministic', async () => {
@@ -144,8 +143,8 @@ describe(DownloadService.name, () => {
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_123.jpg', 'IMG_123+1.jpg');
});
it('should resolve symlinks', async () => {
@@ -291,7 +290,7 @@ describe(DownloadService.name, () => {
id: 'asset-2',
livePhotoVideoId: null,
size: 23_456,
originalPath: APP_MEDIA_LOCATION + '/encoded-video/uuid-MP.mp4',
originalPath: '/data/encoded-video/uuid-MP.mp4',
},
]),
);
+1 -1
View File
@@ -11,7 +11,7 @@ describe(JobService.name, () => {
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(JobService, {}));
({ sut, mocks } = newTestService(JobService));
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
});
+13 -13
View File
@@ -1,7 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { APP_MEDIA_LOCATION, JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { mapLibrary } from 'src/dtos/library.dto';
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { LibraryService } from 'src/services/library.service';
@@ -24,7 +24,7 @@ describe(LibraryService.name, () => {
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(LibraryService, {}));
({ sut, mocks } = newTestService(LibraryService));
mocks.database.tryLock.mockResolvedValue(true);
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
@@ -1171,10 +1171,10 @@ describe(LibraryService.name, () => {
mocks.storage.checkFileExists.mockResolvedValue(true);
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
await expect(sut.validate('library-id', { importPaths: ['/external/user1/'] })).resolves.toEqual({
importPaths: [
{
importPath: '/data/user1/',
importPath: '/external/user1/',
isValid: true,
message: undefined,
},
@@ -1188,10 +1188,10 @@ describe(LibraryService.name, () => {
throw error;
});
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
await expect(sut.validate('library-id', { importPaths: ['/external/user1/'] })).resolves.toEqual({
importPaths: [
{
importPath: '/data/user1/',
importPath: '/external/user1/',
isValid: false,
message: 'Path does not exist (ENOENT)',
},
@@ -1204,10 +1204,10 @@ describe(LibraryService.name, () => {
isDirectory: () => false,
} as Stats);
await expect(sut.validate('library-id', { importPaths: ['/data/user1/file'] })).resolves.toEqual({
await expect(sut.validate('library-id', { importPaths: ['/external/user1/file'] })).resolves.toEqual({
importPaths: [
{
importPath: '/data/user1/file',
importPath: '/external/user1/file',
isValid: false,
message: 'Not a directory',
},
@@ -1220,10 +1220,10 @@ describe(LibraryService.name, () => {
throw new Error('Unknown error');
});
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
await expect(sut.validate('library-id', { importPaths: ['/external/user1/'] })).resolves.toEqual({
importPaths: [
{
importPath: '/data/user1/',
importPath: '/external/user1/',
isValid: false,
message: 'Error: Unknown error',
},
@@ -1238,10 +1238,10 @@ describe(LibraryService.name, () => {
mocks.storage.checkFileExists.mockResolvedValue(false);
await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({
await expect(sut.validate('library-id', { importPaths: ['/external/user1/'] })).resolves.toEqual({
importPaths: [
{
importPath: '/data/user1/',
importPath: '/external/user1/',
isValid: false,
message: 'Lacking read permission for folder',
},
@@ -1264,7 +1264,7 @@ describe(LibraryService.name, () => {
});
it('should detect when import path is in immich media folder', async () => {
const importPaths = [APP_MEDIA_LOCATION + '/thumbs', `${process.cwd()}/xyz`, APP_MEDIA_LOCATION + '/library'];
const importPaths = ['/data/thumbs', `${process.cwd()}/xyz`, '/data/library'];
const library = factory.library({ importPaths });
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
+8 -9
View File
@@ -1,6 +1,5 @@
import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { Exif } from 'src/database';
import {
AssetFileType,
@@ -205,19 +204,19 @@ describe(MediaService.name, () => {
entityId: assetStub.image.id,
pathType: AssetPathType.FullSize,
oldPath: '/uploads/user-id/fullsize/path.webp',
newPath: expect.stringContaining('upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg'),
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-fullsize.jpeg'),
});
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.Preview,
oldPath: '/uploads/user-id/thumbs/path.jpg',
newPath: expect.stringContaining('upload/thumbs/user-id/as/se/asset-id-preview.jpeg'),
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-preview.jpeg'),
});
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.Thumbnail,
oldPath: '/uploads/user-id/webp/path.ext',
newPath: expect.stringContaining('upload/thumbs/user-id/as/se/asset-id-thumbnail.webp'),
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-thumbnail.webp'),
});
expect(mocks.move.create).toHaveBeenCalledTimes(3);
});
@@ -486,8 +485,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = APP_MEDIA_LOCATION + `/thumbs/user-id/as/se/asset-id-preview.${format}`;
const thumbnailPath = APP_MEDIA_LOCATION + `/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
const previewPath = `/data/thumbs/user-id/as/se/asset-id-preview.${format}`;
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -531,8 +530,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = expect.stringContaining(`upload/thumbs/user-id/as/se/asset-id-preview.jpeg`);
const thumbnailPath = expect.stringContaining(`upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`);
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-preview.jpeg`);
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-thumbnail.${format}`);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -2895,7 +2894,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
APP_MEDIA_LOCATION + '/encoded-video/user-id/as/se/asset-id.mp4',
'/data/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:a copy']),
+3 -3
View File
@@ -587,7 +587,7 @@ describe(MetadataService.name, () => {
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
originalPath: expect.stringContaining('upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'),
originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'),
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.Video,
});
@@ -645,7 +645,7 @@ describe(MetadataService.name, () => {
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
originalPath: expect.stringContaining('upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'),
originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'),
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.Video,
});
@@ -703,7 +703,7 @@ describe(MetadataService.name, () => {
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
originalPath: expect.stringContaining('upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'),
originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'),
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.Video,
});
+6 -6
View File
@@ -28,7 +28,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300,
});
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
});
it('should return the disk space as KiB', async () => {
@@ -44,7 +44,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000,
});
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
});
it('should return the disk space as MiB', async () => {
@@ -60,7 +60,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000,
});
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
});
it('should return the disk space as GiB', async () => {
@@ -80,7 +80,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000,
});
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
});
it('should return the disk space as TiB', async () => {
@@ -100,7 +100,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000_000,
});
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
});
it('should return the disk space as PiB', async () => {
@@ -120,7 +120,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000_000_000,
});
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
});
});
@@ -1,6 +1,5 @@
import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { AssetPathType, JobStatus } from 'src/enum';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { albumStub } from 'test/fixtures/album.stub';
@@ -111,10 +110,8 @@ describe(StorageTemplateService.name, () => {
it('should migrate single moving picture', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath =
APP_MEDIA_LOCATION + `/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
const newStillPicturePath =
APP_MEDIA_LOCATION + `/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
@@ -160,7 +157,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
newPath: expect.stringContaining(
`upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`,
`/data/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`,
),
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
@@ -183,7 +180,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
newPath: expect.stringContaining(
`upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`,
`/data/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`,
),
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
@@ -219,7 +216,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
newPath: expect.stringContaining(
`upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`,
`/data/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`,
),
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
@@ -243,9 +240,7 @@ describe(StorageTemplateService.name, () => {
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
newPath:
APP_MEDIA_LOCATION +
`/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
newPath: `/data/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
});
@@ -255,9 +250,8 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const asset = assetStub.storageAsset();
const previousFailedNewPath =
APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
const newPath = APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath));
mocks.move.getByEntity.mockResolvedValue({
@@ -296,9 +290,8 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const asset = assetStub.storageAsset({ fileSizeInByte: 5000 });
const previousFailedNewPath =
APP_MEDIA_LOCATION + `/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
const newPath = APP_MEDIA_LOCATION + `/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
const previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath));
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
@@ -332,8 +325,7 @@ describe(StorageTemplateService.name, () => {
it('should fail move if copying and hash of asset and the new file do not match', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const newPath =
APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
@@ -375,8 +367,8 @@ describe(StorageTemplateService.name, () => {
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
async ({ failedPathChecksum, failedPathSize }) => {
mocks.user.get.mockResolvedValue(userStub.user1);
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${testAsset.originalFileName}`;
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${testAsset.originalFileName}`;
const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${testAsset.originalFileName}`;
const newPath = `/data/library/${userStub.user1.id}/2023/2023-02-23/${testAsset.originalFileName}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path));
mocks.storage.stat.mockResolvedValue({ size: failedPathSize } as Stats);
@@ -423,7 +415,7 @@ describe(StorageTemplateService.name, () => {
it('should handle an asset with a duplicate destination', async () => {
const asset = assetStub.storageAsset();
const oldPath = asset.originalPath;
const newPath = APP_MEDIA_LOCATION + `/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
const newPath2 = newPath.replace('.jpg', '+1.jpg');
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@@ -448,7 +440,7 @@ describe(StorageTemplateService.name, () => {
});
it('should skip when an asset already matches the template', async () => {
const asset = assetStub.storageAsset({ originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg' });
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg' });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
@@ -463,7 +455,7 @@ describe(StorageTemplateService.name, () => {
});
it('should skip when an asset is probably a duplicate', async () => {
const asset = assetStub.storageAsset({ originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg' });
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg' });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
@@ -480,7 +472,7 @@ describe(StorageTemplateService.name, () => {
it('should move an asset', async () => {
const asset = assetStub.storageAsset();
const oldPath = asset.originalPath;
const newPath = APP_MEDIA_LOCATION + `/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({
@@ -508,7 +500,7 @@ describe(StorageTemplateService.name, () => {
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: asset.originalPath,
newPath: `upload/library/${user.storageLabel}/2023/2023-02-23/${asset.originalFileName}`,
newPath: `/data/library/${user.storageLabel}/2023/2023-02-23/${asset.originalFileName}`,
});
await sut.handleMigration();
@@ -516,12 +508,12 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: asset.id,
originalPath: expect.stringContaining(
`upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
),
});
});
@@ -529,7 +521,7 @@ describe(StorageTemplateService.name, () => {
it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => {
const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 });
const oldPath = asset.originalPath;
const newPath = APP_MEDIA_LOCATION + `/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]);
@@ -577,7 +569,7 @@ describe(StorageTemplateService.name, () => {
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: asset.originalPath,
newPath: `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`,
newPath: `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`,
});
mocks.storage.stat.mockResolvedValue({
size: 100,
@@ -588,14 +580,14 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.stat).toHaveBeenCalledWith(
expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
@@ -619,7 +611,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
@@ -630,7 +622,7 @@ describe(StorageTemplateService.name, () => {
const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({
ownerId: user.id,
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
originalFileName: 'IMG_7065.HEIC',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@@ -639,16 +631,16 @@ describe(StorageTemplateService.name, () => {
id: '123',
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
});
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`),
expect.stringContaining(`upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`),
expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`),
);
});
@@ -656,7 +648,7 @@ describe(StorageTemplateService.name, () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
originalFileName: 'IMG_7065.HEIC',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@@ -665,16 +657,16 @@ describe(StorageTemplateService.name, () => {
id: '123',
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
});
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`),
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`),
);
});
@@ -682,7 +674,7 @@ describe(StorageTemplateService.name, () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
originalPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
originalFileName: 'IMG_7065.JPEG',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@@ -691,16 +683,16 @@ describe(StorageTemplateService.name, () => {
id: '123',
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
});
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`),
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`),
);
});
@@ -708,7 +700,7 @@ describe(StorageTemplateService.name, () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
originalPath: 'upload/library/user-id/2022/2022-06-19/IMG_7065.JPG',
originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG',
originalFileName: 'IMG_7065.JPG',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@@ -717,16 +709,16 @@ describe(StorageTemplateService.name, () => {
id: '123',
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
});
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`),
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`),
);
});
});
+32 -16
View File
@@ -20,6 +20,14 @@ describe(StorageService.name, () => {
it('should enable mount folder checking', async () => {
mocks.systemMetadata.get.mockResolvedValue(null);
mocks.asset.getFileSamples.mockResolvedValue([]);
mocks.config.getEnv.mockReturnValue(
mockEnvData({
storage: {
ignoreMountCheckErrors: false,
mediaLocation: '/data',
},
}),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
@@ -33,34 +41,34 @@ describe(StorageService.name, () => {
upload: true,
},
});
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/encoded-video'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/profile'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/thumbs'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/upload'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/backups'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/encoded-video'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/profile'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/thumbs'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/upload'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/backups'));
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/encoded-video/.immich'),
expect.stringContaining('/data/encoded-video/.immich'),
expect.any(Buffer),
);
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/library/.immich'),
expect.stringContaining('/data/library/.immich'),
expect.any(Buffer),
);
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/profile/.immich'),
expect.stringContaining('/data/profile/.immich'),
expect.any(Buffer),
);
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/thumbs/.immich'),
expect.stringContaining('/data/thumbs/.immich'),
expect.any(Buffer),
);
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/upload/.immich'),
expect.stringContaining('/data/upload/.immich'),
expect.any(Buffer),
);
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/backups/.immich'),
expect.stringContaining('/data/backups/.immich'),
expect.any(Buffer),
);
});
@@ -77,6 +85,14 @@ describe(StorageService.name, () => {
},
});
mocks.asset.getFileSamples.mockResolvedValue([]);
mocks.config.getEnv.mockReturnValue(
mockEnvData({
storage: {
ignoreMountCheckErrors: false,
mediaLocation: '/data',
},
}),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
@@ -91,15 +107,15 @@ describe(StorageService.name, () => {
},
});
expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/backups'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/backups'));
expect(mocks.storage.createFile).toHaveBeenCalledTimes(2);
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/library/.immich'),
expect.stringContaining('/data/library/.immich'),
expect.any(Buffer),
);
expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/backups/.immich'),
expect.stringContaining('/data/backups/.immich'),
expect.any(Buffer),
);
});
+59 -24
View File
@@ -1,9 +1,16 @@
import { Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { DatabaseLock, JobName, JobStatus, QueueName, StorageFolder, SystemMetadataKey } from 'src/enum';
import {
BootstrapEventPriority,
DatabaseLock,
JobName,
JobStatus,
QueueName,
StorageFolder,
SystemMetadataKey,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobOf, SystemFlags } from 'src/types';
import { ImmichStartupError } from 'src/utils/misc';
@@ -12,9 +19,32 @@ const docsMessage = `Please see https://immich.app/docs/administration/system-in
@Injectable()
export class StorageService extends BaseService {
@OnEvent({ name: 'AppBootstrap' })
async onBootstrap() {
private detectMediaLocation(): string {
const envData = this.configRepository.getEnv();
if (envData.storage.mediaLocation) {
return envData.storage.mediaLocation;
}
const targets: string[] = [];
const candidates = ['/data', '/usr/src/app/upload'];
for (const candidate of candidates) {
const exists = this.storageRepository.existsSync(candidate);
if (exists) {
targets.push(candidate);
}
}
if (targets.length === 1) {
return targets[0];
}
return '/usr/src/app/upload';
}
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
async onBootstrap() {
StorageCore.setMediaLocation(this.detectMediaLocation());
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags =
@@ -53,6 +83,7 @@ export class StorageService extends BaseService {
this.logger.log('Successfully verified system mount folder checks');
} catch (error) {
const envData = this.configRepository.getEnv();
if (envData.storage.ignoreMountCheckErrors) {
this.logger.error(error as Error);
this.logger.warn('Ignoring mount folder errors');
@@ -63,30 +94,34 @@ export class StorageService extends BaseService {
});
await this.databaseRepository.withLock(DatabaseLock.MediaLocation, async () => {
const current = APP_MEDIA_LOCATION;
const savedValue = await this.systemMetadataRepository.get(SystemMetadataKey.MediaLocation);
let previous = savedValue?.location || '';
const current = StorageCore.getMediaLocation();
const samples = await this.assetRepository.getFileSamples();
if (samples.length > 0) {
const originalPath = samples[0].originalPath;
const savedValue = await this.systemMetadataRepository.get(SystemMetadataKey.MediaLocation);
let previous = savedValue?.location || '';
if (previous !== current) {
this.logger.log(`Media location changed (from=${previous}, to=${current})`);
const samples = await this.assetRepository.getFileSamples();
if (samples.length > 0) {
const originalPath = samples[0].originalPath;
if (!previous) {
previous = originalPath.startsWith('upload/') ? 'upload' : '/usr/src/app/upload';
}
if (previous && originalPath.startsWith(previous)) {
this.logger.warn(
`Detected a change to IMMICH_MEDIA_LOCATION, performing an automatic migration of file paths from ${previous} to ${current}, this may take awhile`,
);
await this.databaseRepository.migrateFilePaths(previous, current);
}
if (!previous) {
previous = originalPath.startsWith('upload/') ? 'upload' : '/usr/src/app/upload';
}
await this.systemMetadataRepository.set(SystemMetadataKey.MediaLocation, { location: current });
if (previous !== current) {
this.logger.log(`Media location changed (from=${previous}, to=${current})`);
if (!originalPath.startsWith(previous)) {
throw new Error(
'Detected an inconsistent media location. For more information, see https://immich.app/errors#inconsistent-media-location',
);
}
this.logger.warn(
`Detected a change to media location, performing an automatic migration of file paths from ${previous} to ${current}, this may take awhile`,
);
await this.databaseRepository.migrateFilePaths(previous, current);
}
}
await this.systemMetadataRepository.set(SystemMetadataKey.MediaLocation, { location: current });
});
}
+6 -6
View File
@@ -236,23 +236,23 @@ describe(UserService.name, () => {
await sut.handleUserDelete({ id: user.id });
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/library/deleted-user'),
expect.stringContaining('/data/library/deleted-user'),
options,
);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/upload/deleted-user'),
expect.stringContaining('/data/upload/deleted-user'),
options,
);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/profile/deleted-user'),
expect.stringContaining('/data/profile/deleted-user'),
options,
);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/thumbs/deleted-user'),
expect.stringContaining('/data/thumbs/deleted-user'),
options,
);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/encoded-video/deleted-user'),
expect.stringContaining('/data/encoded-video/deleted-user'),
options,
);
expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id);
@@ -268,7 +268,7 @@ describe(UserService.name, () => {
const options = { force: true, recursive: true };
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(expect.stringContaining('upload/library/admin'), options);
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(expect.stringContaining('data/library/admin'), options);
});
});