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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 316 additions and 209 deletions

View File

@ -4,7 +4,6 @@ services:
target: dev-container-mobile
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
- IMMICH_MEDIA_LOCATION=/data
volumes: !override # bind mount host to /workspaces/immich
- ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules

View File

@ -6,7 +6,6 @@ services:
hostname: immich-dev
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
- IMMICH_MEDIA_LOCATION=/data
volumes: !override
- ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules

View File

@ -36,7 +36,6 @@ services:
env_file:
- .env
environment:
IMMICH_MEDIA_LOCATION: /data
IMMICH_REPOSITORY: immich-app/immich
IMMICH_REPOSITORY_URL: https://github.com/immich-app/immich
IMMICH_SOURCE_REF: local

View File

@ -19,8 +19,6 @@ services:
build:
context: ../
dockerfile: server/Dockerfile
environment:
- IMMICH_MEDIA_LOCATION=/data
volumes:
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro

View File

@ -18,7 +18,7 @@ services:
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}:/data
- /etc/localtime:/etc/localtime:ro
env_file:
- .env

View File

@ -180,7 +180,7 @@ services:
...
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}:/data
- /etc/localtime:/etc/localtime:ro
+ - originals:/usr/src/app/originals
...

View File

@ -94,19 +94,16 @@ Change media location
```
immich-admin change-media-location
? Enter the previous value of IMMICH_MEDIA_LOCATION: /usr/src/app/upload
? Enter the new value of IMMICH_MEDIA_LOCATION: /data
? Enter the previous value of IMMICH_MEDIA_LOCATION: /data
? Enter the new value of IMMICH_MEDIA_LOCATION: /my-data
...
Previous value: /data
Current value: /my-data
Previous value: /usr/src/app/upload
Current value: /data
Changing database paths from "/usr/src/app/upload/*" to "/data/*"
Changing database paths from "/data/*" to "/my-data/*"
? Do you want to proceed? [Y/n] y
Database file paths updated successfully! 🎉
You may now set IMMICH_MEDIA_LOCATION=/data and restart!
(please remember to update applicable volume mounts e.g. ${UPLOAD_LOCATION}:/data)
...
```

View File

@ -93,7 +93,7 @@ The `immich-server` container will need access to the gallery. Modify your docke
```diff title="docker-compose.yml"
immich-server:
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}:/data
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos:ro

View File

@ -27,11 +27,11 @@ After defining the locations of these files, we will edit the `docker-compose.ym
services:
immich-server:
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video
+ - ${PROFILE_LOCATION}:/usr/src/app/upload/profile
+ - ${BACKUP_LOCATION}:/usr/src/app/upload/backups
- ${UPLOAD_LOCATION}:/data
+ - ${THUMB_LOCATION}:/data/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/data/encoded-video
+ - ${PROFILE_LOCATION}:/data/profile
+ - ${BACKUP_LOCATION}:/data/backups
- /etc/localtime:/etc/localtime:ro
```
@ -44,7 +44,7 @@ docker compose up -d
:::note
Because of the underlying properties of docker bind mounts, it is not recommended to mount the `upload/` and `library/` folders as separate bind mounts if they are on the same device.
For this reason, we mount the HDD or the network storage (NAS) to `/usr/src/app/upload` and then mount the folders we want to access under that folder.
For this reason, we mount the HDD or the network storage (NAS) to `/data` and then mount the folders we want to access under that folder.
The `thumbs/` folder contains both the small thumbnails displayed in the timeline and the larger previews shown when clicking into an image. These cannot be separated.

View File

@ -12,7 +12,7 @@ If you want Immich to be able to delete the images in the external library or ad
```diff
immich-server:
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- ${UPLOAD_LOCATION}:/data
+ - /home/user/photos1:/home/user/photos1:ro
+ - /mnt/photos2:/mnt/photos2:ro # you can delete this line if you only have one mount point, or you can add more lines if you have more than two
```

View File

@ -34,7 +34,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/usr/src/app/upload` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |

View File

@ -3,3 +3,22 @@
## TypeORM Upgrade
The upgrade to Immich `v2.x.x` has a required upgrade path to `v1.132.0+`. This means it is required to start up the application at least once on version `1.132.0` (or later). Doing so will complete database schema upgrades that are required for `v2.0.0`. After Immich has successfully booted on this version, shut the system down and try the `v2.x.x` upgrade again.
## Inconsistent Media Location
:::caution
This error is related to the location of media files _inside the container_. Never move files on the host system when you run into this error message.
:::
Immich automatically tries to detect where your Immich data is located. On start up, it compares the detected media location with the file paths in the database and throws an Inconsistent Media Location error when they do not match.
To fix this issue, verify that the `IMMICH_MEDIA_LOCATION` environment variable and `UPLOAD_LOCATION` volume mount are in sync with the database paths.
If you would like to migrate from one media location to another, simply successfully start Immich on `v1.136.0` or later, then do the following steps:
1. Stop Immich
2. Update `IMMICH_MEDIA_LOCATION` to the new location
3. Update the right-hand side of the `UPLOAD_LOCATION` volume mount to the new location
4. Start up Immich
After version `1.136.0`, Immich can detect when a media location has moved and will automatically update the database paths to keep them in sync.

View File

@ -22,7 +22,6 @@ services:
- IMMICH_ENV=testing
- IMMICH_PORT=2285
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
- IMMICH_MEDIA_LOCATION=/data
volumes:
- ./test-assets:/test-assets
extra_hosts:

View File

@ -186,18 +186,6 @@ export const utils = {
}
},
resetFilesystem: async () => {
const mediaInternal = '/usr/src/app/upload';
const dirs = [
`"${mediaInternal}/thumbs"`,
`"${mediaInternal}/upload"`,
`"${mediaInternal}/library"`,
`"${mediaInternal}/encoded-video"`,
].join(' ');
await execPromise(`docker exec -i "immich-e2e-server" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`);
},
unzip: async (input: string, output: string) => {
await execPromise(`unzip -o -d "${output}" "${input}"`);
},

View File

@ -130,7 +130,7 @@ ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
VOLUME /usr/src/app/upload
VOLUME /data
EXPOSE 2283
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
CMD ["start.sh"]

View File

@ -61,7 +61,7 @@ export class ChangeMediaLocationCommand extends CommandRunner {
immich-server:
...
volumes:
- \${UPLOAD_LOCATION}:/usr/src/app/upload
- \${UPLOAD_LOCATION}:/data
...
)`;

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,

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);

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

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

View File

@ -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: {

View File

@ -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 {

View File

@ -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')] },
});
});
});

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);

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',
},
]),
);

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);
});

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);

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']),

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,
});

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'));
});
});

View File

@ -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`),
);
});
});

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),
);
});

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 });
});
}

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);
});
});

View File

@ -65,7 +65,7 @@ export const assetStub = {
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: 'upload/library/IMG_123.jpg',
originalPath: '/data/library/IMG_123.jpg',
files: [thumbnailFile],
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
@ -101,7 +101,7 @@ export const assetStub = {
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: 'upload/library/IMG_456.jpg',
originalPath: '/data/library/IMG_456.jpg',
files: [previewFile],
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,

View File

@ -0,0 +1,46 @@
import { Kysely } from 'kysely';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { DB } from 'src/schema';
import { StorageService } from 'src/services/storage.service';
import { newMediumService } from 'test/medium.factory';
import { mockEnvData } from 'test/repositories/config.repository.mock';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(StorageService, {
database: db || defaultDatabase,
real: [AssetRepository, DatabaseRepository, SystemMetadataRepository],
mock: [StorageRepository, ConfigRepository, LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(StorageService.name, () => {
describe('onBoostrap', () => {
it('should work', async () => {
const { sut, ctx } = setup();
const configMock = ctx.getMock(ConfigRepository);
configMock.getEnv.mockReturnValue(mockEnvData({}));
const storageMock = ctx.getMock(StorageRepository);
storageMock.mkdirSync.mockReturnValue(void 0);
storageMock.existsSync.mockReturnValue(true);
storageMock.createFile.mockResolvedValue(void 0);
storageMock.overwriteFile.mockResolvedValue(void 0);
storageMock.readFile.mockResolvedValue(Buffer.from('test content'));
await expect(sut.onBootstrap()).resolves.toBeUndefined();
});
});
});

View File

@ -41,10 +41,9 @@ export const makeMockWatcher =
return () => Promise.resolve();
};
export const newStorageRepositoryMock = (reset = true): Mocked<RepositoryInterface<StorageRepository>> => {
if (reset) {
StorageCore.reset();
}
export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRepository>> => {
StorageCore.reset();
StorageCore.setMediaLocation('/data');
return {
createZipStream: vitest.fn(),
@ -53,6 +52,7 @@ export const newStorageRepositoryMock = (reset = true): Mocked<RepositoryInterfa
createFile: vitest.fn(),
createWriteStream: vitest.fn(),
createOrOverwriteFile: vitest.fn(),
existsSync: vitest.fn(),
overwriteFile: vitest.fn(),
unlink: vitest.fn(),
unlinkDir: vitest.fn().mockResolvedValue(true),

View File

@ -224,7 +224,7 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `upload/12/34/IMG_123.jpg`,
originalPath: `/data/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,

View File

@ -33,21 +33,21 @@ describe('get asset filename', () => {
{
asset: {
originalFileName: 'filename',
originalPath: 'upload/library/test/2016/2016-08-30/filename.jpg',
originalPath: '/data/library/test/2016/2016-08-30/filename.jpg',
},
result: 'filename.jpg',
},
{
asset: {
originalFileName: 'new-filename',
originalPath: 'upload/library/89d14e47-a40d-4cae-a347-a914cdef1f22/2016/2016-08-30/filename.jpg',
originalPath: '/data/library/89d14e47-a40d-4cae-a347-a914cdef1f22/2016/2016-08-30/filename.jpg',
},
result: 'new-filename.jpg',
},
{
asset: {
originalFileName: 'new-filename.txt',
originalPath: 'upload/library/test/2016/2016-08-30/filename.txt.jpg',
originalPath: '/data/library/test/2016/2016-08-30/filename.txt.jpg',
},
result: 'new-filename.txt.jpg',
},