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:
@@ -61,7 +61,7 @@ export class ChangeMediaLocationCommand extends CommandRunner {
|
||||
immich-server:
|
||||
...
|
||||
volumes:
|
||||
- \${UPLOAD_LOCATION}:/usr/src/app/upload
|
||||
- \${UPLOAD_LOCATION}:/data
|
||||
...
|
||||
)`;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')] },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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`),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user