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 target: dev-container-mobile
environment: environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/ - IMMICH_SERVER_URL=http://127.0.0.1:2283/
- IMMICH_MEDIA_LOCATION=/data
volumes: !override # bind mount host to /workspaces/immich volumes: !override # bind mount host to /workspaces/immich
- ..:/workspaces/immich - ..:/workspaces/immich
- cli_node_modules:/workspaces/immich/cli/node_modules - cli_node_modules:/workspaces/immich/cli/node_modules

View File

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

View File

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

View File

@ -19,8 +19,6 @@ services:
build: build:
context: ../ context: ../
dockerfile: server/Dockerfile dockerfile: server/Dockerfile
environment:
- IMMICH_MEDIA_LOCATION=/data
volumes: volumes:
- ${UPLOAD_LOCATION}/photos:/data - ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro - /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 # service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes: 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 # 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 - /etc/localtime:/etc/localtime:ro
env_file: env_file:
- .env - .env

View File

@ -180,7 +180,7 @@ services:
... ...
volumes: 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 # 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 - /etc/localtime:/etc/localtime:ro
+ - originals:/usr/src/app/originals + - originals:/usr/src/app/originals
... ...

View File

@ -94,19 +94,16 @@ Change media location
``` ```
immich-admin change-media-location immich-admin change-media-location
? Enter the previous value of IMMICH_MEDIA_LOCATION: /usr/src/app/upload ? Enter the previous value of IMMICH_MEDIA_LOCATION: /data
? Enter the new 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 Changing database paths from "/data/*" to "/my-data/*"
Current value: /data
Changing database paths from "/usr/src/app/upload/*" to "/data/*"
? Do you want to proceed? [Y/n] y ? Do you want to proceed? [Y/n] y
Database file paths updated successfully! 🎉 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" ```diff title="docker-compose.yml"
immich-server: immich-server:
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/data
+ - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro + - /mnt/nas/christmas-trip:/mnt/media/christmas-trip:ro
+ - /home/user/old-pics:/mnt/media/old-pics:ro + - /home/user/old-pics:/mnt/media/old-pics:ro
+ - /mnt/media/videos:/mnt/media/videos: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: services:
immich-server: immich-server:
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/data
+ - ${THUMB_LOCATION}:/usr/src/app/upload/thumbs + - ${THUMB_LOCATION}:/data/thumbs
+ - ${ENCODED_VIDEO_LOCATION}:/usr/src/app/upload/encoded-video + - ${ENCODED_VIDEO_LOCATION}:/data/encoded-video
+ - ${PROFILE_LOCATION}:/usr/src/app/upload/profile + - ${PROFILE_LOCATION}:/data/profile
+ - ${BACKUP_LOCATION}:/usr/src/app/upload/backups + - ${BACKUP_LOCATION}:/data/backups
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
``` ```
@ -44,7 +44,7 @@ docker compose up -d
:::note :::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. 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. 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 ```diff
immich-server: immich-server:
volumes: volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload - ${UPLOAD_LOCATION}:/data
+ - /home/user/photos1:/home/user/photos1:ro + - /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 + - /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 | | `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, 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_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 | | `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 | | | `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 | | | `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |

View File

@ -3,3 +3,22 @@
## TypeORM Upgrade ## 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. 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_ENV=testing
- IMMICH_PORT=2285 - IMMICH_PORT=2285
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true - IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
- IMMICH_MEDIA_LOCATION=/data
volumes: volumes:
- ./test-assets:/test-assets - ./test-assets:/test-assets
extra_hosts: 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) => { unzip: async (input: string, output: string) => {
await execPromise(`unzip -o -d "${output}" "${input}"`); 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_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/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 EXPOSE 2283
ENTRYPOINT ["tini", "--", "/bin/bash", "-c"] ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
CMD ["start.sh"] CMD ["start.sh"]

View File

@ -61,7 +61,7 @@ export class ChangeMediaLocationCommand extends CommandRunner {
immich-server: immich-server:
... ...
volumes: 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 AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 }); 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_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000);
export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number( export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number(
process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000, 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'; import { vitest } from 'vitest';
vitest.mock('src/constants', () => ({ vitest.mock('src/constants', () => ({
APP_MEDIA_LOCATION: '/photos',
ADDED_IN_PREFIX: 'This property was added in ', ADDED_IN_PREFIX: 'This property was added in ',
DEPRECATED_IN_PREFIX: 'This property was deprecated in ', DEPRECATED_IN_PREFIX: 'This property was deprecated in ',
IWorker: 'IWorker', IWorker: 'IWorker',
@ -10,6 +9,10 @@ vitest.mock('src/constants', () => ({
describe('StorageCore', () => { describe('StorageCore', () => {
describe('isImmichPath', () => { describe('isImmichPath', () => {
beforeAll(() => {
StorageCore.setMediaLocation('/photos');
});
it('should return true for APP_MEDIA_LOCATION path', () => { it('should return true for APP_MEDIA_LOCATION path', () => {
const immichPath = '/photos'; const immichPath = '/photos';
expect(StorageCore.isImmichPath(immichPath)).toBe(true); expect(StorageCore.isImmichPath(immichPath)).toBe(true);

View File

@ -1,6 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { StorageAsset } from 'src/database'; import { StorageAsset } from 'src/database';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
@ -32,6 +31,8 @@ export type ThumbnailPathEntity = { id: string; ownerId: string };
let instance: StorageCore | null; let instance: StorageCore | null;
let mediaLocation: string | undefined;
export class StorageCore { export class StorageCore {
private constructor( private constructor(
private assetRepository: AssetRepository, private assetRepository: AssetRepository,
@ -74,6 +75,18 @@ export class StorageCore {
instance = null; 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) { static getFolderLocation(folder: StorageFolder, userId: string) {
return join(StorageCore.getBaseFolder(folder), userId); return join(StorageCore.getBaseFolder(folder), userId);
} }
@ -83,7 +96,7 @@ export class StorageCore {
} }
static getBaseFolder(folder: StorageFolder) { static getBaseFolder(folder: StorageFolder) {
return join(APP_MEDIA_LOCATION, folder); return join(StorageCore.getMediaLocation(), folder);
} }
static getPersonThumbnailPath(person: ThumbnailPathEntity) { static getPersonThumbnailPath(person: ThumbnailPathEntity) {
@ -108,7 +121,7 @@ export class StorageCore {
static isImmichPath(path: string) { static isImmichPath(path: string) {
const resolvedPath = resolve(path); const resolvedPath = resolve(path);
const resolvedAppMediaLocation = resolve(APP_MEDIA_LOCATION); const resolvedAppMediaLocation = StorageCore.getMediaLocation();
const normalizedPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/'; const normalizedPath = resolvedPath.endsWith('/') ? resolvedPath : resolvedPath + '/';
const normalizedAppMediaLocation = resolvedAppMediaLocation.endsWith('/') const normalizedAppMediaLocation = resolvedAppMediaLocation.endsWith('/')
? resolvedAppMediaLocation ? resolvedAppMediaLocation

View File

@ -475,6 +475,8 @@ export enum DatabaseExtension {
export enum BootstrapEventPriority { export enum BootstrapEventPriority {
// Database service should be initialized before anything else, most other services need database access // Database service should be initialized before anything else, most other services need database access
DatabaseService = -200, 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. // Other services may need to queue jobs on bootstrap.
JobService = -190, JobService = -190,
// Initialise config after other bootstrap services, stop other services from using config on bootstrap // Initialise config after other bootstrap services, stop other services from using config on bootstrap

View File

@ -97,6 +97,7 @@ export interface EnvData {
storage: { storage: {
ignoreMountCheckErrors: boolean; ignoreMountCheckErrors: boolean;
mediaLocation?: string;
}; };
workers: ImmichWorker[]; workers: ImmichWorker[];
@ -307,6 +308,7 @@ const getEnv = (): EnvData => {
storage: { storage: {
ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS, ignoreMountCheckErrors: !!dto.IMMICH_IGNORE_MOUNT_CHECK_ERRORS,
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
}, },
telemetry: { telemetry: {

View File

@ -162,6 +162,10 @@ export class StorageRepository {
} }
} }
existsSync(filepath: string) {
return existsSync(filepath);
}
async checkDiskUsage(folder: string): Promise<DiskUsage> { async checkDiskUsage(folder: string): Promise<DiskUsage> {
const stats = await fs.statfs(folder); const stats = await fs.statfs(folder);
return { return {

View File

@ -29,7 +29,7 @@ const uploadFile = {
file: { file: {
uuid: 'random-uuid', uuid: 'random-uuid',
checksum: Buffer.from('checksum', 'utf8'), checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg', originalPath: '/data/library/admin/image.jpeg',
originalName: 'image.jpeg', originalName: 'image.jpeg',
size: 1000, size: 1000,
}, },
@ -42,7 +42,7 @@ const uploadFile = {
uuid: 'random-uuid', uuid: 'random-uuid',
mimeType: 'image/jpeg', mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'), checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`, originalPath: `/data/admin/${filename}`,
originalName: filename, originalName: filename,
size: 1000, size: 1000,
}, },
@ -294,16 +294,16 @@ describe(AssetMediaService.name, () => {
it('should return profile for profile uploads', () => { it('should return profile for profile uploads', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual( 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', () => { it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual( 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, size: 1000,
uuid: 'random-uuid', uuid: 'random-uuid',
checksum: Buffer.from('checksum', 'utf8'), 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; } as unknown as Express.Multer.File;
await sut.onUploadError(request, file); await sut.onUploadError(request, file);
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete, 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)); ).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { 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), profileChangedAt: expect.any(Date),
}); });
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl); expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);

View File

@ -1,5 +1,4 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadResponseDto } from 'src/dtos/download.dto';
import { DownloadService } from 'src/services/download.service'; import { DownloadService } from 'src/services/download.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
@ -49,7 +48,7 @@ describe(DownloadService.name, () => {
expect(archiveMock.addFile).toHaveBeenCalledTimes(1); expect(archiveMock.addFile).toHaveBeenCalledTimes(1);
expect(archiveMock.addFile).toHaveBeenNthCalledWith( expect(archiveMock.addFile).toHaveBeenNthCalledWith(
1, 1,
expect.stringContaining('upload/library/IMG_123.jpg'), expect.stringContaining('/data/library/IMG_123.jpg'),
'IMG_123.jpg', 'IMG_123.jpg',
); );
}); });
@ -75,8 +74,8 @@ describe(DownloadService.name, () => {
expect(mocks.logger.warn).toHaveBeenCalledTimes(2); expect(mocks.logger.warn).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
}); });
it('should download an archive', async () => { it('should download an archive', async () => {
@ -98,8 +97,8 @@ describe(DownloadService.name, () => {
}); });
expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg');
}); });
it('should handle duplicate file names', async () => { it('should handle duplicate file names', async () => {
@ -121,8 +120,8 @@ describe(DownloadService.name, () => {
}); });
expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/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(2, '/data/library/IMG_123.jpg', 'IMG_123+1.jpg');
}); });
it('should be deterministic', async () => { it('should be deterministic', async () => {
@ -144,8 +143,8 @@ describe(DownloadService.name, () => {
}); });
expect(archiveMock.addFile).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg'); expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/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(2, '/data/library/IMG_123.jpg', 'IMG_123+1.jpg');
}); });
it('should resolve symlinks', async () => { it('should resolve symlinks', async () => {
@ -291,7 +290,7 @@ describe(DownloadService.name, () => {
id: 'asset-2', id: 'asset-2',
livePhotoVideoId: null, livePhotoVideoId: null,
size: 23_456, 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; let mocks: ServiceMocks;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(JobService, {})); ({ sut, mocks } = newTestService(JobService));
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
}); });

View File

@ -1,7 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config'; 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 { mapLibrary } from 'src/dtos/library.dto';
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum'; import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { LibraryService } from 'src/services/library.service'; import { LibraryService } from 'src/services/library.service';
@ -24,7 +24,7 @@ describe(LibraryService.name, () => {
let mocks: ServiceMocks; let mocks: ServiceMocks;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(LibraryService, {})); ({ sut, mocks } = newTestService(LibraryService));
mocks.database.tryLock.mockResolvedValue(true); mocks.database.tryLock.mockResolvedValue(true);
mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices); mocks.config.getWorker.mockReturnValue(ImmichWorker.Microservices);
@ -1171,10 +1171,10 @@ describe(LibraryService.name, () => {
mocks.storage.checkFileExists.mockResolvedValue(true); 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: [ importPaths: [
{ {
importPath: '/data/user1/', importPath: '/external/user1/',
isValid: true, isValid: true,
message: undefined, message: undefined,
}, },
@ -1188,10 +1188,10 @@ describe(LibraryService.name, () => {
throw error; 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: [ importPaths: [
{ {
importPath: '/data/user1/', importPath: '/external/user1/',
isValid: false, isValid: false,
message: 'Path does not exist (ENOENT)', message: 'Path does not exist (ENOENT)',
}, },
@ -1204,10 +1204,10 @@ describe(LibraryService.name, () => {
isDirectory: () => false, isDirectory: () => false,
} as Stats); } 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: [ importPaths: [
{ {
importPath: '/data/user1/file', importPath: '/external/user1/file',
isValid: false, isValid: false,
message: 'Not a directory', message: 'Not a directory',
}, },
@ -1220,10 +1220,10 @@ describe(LibraryService.name, () => {
throw new Error('Unknown error'); 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: [ importPaths: [
{ {
importPath: '/data/user1/', importPath: '/external/user1/',
isValid: false, isValid: false,
message: 'Error: Unknown error', message: 'Error: Unknown error',
}, },
@ -1238,10 +1238,10 @@ describe(LibraryService.name, () => {
mocks.storage.checkFileExists.mockResolvedValue(false); 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: [ importPaths: [
{ {
importPath: '/data/user1/', importPath: '/external/user1/',
isValid: false, isValid: false,
message: 'Lacking read permission for folder', 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 () => { 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 }); const library = factory.library({ importPaths });
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats); mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);

View File

@ -1,6 +1,5 @@
import { OutputInfo } from 'sharp'; import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { Exif } from 'src/database'; import { Exif } from 'src/database';
import { import {
AssetFileType, AssetFileType,
@ -205,19 +204,19 @@ describe(MediaService.name, () => {
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.FullSize, pathType: AssetPathType.FullSize,
oldPath: '/uploads/user-id/fullsize/path.webp', 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({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.Preview, pathType: AssetPathType.Preview,
oldPath: '/uploads/user-id/thumbs/path.jpg', 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({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id, entityId: assetStub.image.id,
pathType: AssetPathType.Thumbnail, pathType: AssetPathType.Thumbnail,
oldPath: '/uploads/user-id/webp/path.ext', 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); expect(mocks.move.create).toHaveBeenCalledTimes(3);
}); });
@ -486,8 +485,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = APP_MEDIA_LOCATION + `/thumbs/user-id/as/se/asset-id-preview.${format}`; const previewPath = `/data/thumbs/user-id/as/se/asset-id-preview.${format}`;
const thumbnailPath = APP_MEDIA_LOCATION + `/thumbs/user-id/as/se/asset-id-thumbnail.webp`; const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
await sut.handleGenerateThumbnails({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@ -531,8 +530,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image); mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = expect.stringContaining(`upload/thumbs/user-id/as/se/asset-id-preview.jpeg`); const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-preview.jpeg`);
const thumbnailPath = expect.stringContaining(`upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`); const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-thumbnail.${format}`);
await sut.handleGenerateThumbnails({ id: assetStub.image.id }); await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@ -2895,7 +2894,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith( expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext', '/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({ expect.objectContaining({
inputOptions: expect.any(Array), inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:a copy']), outputOptions: expect.arrayContaining(['-c:a copy']),

View File

@ -587,7 +587,7 @@ describe(MetadataService.name, () => {
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4', 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, ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.Video, type: AssetType.Video,
}); });
@ -645,7 +645,7 @@ describe(MetadataService.name, () => {
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4', 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, ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.Video, type: AssetType.Video,
}); });
@ -703,7 +703,7 @@ describe(MetadataService.name, () => {
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4', 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, ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.Video, type: AssetType.Video,
}); });

View File

@ -28,7 +28,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300, 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 () => { it('should return the disk space as KiB', async () => {
@ -44,7 +44,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000, 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 () => { it('should return the disk space as MiB', async () => {
@ -60,7 +60,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000, 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 () => { it('should return the disk space as GiB', async () => {
@ -80,7 +80,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000, 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 () => { it('should return the disk space as TiB', async () => {
@ -100,7 +100,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000_000, 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 () => { it('should return the disk space as PiB', async () => {
@ -120,7 +120,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300_000_000_000_000_000, 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 { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config'; import { defaults, SystemConfig } from 'src/config';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { AssetPathType, JobStatus } from 'src/enum'; import { AssetPathType, JobStatus } from 'src/enum';
import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageTemplateService } from 'src/services/storage-template.service';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
@ -111,10 +110,8 @@ describe(StorageTemplateService.name, () => {
it('should migrate single moving picture', async () => { it('should migrate single moving picture', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath = const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
APP_MEDIA_LOCATION + `/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`; const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
const newStillPicturePath =
APP_MEDIA_LOCATION + `/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset); mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
@ -160,7 +157,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id, entityId: asset.id,
newPath: expect.stringContaining( 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, oldPath: asset.originalPath,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
@ -183,7 +180,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id, entityId: asset.id,
newPath: expect.stringContaining( 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, oldPath: asset.originalPath,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
@ -219,7 +216,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id, entityId: asset.id,
newPath: expect.stringContaining( 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, oldPath: asset.originalPath,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
@ -243,9 +240,7 @@ describe(StorageTemplateService.name, () => {
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0'); const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({ expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id, entityId: asset.id,
newPath: newPath: `/data/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
APP_MEDIA_LOCATION +
`/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
oldPath: asset.originalPath, oldPath: asset.originalPath,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
}); });
@ -255,9 +250,8 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
const asset = assetStub.storageAsset(); const asset = assetStub.storageAsset();
const previousFailedNewPath = const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`; const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
const newPath = APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath)); mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath));
mocks.move.getByEntity.mockResolvedValue({ mocks.move.getByEntity.mockResolvedValue({
@ -296,9 +290,8 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
const asset = assetStub.storageAsset({ fileSizeInByte: 5000 }); const asset = assetStub.storageAsset({ fileSizeInByte: 5000 });
const previousFailedNewPath = const previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
APP_MEDIA_LOCATION + `/library/${asset.ownerId}/2022/June/${asset.originalFileName}`; const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
const newPath = APP_MEDIA_LOCATION + `/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath)); mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath));
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); 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 () => { it('should fail move if copying and hash of asset and the new file do not match', async () => {
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
const newPath = const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats); 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', 'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
async ({ failedPathChecksum, failedPathSize }) => { async ({ failedPathChecksum, failedPathSize }) => {
mocks.user.get.mockResolvedValue(userStub.user1); mocks.user.get.mockResolvedValue(userStub.user1);
const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${testAsset.originalFileName}`; const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${testAsset.originalFileName}`;
const newPath = `upload/library/${userStub.user1.id}/2023/2023-02-23/${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.checkFileExists.mockImplementation((path) => Promise.resolve(previousFailedNewPath === path));
mocks.storage.stat.mockResolvedValue({ size: failedPathSize } as Stats); 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 () => { it('should handle an asset with a duplicate destination', async () => {
const asset = assetStub.storageAsset(); const asset = assetStub.storageAsset();
const oldPath = asset.originalPath; 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'); const newPath2 = newPath.replace('.jpg', '+1.jpg');
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@ -448,7 +440,7 @@ describe(StorageTemplateService.name, () => {
}); });
it('should skip when an asset already matches the template', async () => { 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.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.user.getList.mockResolvedValue([userStub.user1]);
@ -463,7 +455,7 @@ describe(StorageTemplateService.name, () => {
}); });
it('should skip when an asset is probably a duplicate', async () => { 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.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.user.getList.mockResolvedValue([userStub.user1]);
@ -480,7 +472,7 @@ describe(StorageTemplateService.name, () => {
it('should move an asset', async () => { it('should move an asset', async () => {
const asset = assetStub.storageAsset(); const asset = assetStub.storageAsset();
const oldPath = asset.originalPath; 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.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({ mocks.move.create.mockResolvedValue({
@ -508,7 +500,7 @@ describe(StorageTemplateService.name, () => {
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
oldPath: asset.originalPath, 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(); await sut.handleMigration();
@ -516,12 +508,12 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/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({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: asset.id, id: asset.id,
originalPath: expect.stringContaining( 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 () => { 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 asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 });
const oldPath = asset.originalPath; 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.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' }); mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]); mocks.user.getList.mockResolvedValue([userStub.user1]);
@ -577,7 +569,7 @@ describe(StorageTemplateService.name, () => {
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
oldPath: asset.originalPath, 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({ mocks.storage.stat.mockResolvedValue({
size: 100, size: 100,
@ -588,14 +580,14 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/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( expect(mocks.storage.copyFile).toHaveBeenCalledWith(
'/original/path.jpg', '/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(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(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
@ -619,7 +611,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg', '/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(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
@ -630,7 +622,7 @@ describe(StorageTemplateService.name, () => {
const user = factory.userAdmin({ storageLabel: 'label-1' }); const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
ownerId: user.id, 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', originalFileName: 'IMG_7065.HEIC',
}); });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@ -639,16 +631,16 @@ describe(StorageTemplateService.name, () => {
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`, oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`, newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
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(`upload/library/${user.storageLabel}/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 user = factory.userAdmin();
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
ownerId: user.id, 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', originalFileName: 'IMG_7065.HEIC',
}); });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@ -665,16 +657,16 @@ describe(StorageTemplateService.name, () => {
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`, oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.heic`, newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.heic`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
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(`upload/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 user = factory.userAdmin();
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
ownerId: user.id, 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', originalFileName: 'IMG_7065.JPEG',
}); });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@ -691,16 +683,16 @@ describe(StorageTemplateService.name, () => {
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`, oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`, newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
expect.stringContaining(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`), expect.stringContaining(`/data/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.jpg`),
); );
}); });
@ -708,7 +700,7 @@ describe(StorageTemplateService.name, () => {
const user = factory.userAdmin(); const user = factory.userAdmin();
const asset = assetStub.storageAsset({ const asset = assetStub.storageAsset({
ownerId: user.id, 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', originalFileName: 'IMG_7065.JPG',
}); });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset])); mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@ -717,16 +709,16 @@ describe(StorageTemplateService.name, () => {
id: '123', id: '123',
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.Original, pathType: AssetPathType.Original,
oldPath: `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`, oldPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
newPath: `upload/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`, newPath: `/data/library/${user.id}/2023/2023-02-23/IMG_7065.jpg`,
}); });
await sut.handleMigration(); await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled(); expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith( expect(mocks.storage.rename).toHaveBeenCalledWith(
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(`upload/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 () => { it('should enable mount folder checking', async () => {
mocks.systemMetadata.get.mockResolvedValue(null); mocks.systemMetadata.get.mockResolvedValue(null);
mocks.asset.getFileSamples.mockResolvedValue([]); mocks.asset.getFileSamples.mockResolvedValue([]);
mocks.config.getEnv.mockReturnValue(
mockEnvData({
storage: {
ignoreMountCheckErrors: false,
mediaLocation: '/data',
},
}),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
@ -33,34 +41,34 @@ describe(StorageService.name, () => {
upload: true, upload: true,
}, },
}); });
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/encoded-video')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/encoded-video'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/library')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/profile')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/profile'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/thumbs')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/thumbs'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/upload')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/upload'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/backups')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/backups'));
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/encoded-video/.immich'), expect.stringContaining('/data/encoded-video/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/library/.immich'), expect.stringContaining('/data/library/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/profile/.immich'), expect.stringContaining('/data/profile/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/thumbs/.immich'), expect.stringContaining('/data/thumbs/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/upload/.immich'), expect.stringContaining('/data/upload/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/backups/.immich'), expect.stringContaining('/data/backups/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
}); });
@ -77,6 +85,14 @@ describe(StorageService.name, () => {
}, },
}); });
mocks.asset.getFileSamples.mockResolvedValue([]); mocks.asset.getFileSamples.mockResolvedValue([]);
mocks.config.getEnv.mockReturnValue(
mockEnvData({
storage: {
ignoreMountCheckErrors: false,
mediaLocation: '/data',
},
}),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined(); await expect(sut.onBootstrap()).resolves.toBeUndefined();
@ -91,15 +107,15 @@ describe(StorageService.name, () => {
}, },
}); });
expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2); expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/library')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/library'));
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/backups')); expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/backups'));
expect(mocks.storage.createFile).toHaveBeenCalledTimes(2); expect(mocks.storage.createFile).toHaveBeenCalledTimes(2);
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/library/.immich'), expect.stringContaining('/data/library/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
expect(mocks.storage.createFile).toHaveBeenCalledWith( expect(mocks.storage.createFile).toHaveBeenCalledWith(
expect.stringContaining('upload/backups/.immich'), expect.stringContaining('/data/backups/.immich'),
expect.any(Buffer), expect.any(Buffer),
); );
}); });

View File

@ -1,9 +1,16 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { join } from 'node:path'; import { join } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators'; 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 { BaseService } from 'src/services/base.service';
import { JobOf, SystemFlags } from 'src/types'; import { JobOf, SystemFlags } from 'src/types';
import { ImmichStartupError } from 'src/utils/misc'; import { ImmichStartupError } from 'src/utils/misc';
@ -12,9 +19,32 @@ const docsMessage = `Please see https://immich.app/docs/administration/system-in
@Injectable() @Injectable()
export class StorageService extends BaseService { export class StorageService extends BaseService {
@OnEvent({ name: 'AppBootstrap' }) private detectMediaLocation(): string {
async onBootstrap() {
const envData = this.configRepository.getEnv(); 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 () => { await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = const flags =
@ -53,6 +83,7 @@ export class StorageService extends BaseService {
this.logger.log('Successfully verified system mount folder checks'); this.logger.log('Successfully verified system mount folder checks');
} catch (error) { } catch (error) {
const envData = this.configRepository.getEnv();
if (envData.storage.ignoreMountCheckErrors) { if (envData.storage.ignoreMountCheckErrors) {
this.logger.error(error as Error); this.logger.error(error as Error);
this.logger.warn('Ignoring mount folder errors'); this.logger.warn('Ignoring mount folder errors');
@ -63,30 +94,34 @@ export class StorageService extends BaseService {
}); });
await this.databaseRepository.withLock(DatabaseLock.MediaLocation, async () => { await this.databaseRepository.withLock(DatabaseLock.MediaLocation, async () => {
const current = APP_MEDIA_LOCATION; const current = StorageCore.getMediaLocation();
const savedValue = await this.systemMetadataRepository.get(SystemMetadataKey.MediaLocation); const samples = await this.assetRepository.getFileSamples();
let previous = savedValue?.location || ''; if (samples.length > 0) {
const originalPath = samples[0].originalPath;
const savedValue = await this.systemMetadataRepository.get(SystemMetadataKey.MediaLocation);
let previous = savedValue?.location || '';
if (previous !== current) { if (!previous) {
this.logger.log(`Media location changed (from=${previous}, to=${current})`); previous = originalPath.startsWith('upload/') ? 'upload' : '/usr/src/app/upload';
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);
}
} }
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 }); await sut.handleUserDelete({ id: user.id });
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/library/deleted-user'), expect.stringContaining('/data/library/deleted-user'),
options, options,
); );
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/upload/deleted-user'), expect.stringContaining('/data/upload/deleted-user'),
options, options,
); );
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/profile/deleted-user'), expect.stringContaining('/data/profile/deleted-user'),
options, options,
); );
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/thumbs/deleted-user'), expect.stringContaining('/data/thumbs/deleted-user'),
options, options,
); );
expect(mocks.storage.unlinkDir).toHaveBeenCalledWith( expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
expect.stringContaining('upload/encoded-video/deleted-user'), expect.stringContaining('/data/encoded-video/deleted-user'),
options, options,
); );
expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id); expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id);
@ -268,7 +268,7 @@ describe(UserService.name, () => {
const options = { force: true, recursive: true }; 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, owner: userStub.user1,
ownerId: 'user-id', ownerId: 'user-id',
deviceId: 'device-id', deviceId: 'device-id',
originalPath: 'upload/library/IMG_123.jpg', originalPath: '/data/library/IMG_123.jpg',
files: [thumbnailFile], files: [thumbnailFile],
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image, type: AssetType.Image,
@ -101,7 +101,7 @@ export const assetStub = {
owner: userStub.user1, owner: userStub.user1,
ownerId: 'user-id', ownerId: 'user-id',
deviceId: 'device-id', deviceId: 'device-id',
originalPath: 'upload/library/IMG_456.jpg', originalPath: '/data/library/IMG_456.jpg',
files: [previewFile], files: [previewFile],
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image, 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(); return () => Promise.resolve();
}; };
export const newStorageRepositoryMock = (reset = true): Mocked<RepositoryInterface<StorageRepository>> => { export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRepository>> => {
if (reset) { StorageCore.reset();
StorageCore.reset(); StorageCore.setMediaLocation('/data');
}
return { return {
createZipStream: vitest.fn(), createZipStream: vitest.fn(),
@ -53,6 +52,7 @@ export const newStorageRepositoryMock = (reset = true): Mocked<RepositoryInterfa
createFile: vitest.fn(), createFile: vitest.fn(),
createWriteStream: vitest.fn(), createWriteStream: vitest.fn(),
createOrOverwriteFile: vitest.fn(), createOrOverwriteFile: vitest.fn(),
existsSync: vitest.fn(),
overwriteFile: vitest.fn(), overwriteFile: vitest.fn(),
unlink: vitest.fn(), unlink: vitest.fn(),
unlinkDir: vitest.fn().mockResolvedValue(true), unlinkDir: vitest.fn().mockResolvedValue(true),

View File

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

View File

@ -33,21 +33,21 @@ describe('get asset filename', () => {
{ {
asset: { asset: {
originalFileName: 'filename', 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', result: 'filename.jpg',
}, },
{ {
asset: { asset: {
originalFileName: 'new-filename', 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', result: 'new-filename.jpg',
}, },
{ {
asset: { asset: {
originalFileName: 'new-filename.txt', 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', result: 'new-filename.txt.jpg',
}, },