From 493d85b02103f371c83f9cc2aac93e2bdc46b7f1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 18 Jul 2025 10:57:29 -0400 Subject: [PATCH] feat!: absolute file paths (#19995) feat: absolute file paths --- docs/docs/administration/server-commands.md | 42 ++- docs/docs/install/environment-variables.md | 28 +- server/src/app.module.ts | 4 +- server/src/commands/index.ts | 10 +- server/src/commands/media-location.command.ts | 106 +++++++ server/src/constants.ts | 2 +- server/src/dtos/env.dto.ts | 6 +- server/src/enum.ts | 2 + server/src/queries/asset.repository.sql | 24 ++ server/src/queries/person.repository.sql | 11 + server/src/queries/user.repository.sql | 11 + server/src/repositories/asset.repository.ts | 18 ++ .../repositories/config.repository.spec.ts | 12 +- server/src/repositories/config.repository.ts | 8 +- .../src/repositories/database.repository.ts | 33 +++ server/src/repositories/person.repository.ts | 10 + server/src/repositories/user.repository.ts | 10 + .../1752759108283-ConvertToAbsolutePaths.ts | 39 +++ .../src/services/asset-media.service.spec.ts | 10 +- server/src/services/auth.service.spec.ts | 2 +- server/src/services/cli.service.ts | 58 ++++ server/src/services/download.service.spec.ts | 15 +- server/src/services/library.service.spec.ts | 4 +- server/src/services/media.service.spec.ts | 273 +++++++++--------- server/src/services/metadata.service.spec.ts | 6 +- server/src/services/server.service.spec.ts | 12 +- .../services/storage-template.service.spec.ts | 72 +++-- server/src/services/storage.service.spec.ts | 58 ++-- server/src/services/storage.service.ts | 19 +- server/src/services/user.service.spec.ts | 27 +- server/src/types.ts | 2 + server/src/utils/file.ts | 10 +- .../repositories/asset.repository.mock.ts | 1 + .../repositories/database.repository.mock.ts | 1 + 34 files changed, 689 insertions(+), 257 deletions(-) create mode 100644 server/src/commands/media-location.command.ts create mode 100644 server/src/schema/migrations/1752759108283-ConvertToAbsolutePaths.ts 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