mirror of
https://github.com/immich-app/immich.git
synced 2026-02-11 19:38:54 +03:00
Merge branch 'main' into feature/readonly-sharing
# Conflicts: # server/src/services/album.service.ts
This commit is contained in:
@@ -25,6 +25,7 @@ module.exports = {
|
||||
'unicorn/prefer-top-level-await': 'off',
|
||||
'unicorn/prefer-event-target': 'off',
|
||||
'unicorn/no-thenable': 'off',
|
||||
'unicorn/import-style': 'off',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
|
||||
@@ -5,7 +5,7 @@ RUN apt-get install --no-install-recommends -yqq tini
|
||||
WORKDIR /usr/src/app
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
RUN npm ci && \
|
||||
# sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
||||
rm -rf node_modules/@img/sharp-libvips* && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
||||
@@ -22,9 +22,10 @@ FROM dev AS prod
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev --omit=optional
|
||||
COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
|
||||
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
|
||||
|
||||
# web build
|
||||
FROM node:iron-alpine3.18@sha256:fa5d3cf51725bd42d32e67917623038539dbe720dab082f590785c001eb4dfef as web
|
||||
FROM node:iron-alpine3.18@sha256:3fb85a68652064ab109ed9730f45a3ede11f064afdd3ad9f96ef7e8a3c55f47e as web
|
||||
|
||||
WORKDIR /usr/src/open-api/typescript-sdk
|
||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||
|
||||
1834
server/package-lock.json
generated
1834
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,9 @@
|
||||
"@nestjs/swagger": "^7.1.8",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^10.2.2",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.43.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.49.0",
|
||||
"@opentelemetry/sdk-node": "^0.49.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.44.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.50.0",
|
||||
"@opentelemetry/sdk-node": "^0.50.0",
|
||||
"@socket.io/postgres-adapter": "^0.3.1",
|
||||
"@types/picomatch": "^2.3.3",
|
||||
"archiver": "^7.0.0",
|
||||
@@ -59,7 +59,6 @@
|
||||
"class-validator": "^0.14.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"exiftool-vendored": "~24.6.0",
|
||||
"exiftool-vendored.pl": "12.78",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"geo-tz": "^8.0.0",
|
||||
@@ -113,7 +112,7 @@
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^51.0.0",
|
||||
"eslint-plugin-unicorn": "^52.0.0",
|
||||
"jest": "^29.6.4",
|
||||
"jest-when": "^3.6.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
@@ -168,6 +167,6 @@
|
||||
"globalSetup": "<rootDir>/test/global-setup.js"
|
||||
},
|
||||
"volta": {
|
||||
"node": "20.12.0"
|
||||
"node": "20.12.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,6 @@ export class CreateLibraryDto {
|
||||
@ArrayUnique()
|
||||
@ArrayMaxSize(128)
|
||||
exclusionPatterns?: string[];
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isWatched?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateLibraryDto {
|
||||
|
||||
@@ -1,115 +1,137 @@
|
||||
import { ConcurrentQueueName } from 'src/interfaces/job.interface';
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity('system_config')
|
||||
export class SystemConfigEntity<T = SystemConfigValue> {
|
||||
@PrimaryColumn()
|
||||
key!: SystemConfigKey;
|
||||
export type SystemConfigValue = string | string[] | number | boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
|
||||
value!: T | T[];
|
||||
}
|
||||
// https://stackoverflow.com/a/47058976
|
||||
// https://stackoverflow.com/a/70692231
|
||||
type PathsToStringProps<T> = T extends SystemConfigValue
|
||||
? []
|
||||
: {
|
||||
[K in keyof T]: [K, ...PathsToStringProps<T[K]>];
|
||||
}[keyof T];
|
||||
|
||||
export type SystemConfigValue = string | number | boolean;
|
||||
type Join<T extends string[], D extends string> = T extends []
|
||||
? never
|
||||
: T extends [infer F]
|
||||
? F
|
||||
: T extends [infer F, ...infer R]
|
||||
? F extends string
|
||||
? `${F}${D}${Join<Extract<R, string[]>, D>}`
|
||||
: never
|
||||
: string;
|
||||
|
||||
// dot notation matches path in `SystemConfig`
|
||||
export enum SystemConfigKey {
|
||||
FFMPEG_CRF = 'ffmpeg.crf',
|
||||
FFMPEG_THREADS = 'ffmpeg.threads',
|
||||
FFMPEG_PRESET = 'ffmpeg.preset',
|
||||
FFMPEG_TARGET_VIDEO_CODEC = 'ffmpeg.targetVideoCodec',
|
||||
FFMPEG_ACCEPTED_VIDEO_CODECS = 'ffmpeg.acceptedVideoCodecs',
|
||||
FFMPEG_TARGET_AUDIO_CODEC = 'ffmpeg.targetAudioCodec',
|
||||
FFMPEG_ACCEPTED_AUDIO_CODECS = 'ffmpeg.acceptedAudioCodecs',
|
||||
FFMPEG_TARGET_RESOLUTION = 'ffmpeg.targetResolution',
|
||||
FFMPEG_MAX_BITRATE = 'ffmpeg.maxBitrate',
|
||||
FFMPEG_BFRAMES = 'ffmpeg.bframes',
|
||||
FFMPEG_REFS = 'ffmpeg.refs',
|
||||
FFMPEG_GOP_SIZE = 'ffmpeg.gopSize',
|
||||
FFMPEG_NPL = 'ffmpeg.npl',
|
||||
FFMPEG_TEMPORAL_AQ = 'ffmpeg.temporalAQ',
|
||||
FFMPEG_CQ_MODE = 'ffmpeg.cqMode',
|
||||
FFMPEG_TWO_PASS = 'ffmpeg.twoPass',
|
||||
FFMPEG_PREFERRED_HW_DEVICE = 'ffmpeg.preferredHwDevice',
|
||||
FFMPEG_TRANSCODE = 'ffmpeg.transcode',
|
||||
FFMPEG_ACCEL = 'ffmpeg.accel',
|
||||
FFMPEG_TONEMAP = 'ffmpeg.tonemap',
|
||||
// TODO: migrate to key value per section
|
||||
export const SystemConfigKey = {
|
||||
FFMPEG_CRF: 'ffmpeg.crf',
|
||||
FFMPEG_THREADS: 'ffmpeg.threads',
|
||||
FFMPEG_PRESET: 'ffmpeg.preset',
|
||||
FFMPEG_TARGET_VIDEO_CODEC: 'ffmpeg.targetVideoCodec',
|
||||
FFMPEG_ACCEPTED_VIDEO_CODECS: 'ffmpeg.acceptedVideoCodecs',
|
||||
FFMPEG_TARGET_AUDIO_CODEC: 'ffmpeg.targetAudioCodec',
|
||||
FFMPEG_ACCEPTED_AUDIO_CODECS: 'ffmpeg.acceptedAudioCodecs',
|
||||
FFMPEG_TARGET_RESOLUTION: 'ffmpeg.targetResolution',
|
||||
FFMPEG_MAX_BITRATE: 'ffmpeg.maxBitrate',
|
||||
FFMPEG_BFRAMES: 'ffmpeg.bframes',
|
||||
FFMPEG_REFS: 'ffmpeg.refs',
|
||||
FFMPEG_GOP_SIZE: 'ffmpeg.gopSize',
|
||||
FFMPEG_NPL: 'ffmpeg.npl',
|
||||
FFMPEG_TEMPORAL_AQ: 'ffmpeg.temporalAQ',
|
||||
FFMPEG_CQ_MODE: 'ffmpeg.cqMode',
|
||||
FFMPEG_TWO_PASS: 'ffmpeg.twoPass',
|
||||
FFMPEG_PREFERRED_HW_DEVICE: 'ffmpeg.preferredHwDevice',
|
||||
FFMPEG_TRANSCODE: 'ffmpeg.transcode',
|
||||
FFMPEG_ACCEL: 'ffmpeg.accel',
|
||||
FFMPEG_TONEMAP: 'ffmpeg.tonemap',
|
||||
|
||||
JOB_THUMBNAIL_GENERATION_CONCURRENCY = 'job.thumbnailGeneration.concurrency',
|
||||
JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
|
||||
JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
|
||||
JOB_FACE_DETECTION_CONCURRENCY = 'job.faceDetection.concurrency',
|
||||
JOB_CLIP_ENCODING_CONCURRENCY = 'job.smartSearch.concurrency',
|
||||
JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
|
||||
JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
|
||||
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
|
||||
JOB_SIDECAR_CONCURRENCY = 'job.sidecar.concurrency',
|
||||
JOB_LIBRARY_CONCURRENCY = 'job.library.concurrency',
|
||||
JOB_MIGRATION_CONCURRENCY = 'job.migration.concurrency',
|
||||
JOB_THUMBNAIL_GENERATION_CONCURRENCY: 'job.thumbnailGeneration.concurrency',
|
||||
JOB_METADATA_EXTRACTION_CONCURRENCY: 'job.metadataExtraction.concurrency',
|
||||
JOB_VIDEO_CONVERSION_CONCURRENCY: 'job.videoConversion.concurrency',
|
||||
JOB_FACE_DETECTION_CONCURRENCY: 'job.faceDetection.concurrency',
|
||||
JOB_CLIP_ENCODING_CONCURRENCY: 'job.smartSearch.concurrency',
|
||||
JOB_BACKGROUND_TASK_CONCURRENCY: 'job.backgroundTask.concurrency',
|
||||
JOB_SEARCH_CONCURRENCY: 'job.search.concurrency',
|
||||
JOB_SIDECAR_CONCURRENCY: 'job.sidecar.concurrency',
|
||||
JOB_LIBRARY_CONCURRENCY: 'job.library.concurrency',
|
||||
JOB_MIGRATION_CONCURRENCY: 'job.migration.concurrency',
|
||||
|
||||
LIBRARY_SCAN_ENABLED = 'library.scan.enabled',
|
||||
LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression',
|
||||
LIBRARY_SCAN_ENABLED: 'library.scan.enabled',
|
||||
LIBRARY_SCAN_CRON_EXPRESSION: 'library.scan.cronExpression',
|
||||
|
||||
LIBRARY_WATCH_ENABLED = 'library.watch.enabled',
|
||||
LIBRARY_WATCH_ENABLED: 'library.watch.enabled',
|
||||
|
||||
LOGGING_ENABLED = 'logging.enabled',
|
||||
LOGGING_LEVEL = 'logging.level',
|
||||
LOGGING_ENABLED: 'logging.enabled',
|
||||
LOGGING_LEVEL: 'logging.level',
|
||||
|
||||
MACHINE_LEARNING_ENABLED = 'machineLearning.enabled',
|
||||
MACHINE_LEARNING_URL = 'machineLearning.url',
|
||||
MACHINE_LEARNING_ENABLED: 'machineLearning.enabled',
|
||||
MACHINE_LEARNING_URL: 'machineLearning.url',
|
||||
|
||||
MACHINE_LEARNING_CLIP_ENABLED = 'machineLearning.clip.enabled',
|
||||
MACHINE_LEARNING_CLIP_MODEL_NAME = 'machineLearning.clip.modelName',
|
||||
MACHINE_LEARNING_CLIP_ENABLED: 'machineLearning.clip.enabled',
|
||||
MACHINE_LEARNING_CLIP_MODEL_NAME: 'machineLearning.clip.modelName',
|
||||
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED = 'machineLearning.facialRecognition.enabled',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME = 'machineLearning.facialRecognition.modelName',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE = 'machineLearning.facialRecognition.minScore',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE = 'machineLearning.facialRecognition.maxDistance',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES = 'machineLearning.facialRecognition.minFaces',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_ENABLED: 'machineLearning.facialRecognition.enabled',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MODEL_NAME: 'machineLearning.facialRecognition.modelName',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_SCORE: 'machineLearning.facialRecognition.minScore',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MAX_DISTANCE: 'machineLearning.facialRecognition.maxDistance',
|
||||
MACHINE_LEARNING_FACIAL_RECOGNITION_MIN_FACES: 'machineLearning.facialRecognition.minFaces',
|
||||
|
||||
MAP_ENABLED = 'map.enabled',
|
||||
MAP_LIGHT_STYLE = 'map.lightStyle',
|
||||
MAP_DARK_STYLE = 'map.darkStyle',
|
||||
MAP_ENABLED: 'map.enabled',
|
||||
MAP_LIGHT_STYLE: 'map.lightStyle',
|
||||
MAP_DARK_STYLE: 'map.darkStyle',
|
||||
|
||||
REVERSE_GEOCODING_ENABLED = 'reverseGeocoding.enabled',
|
||||
REVERSE_GEOCODING_ENABLED: 'reverseGeocoding.enabled',
|
||||
|
||||
NEW_VERSION_CHECK_ENABLED = 'newVersionCheck.enabled',
|
||||
NEW_VERSION_CHECK_ENABLED: 'newVersionCheck.enabled',
|
||||
|
||||
OAUTH_AUTO_LAUNCH = 'oauth.autoLaunch',
|
||||
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
|
||||
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
|
||||
OAUTH_CLIENT_ID = 'oauth.clientId',
|
||||
OAUTH_CLIENT_SECRET = 'oauth.clientSecret',
|
||||
OAUTH_DEFAULT_STORAGE_QUOTA = 'oauth.defaultStorageQuota',
|
||||
OAUTH_ENABLED = 'oauth.enabled',
|
||||
OAUTH_ISSUER_URL = 'oauth.issuerUrl',
|
||||
OAUTH_MOBILE_OVERRIDE_ENABLED = 'oauth.mobileOverrideEnabled',
|
||||
OAUTH_MOBILE_REDIRECT_URI = 'oauth.mobileRedirectUri',
|
||||
OAUTH_SCOPE = 'oauth.scope',
|
||||
OAUTH_SIGNING_ALGORITHM = 'oauth.signingAlgorithm',
|
||||
OAUTH_STORAGE_LABEL_CLAIM = 'oauth.storageLabelClaim',
|
||||
OAUTH_STORAGE_QUOTA_CLAIM = 'oauth.storageQuotaClaim',
|
||||
OAUTH_AUTO_LAUNCH: 'oauth.autoLaunch',
|
||||
OAUTH_AUTO_REGISTER: 'oauth.autoRegister',
|
||||
OAUTH_BUTTON_TEXT: 'oauth.buttonText',
|
||||
OAUTH_CLIENT_ID: 'oauth.clientId',
|
||||
OAUTH_CLIENT_SECRET: 'oauth.clientSecret',
|
||||
OAUTH_DEFAULT_STORAGE_QUOTA: 'oauth.defaultStorageQuota',
|
||||
OAUTH_ENABLED: 'oauth.enabled',
|
||||
OAUTH_ISSUER_URL: 'oauth.issuerUrl',
|
||||
OAUTH_MOBILE_OVERRIDE_ENABLED: 'oauth.mobileOverrideEnabled',
|
||||
OAUTH_MOBILE_REDIRECT_URI: 'oauth.mobileRedirectUri',
|
||||
OAUTH_SCOPE: 'oauth.scope',
|
||||
OAUTH_SIGNING_ALGORITHM: 'oauth.signingAlgorithm',
|
||||
OAUTH_STORAGE_LABEL_CLAIM: 'oauth.storageLabelClaim',
|
||||
OAUTH_STORAGE_QUOTA_CLAIM: 'oauth.storageQuotaClaim',
|
||||
|
||||
PASSWORD_LOGIN_ENABLED = 'passwordLogin.enabled',
|
||||
PASSWORD_LOGIN_ENABLED: 'passwordLogin.enabled',
|
||||
|
||||
SERVER_EXTERNAL_DOMAIN = 'server.externalDomain',
|
||||
SERVER_LOGIN_PAGE_MESSAGE = 'server.loginPageMessage',
|
||||
SERVER_EXTERNAL_DOMAIN: 'server.externalDomain',
|
||||
SERVER_LOGIN_PAGE_MESSAGE: 'server.loginPageMessage',
|
||||
|
||||
STORAGE_TEMPLATE_ENABLED = 'storageTemplate.enabled',
|
||||
STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED = 'storageTemplate.hashVerificationEnabled',
|
||||
STORAGE_TEMPLATE = 'storageTemplate.template',
|
||||
STORAGE_TEMPLATE_ENABLED: 'storageTemplate.enabled',
|
||||
STORAGE_TEMPLATE_HASH_VERIFICATION_ENABLED: 'storageTemplate.hashVerificationEnabled',
|
||||
STORAGE_TEMPLATE: 'storageTemplate.template',
|
||||
|
||||
THUMBNAIL_WEBP_SIZE = 'thumbnail.webpSize',
|
||||
THUMBNAIL_JPEG_SIZE = 'thumbnail.jpegSize',
|
||||
THUMBNAIL_QUALITY = 'thumbnail.quality',
|
||||
THUMBNAIL_COLORSPACE = 'thumbnail.colorspace',
|
||||
IMAGE_THUMBNAIL_FORMAT: 'image.thumbnailFormat',
|
||||
IMAGE_THUMBNAIL_SIZE: 'image.thumbnailSize',
|
||||
IMAGE_PREVIEW_FORMAT: 'image.previewFormat',
|
||||
IMAGE_PREVIEW_SIZE: 'image.previewSize',
|
||||
IMAGE_QUALITY: 'image.quality',
|
||||
IMAGE_COLORSPACE: 'image.colorspace',
|
||||
|
||||
TRASH_ENABLED = 'trash.enabled',
|
||||
TRASH_DAYS = 'trash.days',
|
||||
TRASH_ENABLED: 'trash.enabled',
|
||||
TRASH_DAYS: 'trash.days',
|
||||
|
||||
THEME_CUSTOM_CSS = 'theme.customCss',
|
||||
THEME_CUSTOM_CSS: 'theme.customCss',
|
||||
|
||||
USER_DELETE_DELAY = 'user.deleteDelay',
|
||||
USER_DELETE_DELAY: 'user.deleteDelay',
|
||||
} as const satisfies Record<string, Join<PathsToStringProps<SystemConfig>, '.'>>;
|
||||
|
||||
export type SystemConfigKeyPaths = (typeof SystemConfigKey)[keyof typeof SystemConfigKey];
|
||||
|
||||
@Entity('system_config')
|
||||
export class SystemConfigEntity<T = SystemConfigValue> {
|
||||
@PrimaryColumn({ type: 'varchar' })
|
||||
key!: SystemConfigKeyPaths;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, transformer: { to: JSON.stringify, from: JSON.parse } })
|
||||
value!: T;
|
||||
}
|
||||
|
||||
export enum TranscodePolicy {
|
||||
@@ -131,6 +153,7 @@ export enum VideoCodec {
|
||||
H264 = 'h264',
|
||||
HEVC = 'hevc',
|
||||
VP9 = 'vp9',
|
||||
AV1 = 'av1',
|
||||
}
|
||||
|
||||
export enum AudioCodec {
|
||||
|
||||
@@ -146,7 +146,7 @@ export interface IAssetRepository {
|
||||
): Promise<AssetEntity[]>;
|
||||
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
|
||||
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
|
||||
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
|
||||
getByChecksum(libraryId: string, checksum: Buffer): Promise<AssetEntity | null>;
|
||||
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
|
||||
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||
getById(id: string, relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null>;
|
||||
|
||||
@@ -464,7 +464,7 @@ FROM
|
||||
WHERE
|
||||
(
|
||||
(
|
||||
("AssetEntity"."ownerId" = $1)
|
||||
("AssetEntity"."libraryId" = $1)
|
||||
AND ("AssetEntity"."checksum" = $2)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -265,8 +265,8 @@ export class AssetRepository implements IAssetRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
|
||||
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null> {
|
||||
return this.repository.findOne({ where: { ownerId: userId, checksum } });
|
||||
getByChecksum(libraryId: string, checksum: Buffer): Promise<AssetEntity | null> {
|
||||
return this.repository.findOne({ where: { libraryId, checksum } });
|
||||
}
|
||||
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null> {
|
||||
|
||||
@@ -175,6 +175,7 @@ describe(AlbumService.name, () => {
|
||||
it('creates album', async () => {
|
||||
albumMock.create.mockResolvedValue(albumStub.empty);
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['123']));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumName: 'Empty album',
|
||||
@@ -193,6 +194,7 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-id', {});
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['123']));
|
||||
});
|
||||
|
||||
it('should require valid userIds', async () => {
|
||||
@@ -206,6 +208,31 @@ describe(AlbumService.name, () => {
|
||||
expect(userMock.get).toHaveBeenCalledWith('user-3', {});
|
||||
expect(albumMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should only add assets the user is allowed to access', async () => {
|
||||
userMock.get.mockResolvedValue(userStub.user1);
|
||||
albumMock.create.mockResolvedValue(albumStub.oneAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
albumName: 'Test album',
|
||||
description: '',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
});
|
||||
|
||||
expect(albumMock.create).toHaveBeenCalledWith({
|
||||
ownerId: authStub.admin.user.id,
|
||||
albumName: 'Test album',
|
||||
description: '',
|
||||
sharedUsers: [],
|
||||
assets: [{ id: 'asset-1' }],
|
||||
albumThumbnailAssetId: 'asset-1',
|
||||
});
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
new Set(['asset-1', 'asset-2']),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
|
||||
@@ -121,14 +121,17 @@ export class AlbumService {
|
||||
}
|
||||
}
|
||||
|
||||
const allowedAssetIdsSet = await this.access.checkAccess(auth, Permission.ASSET_SHARE, new Set(dto.assetIds));
|
||||
const assets = [...allowedAssetIdsSet].map((id) => ({ id }) as AssetEntity);
|
||||
|
||||
const album = await this.albumRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
albumName: dto.albumName,
|
||||
description: dto.description,
|
||||
albumPermissions:
|
||||
dto.sharedWithUserIds?.map((userId) => ({ users: { id: userId } }) as AlbumPermissionEntity) ?? [],
|
||||
assets: (dto.assetIds || []).map((id) => ({ id }) as AssetEntity),
|
||||
albumThumbnailAssetId: dto.assetIds?.[0] || null,
|
||||
assets,
|
||||
albumThumbnailAssetId: assets[0]?.id || null,
|
||||
});
|
||||
|
||||
return mapAlbumWithAssets(album);
|
||||
|
||||
@@ -695,7 +695,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should not schedule delete-files job for readonly assets', async () => {
|
||||
it('should only delete generated files for readonly assets', async () => {
|
||||
when(assetMock.getById)
|
||||
.calledWith(assetStub.readOnly.id, {
|
||||
faces: {
|
||||
@@ -709,7 +709,20 @@ describe(AssetService.name, () => {
|
||||
|
||||
await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([]);
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.DELETE_FILES,
|
||||
data: {
|
||||
files: [
|
||||
assetStub.readOnly.thumbnailPath,
|
||||
assetStub.readOnly.previewPath,
|
||||
assetStub.readOnly.encodedVideoPath,
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
expect(assetMock.remove).toHaveBeenCalledWith(assetStub.readOnly);
|
||||
});
|
||||
@@ -748,7 +761,6 @@ describe(AssetService.name, () => {
|
||||
assetStub.external.thumbnailPath,
|
||||
assetStub.external.previewPath,
|
||||
assetStub.external.encodedVideoPath,
|
||||
assetStub.external.sidecarPath,
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -399,14 +399,12 @@ export class AssetService {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
||||
}
|
||||
|
||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath, asset.sidecarPath];
|
||||
if (!fromExternal) {
|
||||
files.push(asset.originalPath);
|
||||
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
|
||||
if (!(asset.isExternal || asset.isReadOnly)) {
|
||||
files.push(asset.sidecarPath, asset.originalPath);
|
||||
}
|
||||
|
||||
if (!asset.isReadOnly) {
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
}
|
||||
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { SystemConfig, SystemConfigKey } from 'src/entities/system-config.entity';
|
||||
import { SystemConfig, SystemConfigKey, SystemConfigKeyPaths } from 'src/entities/system-config.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
@@ -360,7 +360,7 @@ describe(JobService.name, () => {
|
||||
});
|
||||
}
|
||||
|
||||
const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKey }> = [
|
||||
const featureTests: Array<{ queue: QueueName; feature: FeatureFlag; configKey: SystemConfigKeyPaths }> = [
|
||||
{
|
||||
queue: QueueName.SMART_SEARCH,
|
||||
feature: FeatureFlag.SMART_SEARCH,
|
||||
|
||||
@@ -1058,14 +1058,6 @@ describe(LibraryService.name, () => {
|
||||
|
||||
expect(libraryMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not create watched', async () => {
|
||||
await expect(
|
||||
sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(storageMock.watch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -266,9 +266,6 @@ export class LibraryService extends EventEmitter {
|
||||
if (dto.exclusionPatterns && dto.exclusionPatterns.length > 0) {
|
||||
throw new BadRequestException('Upload libraries cannot have exclusion patterns');
|
||||
}
|
||||
if (dto.isWatched) {
|
||||
throw new BadRequestException('Upload libraries cannot be watched');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,25 +210,21 @@ describe(MediaService.name, () => {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for an image', async () => {
|
||||
it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_PREVIEW_FORMAT, value: format }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`;
|
||||
|
||||
await sut.handleGeneratePreview({ id: assetStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
{
|
||||
size: 1440,
|
||||
format: ImageFormat.JPEG,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
},
|
||||
);
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', previewPath, {
|
||||
size: 1440,
|
||||
format,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath });
|
||||
});
|
||||
|
||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||
@@ -342,25 +338,25 @@ describe(MediaService.name, () => {
|
||||
expect(assetMock.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||
it.each(Object.values(ImageFormat))(
|
||||
'should generate a %s thumbnail for an image when specified',
|
||||
async (format) => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.IMAGE_THUMBNAIL_FORMAT, value: format }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`;
|
||||
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith(
|
||||
'/original/path.jpg',
|
||||
'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
||||
{
|
||||
format: ImageFormat.WEBP,
|
||||
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
|
||||
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id/as/se');
|
||||
expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.jpg', thumbnailPath, {
|
||||
size: 250,
|
||||
format,
|
||||
quality: 80,
|
||||
colorspace: Colorspace.SRGB,
|
||||
},
|
||||
);
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: 'asset-id',
|
||||
thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
|
||||
});
|
||||
});
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate a P3 thumbnail for a wide gamut image', async () => {
|
||||
@@ -747,6 +743,67 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not include hevc tag when target is hevc and video stream is copied from a different codec', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamH264);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v copy',
|
||||
'-c:a aac',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should include hevc tag when target is hevc and copying hevc video stream', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_VIDEO_CODECS, value: [VideoCodec.H264, VideoCodec.HEVC] },
|
||||
{ key: SystemConfigKey.FFMPEG_ACCEPTED_AUDIO_CODECS, value: [AudioCodec.AAC] },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v copy',
|
||||
'-c:a aac',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-tag:v hvc1',
|
||||
'-v verbose',
|
||||
'-preset ultrafast',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should copy audio stream when audio matches target', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.audioStreamAac);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TRANSCODE, value: TranscodePolicy.OPTIMAL }]);
|
||||
@@ -1091,9 +1148,9 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for h264 if thread limit is above 0', async () => {
|
||||
it('should disable thread pooling for h264 if thread limit is 1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 }]);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
@@ -1111,9 +1168,8 @@ describe(MediaService.name, () => {
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x264-params "pools=none"',
|
||||
'-x264-params "frame-threads=2"',
|
||||
'-threads 1',
|
||||
'-x264-params frame-threads=1:pools=none',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
@@ -1148,10 +1204,10 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable thread pooling for hevc if thread limit is above 0', async () => {
|
||||
it('should disable thread pooling for hevc if thread limit is 1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 2 },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 1 },
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.HEVC },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
@@ -1172,9 +1228,8 @@ describe(MediaService.name, () => {
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset ultrafast',
|
||||
'-threads 2',
|
||||
'-x265-params "pools=none"',
|
||||
'-x265-params "frame-threads=2"',
|
||||
'-threads 1',
|
||||
'-x265-params frame-threads=1:pools=none',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
@@ -1213,6 +1268,157 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should use av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 }]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should map `veryslow` preset to 4 for av1', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_PRESET, value: 'veryslow' },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 4',
|
||||
'-crf 23',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set max bitrate for av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
'-svtav1-params mbr=2M',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set threads for av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 4 },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
'-svtav1-params lp=4',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should set both bitrate and threads for av1 if specified', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVp9);
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.AV1 },
|
||||
{ key: SystemConfigKey.FFMPEG_THREADS, value: 4 },
|
||||
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '2M' },
|
||||
]);
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
{
|
||||
inputOptions: [],
|
||||
outputOptions: [
|
||||
'-c:v av1',
|
||||
'-c:a copy',
|
||||
'-movflags faststart',
|
||||
'-fps_mode passthrough',
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
'-svtav1-params lp=4:mbr=2M',
|
||||
],
|
||||
twoPass: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip transcoding for audioless videos with optimal policy if video codec is correct', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
|
||||
configMock.load.mockResolvedValue([
|
||||
|
||||
@@ -32,6 +32,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { ImmichLogger } from 'src/utils/logger';
|
||||
import {
|
||||
AV1Config,
|
||||
H264Config,
|
||||
HEVCConfig,
|
||||
NVENCConfig,
|
||||
@@ -167,12 +168,15 @@ export class MediaService {
|
||||
}
|
||||
|
||||
async handleGeneratePreview({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, ImageFormat.JPEG);
|
||||
const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat);
|
||||
await this.assetRepository.update({ id: asset.id, previewPath });
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@@ -210,18 +214,21 @@ export class MediaService {
|
||||
}
|
||||
}
|
||||
this.logger.log(
|
||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} thumbnail for asset ${asset.id}`,
|
||||
`Successfully generated ${format.toUpperCase()} ${asset.type.toLowerCase()} ${type} for asset ${asset.id}`,
|
||||
);
|
||||
return path;
|
||||
}
|
||||
|
||||
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||
const [{ image }, [asset]] = await Promise.all([
|
||||
this.configCore.getConfig(),
|
||||
this.assetRepository.getByIds([id], { exifInfo: true }),
|
||||
]);
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, ImageFormat.WEBP);
|
||||
const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat);
|
||||
await this.assetRepository.update({ id: asset.id, thumbnailPath });
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
@@ -433,6 +440,9 @@ export class MediaService {
|
||||
case VideoCodec.VP9: {
|
||||
return new VP9Config(config);
|
||||
}
|
||||
case VideoCodec.AV1: {
|
||||
return new AV1Config(config);
|
||||
}
|
||||
default: {
|
||||
throw new UnsupportedMediaTypeException(`Codec '${config.targetVideoCodec}' is unsupported`);
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue(null);
|
||||
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
assetMock.create.mockImplementation((asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }));
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
|
||||
@@ -467,6 +467,30 @@ describe(MetadataService.name, () => {
|
||||
expect(jobMock.queue).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => {
|
||||
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
|
||||
metadataMock.readTags.mockResolvedValue({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
MicroVideo: 1,
|
||||
MicroVideoOffset: 1,
|
||||
});
|
||||
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
|
||||
assetMock.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
|
||||
const video = randomBytes(512);
|
||||
storageMock.readFile.mockResolvedValue(video);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
|
||||
id: assetStub.livePhotoMotionAsset.id,
|
||||
isVisible: false,
|
||||
});
|
||||
expect(assetMock.update).toHaveBeenNthCalledWith(2, {
|
||||
id: assetStub.livePhotoStillAsset.id,
|
||||
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should save all metadata', async () => {
|
||||
const tags: ImmichTags = {
|
||||
BitsPerSample: 1,
|
||||
|
||||
@@ -405,13 +405,19 @@ export class MetadataService {
|
||||
}
|
||||
const checksum = this.cryptoRepository.hashSha1(video);
|
||||
|
||||
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
|
||||
let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId, checksum);
|
||||
if (motionAsset) {
|
||||
this.logger.debug(
|
||||
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
|
||||
'base64',
|
||||
)} already exists in the repository`,
|
||||
);
|
||||
|
||||
// Hide the motion photo video asset if it's not already hidden to prepare for linking
|
||||
if (motionAsset.isVisible) {
|
||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
|
||||
}
|
||||
} else {
|
||||
// We create a UUID in advance so that each extracted video can have a unique filename
|
||||
// (allowing us to delete old ones if necessary)
|
||||
@@ -438,11 +444,14 @@ export class MetadataService {
|
||||
this.storageCore.ensureFolders(motionPath);
|
||||
await this.storageRepository.writeFile(motionAsset.originalPath, video);
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
|
||||
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
||||
}
|
||||
|
||||
if (asset.livePhotoVideoId !== motionAsset.id) {
|
||||
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
|
||||
// If the asset already had an associated livePhotoVideo, delete it, because
|
||||
// its checksum doesn't match the checksum of the motionAsset we just extracted
|
||||
// (if it did, getByChecksum() would've returned non-null)
|
||||
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
|
||||
// note asset.livePhotoVideoId is not motionAsset.id yet
|
||||
if (asset.livePhotoVideoId) {
|
||||
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
|
||||
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
|
||||
|
||||
@@ -346,19 +346,6 @@ describe(SystemConfigService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshConfig', () => {
|
||||
it('should notify the subscribers', async () => {
|
||||
const changeMock = jest.fn();
|
||||
const subscription = sut.config$.subscribe(changeMock);
|
||||
|
||||
await sut.refreshConfig();
|
||||
|
||||
expect(changeMock).toHaveBeenCalledWith(defaults);
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomCss', () => {
|
||||
it('should return the default theme', async () => {
|
||||
await expect(sut.getCustomCss()).resolves.toEqual(defaults.theme.customCss);
|
||||
|
||||
@@ -90,13 +90,6 @@ export class SystemConfigService {
|
||||
return mapConfig(newConfig);
|
||||
}
|
||||
|
||||
// this is only used by the cli on config change, and it's not actually needed anymore
|
||||
async refreshConfig() {
|
||||
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
|
||||
await this.core.refreshConfig();
|
||||
return true;
|
||||
}
|
||||
|
||||
getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
|
||||
const options = new SystemConfigTemplateStorageOptionDto();
|
||||
|
||||
|
||||
BIN
server/src/utils/.media.ts.kate-swp
Normal file
BIN
server/src/utils/.media.ts.kate-swp
Normal file
Binary file not shown.
@@ -37,9 +37,12 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||
}
|
||||
|
||||
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
|
||||
const videoCodec = [TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy';
|
||||
const audioCodec = [TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy';
|
||||
|
||||
const options = [
|
||||
`-c:v ${[TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target) ? this.getVideoCodec() : 'copy'}`,
|
||||
`-c:a ${[TranscodeTarget.ALL, TranscodeTarget.AUDIO].includes(target) ? this.getAudioCodec() : 'copy'}`,
|
||||
`-c:v ${videoCodec}`,
|
||||
`-c:a ${audioCodec}`,
|
||||
// Makes a second pass moving the moov atom to the
|
||||
// beginning of the file for improved playback speed.
|
||||
'-movflags faststart',
|
||||
@@ -61,7 +64,10 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||
options.push(`-g ${this.getGopSize()}`);
|
||||
}
|
||||
|
||||
if (this.config.targetVideoCodec === VideoCodec.HEVC) {
|
||||
if (
|
||||
this.config.targetVideoCodec === VideoCodec.HEVC &&
|
||||
(videoCodec !== 'copy' || videoStream.codecName === 'hevc')
|
||||
) {
|
||||
options.push('-tag:v hvc1');
|
||||
}
|
||||
|
||||
@@ -118,7 +124,7 @@ class BaseConfig implements VideoCodecSWConfig {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.isBitrateConstrained() || this.config.targetVideoCodec === VideoCodec.VP9;
|
||||
return this.isBitrateConstrained();
|
||||
}
|
||||
|
||||
getBitrateDistribution() {
|
||||
@@ -259,7 +265,7 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
|
||||
}
|
||||
|
||||
getSupportedCodecs() {
|
||||
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9];
|
||||
return [VideoCodec.H264, VideoCodec.HEVC];
|
||||
}
|
||||
|
||||
validateDevices(devices: string[]) {
|
||||
@@ -343,27 +349,23 @@ export class ThumbnailConfig extends BaseConfig {
|
||||
|
||||
export class H264Config extends BaseConfig {
|
||||
getThreadOptions() {
|
||||
if (this.config.threads <= 0) {
|
||||
return [];
|
||||
const options = super.getThreadOptions();
|
||||
if (this.config.threads === 1) {
|
||||
options.push('-x264-params frame-threads=1:pools=none');
|
||||
}
|
||||
return [
|
||||
...super.getThreadOptions(),
|
||||
'-x264-params "pools=none"',
|
||||
`-x264-params "frame-threads=${this.config.threads}"`,
|
||||
];
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
export class HEVCConfig extends BaseConfig {
|
||||
getThreadOptions() {
|
||||
if (this.config.threads <= 0) {
|
||||
return [];
|
||||
const options = super.getThreadOptions();
|
||||
if (this.config.threads === 1) {
|
||||
options.push('-x265-params frame-threads=1:pools=none');
|
||||
}
|
||||
return [
|
||||
...super.getThreadOptions(),
|
||||
'-x265-params "pools=none"',
|
||||
`-x265-params "frame-threads=${this.config.threads}"`,
|
||||
];
|
||||
|
||||
return options;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +394,44 @@ export class VP9Config extends BaseConfig {
|
||||
getThreadOptions() {
|
||||
return ['-row-mt 1', ...super.getThreadOptions()];
|
||||
}
|
||||
|
||||
eligibleForTwoPass() {
|
||||
return this.config.twoPass;
|
||||
}
|
||||
}
|
||||
|
||||
export class AV1Config extends BaseConfig {
|
||||
getPresetOptions() {
|
||||
const speed = this.getPresetIndex() + 4; // Use 4 as slowest, giving us an effective range of 4-12 which is far more useful than 0-8
|
||||
if (speed >= 0) {
|
||||
return [`-preset ${speed}`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
getBitrateOptions() {
|
||||
const options = [`-crf ${this.config.crf}`];
|
||||
const bitrates = this.getBitrateDistribution();
|
||||
const svtparams = [];
|
||||
if (this.config.threads > 0) {
|
||||
svtparams.push(`lp=${this.config.threads}`);
|
||||
}
|
||||
if (bitrates.max > 0) {
|
||||
svtparams.push(`mbr=${bitrates.max}${bitrates.unit}`);
|
||||
}
|
||||
if (svtparams.length > 0) {
|
||||
options.push(`-svtav1-params ${svtparams.join(':')}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
getThreadOptions() {
|
||||
return []; // Already set above with svtav1-params
|
||||
}
|
||||
|
||||
eligibleForTwoPass() {
|
||||
return this.config.twoPass;
|
||||
}
|
||||
}
|
||||
|
||||
export class NVENCConfig extends BaseHWConfig {
|
||||
@@ -525,6 +565,10 @@ export class QSVConfig extends BaseHWConfig {
|
||||
return options;
|
||||
}
|
||||
|
||||
getSupportedCodecs() {
|
||||
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9];
|
||||
}
|
||||
|
||||
// recommended from https://github.com/intel/media-delivery/blob/master/doc/benchmarks/intel-iris-xe-max-graphics/intel-iris-xe-max-graphics.md
|
||||
getBFrames() {
|
||||
if (this.config.bframes < 0) {
|
||||
@@ -603,6 +647,10 @@ export class VAAPIConfig extends BaseHWConfig {
|
||||
return options;
|
||||
}
|
||||
|
||||
getSupportedCodecs() {
|
||||
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.VP9];
|
||||
}
|
||||
|
||||
useCQP() {
|
||||
return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9;
|
||||
}
|
||||
|
||||
4
server/test/fixtures/media.stub.ts
vendored
4
server/test/fixtures/media.stub.ts
vendored
@@ -173,4 +173,8 @@ export const probeStub = {
|
||||
bitrate: 0,
|
||||
},
|
||||
}),
|
||||
videoStreamH264: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
videoStreams: [{ ...probeStubDefaultVideoStream[0], codecName: 'h264' }],
|
||||
}),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user