diff --git a/docs/docs/administration/server-commands.md b/docs/docs/administration/server-commands.md
index b414f5deaa..b275d8fede 100644
--- a/docs/docs/administration/server-commands.md
+++ b/docs/docs/administration/server-commands.md
@@ -2,16 +2,17 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
-| Command | Description |
-| ------------------------ | ------------------------------------- |
-| `help` | Display help |
-| `reset-admin-password` | Reset the password for the admin user |
-| `disable-password-login` | Disable password login |
-| `enable-password-login` | Enable password login |
-| `enable-oauth-login` | Enable OAuth login |
-| `disable-oauth-login` | Disable OAuth login |
-| `list-users` | List Immich users |
-| `version` | Print Immich version |
+| Command | Description |
+| ------------------------ | ------------------------------------------------------------- |
+| `help` | Display help |
+| `reset-admin-password` | Reset the password for the admin user |
+| `disable-password-login` | Disable password login |
+| `enable-password-login` | Enable password login |
+| `enable-oauth-login` | Enable OAuth login |
+| `disable-oauth-login` | Disable OAuth login |
+| `list-users` | List Immich users |
+| `version` | Print Immich version |
+| `change-media-location` | Change database file paths to align with a new media location |
## How to run a command
@@ -88,3 +89,24 @@ Print Immich Version
immich-admin version
v1.129.0
```
+
+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
+
+ Previous value: /usr/src/app/upload
+ Current value: /data
+
+ Changing database paths from "/usr/src/app/upload/*" to "/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)
+```
diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md
index b9dab5b9c8..8d5ab55049 100644
--- a/docs/docs/install/environment-variables.md
+++ b/docs/docs/install/environment-variables.md
@@ -29,20 +29,20 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
-| Variable | Description | Default | Containers | Workers |
-| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
-| `TZ` | Timezone | \*1 | 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**\*2⚠️ | `./upload`\*3 | 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 | |
-| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
-| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
-| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
-| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
-| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
+| Variable | Description | Default | Containers | Workers |
+| :---------------------------------- | :---------------------------------------------------------------------------------------- | :---------------------------------: | :----------------------- | :----------------- |
+| `TZ` | Timezone | \*1 | 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**\*2⚠️ | `/usr/src/app/upload`\*3 | 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 | |
+| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
+| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
+| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
+| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
+| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
diff --git a/server/src/app.module.ts b/server/src/app.module.ts
index 9ea75d78c4..8d261463e7 100644
--- a/server/src/app.module.ts
+++ b/server/src/app.module.ts
@@ -5,7 +5,7 @@ import { ScheduleModule, SchedulerRegistry } from '@nestjs/schedule';
import { ClsModule } from 'nestjs-cls';
import { KyselyModule } from 'nestjs-kysely';
import { OpenTelemetryModule } from 'nestjs-otel';
-import { commands } from 'src/commands';
+import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
@@ -97,7 +97,7 @@ export class MicroservicesModule extends BaseModule {}
@Module({
imports: [...imports],
- providers: [...common, ...commands, SchedulerRegistry],
+ providers: [...common, ...commandsAndQuestions, SchedulerRegistry],
})
export class ImmichAdminModule implements OnModuleDestroy {
constructor(private service: CliService) {}
diff --git a/server/src/commands/index.ts b/server/src/commands/index.ts
index ce085f6e34..46a8d13e35 100644
--- a/server/src/commands/index.ts
+++ b/server/src/commands/index.ts
@@ -1,11 +1,16 @@
import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin';
import { ListUsersCommand } from 'src/commands/list-users.command';
+import {
+ ChangeMediaLocationCommand,
+ PromptConfirmMoveQuestions,
+ PromptMediaLocationQuestions,
+} from 'src/commands/media-location.command';
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
import { VersionCommand } from 'src/commands/version.command';
-export const commands = [
+export const commandsAndQuestions = [
ResetAdminPasswordCommand,
PromptPasswordQuestions,
PromptEmailQuestion,
@@ -17,4 +22,7 @@ export const commands = [
VersionCommand,
GrantAdminCommand,
RevokeAdminCommand,
+ ChangeMediaLocationCommand,
+ PromptMediaLocationQuestions,
+ PromptConfirmMoveQuestions,
];
diff --git a/server/src/commands/media-location.command.ts b/server/src/commands/media-location.command.ts
new file mode 100644
index 0000000000..0935fe202d
--- /dev/null
+++ b/server/src/commands/media-location.command.ts
@@ -0,0 +1,106 @@
+import { Command, CommandRunner, InquirerService, Question, QuestionSet } from 'nest-commander';
+import { CliService } from 'src/services/cli.service';
+
+@Command({
+ name: 'change-media-location',
+ description: 'Change database file paths to align with a new media location',
+})
+export class ChangeMediaLocationCommand extends CommandRunner {
+ constructor(
+ private service: CliService,
+ private inquirer: InquirerService,
+ ) {
+ super();
+ }
+
+ private async showSamplePaths(hint?: string) {
+ hint = hint ? ` (${hint})` : '';
+
+ const paths = await this.service.getSampleFilePaths();
+ if (paths.length > 0) {
+ let message = ` Examples from the database${hint}:\n`;
+ for (const path of paths) {
+ message += ` - ${path}\n`;
+ }
+
+ console.log(`\n${message}`);
+ }
+ }
+
+ async run(): Promise {
+ try {
+ await this.showSamplePaths();
+
+ const { oldValue, newValue } = await this.inquirer.ask<{ oldValue: string; newValue: string }>(
+ 'prompt-media-location',
+ {},
+ );
+
+ const success = await this.service.migrateFilePaths({
+ oldValue,
+ newValue,
+ confirm: async ({ sourceFolder, targetFolder }) => {
+ console.log(`
+ Previous value: ${oldValue}
+ Current value: ${newValue}
+
+ Changing from "${sourceFolder}/*" to "${targetFolder}/*"
+`);
+
+ const { value: confirmed } = await this.inquirer.ask<{ value: boolean }>('prompt-confirm-move', {});
+ return confirmed;
+ },
+ });
+
+ const successMessage = `Matching database file paths were updated successfully! 🎉
+
+ You may now set IMMICH_MEDIA_LOCATION=${newValue} and restart!
+
+ (please remember to update applicable volume mounts e.g
+ services:
+ immich-server:
+ ...
+ volumes:
+ - \${UPLOAD_LOCATION}:/usr/src/app/upload
+ ...
+ )`;
+
+ console.log(`\n ${success ? successMessage : 'No rows were updated'}\n`);
+
+ await this.showSamplePaths('after');
+ } catch (error) {
+ console.error(error);
+ console.error('Unable to update database file paths.');
+ }
+ }
+}
+
+const currentValue = process.env.IMMICH_MEDIA_LOCATION || '';
+
+const makePrompt = (which: string) => {
+ return `Enter the ${which} value of IMMICH_MEDIA_LOCATION:${currentValue ? ` [${currentValue}]` : ''}`;
+};
+
+@QuestionSet({ name: 'prompt-media-location' })
+export class PromptMediaLocationQuestions {
+ @Question({ message: makePrompt('previous'), name: 'oldValue' })
+ oldValue(value: string) {
+ return value || currentValue;
+ }
+
+ @Question({ message: makePrompt('new'), name: 'newValue' })
+ newValue(value: string) {
+ return value || currentValue;
+ }
+}
+
+@QuestionSet({ name: 'prompt-confirm-move' })
+export class PromptConfirmMoveQuestions {
+ @Question({
+ message: 'Do you want to proceed? [Y/n]',
+ name: 'value',
+ })
+ value(value: string): boolean {
+ return ['yes', 'y'].includes((value || 'y').toLowerCase());
+ }
+}
diff --git a/server/src/constants.ts b/server/src/constants.ts
index 447d8a09c9..2d803c2e95 100644
--- a/server/src/constants.ts
+++ b/server/src/constants.ts
@@ -47,7 +47,7 @@ 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 || './upload';
+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(
diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts
index 99fd1d2149..3543d8dae9 100644
--- a/server/src/dtos/env.dto.ts
+++ b/server/src/dtos/env.dto.ts
@@ -1,5 +1,5 @@
import { Transform, Type } from 'class-transformer';
-import { IsEnum, IsInt, IsString } from 'class-validator';
+import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
import { DatabaseSslMode, ImmichEnvironment, LogLevel } from 'src/enum';
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
@@ -48,6 +48,10 @@ export class EnvDto {
@Optional()
IMMICH_LOG_LEVEL?: LogLevel;
+ @Optional()
+ @Matches(/^\//, { message: 'IMMICH_MEDIA_LOCATION must be an absolute path' })
+ IMMICH_MEDIA_LOCATION?: string;
+
@IsInt()
@Optional()
@Type(() => Number)
diff --git a/server/src/enum.ts b/server/src/enum.ts
index 587a76126f..e41a790999 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -198,6 +198,7 @@ export enum StorageFolder {
}
export enum SystemMetadataKey {
+ MediaLocation = 'MediaLocation',
ReverseGeocodingState = 'reverse-geocoding-state',
FacialRecognitionState = 'facial-recognition-state',
MemoriesState = 'memories-state',
@@ -544,6 +545,7 @@ export enum DatabaseLock {
CLIPDimSize = 512,
Library = 1337,
NightlyJobs = 600,
+ MediaLocation = 700,
GetSystemConfig = 69,
BackupDatabase = 42,
MemoryCreation = 777,
diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql
index e482a38a9a..9425bd9a11 100644
--- a/server/src/queries/asset.repository.sql
+++ b/server/src/queries/asset.repository.sql
@@ -168,6 +168,30 @@ from
where
"livePhotoVideoId" = $1::uuid
+-- AssetRepository.getFileSamples
+select
+ "asset"."id",
+ "asset"."originalPath",
+ "asset"."sidecarPath",
+ "asset"."encodedVideoPath",
+ (
+ select
+ coalesce(json_agg(agg), '[]')
+ from
+ (
+ select
+ "path"
+ from
+ "asset_file"
+ where
+ "asset"."id" = "asset_file"."assetId"
+ ) as agg
+ ) as "files"
+from
+ "asset"
+limit
+ 3
+
-- AssetRepository.getById
select
"asset".*
diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql
index 3e41edde9c..8ad5b96bbc 100644
--- a/server/src/queries/person.repository.sql
+++ b/server/src/queries/person.repository.sql
@@ -12,6 +12,17 @@ delete from "person"
where
"person"."id" in ($1)
+-- PersonRepository.getFileSamples
+select
+ "id",
+ "thumbnailPath"
+from
+ "person"
+where
+ "thumbnailPath" != ''
+limit
+ 3
+
-- PersonRepository.getAllForUser
select
"person".*
diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql
index f1809464bf..6a02654781 100644
--- a/server/src/queries/user.repository.sql
+++ b/server/src/queries/user.repository.sql
@@ -78,6 +78,17 @@ where
"user"."isAdmin" = $1
and "user"."deletedAt" is null
+-- UserRepository.getFileSamples
+select
+ "id",
+ "profileImagePath"
+from
+ "user"
+where
+ "profileImagePath" != ''
+limit
+ 3
+
-- UserRepository.hasAdmin
select
"user"."id"
diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts
index cb7e804f6f..edbafaa22d 100644
--- a/server/src/repositories/asset.repository.ts
+++ b/server/src/repositories/asset.repository.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
+import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Stack } from 'src/database';
@@ -335,6 +336,23 @@ export class AssetRepository {
return count;
}
+ @GenerateSql()
+ getFileSamples() {
+ return this.db
+ .selectFrom('asset')
+ .select((eb) => [
+ 'asset.id',
+ 'asset.originalPath',
+ 'asset.sidecarPath',
+ 'asset.encodedVideoPath',
+ jsonArrayFrom(eb.selectFrom('asset_file').select('path').whereRef('asset.id', '=', 'asset_file.assetId')).as(
+ 'files',
+ ),
+ ])
+ .limit(sql.lit(3))
+ .execute();
+ }
+
@GenerateSql({ params: [DummyValue.UUID] })
getById(id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags }: GetByIdsRelations = {}) {
return this.db
diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts
index a096c6a4bc..99cba43b99 100644
--- a/server/src/repositories/config.repository.spec.ts
+++ b/server/src/repositories/config.repository.spec.ts
@@ -13,6 +13,7 @@ const resetEnv = () => {
'IMMICH_WORKERS_EXCLUDE',
'IMMICH_TRUSTED_PROXIES',
'IMMICH_API_METRICS_PORT',
+ 'IMMICH_MEDIA_LOCATION',
'IMMICH_MICROSERVICES_METRICS_PORT',
'IMMICH_TELEMETRY_INCLUDE',
'IMMICH_TELEMETRY_EXCLUDE',
@@ -76,6 +77,13 @@ describe('getEnv', () => {
});
});
+ describe('IMMICH_MEDIA_LOCATION', () => {
+ it('should throw an error for relative paths', () => {
+ process.env.IMMICH_MEDIA_LOCATION = './relative/path';
+ expect(() => getEnv()).toThrowError('IMMICH_MEDIA_LOCATION must be an absolute path');
+ });
+ });
+
describe('database', () => {
it('should use defaults', () => {
const { database } = getEnv();
@@ -95,7 +103,7 @@ describe('getEnv', () => {
it('should validate DB_SSL_MODE', () => {
process.env.DB_SSL_MODE = 'invalid';
- expect(() => getEnv()).toThrowError('Invalid environment variables: DB_SSL_MODE');
+ expect(() => getEnv()).toThrowError('DB_SSL_MODE must be one of the following values:');
});
it('should accept a valid DB_SSL_MODE', () => {
@@ -239,7 +247,7 @@ describe('getEnv', () => {
it('should reject invalid trusted proxies', () => {
process.env.IMMICH_TRUSTED_PROXIES = '10.1';
- expect(() => getEnv()).toThrowError('Invalid environment variables: IMMICH_TRUSTED_PROXIES');
+ expect(() => getEnv()).toThrow('IMMICH_TRUSTED_PROXIES must be an ip address, or ip address range');
});
});
diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts
index 7038338927..c9e96a1803 100644
--- a/server/src/repositories/config.repository.ts
+++ b/server/src/repositories/config.repository.ts
@@ -131,9 +131,11 @@ const getEnv = (): EnvData => {
const dto = plainToInstance(EnvDto, process.env);
const errors = validateSync(dto);
if (errors.length > 0) {
- throw new Error(
- `Invalid environment variables: ${errors.map((error) => `${error.property}=${error.value}`).join(', ')}`,
- );
+ const messages = [`Invalid environment variables: `];
+ for (const error of errors) {
+ messages.push(` - ${error.property}=${error.value} (${Object.values(error.constraints || {}).join(', ')})`);
+ }
+ throw new Error(messages.join('\n'));
}
const includedWorkers = asSet(dto.IMMICH_WORKERS_INCLUDE, [ImmichWorker.Api, ImmichWorker.Microservices]);
diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts
index 8b5c728ce4..1f83630cfa 100644
--- a/server/src/repositories/database.repository.ts
+++ b/server/src/repositories/database.repository.ts
@@ -436,6 +436,39 @@ export class DatabaseRepository {
this.logger.debug('Finished running kysely migrations');
}
+ async migrateFilePaths(sourceFolder: string, targetFolder: string): Promise {
+ // escaping regex special characters with a backslash
+ const sourceRegex = '^' + sourceFolder.replaceAll(/[-[\]{}()*+?.,\\^$|#\s]/g, String.raw`\$&`);
+ const source = sql.raw(`'${sourceRegex}'`);
+ const target = sql.lit(targetFolder);
+
+ await this.db.transaction().execute(async (tx) => {
+ await tx
+ .updateTable('asset')
+ .set((eb) => ({
+ originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
+ encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
+ sidecarPath: eb.fn('REGEXP_REPLACE', ['sidecarPath', source, target]),
+ }))
+ .execute();
+
+ await tx
+ .updateTable('asset_file')
+ .set((eb) => ({ path: eb.fn('REGEXP_REPLACE', ['path', source, target]) }))
+ .execute();
+
+ await tx
+ .updateTable('person')
+ .set((eb) => ({ thumbnailPath: eb.fn('REGEXP_REPLACE', ['thumbnailPath', source, target]) }))
+ .execute();
+
+ await tx
+ .updateTable('user')
+ .set((eb) => ({ profileImagePath: eb.fn('REGEXP_REPLACE', ['profileImagePath', source, target]) }))
+ .execute();
+ });
+ }
+
async withLock(lock: DatabaseLock, callback: () => Promise): Promise {
let res;
await this.asyncLock.acquire(DatabaseLock[lock], async () => {
diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts
index 5b7d1d3700..f653bb8179 100644
--- a/server/src/repositories/person.repository.ts
+++ b/server/src/repositories/person.repository.ts
@@ -142,6 +142,16 @@ export class PersonRepository {
.stream();
}
+ @GenerateSql()
+ getFileSamples() {
+ return this.db
+ .selectFrom('person')
+ .select(['id', 'thumbnailPath'])
+ .where('thumbnailPath', '!=', sql.lit(''))
+ .limit(sql.lit(3))
+ .execute();
+ }
+
@GenerateSql({ params: [{ take: 1, skip: 0 }, DummyValue.UUID] })
async getAllForUser(pagination: PaginationOptions, userId: string, options?: PersonSearchOptions) {
const items = await this.db
diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts
index 715aa2dc32..9d5f19b26a 100644
--- a/server/src/repositories/user.repository.ts
+++ b/server/src/repositories/user.repository.ts
@@ -79,6 +79,16 @@ export class UserRepository {
.executeTakeFirst();
}
+ @GenerateSql()
+ getFileSamples() {
+ return this.db
+ .selectFrom('user')
+ .select(['id', 'profileImagePath'])
+ .where('profileImagePath', '!=', sql.lit(''))
+ .limit(sql.lit(3))
+ .execute();
+ }
+
@GenerateSql()
async hasAdmin(): Promise {
const admin = await this.db
diff --git a/server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts b/server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts
new file mode 100644
index 0000000000..68b0c7931e
--- /dev/null
+++ b/server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts
@@ -0,0 +1,39 @@
+import { Kysely, sql } from 'kysely';
+import { LoggingRepository } from 'src/repositories/logging.repository';
+
+const logger = LoggingRepository.create();
+logger.setContext('Migrations');
+
+export async function up(db: Kysely): Promise {
+ if (process.env.IMMICH_MEDIA_LOCATION) {
+ // do not automatically convert paths for a custom location/setting
+ return;
+ }
+
+ // we construct paths using `path.join(mediaLocation, ...)`, which strips the leading './'
+ const source = 'upload';
+ const target = '/usr/src/app/upload';
+
+ logger.log(`Converting database file paths from relative to absolute (source=${source}/*, target=${target}/*)`);
+
+ // escaping regex special characters with a backslash
+ const sourceRegex = '^' + source.replaceAll(/[-[\]{}()*+?.,\\^$|#\s]/g, String.raw`\$&`);
+
+ const items: Array<{ table: string; column: string }> = [
+ { table: 'asset', column: 'originalPath' },
+ { table: 'asset', column: 'encodedVideoPath' },
+ { table: 'asset', column: 'sidecarPath' },
+ { table: 'asset_file', column: 'path' },
+ { table: 'person', column: 'thumbnailPath' },
+ { table: 'user', column: 'profileImagePath' },
+ ];
+
+ for (const { table, column } of items) {
+ const query = `UPDATE "${table}" SET "${column}" = REGEXP_REPLACE("${column}", '${sourceRegex}', '${target}') WHERE "${column}" IS NOT NULL`;
+ await sql.raw(query).execute(db);
+ }
+}
+
+export async function down(): Promise {
+ // not supported
+}
diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts
index dccb79c585..08b42b6cbf 100644
--- a/server/src/services/asset-media.service.spec.ts
+++ b/server/src/services/asset-media.service.spec.ts
@@ -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(
- 'upload/profile/admin_id',
+ expect.stringContaining('upload/profile/admin_id'),
);
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/profile/admin_id'));
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
- 'upload/upload/admin_id/ra/nd',
+ expect.stringContaining('upload/upload/admin_id/ra/nd'),
);
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/upload/admin_id/ra/nd'));
});
});
@@ -913,7 +913,7 @@ describe(AssetMediaService.name, () => {
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
- data: { files: ['upload/upload/user-id/ra/nd/random-uuid.jpg'] },
+ data: { files: [expect.stringContaining('upload/upload/user-id/ra/nd/random-uuid.jpg')] },
});
});
});
diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts
index f52fd9dd81..129877bbdd 100644
--- a/server/src/services/auth.service.spec.ts
+++ b/server/src/services/auth.service.spec.ts
@@ -789,7 +789,7 @@ describe(AuthService.name, () => {
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
- profileImagePath: `upload/profile/${user.id}/${fileId}.jpg`,
+ profileImagePath: expect.stringContaining(`upload/profile/${user.id}/${fileId}.jpg`),
profileChangedAt: expect.any(Date),
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
diff --git a/server/src/services/cli.service.ts b/server/src/services/cli.service.ts
index 021a5240f6..674b885dc4 100644
--- a/server/src/services/cli.service.ts
+++ b/server/src/services/cli.service.ts
@@ -1,4 +1,5 @@
import { Injectable } from '@nestjs/common';
+import { isAbsolute } from 'node:path';
import { SALT_ROUNDS } from 'src/constants';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { BaseService } from 'src/services/base.service';
@@ -67,6 +68,63 @@ export class CliService extends BaseService {
await this.updateConfig(config);
}
+ async getSampleFilePaths(): Promise {
+ const [assets, people, users] = await Promise.all([
+ this.assetRepository.getFileSamples(),
+ this.personRepository.getFileSamples(),
+ this.userRepository.getFileSamples(),
+ ]);
+
+ const paths = [];
+
+ for (const person of people) {
+ paths.push(person.thumbnailPath);
+ }
+
+ for (const user of users) {
+ paths.push(user.profileImagePath);
+ }
+
+ for (const asset of assets) {
+ paths.push(
+ asset.originalPath,
+ asset.sidecarPath,
+ asset.encodedVideoPath,
+ ...asset.files.map((file) => file.path),
+ );
+ }
+
+ return paths.filter(Boolean) as string[];
+ }
+
+ async migrateFilePaths({
+ oldValue,
+ newValue,
+ confirm,
+ }: {
+ oldValue: string;
+ newValue: string;
+ confirm: (data: { sourceFolder: string; targetFolder: string }) => Promise;
+ }): Promise {
+ let sourceFolder = oldValue;
+ if (sourceFolder.startsWith('./')) {
+ sourceFolder = sourceFolder.slice(2);
+ }
+
+ const targetFolder = newValue;
+ if (!isAbsolute(targetFolder)) {
+ throw new Error('Target media location must be an absolute path');
+ }
+
+ if (!(await confirm({ sourceFolder, targetFolder }))) {
+ return false;
+ }
+
+ await this.databaseRepository.migrateFilePaths(sourceFolder, targetFolder);
+
+ return true;
+ }
+
cleanup() {
return this.databaseRepository.shutdown();
}
diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts
index 7646637093..940767ff67 100644
--- a/server/src/services/download.service.spec.ts
+++ b/server/src/services/download.service.spec.ts
@@ -1,4 +1,5 @@
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';
@@ -46,7 +47,11 @@ describe(DownloadService.name, () => {
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(1);
- expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
+ expect(archiveMock.addFile).toHaveBeenNthCalledWith(
+ 1,
+ expect.stringContaining('upload/library/IMG_123.jpg'),
+ 'IMG_123.jpg',
+ );
});
it('should log a warning if the original path could not be resolved', async () => {
@@ -279,9 +284,15 @@ describe(DownloadService.name, () => {
mocks.downloadRepository.downloadAssetIds.mockReturnValue(
makeStream([{ id: 'asset-1', livePhotoVideoId: 'asset-3', size: 5000 }]),
);
+
mocks.downloadRepository.downloadMotionAssetIds.mockReturnValue(
makeStream([
- { id: 'asset-2', livePhotoVideoId: null, size: 23_456, originalPath: 'upload/encoded-video/uuid-MP.mp4' },
+ {
+ id: 'asset-2',
+ livePhotoVideoId: null,
+ size: 23_456,
+ originalPath: APP_MEDIA_LOCATION + '/encoded-video/uuid-MP.mp4',
+ },
]),
);
diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts
index dac2b64ebc..308c80fb37 100644
--- a/server/src/services/library.service.spec.ts
+++ b/server/src/services/library.service.spec.ts
@@ -1,7 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
-import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
+import { APP_MEDIA_LOCATION, 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';
@@ -1264,7 +1264,7 @@ describe(LibraryService.name, () => {
});
it('should detect when import path is in immich media folder', async () => {
- const importPaths = ['upload/thumbs', `${process.cwd()}/xyz`, 'upload/library'];
+ const importPaths = [APP_MEDIA_LOCATION + '/thumbs', `${process.cwd()}/xyz`, APP_MEDIA_LOCATION + '/library'];
const library = factory.library({ importPaths });
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts
index d3c361bb8a..0f4ba769c0 100644
--- a/server/src/services/media.service.spec.ts
+++ b/server/src/services/media.service.spec.ts
@@ -1,5 +1,6 @@
import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config';
+import { APP_MEDIA_LOCATION } from 'src/constants';
import { Exif } from 'src/database';
import {
AssetFileType,
@@ -204,19 +205,19 @@ describe(MediaService.name, () => {
entityId: assetStub.image.id,
pathType: AssetPathType.FullSize,
oldPath: '/uploads/user-id/fullsize/path.webp',
- newPath: 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
+ newPath: expect.stringContaining('upload/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: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ newPath: expect.stringContaining('upload/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: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
+ newPath: expect.stringContaining('upload/thumbs/user-id/as/se/asset-id-thumbnail.webp'),
});
expect(mocks.move.create).toHaveBeenCalledTimes(3);
});
@@ -295,7 +296,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, {
@@ -315,7 +316,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
@@ -327,7 +328,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
+ expect.any(String),
);
expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce();
@@ -341,12 +342,12 @@ describe(MediaService.name, () => {
{
assetId: 'asset-id',
type: AssetFileType.Preview,
- path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ path: expect.any(String),
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
- path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
+ path: expect.any(String),
},
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
@@ -357,10 +358,10 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
expect.objectContaining({
inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
@@ -377,12 +378,12 @@ describe(MediaService.name, () => {
{
assetId: 'asset-id',
type: AssetFileType.Preview,
- path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ path: expect.any(String),
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
- path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
+ path: expect.any(String),
},
]);
});
@@ -392,10 +393,10 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
expect.objectContaining({
inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
@@ -412,12 +413,12 @@ describe(MediaService.name, () => {
{
assetId: 'asset-id',
type: AssetFileType.Preview,
- path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ path: expect.any(String),
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
- path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
+ path: expect.any(String),
},
]);
});
@@ -432,7 +433,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
expect.objectContaining({
inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: [
@@ -453,7 +454,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
expect.objectContaining({
inputOptions: ['-sws_flags accurate_rnd+full_chroma_int'],
outputOptions: expect.any(Array),
@@ -471,7 +472,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]),
@@ -485,12 +486,12 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
- const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
- const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
+ 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`;
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, {
colorspace: Colorspace.Srgb,
@@ -530,12 +531,12 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
- const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.jpeg`;
- const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
+ 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}`);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledOnce();
expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.image.originalPath, {
colorspace: Colorspace.Srgb,
@@ -658,12 +659,12 @@ describe(MediaService.name, () => {
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: false }),
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: false }),
- 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
+ expect.any(String),
);
expect(mocks.media.generateThumbhash).toHaveBeenCalledOnce();
@@ -704,7 +705,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
);
});
@@ -734,7 +735,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp',
+ expect.any(String),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
fullsizeBuffer,
@@ -746,7 +747,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
);
});
@@ -774,7 +775,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
+ expect.any(String),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
@@ -786,7 +787,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
+ expect.any(String),
);
});
@@ -815,7 +816,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
+ expect.any(String),
);
});
@@ -838,7 +839,7 @@ describe(MediaService.name, () => {
expect(mocks.media.generateThumbnail).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
- 'upload/thumbs/user-id/as/se/asset-id-fullsize.jpeg',
+ expect.stringContaining('fullsize.jpeg'),
);
});
@@ -869,7 +870,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
raw: rawInfo,
},
- 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp',
+ expect.any(String),
);
});
});
@@ -911,7 +912,7 @@ describe(MediaService.name, () => {
);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
colorspace: Colorspace.P3,
orientation: undefined,
@@ -933,12 +934,9 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 250,
},
- 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
+ expect.any(String),
);
- expect(mocks.person.update).toHaveBeenCalledWith({
- id: 'person-1',
- thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
- });
+ expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
});
it('should use preview path if video', async () => {
@@ -953,7 +951,7 @@ describe(MediaService.name, () => {
);
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.previewPath, {
colorspace: Colorspace.P3,
orientation: undefined,
@@ -975,12 +973,9 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 250,
},
- 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
+ expect.any(String),
);
- expect(mocks.person.update).toHaveBeenCalledWith({
- id: 'person-1',
- thumbnailPath: 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
- });
+ expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
});
it('should generate a thumbnail without going negative', async () => {
@@ -1015,7 +1010,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 250,
},
- 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
+ expect.any(String),
);
});
@@ -1052,7 +1047,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 250,
},
- 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
+ expect.any(String),
);
});
@@ -1089,7 +1084,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 250,
},
- 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
+ expect.any(String),
);
});
@@ -1126,7 +1121,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 250,
},
- 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
+ expect.any(String),
);
});
@@ -1168,7 +1163,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
size: 250,
},
- 'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
+ expect.any(String),
);
});
@@ -1288,7 +1283,7 @@ describe(MediaService.name, () => {
expect(mocks.storage.mkdirSync).toHaveBeenCalled();
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-map 0:1', '-map 0:3']),
@@ -1308,7 +1303,7 @@ describe(MediaService.name, () => {
expect(mocks.storage.mkdirSync).toHaveBeenCalled();
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:2']),
@@ -1354,7 +1349,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
@@ -1369,7 +1364,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
@@ -1384,7 +1379,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
@@ -1399,7 +1394,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.any(Array),
@@ -1416,7 +1411,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]),
@@ -1431,7 +1426,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]),
@@ -1446,7 +1441,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]),
@@ -1463,7 +1458,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]),
@@ -1480,7 +1475,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]),
@@ -1497,7 +1492,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']),
@@ -1518,7 +1513,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining(['-tag:v hvc1']),
@@ -1539,7 +1534,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']),
@@ -1554,7 +1549,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']),
@@ -1568,7 +1563,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']),
@@ -1627,7 +1622,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']),
@@ -1642,7 +1637,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']),
@@ -1657,7 +1652,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']),
@@ -1672,7 +1667,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy']),
@@ -1693,7 +1688,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']),
@@ -1714,7 +1709,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]),
@@ -1729,7 +1724,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-cpu-used 2']),
@@ -1744,7 +1739,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]),
@@ -1759,7 +1754,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-threads 2']),
@@ -1774,7 +1769,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']),
@@ -1789,7 +1784,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]),
@@ -1804,7 +1799,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']),
@@ -1819,7 +1814,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]),
@@ -1834,7 +1829,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
@@ -1859,7 +1854,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-preset 4']),
@@ -1874,7 +1869,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']),
@@ -1889,7 +1884,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-svtav1-params lp=4']),
@@ -1906,7 +1901,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']),
@@ -1950,7 +1945,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.arrayContaining([
@@ -1987,7 +1982,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]),
@@ -2004,7 +1999,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']),
@@ -2021,7 +2016,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.not.stringContaining('-maxrate'),
@@ -2038,7 +2033,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]),
@@ -2053,7 +2048,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']),
outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]),
@@ -2070,7 +2065,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel cuda',
@@ -2092,7 +2087,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
outputOptions: expect.arrayContaining([
@@ -2113,7 +2108,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]),
@@ -2130,7 +2125,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device qsv=hw,child_device=/dev/dri/renderD128',
@@ -2170,7 +2165,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device qsv=hw,child_device=/dev/dri/renderD128',
@@ -2190,7 +2185,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device qsv=hw,child_device=/dev/dri/renderD128',
@@ -2210,7 +2205,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device qsv=hw,child_device=/dev/dri/renderD128',
@@ -2239,7 +2234,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device qsv=hw,child_device=/dev/dri/renderD129',
@@ -2261,7 +2256,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel qsv',
@@ -2287,7 +2282,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel qsv',
@@ -2315,7 +2310,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']),
outputOptions: expect.any(Array),
@@ -2334,7 +2329,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel qsv',
@@ -2354,7 +2349,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
@@ -2386,7 +2381,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
@@ -2410,7 +2405,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
@@ -2436,7 +2431,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
@@ -2455,7 +2450,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD129',
@@ -2476,7 +2471,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
@@ -2498,7 +2493,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel vaapi',
@@ -2523,7 +2518,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']),
outputOptions: expect.arrayContaining([
@@ -2546,7 +2541,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']),
outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]),
@@ -2565,7 +2560,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_device /dev/dri/renderD129']),
outputOptions: expect.any(Array),
@@ -2584,7 +2579,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
expect(mocks.media.transcode).toHaveBeenLastCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-init_hw_device vaapi=accel:/dev/dri/renderD128',
@@ -2607,7 +2602,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledTimes(3);
expect(mocks.media.transcode).toHaveBeenLastCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264']),
@@ -2624,7 +2619,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
expect(mocks.media.transcode).toHaveBeenLastCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264']),
@@ -2649,7 +2644,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel rkmpp',
@@ -2689,7 +2684,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']),
@@ -2706,7 +2701,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']),
@@ -2723,7 +2718,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([
@@ -2745,7 +2740,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([
@@ -2764,7 +2759,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: [],
outputOptions: expect.arrayContaining([
@@ -2786,7 +2781,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
@@ -2805,7 +2800,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
@@ -2824,7 +2819,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
@@ -2843,7 +2838,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy', '-vf format=yuv420p']),
@@ -2858,7 +2853,7 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ expect.any(String),
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy', '-vf scale=-2:720,format=yuv420p']),
@@ -2874,19 +2869,15 @@ describe(MediaService.name, () => {
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mocks.media.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true });
- expect(mocks.media.transcode).toHaveBeenCalledWith(
- assetStub.video.originalPath,
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
- {
- inputOptions: expect.any(Array),
- outputOptions: expect.any(Array),
- twoPass: false,
- progress: {
- frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount,
- percentInterval: expect.any(Number),
- },
+ expect(mocks.media.transcode).toHaveBeenCalledWith(assetStub.video.originalPath, expect.any(String), {
+ inputOptions: expect.any(Array),
+ outputOptions: expect.any(Array),
+ twoPass: false,
+ progress: {
+ frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount,
+ percentInterval: expect.any(Number),
},
- );
+ });
});
it('should not count frames for progress when log level is not debug', async () => {
@@ -2904,7 +2895,7 @@ describe(MediaService.name, () => {
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
- 'upload/encoded-video/user-id/as/se/asset-id.mp4',
+ APP_MEDIA_LOCATION + '/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining(['-c:a copy']),
diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts
index 77f0b50a0a..cc0956b9a8 100644
--- a/server/src/services/metadata.service.spec.ts
+++ b/server/src/services/metadata.service.spec.ts
@@ -587,7 +587,7 @@ describe(MetadataService.name, () => {
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
- originalPath: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4',
+ originalPath: expect.stringContaining('upload/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: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4',
+ originalPath: expect.stringContaining('upload/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: 'upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4',
+ originalPath: expect.stringContaining('upload/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'),
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId,
type: AssetType.Video,
});
diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts
index 0ddf3d69b1..06ddd32601 100644
--- a/server/src/services/server.service.spec.ts
+++ b/server/src/services/server.service.spec.ts
@@ -28,7 +28,7 @@ describe(ServerService.name, () => {
diskUseRaw: 300,
});
- expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith('upload/library');
+ expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/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('upload/library');
+ expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/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('upload/library');
+ expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/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('upload/library');
+ expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/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('upload/library');
+ expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/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('upload/library');
+ expect(mocks.storage.checkDiskUsage).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
});
});
diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts
index 2751651dbf..882ffcd328 100644
--- a/server/src/services/storage-template.service.spec.ts
+++ b/server/src/services/storage-template.service.spec.ts
@@ -1,5 +1,6 @@
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';
@@ -110,8 +111,10 @@ describe(StorageTemplateService.name, () => {
it('should migrate single moving picture', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
- const newMotionPicturePath = `upload/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
- const newStillPicturePath = `upload/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
+ 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}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
@@ -156,7 +159,9 @@ describe(StorageTemplateService.name, () => {
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
- newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`,
+ newPath: expect.stringContaining(
+ `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${album.albumName}/${asset.originalFileName}`,
+ ),
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
});
@@ -177,7 +182,9 @@ describe(StorageTemplateService.name, () => {
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
- newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`,
+ newPath: expect.stringContaining(
+ `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/other/${month}/${asset.originalFileName}`,
+ ),
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
});
@@ -211,7 +218,9 @@ describe(StorageTemplateService.name, () => {
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
- newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`,
+ newPath: expect.stringContaining(
+ `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month} - ${album.albumName}/${asset.originalFileName}`,
+ ),
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
});
@@ -234,7 +243,9 @@ describe(StorageTemplateService.name, () => {
const month = (asset.fileCreatedAt.getMonth() + 1).toString().padStart(2, '0');
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: asset.id,
- newPath: `upload/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
+ newPath:
+ APP_MEDIA_LOCATION +
+ `/library/${user.id}/${asset.fileCreatedAt.getFullYear()}/${month}/${asset.originalFileName}`,
oldPath: asset.originalPath,
pathType: AssetPathType.Original,
});
@@ -244,8 +255,9 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const asset = assetStub.storageAsset();
- const previousFailedNewPath = `upload/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
- const newPath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
+ 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}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath));
mocks.move.getByEntity.mockResolvedValue({
@@ -284,8 +296,9 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const asset = assetStub.storageAsset({ fileSizeInByte: 5000 });
- const previousFailedNewPath = `upload/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
- const newPath = `upload/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
+ 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}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === previousFailedNewPath));
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
@@ -319,7 +332,8 @@ 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 = `upload/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
+ const newPath =
+ APP_MEDIA_LOCATION + `/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
@@ -409,7 +423,7 @@ describe(StorageTemplateService.name, () => {
it('should handle an asset with a duplicate destination', async () => {
const asset = assetStub.storageAsset();
const oldPath = asset.originalPath;
- const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
+ const newPath = APP_MEDIA_LOCATION + `/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
const newPath2 = newPath.replace('.jpg', '+1.jpg');
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
@@ -466,7 +480,7 @@ describe(StorageTemplateService.name, () => {
it('should move an asset', async () => {
const asset = assetStub.storageAsset();
const oldPath = asset.originalPath;
- const newPath = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
+ const newPath = APP_MEDIA_LOCATION + `/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({
@@ -502,18 +516,20 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
- `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
+ expect.stringContaining(`upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).toHaveBeenCalledWith({
id: asset.id,
- originalPath: `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
+ originalPath: expect.stringContaining(
+ `upload/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`,
+ ),
});
});
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 = `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
+ const newPath = APP_MEDIA_LOCATION + `/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]);
@@ -572,14 +588,14 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
- `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`,
+ expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(
'/original/path.jpg',
- `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`,
+ expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.stat).toHaveBeenCalledWith(
- `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`,
+ expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
@@ -603,7 +619,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
- `upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`,
+ expect.stringContaining(`upload/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
@@ -631,8 +647,8 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
- `upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
- `upload/library/${user.storageLabel}/2022/2022-06-19/IMG_7065.heic`,
+ 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`),
);
});
@@ -657,8 +673,8 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
- `upload/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
- `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(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.heic`),
);
});
@@ -683,8 +699,8 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
- `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
- `upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`,
+ 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`),
);
});
@@ -709,8 +725,8 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
- `upload/library/${user.id}/2022/2022-06-19/IMG_7065.JPG`,
- `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(`upload/library/${user.id}/2022/2022-06-19/IMG_7065.jpg`),
);
});
});
diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts
index 0e051f2642..567b78ac09 100644
--- a/server/src/services/storage.service.spec.ts
+++ b/server/src/services/storage.service.spec.ts
@@ -32,18 +32,36 @@ describe(StorageService.name, () => {
upload: true,
},
});
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/encoded-video');
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library');
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/profile');
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/thumbs');
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/upload');
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups');
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer));
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer));
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer));
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer));
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer));
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer));
+ 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.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/encoded-video/.immich'),
+ expect.any(Buffer),
+ );
+ expect(mocks.storage.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/library/.immich'),
+ expect.any(Buffer),
+ );
+ expect(mocks.storage.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/profile/.immich'),
+ expect.any(Buffer),
+ );
+ expect(mocks.storage.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/thumbs/.immich'),
+ expect.any(Buffer),
+ );
+ expect(mocks.storage.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/upload/.immich'),
+ expect.any(Buffer),
+ );
+ expect(mocks.storage.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/backups/.immich'),
+ expect.any(Buffer),
+ );
});
it('should enable mount folder checking for a new folder type', async () => {
@@ -71,11 +89,17 @@ describe(StorageService.name, () => {
},
});
expect(mocks.storage.mkdirSync).toHaveBeenCalledTimes(2);
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/library');
- expect(mocks.storage.mkdirSync).toHaveBeenCalledWith('upload/backups');
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/library'));
+ expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('upload/backups'));
expect(mocks.storage.createFile).toHaveBeenCalledTimes(2);
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer));
- expect(mocks.storage.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer));
+ expect(mocks.storage.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/library/.immich'),
+ expect.any(Buffer),
+ );
+ expect(mocks.storage.createFile).toHaveBeenCalledWith(
+ expect.stringContaining('upload/backups/.immich'),
+ expect.any(Buffer),
+ );
});
it('should throw an error if .immich is missing', async () => {
@@ -131,7 +155,7 @@ describe(StorageService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
- expect(mocks.systemMetadata.set).not.toHaveBeenCalled();
+ expect(mocks.systemMetadata.set).not.toHaveBeenCalledWith(SystemMetadataKey.SystemFlags, expect.anything());
});
});
diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts
index 6e3ef4820a..632e0c1385 100644
--- a/server/src/services/storage.service.ts
+++ b/server/src/services/storage.service.ts
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
-import { join, resolve } from 'node:path';
+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';
@@ -60,6 +61,17 @@ 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);
+ const previous = savedValue?.location || '';
+
+ if (previous !== current) {
+ this.logger.log(`Media location changed (from=${previous}, to=${current})`);
+ await this.systemMetadataRepository.set(SystemMetadataKey.MediaLocation, { location: current });
+ }
+ });
}
@OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask })
@@ -87,9 +99,8 @@ export class StorageService extends BaseService {
try {
await this.storageRepository.readFile(internalPath);
} catch (error) {
- const fullyQualifiedPath = resolve(process.cwd(), internalPath);
- this.logger.error(`Failed to read ${fullyQualifiedPath} (${internalPath}): ${error}`);
- throw new ImmichStartupError(`Failed to read: "${externalPath} (${fullyQualifiedPath}) - ${docsMessage}"`);
+ this.logger.error(`Failed to read (${internalPath}): ${error}`);
+ throw new ImmichStartupError(`Failed to read: "${externalPath} (${internalPath}) - ${docsMessage}"`);
}
}
diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts
index 3a85389ace..b4e616974e 100644
--- a/server/src/services/user.service.spec.ts
+++ b/server/src/services/user.service.spec.ts
@@ -235,11 +235,26 @@ describe(UserService.name, () => {
await sut.handleUserDelete({ id: user.id });
- expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options);
- expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options);
- expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options);
- expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options);
- expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options);
+ expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
+ expect.stringContaining('upload/library/deleted-user'),
+ options,
+ );
+ expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
+ expect.stringContaining('upload/upload/deleted-user'),
+ options,
+ );
+ expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
+ expect.stringContaining('upload/profile/deleted-user'),
+ options,
+ );
+ expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
+ expect.stringContaining('upload/thumbs/deleted-user'),
+ options,
+ );
+ expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(
+ expect.stringContaining('upload/encoded-video/deleted-user'),
+ options,
+ );
expect(mocks.album.deleteAll).toHaveBeenCalledWith(user.id);
expect(mocks.user.delete).toHaveBeenCalledWith(user, true);
});
@@ -253,7 +268,7 @@ describe(UserService.name, () => {
const options = { force: true, recursive: true };
- expect(mocks.storage.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
+ expect(mocks.storage.unlinkDir).toHaveBeenCalledWith(expect.stringContaining('upload/library/admin'), options);
});
});
diff --git a/server/src/types.ts b/server/src/types.ts
index 79aeedd47d..9cd1aa996b 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -451,11 +451,13 @@ export type MemoriesState = {
/** memories have already been created through this date */
lastOnThisDayDate: string;
};
+export type MediaLocation = { location: string };
export interface SystemMetadata extends Record> {
[SystemMetadataKey.AdminOnboarding]: { isOnboarded: boolean };
[SystemMetadataKey.FacialRecognitionState]: { lastRun?: string };
[SystemMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: Date };
+ [SystemMetadataKey.MediaLocation]: MediaLocation;
[SystemMetadataKey.ReverseGeocodingState]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.SystemConfig]: DeepPartial;
[SystemMetadataKey.SystemFlags]: DeepPartial;
diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts
index 3e1a1b7f68..2331a45a62 100644
--- a/server/src/utils/file.ts
+++ b/server/src/utils/file.ts
@@ -1,7 +1,7 @@
import { HttpException, StreamableFile } from '@nestjs/common';
import { NextFunction, Response } from 'express';
import { access, constants } from 'node:fs/promises';
-import { basename, extname, isAbsolute } from 'node:path';
+import { basename, extname } from 'node:path';
import { promisify } from 'node:util';
import { CacheControl } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -62,15 +62,9 @@ export const sendFile = async (
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
}
- // configure options for serving
- const options: SendFileOptions = { dotfiles: 'allow' };
- if (!isAbsolute(file.path)) {
- options.root = process.cwd();
- }
-
await access(file.path, constants.R_OK);
- return await _sendFile(file.path, options);
+ return await _sendFile(file.path, { dotfiles: 'allow' });
} catch (error: Error | any) {
// ignore client-closed connection
if (isConnectionAborted(error) || res.headersSent) {
diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts
index a29babbf54..6fca29d98e 100644
--- a/server/test/repositories/asset.repository.mock.ts
+++ b/server/test/repositories/asset.repository.mock.ts
@@ -39,5 +39,6 @@ export const newAssetRepositoryMock = (): Mocked