mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/mobile-ocr
This commit is contained in:
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
cd plugins && mise run build
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f
|
||||
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
COREPACK_HOME=/tmp \
|
||||
PNPM_HOME=/buildcache/pnpm-store
|
||||
|
||||
RUN npm install --global corepack@latest && \
|
||||
|
||||
+18
-18
@@ -7,8 +7,8 @@
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"format": "prettier --cache --check .",
|
||||
"format:fix": "prettier --cache --write --list-different .",
|
||||
"start": "pnpm run start:dev",
|
||||
"nest": "nest",
|
||||
"start:dev": "nest start --watch --",
|
||||
@@ -22,12 +22,12 @@
|
||||
"test:cov": "vitest --config test/vitest.config.mjs --coverage",
|
||||
"test:medium": "vitest --config test/vitest.config.medium.mjs",
|
||||
"typeorm": "typeorm",
|
||||
"migrations:debug": "node ./dist/bin/migrations.js debug",
|
||||
"migrations:generate": "node ./dist/bin/migrations.js generate",
|
||||
"migrations:create": "node ./dist/bin/migrations.js create",
|
||||
"migrations:run": "node ./dist/bin/migrations.js run",
|
||||
"migrations:revert": "node ./dist/bin/migrations.js revert",
|
||||
"schema:drop": "node ./dist/bin/migrations.js query 'DROP schema public cascade; CREATE schema public;'",
|
||||
"migrations:debug": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate --debug",
|
||||
"migrations:generate": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
|
||||
"migrations:create": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations generate",
|
||||
"migrations:run": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations run",
|
||||
"migrations:revert": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} migrations revert",
|
||||
"schema:drop": "sql-tools -u ${DB_URL:-postgres://postgres:postgres@localhost:5432/immich} query 'DROP schema public cascade; CREATE schema public;'",
|
||||
"schema:reset": "pnpm run schema:drop && pnpm run migrations:run",
|
||||
"sync:open-api": "node ./dist/bin/sync-open-api.js",
|
||||
"sync:sql": "node ./dist/bin/sync-sql.js",
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/sql-tools": "^0.2.0",
|
||||
"@immich/sql-tools": "^0.3.2",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
@@ -46,14 +46,14 @@
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.212.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.212.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.60.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.58.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.64.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.213.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.213.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.61.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.59.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.65.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.212.0",
|
||||
"@opentelemetry/sdk-node": "^0.213.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
@@ -66,7 +66,7 @@
|
||||
"bullmq": "^5.51.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"class-validator": "^0.15.0",
|
||||
"compression": "^1.8.0",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
@@ -82,7 +82,7 @@
|
||||
"jose": "^5.10.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "0.28.2",
|
||||
"kysely": "0.28.11",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
@@ -136,7 +136,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
|
||||
|
||||
import { schemaDiff, schemaFromCode, schemaFromDatabase } from '@immich/sql-tools';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import 'src/schema';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
const main = async () => {
|
||||
const command = process.argv[2];
|
||||
const path = process.argv[3] || 'src/Migration';
|
||||
|
||||
switch (command) {
|
||||
case 'debug': {
|
||||
await debug();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'run': {
|
||||
await runMigrations();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'revert': {
|
||||
await revert();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'query': {
|
||||
const query = process.argv[3];
|
||||
await runQuery(query);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'create': {
|
||||
create(path, [], []);
|
||||
return;
|
||||
}
|
||||
|
||||
case 'generate': {
|
||||
await generate(path);
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
console.log(`Usage:
|
||||
node dist/bin/migrations.js create <name>
|
||||
node dist/bin/migrations.js generate <name>
|
||||
node dist/bin/migrations.js run
|
||||
node dist/bin/migrations.js revert
|
||||
`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getDatabaseClient = () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
return new Kysely<any>(getKyselyConfig(database.config));
|
||||
};
|
||||
|
||||
const runQuery = async (query: string) => {
|
||||
const db = getDatabaseClient();
|
||||
await sql.raw(query).execute(db);
|
||||
await db.destroy();
|
||||
};
|
||||
|
||||
const runMigrations = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const logger = LoggingRepository.create();
|
||||
const db = getDatabaseClient();
|
||||
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
|
||||
await databaseRepository.runMigrations();
|
||||
await db.destroy();
|
||||
};
|
||||
|
||||
const revert = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const logger = LoggingRepository.create();
|
||||
const db = getDatabaseClient();
|
||||
const databaseRepository = new DatabaseRepository(db, logger, configRepository);
|
||||
|
||||
try {
|
||||
const migrationName = await databaseRepository.revertLastMigration();
|
||||
if (!migrationName) {
|
||||
console.log('No migrations to revert');
|
||||
return;
|
||||
}
|
||||
|
||||
markMigrationAsReverted(migrationName);
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const debug = async () => {
|
||||
const { up } = await compare();
|
||||
const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n');
|
||||
// const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
|
||||
writeFileSync('./migrations.sql', upSql + '\n\n');
|
||||
console.log('Wrote migrations.sql');
|
||||
};
|
||||
|
||||
const generate = async (path: string) => {
|
||||
const { up, down } = await compare();
|
||||
if (up.items.length === 0) {
|
||||
console.log('No changes detected');
|
||||
return;
|
||||
}
|
||||
create(path, up.asSql(), down.asSql());
|
||||
};
|
||||
|
||||
const create = (path: string, up: string[], down: string[]) => {
|
||||
const timestamp = Date.now();
|
||||
const name = basename(path, extname(path));
|
||||
const filename = `${timestamp}-${name}.ts`;
|
||||
const folder = dirname(path);
|
||||
const fullPath = join(folder, filename);
|
||||
mkdirSync(folder, { recursive: true });
|
||||
writeFileSync(fullPath, asMigration({ up, down }));
|
||||
console.log(`Wrote ${fullPath}`);
|
||||
};
|
||||
|
||||
const compare = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
|
||||
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
|
||||
const target = await schemaFromDatabase({ connection: database.config });
|
||||
|
||||
console.log(source.warnings.join('\n'));
|
||||
|
||||
const up = schemaDiff(source, target, {
|
||||
tables: { ignoreExtra: true },
|
||||
functions: { ignoreExtra: false },
|
||||
parameters: { ignoreExtra: true },
|
||||
});
|
||||
const down = schemaDiff(target, source, {
|
||||
tables: { ignoreExtra: false, ignoreMissing: true },
|
||||
functions: { ignoreExtra: false },
|
||||
extensions: { ignoreMissing: true },
|
||||
parameters: { ignoreMissing: true },
|
||||
});
|
||||
|
||||
return { up, down };
|
||||
};
|
||||
|
||||
type MigrationProps = {
|
||||
up: string[];
|
||||
down: string[];
|
||||
};
|
||||
|
||||
const asMigration = ({ up, down }: MigrationProps) => {
|
||||
const upSql = up.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
|
||||
const downSql = down.map((sql) => ` await sql\`${sql}\`.execute(db);`).join('\n');
|
||||
|
||||
return `import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
${upSql}
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
${downSql}
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
const markMigrationAsReverted = (migrationName: string) => {
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
const distRoot = join(__dirname, '..');
|
||||
const projectRoot = join(distRoot, '..');
|
||||
const sourceFolder = join(projectRoot, 'src', 'schema', 'migrations');
|
||||
const distFolder = join(distRoot, 'schema', 'migrations');
|
||||
|
||||
const sourcePath = join(sourceFolder, `${migrationName}.ts`);
|
||||
const revertedFolder = join(sourceFolder, 'reverted');
|
||||
const revertedPath = join(revertedFolder, `${migrationName}.ts`);
|
||||
|
||||
if (existsSync(revertedPath)) {
|
||||
console.log(`Migration ${migrationName} is already marked as reverted`);
|
||||
} else if (existsSync(sourcePath)) {
|
||||
mkdirSync(revertedFolder, { recursive: true });
|
||||
renameSync(sourcePath, revertedPath);
|
||||
console.log(`Moved ${sourcePath} to ${revertedPath}`);
|
||||
} else {
|
||||
console.warn(`Source migration file not found for ${migrationName}`);
|
||||
}
|
||||
|
||||
const distBase = join(distFolder, migrationName);
|
||||
for (const extension of ['.js', '.js.map', '.d.ts']) {
|
||||
const filePath = `${distBase}${extension}`;
|
||||
if (existsSync(filePath)) {
|
||||
rmSync(filePath, { force: true });
|
||||
console.log(`Removed ${filePath}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
main()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
console.log('Something went wrong');
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -206,7 +206,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
targetAudioCodec: AudioCodec.Aac,
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
||||
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
||||
targetResolution: '720',
|
||||
maxBitrate: '0',
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Duration } from 'luxon';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SemVer } from 'semver';
|
||||
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
|
||||
export const ErrorMessages = {
|
||||
InconsistentMediaLocation:
|
||||
@@ -201,3 +201,11 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
[ApiTag.Workflows]:
|
||||
'A workflow is a set of actions that run whenever a triggering event occurs. Workflows also can include filters to further limit execution.',
|
||||
};
|
||||
|
||||
export const AUDIO_ENCODER: Record<AudioCodec, string> = {
|
||||
[AudioCodec.Aac]: 'aac',
|
||||
[AudioCodec.Mp3]: 'mp3',
|
||||
[AudioCodec.Libopus]: 'libopus',
|
||||
[AudioCodec.Opus]: 'libopus',
|
||||
[AudioCodec.PcmS16le]: 'pcm_s16le',
|
||||
};
|
||||
|
||||
@@ -207,12 +207,28 @@ describe(AssetController.name, () => {
|
||||
});
|
||||
|
||||
it('should reject invalid rating', async () => {
|
||||
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: null }]) {
|
||||
for (const test of [{ rating: 7 }, { rating: 3.5 }, { rating: -2 }]) {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}`).send(test);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
}
|
||||
});
|
||||
|
||||
it('should convert rating 0 to null', async () => {
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send({ rating: 0 });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, assetId, { rating: null });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should leave correct ratings as-is', async () => {
|
||||
const assetId = factory.uuid();
|
||||
for (const test of [{ rating: -1 }, { rating: 1 }, { rating: 5 }]) {
|
||||
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}`).send(test);
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, assetId, test);
|
||||
expect(status).toBe(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/statistics', () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { Permission, SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SharedLinkController.name, () => {
|
||||
@@ -31,4 +32,16 @@ describe(SharedLinkController.name, () => {
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /shared-links/:id/assets', () => {
|
||||
it('should require shared link update permission', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/shared-links/${factory.uuid()}/assets`).send({ assetIds: [] });
|
||||
|
||||
expect(ctx.authenticate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: expect.objectContaining({ permission: Permission.SharedLinkUpdate, sharedLinkRoute: false }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -180,7 +180,7 @@ export class SharedLinkController {
|
||||
}
|
||||
|
||||
@Delete(':id/assets')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Authenticated({ permission: Permission.SharedLinkUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Remove assets from a shared link',
|
||||
description:
|
||||
|
||||
@@ -154,10 +154,11 @@ export class StorageCore {
|
||||
}
|
||||
|
||||
async moveAssetVideo(asset: StorageAsset) {
|
||||
const encodedVideoFile = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||
return this.moveFile({
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.EncodedVideo,
|
||||
oldPath: asset.encodedVideoPath,
|
||||
oldPath: encodedVideoFile?.path || null,
|
||||
newPath: StorageCore.getEncodedVideoPath(asset),
|
||||
});
|
||||
}
|
||||
@@ -303,21 +304,15 @@ export class StorageCore {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
}
|
||||
case AssetFileType.FullSize: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
||||
}
|
||||
case AssetFileType.Preview: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
|
||||
}
|
||||
case AssetFileType.Thumbnail: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
|
||||
}
|
||||
case AssetPathType.EncodedVideo: {
|
||||
return this.assetRepository.update({ id, encodedVideoPath: newPath });
|
||||
}
|
||||
|
||||
case AssetFileType.FullSize:
|
||||
case AssetFileType.EncodedVideo:
|
||||
case AssetFileType.Thumbnail:
|
||||
case AssetFileType.Preview:
|
||||
case AssetFileType.Sidecar: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: pathType as AssetFileType, path: newPath });
|
||||
}
|
||||
|
||||
case PersonPathType.Face: {
|
||||
return this.personRepository.update({ id, thumbnailPath: newPath });
|
||||
}
|
||||
|
||||
+14
-14
@@ -1,4 +1,4 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
@@ -31,7 +32,7 @@ export type AuthUser = {
|
||||
};
|
||||
|
||||
export type AlbumUser = {
|
||||
user: User;
|
||||
user: ShallowDehydrateObject<User>;
|
||||
role: AlbumUserRole;
|
||||
};
|
||||
|
||||
@@ -67,7 +68,7 @@ export type Activity = {
|
||||
updatedAt: Date;
|
||||
albumId: string;
|
||||
userId: string;
|
||||
user: User;
|
||||
user: ShallowDehydrateObject<User>;
|
||||
assetId: string | null;
|
||||
comment: string | null;
|
||||
isLiked: boolean;
|
||||
@@ -105,7 +106,7 @@ export type Memory = {
|
||||
data: object;
|
||||
ownerId: string;
|
||||
isSaved: boolean;
|
||||
assets: MapAsset[];
|
||||
assets: ShallowDehydrateObject<MapAsset>[];
|
||||
};
|
||||
|
||||
export type Asset = {
|
||||
@@ -153,15 +154,14 @@ export type StorageAsset = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
files: AssetFile[];
|
||||
encodedVideoPath: string | null;
|
||||
};
|
||||
|
||||
export type Stack = {
|
||||
id: string;
|
||||
primaryAssetId: string;
|
||||
owner?: User;
|
||||
owner?: ShallowDehydrateObject<User>;
|
||||
ownerId: string;
|
||||
assets: MapAsset[];
|
||||
assets: ShallowDehydrateObject<MapAsset>[];
|
||||
assetCount?: number;
|
||||
};
|
||||
|
||||
@@ -177,11 +177,11 @@ export type AuthSharedLink = {
|
||||
|
||||
export type SharedLink = {
|
||||
id: string;
|
||||
album?: Album | null;
|
||||
album?: ShallowDehydrateObject<Album> | null;
|
||||
albumId: string | null;
|
||||
allowDownload: boolean;
|
||||
allowUpload: boolean;
|
||||
assets: MapAsset[];
|
||||
assets: ShallowDehydrateObject<MapAsset>[];
|
||||
createdAt: Date;
|
||||
description: string | null;
|
||||
expiresAt: Date | null;
|
||||
@@ -194,8 +194,8 @@ export type SharedLink = {
|
||||
};
|
||||
|
||||
export type Album = Selectable<AlbumTable> & {
|
||||
owner: User;
|
||||
assets: MapAsset[];
|
||||
owner: ShallowDehydrateObject<User>;
|
||||
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
|
||||
};
|
||||
|
||||
export type AuthSession = {
|
||||
@@ -205,9 +205,9 @@ export type AuthSession = {
|
||||
|
||||
export type Partner = {
|
||||
sharedById: string;
|
||||
sharedBy: User;
|
||||
sharedBy: ShallowDehydrateObject<User>;
|
||||
sharedWithId: string;
|
||||
sharedWith: User;
|
||||
sharedWith: ShallowDehydrateObject<User>;
|
||||
createdAt: Date;
|
||||
createId: string;
|
||||
updatedAt: Date;
|
||||
@@ -270,7 +270,7 @@ export type AssetFace = {
|
||||
imageWidth: number;
|
||||
personId: string | null;
|
||||
sourceType: SourceType;
|
||||
person?: Person | null;
|
||||
person?: ShallowDehydrateObject<Person> | null;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
isVisible: boolean;
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import { mapAlbum } from 'src/dtos/album.dto';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { getForAlbum } from 'test/mappers';
|
||||
|
||||
describe('mapAlbum', () => {
|
||||
it('should set start and end dates', () => {
|
||||
const startDate = new Date('2023-02-22T05:06:29.716Z');
|
||||
const endDate = new Date('2025-01-01T01:02:03.456Z');
|
||||
const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build();
|
||||
const dto = mapAlbum(album, false);
|
||||
expect(dto.startDate).toEqual(startDate);
|
||||
expect(dto.endDate).toEqual(endDate);
|
||||
const album = AlbumFactory.from()
|
||||
.asset({ localDateTime: endDate }, (builder) => builder.exif())
|
||||
.asset({ localDateTime: startDate }, (builder) => builder.exif())
|
||||
.build();
|
||||
const dto = mapAlbum(getForAlbum(album), false);
|
||||
expect(dto.startDate).toEqual(startDate.toISOString());
|
||||
expect(dto.endDate).toEqual(endDate.toISOString());
|
||||
});
|
||||
|
||||
it('should not set start and end dates for empty assets', () => {
|
||||
const dto = mapAlbum(AlbumFactory.create(), false);
|
||||
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
|
||||
expect(dto.startDate).toBeUndefined();
|
||||
expect(dto.endDate).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import _ from 'lodash';
|
||||
import { AlbumUser, AuthSharedLink, User } from 'src/database';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class AlbumInfoDto {
|
||||
@@ -151,10 +154,10 @@ export class AlbumResponseDto {
|
||||
albumName!: string;
|
||||
@ApiProperty({ description: 'Album description' })
|
||||
description!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Creation date', format: 'date-time' })
|
||||
createdAt!: string;
|
||||
@ApiProperty({ description: 'Last update date', format: 'date-time' })
|
||||
updatedAt!: string;
|
||||
@ApiProperty({ description: 'Thumbnail asset ID' })
|
||||
albumThumbnailAssetId!: string | null;
|
||||
@ApiProperty({ description: 'Is shared album' })
|
||||
@@ -172,12 +175,12 @@ export class AlbumResponseDto {
|
||||
owner!: UserResponseDto;
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets' })
|
||||
assetCount!: number;
|
||||
@ApiPropertyOptional({ description: 'Last modified asset timestamp' })
|
||||
lastModifiedAssetTimestamp?: Date;
|
||||
@ApiPropertyOptional({ description: 'Start date (earliest asset)' })
|
||||
startDate?: Date;
|
||||
@ApiPropertyOptional({ description: 'End date (latest asset)' })
|
||||
endDate?: Date;
|
||||
@ApiPropertyOptional({ description: 'Last modified asset timestamp', format: 'date-time' })
|
||||
lastModifiedAssetTimestamp?: string;
|
||||
@ApiPropertyOptional({ description: 'Start date (earliest asset)', format: 'date-time' })
|
||||
startDate?: string;
|
||||
@ApiPropertyOptional({ description: 'End date (latest asset)', format: 'date-time' })
|
||||
endDate?: string;
|
||||
@ApiProperty({ description: 'Activity feed enabled' })
|
||||
isActivityEnabled!: boolean;
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||
@@ -191,8 +194,8 @@ export class AlbumResponseDto {
|
||||
|
||||
export type MapAlbumDto = {
|
||||
albumUsers?: AlbumUser[];
|
||||
assets?: MapAsset[];
|
||||
sharedLinks?: AuthSharedLink[];
|
||||
assets?: ShallowDehydrateObject<MapAsset>[];
|
||||
sharedLinks?: ShallowDehydrateObject<AuthSharedLink>[];
|
||||
albumName: string;
|
||||
description: string;
|
||||
albumThumbnailAssetId: string | null;
|
||||
@@ -200,12 +203,16 @@ export type MapAlbumDto = {
|
||||
updatedAt: Date;
|
||||
id: string;
|
||||
ownerId: string;
|
||||
owner: User;
|
||||
owner: ShallowDehydrateObject<User>;
|
||||
isActivityEnabled: boolean;
|
||||
order: AssetOrder;
|
||||
};
|
||||
|
||||
export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDto): AlbumResponseDto => {
|
||||
export const mapAlbum = (
|
||||
entity: MaybeDehydrated<MapAlbumDto>,
|
||||
withAssets: boolean,
|
||||
auth?: AuthDto,
|
||||
): AlbumResponseDto => {
|
||||
const albumUsers: AlbumUserResponseDto[] = [];
|
||||
|
||||
if (entity.albumUsers) {
|
||||
@@ -236,16 +243,16 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
|
||||
albumName: entity.albumName,
|
||||
description: entity.description,
|
||||
albumThumbnailAssetId: entity.albumThumbnailAssetId,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
createdAt: asDateString(entity.createdAt),
|
||||
updatedAt: asDateString(entity.updatedAt),
|
||||
id: entity.id,
|
||||
ownerId: entity.ownerId,
|
||||
owner: mapUser(entity.owner),
|
||||
albumUsers: albumUsersSorted,
|
||||
shared: hasSharedUser || hasSharedLink,
|
||||
hasSharedLink,
|
||||
startDate,
|
||||
endDate,
|
||||
startDate: asDateString(startDate),
|
||||
endDate: asDateString(endDate),
|
||||
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
|
||||
assetCount: entity.assets?.length || 0,
|
||||
isActivityEnabled: entity.isActivityEnabled,
|
||||
@@ -253,5 +260,5 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
|
||||
};
|
||||
};
|
||||
|
||||
export const mapAlbumWithAssets = (entity: MapAlbumDto) => mapAlbum(entity, true);
|
||||
export const mapAlbumWithoutAssets = (entity: MapAlbumDto) => mapAlbum(entity, false);
|
||||
export const mapAlbumWithAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, true);
|
||||
export const mapAlbumWithoutAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { getForAsset } from 'test/mappers';
|
||||
|
||||
describe('mapAsset', () => {
|
||||
describe('peopleWithFaces', () => {
|
||||
@@ -41,7 +42,7 @@ describe('mapAsset', () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
const result = mapAsset(getForAsset(asset));
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
@@ -80,7 +81,7 @@ describe('mapAsset', () => {
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
const result = mapAsset(getForAsset(asset));
|
||||
|
||||
expect(result.unassignedFaces).toBeDefined();
|
||||
expect(result.unassignedFaces).toHaveLength(1);
|
||||
@@ -130,7 +131,7 @@ describe('mapAsset', () => {
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
const result = mapAsset(getForAsset(asset));
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(2);
|
||||
@@ -179,7 +180,7 @@ describe('mapAsset', () => {
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
const result = mapAsset(getForAsset(asset));
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Selectable } from 'kysely';
|
||||
import { Selectable, ShallowDehydrateObject } from 'kysely';
|
||||
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -14,9 +14,10 @@ import {
|
||||
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
||||
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { ImageDimensions } from 'src/types';
|
||||
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
@@ -39,7 +40,7 @@ export class SanitizedAssetResponseDto {
|
||||
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
|
||||
example: '2024-01-15T14:30:00.000Z',
|
||||
})
|
||||
localDateTime!: Date;
|
||||
localDateTime!: string;
|
||||
@ApiProperty({ description: 'Video duration (for videos)' })
|
||||
duration!: string;
|
||||
@ApiPropertyOptional({ description: 'Live photo video ID' })
|
||||
@@ -59,7 +60,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
|
||||
example: '2024-01-15T20:30:00.000Z',
|
||||
})
|
||||
createdAt!: Date;
|
||||
createdAt!: string;
|
||||
@ApiProperty({ description: 'Device asset ID' })
|
||||
deviceAssetId!: string;
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
@@ -86,7 +87,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
|
||||
example: '2024-01-15T19:30:00.000Z',
|
||||
})
|
||||
fileCreatedAt!: Date;
|
||||
fileCreatedAt!: string;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
@@ -94,7 +95,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
|
||||
example: '2024-01-16T10:15:00.000Z',
|
||||
})
|
||||
fileModifiedAt!: Date;
|
||||
fileModifiedAt!: string;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
@@ -102,7 +103,7 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
|
||||
example: '2024-01-16T12:45:30.000Z',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
updatedAt!: string;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
@ApiProperty({ description: 'Is archived' })
|
||||
@@ -151,13 +152,12 @@ export type MapAsset = {
|
||||
deviceId: string;
|
||||
duplicateId: string | null;
|
||||
duration: string | null;
|
||||
edits?: AssetEditActionItem[];
|
||||
encodedVideoPath: string | null;
|
||||
exifInfo?: Selectable<Exif> | null;
|
||||
faces?: AssetFace[];
|
||||
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
|
||||
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
|
||||
faces?: ShallowDehydrateObject<AssetFace>[];
|
||||
fileCreatedAt: Date;
|
||||
fileModifiedAt: Date;
|
||||
files?: AssetFile[];
|
||||
files?: ShallowDehydrateObject<AssetFile>[];
|
||||
isExternal: boolean;
|
||||
isFavorite: boolean;
|
||||
isOffline: boolean;
|
||||
@@ -167,11 +167,11 @@ export type MapAsset = {
|
||||
localDateTime: Date;
|
||||
originalFileName: string;
|
||||
originalPath: string;
|
||||
owner?: User | null;
|
||||
owner?: ShallowDehydrateObject<User> | null;
|
||||
ownerId: string;
|
||||
stack?: Stack | null;
|
||||
stack?: (ShallowDehydrateObject<Stack> & { assets: Stack['assets'] }) | null;
|
||||
stackId: string | null;
|
||||
tags?: Tag[];
|
||||
tags?: ShallowDehydrateObject<Tag>[];
|
||||
thumbhash: Buffer<ArrayBufferLike> | null;
|
||||
type: AssetType;
|
||||
width: number | null;
|
||||
@@ -197,7 +197,7 @@ export type AssetMapOptions = {
|
||||
};
|
||||
|
||||
const peopleWithFaces = (
|
||||
faces?: AssetFace[],
|
||||
faces?: MaybeDehydrated<AssetFace>[],
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): PersonWithFacesResponseDto[] => {
|
||||
@@ -213,7 +213,10 @@ const peopleWithFaces = (
|
||||
}
|
||||
|
||||
if (!peopleFaces.has(face.person.id)) {
|
||||
peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] });
|
||||
peopleFaces.set(face.person.id, {
|
||||
...mapPerson(face.person),
|
||||
faces: [],
|
||||
});
|
||||
}
|
||||
const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions);
|
||||
peopleFaces.get(face.person.id)!.faces.push(mappedFace);
|
||||
@@ -234,7 +237,7 @@ const mapStack = (entity: { stack?: Stack | null }) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto {
|
||||
const { stripMetadata = false, withStack = false } = options;
|
||||
|
||||
if (stripMetadata) {
|
||||
@@ -243,7 +246,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
type: entity.type,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||
localDateTime: entity.localDateTime,
|
||||
localDateTime: asDateString(entity.localDateTime),
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
hasMetadata: false,
|
||||
@@ -257,7 +260,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt,
|
||||
createdAt: asDateString(entity.createdAt),
|
||||
deviceAssetId: entity.deviceAssetId,
|
||||
ownerId: entity.ownerId,
|
||||
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
||||
@@ -268,10 +271,10 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
originalFileName: entity.originalFileName,
|
||||
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
||||
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
||||
fileCreatedAt: entity.fileCreatedAt,
|
||||
fileModifiedAt: entity.fileModifiedAt,
|
||||
localDateTime: entity.localDateTime,
|
||||
updatedAt: entity.updatedAt,
|
||||
fileCreatedAt: asDateString(entity.fileCreatedAt),
|
||||
fileModifiedAt: asDateString(entity.fileModifiedAt),
|
||||
localDateTime: asDateString(entity.localDateTime),
|
||||
updatedAt: asDateString(entity.updatedAt),
|
||||
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
||||
isArchived: entity.visibility === AssetVisibility.Archive,
|
||||
isTrashed: !!entity.deletedAt,
|
||||
@@ -283,7 +286,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
|
||||
unassignedFaces: entity.faces
|
||||
?.filter((face) => !face.person)
|
||||
.map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)),
|
||||
.map((face) => mapFacesWithoutPerson(face, entity.edits, assetDimensions)),
|
||||
checksum: hexOrBufferToBase64(entity.checksum)!,
|
||||
stack: withStack ? mapStack(entity) : undefined,
|
||||
isOffline: entity.isOffline,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
@@ -56,12 +57,19 @@ export class UpdateAssetBase {
|
||||
@IsNotEmpty()
|
||||
longitude?: number;
|
||||
|
||||
@ApiProperty({ description: 'Rating' })
|
||||
@Optional()
|
||||
@Property({
|
||||
description: 'Rating in range [1-5], or null for unrated',
|
||||
history: new HistoryBuilder()
|
||||
.added('v1')
|
||||
.stable('v2')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
|
||||
})
|
||||
@Optional({ nullable: true })
|
||||
@IsInt()
|
||||
@Max(5)
|
||||
@Min(-1)
|
||||
rating?: number;
|
||||
@Transform(({ value }) => (value === 0 ? null : value))
|
||||
rating?: number | null;
|
||||
|
||||
@ApiProperty({ description: 'Asset description' })
|
||||
@Optional()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsLatitude, IsLongitude } from 'class-validator';
|
||||
import { IsGreaterThanOrEqualTo } from 'src/validation';
|
||||
|
||||
export class BBoxDto {
|
||||
@ApiProperty({ format: 'double', description: 'West longitude (-180 to 180)' })
|
||||
@IsLongitude()
|
||||
west!: number;
|
||||
|
||||
@ApiProperty({ format: 'double', description: 'South latitude (-90 to 90)' })
|
||||
@IsLatitude()
|
||||
south!: number;
|
||||
|
||||
@ApiProperty({
|
||||
format: 'double',
|
||||
description: 'East longitude (-180 to 180). May be less than west when crossing the antimeridian.',
|
||||
})
|
||||
@IsLongitude()
|
||||
east!: number;
|
||||
|
||||
@ApiProperty({ format: 'double', description: 'North latitude (-90 to 90). Must be >= south.' })
|
||||
@IsLatitude()
|
||||
@IsGreaterThanOrEqualTo('south')
|
||||
north!: number;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Exif } from 'src/database';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
|
||||
export class ExifResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Camera make' })
|
||||
@@ -16,9 +18,9 @@ export class ExifResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Image orientation' })
|
||||
orientation?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' })
|
||||
dateTimeOriginal?: Date | null = null;
|
||||
dateTimeOriginal?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' })
|
||||
modifyDate?: Date | null = null;
|
||||
modifyDate?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Time zone' })
|
||||
timeZone?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Lens model' })
|
||||
@@ -49,7 +51,7 @@ export class ExifResponseDto {
|
||||
rating?: number | null = null;
|
||||
}
|
||||
|
||||
export function mapExif(entity: Exif): ExifResponseDto {
|
||||
export function mapExif(entity: MaybeDehydrated<Exif>): ExifResponseDto {
|
||||
return {
|
||||
make: entity.make,
|
||||
model: entity.model,
|
||||
@@ -57,8 +59,8 @@ export function mapExif(entity: Exif): ExifResponseDto {
|
||||
exifImageHeight: entity.exifImageHeight,
|
||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: entity.dateTimeOriginal,
|
||||
modifyDate: entity.modifyDate,
|
||||
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
|
||||
modifyDate: asDateString(entity.modifyDate),
|
||||
timeZone: entity.timeZone,
|
||||
lensModel: entity.lensModel,
|
||||
fNumber: entity.fNumber,
|
||||
@@ -80,7 +82,7 @@ export function mapSanitizedExif(entity: Exif): ExifResponseDto {
|
||||
return {
|
||||
fileSizeInByte: entity.fileSizeInByte ? Number.parseInt(entity.fileSizeInByte.toString()) : null,
|
||||
orientation: entity.orientation,
|
||||
dateTimeOriginal: entity.dateTimeOriginal,
|
||||
dateTimeOriginal: asDateString(entity.dateTimeOriginal),
|
||||
timeZone: entity.timeZone,
|
||||
projectionType: entity.projectionType,
|
||||
exifImageWidth: entity.exifImageWidth,
|
||||
|
||||
@@ -9,8 +9,8 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { ImageDimensions } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { ImageDimensions, MaybeDehydrated } from 'src/types';
|
||||
import { asBirthDateString, asDateString } from 'src/utils/date';
|
||||
import { transformFaceBoundingBox } from 'src/utils/transform';
|
||||
import {
|
||||
IsDateStringFormat,
|
||||
@@ -33,7 +33,7 @@ export class PersonCreateDto {
|
||||
@MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
|
||||
@IsDateStringFormat('yyyy-MM-dd')
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
birthDate?: Date | null;
|
||||
birthDate?: string | null;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Person visibility (hidden)' })
|
||||
isHidden?: boolean;
|
||||
@@ -105,8 +105,12 @@ export class PersonResponseDto {
|
||||
thumbnailPath!: string;
|
||||
@ApiProperty({ description: 'Is hidden' })
|
||||
isHidden!: boolean;
|
||||
@Property({ description: 'Last update date', history: new HistoryBuilder().added('v1.107.0').stable('v2') })
|
||||
updatedAt?: Date;
|
||||
@Property({
|
||||
description: 'Last update date',
|
||||
format: 'date-time',
|
||||
history: new HistoryBuilder().added('v1.107.0').stable('v2'),
|
||||
})
|
||||
updatedAt?: string;
|
||||
@Property({ description: 'Is favorite', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||
isFavorite?: boolean;
|
||||
@Property({ description: 'Person color (hex)', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||
@@ -222,21 +226,21 @@ export class PeopleResponseDto {
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
export function mapPerson(person: Person): PersonResponseDto {
|
||||
export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
|
||||
return {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
birthDate: asDateString(person.birthDate),
|
||||
birthDate: asBirthDateString(person.birthDate),
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
color: person.color ?? undefined,
|
||||
updatedAt: person.updatedAt,
|
||||
updatedAt: asDateString(person.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFacesWithoutPerson(
|
||||
face: Selectable<AssetFaceTable>,
|
||||
face: MaybeDehydrated<Selectable<AssetFaceTable>>,
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): AssetFaceWithoutPersonResponseDto {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsNotEmpty, IsString, Max, Min } from 'class-validator';
|
||||
import { Place } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
|
||||
@@ -103,12 +103,21 @@ class BaseSearchDto {
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
|
||||
albumIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Filter by rating', minimum: -1, maximum: 5 })
|
||||
@Optional()
|
||||
@Property({
|
||||
type: 'number',
|
||||
description: 'Filter by rating [1-5], or null for unrated',
|
||||
minimum: -1,
|
||||
maximum: 5,
|
||||
history: new HistoryBuilder()
|
||||
.added('v1')
|
||||
.stable('v2')
|
||||
.updated('v2.6.0', 'Using -1 as a rating is deprecated and will be removed in the next major version.'),
|
||||
})
|
||||
@Optional({ nullable: true })
|
||||
@IsInt()
|
||||
@Max(5)
|
||||
@Min(-1)
|
||||
rating?: number;
|
||||
rating?: number | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by OCR text content' })
|
||||
@IsString()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsInt,
|
||||
@@ -92,6 +92,16 @@ export class SystemConfigFFmpegDto {
|
||||
targetAudioCodec!: AudioCodec;
|
||||
|
||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
|
||||
@Transform(({ value }) => {
|
||||
if (Array.isArray(value)) {
|
||||
const libopusIndex = value.indexOf('libopus');
|
||||
if (libopusIndex !== -1) {
|
||||
value[libopusIndex] = 'opus';
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
})
|
||||
acceptedAudioCodecs!: AudioCodec[];
|
||||
|
||||
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Tag } from 'src/database';
|
||||
import { MaybeDehydrated } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TagCreateDto {
|
||||
@@ -54,22 +56,22 @@ export class TagResponseDto {
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Tag value (full path)' })
|
||||
value!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Creation date', format: 'date-time' })
|
||||
createdAt!: string;
|
||||
@ApiProperty({ description: 'Last update date', format: 'date-time' })
|
||||
updatedAt!: string;
|
||||
@ApiPropertyOptional({ description: 'Tag color (hex)' })
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function mapTag(entity: Tag): TagResponseDto {
|
||||
export function mapTag(entity: MaybeDehydrated<Tag>): TagResponseDto {
|
||||
return {
|
||||
id: entity.id,
|
||||
parentId: entity.parentId ?? undefined,
|
||||
name: entity.value.split('/').at(-1) as string,
|
||||
value: entity.value,
|
||||
createdAt: entity.createdAt,
|
||||
updatedAt: entity.updatedAt,
|
||||
createdAt: asDateString(entity.createdAt),
|
||||
updatedAt: asDateString(entity.updatedAt),
|
||||
color: entity.color ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { IsString } from 'class-validator';
|
||||
import type { BBoxDto } from 'src/dtos/bbox.dto';
|
||||
import { AssetOrder, AssetVisibility } from 'src/enum';
|
||||
import { ValidateBBox } from 'src/utils/bbox';
|
||||
import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TimeBucketDto {
|
||||
@@ -59,6 +60,9 @@ export class TimeBucketDto {
|
||||
description: 'Include location data in the response',
|
||||
})
|
||||
withCoordinates?: boolean;
|
||||
|
||||
@ValidateBBox({ optional: true })
|
||||
bbox?: BBoxDto;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||
import { User, UserAdmin } from 'src/database';
|
||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { MaybeDehydrated, UserMetadataItem } from 'src/types';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
|
||||
|
||||
export class UserUpdateMeDto {
|
||||
@@ -47,8 +48,8 @@ export class UserResponseDto {
|
||||
profileImagePath!: string;
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'Avatar color' })
|
||||
avatarColor!: UserAvatarColor;
|
||||
@ApiProperty({ description: 'Profile change date' })
|
||||
profileChangedAt!: Date;
|
||||
@ApiProperty({ description: 'Profile change date', format: 'date-time' })
|
||||
profileChangedAt!: string;
|
||||
}
|
||||
|
||||
export class UserLicense {
|
||||
@@ -68,14 +69,14 @@ const emailToAvatarColor = (email: string): UserAvatarColor => {
|
||||
return values[randomIndex];
|
||||
};
|
||||
|
||||
export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
|
||||
export const mapUser = (entity: MaybeDehydrated<User | UserAdmin>): UserResponseDto => {
|
||||
return {
|
||||
id: entity.id,
|
||||
email: entity.email,
|
||||
name: entity.name,
|
||||
profileImagePath: entity.profileImagePath,
|
||||
avatarColor: entity.avatarColor ?? emailToAvatarColor(entity.email),
|
||||
profileChangedAt: entity.profileChangedAt,
|
||||
profileChangedAt: asDateString(entity.profileChangedAt),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+4
-1
@@ -45,6 +45,7 @@ export enum AssetFileType {
|
||||
Preview = 'preview',
|
||||
Thumbnail = 'thumbnail',
|
||||
Sidecar = 'sidecar',
|
||||
EncodedVideo = 'encoded_video',
|
||||
}
|
||||
|
||||
export enum AlbumUserRole {
|
||||
@@ -409,7 +410,9 @@ export enum VideoCodec {
|
||||
export enum AudioCodec {
|
||||
Mp3 = 'mp3',
|
||||
Aac = 'aac',
|
||||
LibOpus = 'libopus',
|
||||
/** @deprecated Use `Opus` instead */
|
||||
Libopus = 'libopus',
|
||||
Opus = 'opus',
|
||||
PcmS16le = 'pcm_s16le',
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,6 @@ where
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."ownerId",
|
||||
"asset"."encodedVideoPath",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -463,7 +462,6 @@ select
|
||||
"asset"."libraryId",
|
||||
"asset"."ownerId",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath",
|
||||
"asset"."isOffline",
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
@@ -521,12 +519,17 @@ select
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."type" = $1
|
||||
and (
|
||||
"asset"."encodedVideoPath" is null
|
||||
or "asset"."encodedVideoPath" = $2
|
||||
"asset"."type" = 'VIDEO'
|
||||
and not exists (
|
||||
select
|
||||
"asset_file"."id"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = 'encoded_video'
|
||||
)
|
||||
and "asset"."visibility" != $3
|
||||
and "asset"."visibility" != 'hidden'
|
||||
and "asset"."deletedAt" is null
|
||||
|
||||
-- AssetJobRepository.getForVideoConversion
|
||||
@@ -534,12 +537,27 @@ select
|
||||
"asset"."id",
|
||||
"asset"."ownerId",
|
||||
"asset"."originalPath",
|
||||
"asset"."encodedVideoPath"
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "files"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
and "asset"."type" = 'VIDEO'
|
||||
|
||||
-- AssetJobRepository.streamForMetadataExtraction
|
||||
select
|
||||
@@ -562,6 +580,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."originalPath",
|
||||
"asset"."isExternal",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."fileCreatedAt",
|
||||
@@ -593,6 +612,7 @@ from
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."id" = $2
|
||||
and "asset"."visibility" != $3
|
||||
|
||||
-- AssetJobRepository.streamForStorageTemplateJob
|
||||
select
|
||||
@@ -602,6 +622,7 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."originalPath",
|
||||
"asset"."isExternal",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."fileCreatedAt",
|
||||
@@ -632,6 +653,7 @@ from
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" != $2
|
||||
|
||||
-- AssetJobRepository.streamForDeletedJob
|
||||
select
|
||||
|
||||
@@ -123,13 +123,13 @@ with
|
||||
) as "year"
|
||||
)
|
||||
select
|
||||
"a".*,
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
"a".*
|
||||
from
|
||||
"today"
|
||||
inner join lateral (
|
||||
select
|
||||
"asset".*
|
||||
"asset"."id",
|
||||
"asset"."localDateTime"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
|
||||
@@ -151,7 +151,6 @@ with
|
||||
limit
|
||||
$7
|
||||
) as "a" on true
|
||||
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
|
||||
)
|
||||
select
|
||||
date_part(
|
||||
@@ -439,6 +438,7 @@ with
|
||||
and "stack"."primaryAssetId" != "asset"."id"
|
||||
)
|
||||
order by
|
||||
(asset."localDateTime" AT TIME ZONE 'UTC')::date desc,
|
||||
"asset"."fileCreatedAt" desc
|
||||
),
|
||||
"agg" as (
|
||||
@@ -629,13 +629,21 @@ order by
|
||||
|
||||
-- AssetRepository.getForVideo
|
||||
select
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath"
|
||||
"asset"."originalPath",
|
||||
(
|
||||
select
|
||||
"asset_file"."path"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
) as "encodedVideoPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
"asset"."id" = $2
|
||||
and "asset"."type" = $3
|
||||
|
||||
-- AssetRepository.getForOcr
|
||||
select
|
||||
|
||||
@@ -102,22 +102,30 @@ order by
|
||||
"shared_link"."createdAt" desc
|
||||
|
||||
-- SharedLinkRepository.getAll
|
||||
select distinct
|
||||
on ("shared_link"."createdAt") "shared_link".*,
|
||||
"assets"."assets",
|
||||
select
|
||||
"shared_link".*,
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"shared_link_asset"
|
||||
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
|
||||
where
|
||||
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
|
||||
and "asset"."deletedAt" is null
|
||||
order by
|
||||
"asset"."fileCreatedAt" asc
|
||||
limit
|
||||
$1
|
||||
) as agg
|
||||
) as "assets",
|
||||
to_json("album") as "album"
|
||||
from
|
||||
"shared_link"
|
||||
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
|
||||
left join lateral (
|
||||
select
|
||||
json_agg("asset") as "assets"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = "shared_link_asset"."assetId"
|
||||
and "asset"."deletedAt" is null
|
||||
) as "assets" on true
|
||||
left join lateral (
|
||||
select
|
||||
"album".*,
|
||||
@@ -152,12 +160,12 @@ from
|
||||
and "album"."deletedAt" is null
|
||||
) as "album" on true
|
||||
where
|
||||
"shared_link"."userId" = $1
|
||||
"shared_link"."userId" = $2
|
||||
and (
|
||||
"shared_link"."type" = $2
|
||||
"shared_link"."type" = $3
|
||||
or "album"."id" is not null
|
||||
)
|
||||
and "shared_link"."albumId" = $3
|
||||
and "shared_link"."albumId" = $4
|
||||
order by
|
||||
"shared_link"."createdAt" desc
|
||||
|
||||
@@ -236,3 +244,37 @@ where
|
||||
or "album"."id" is not null
|
||||
)
|
||||
and "shared_link"."slug" = $2
|
||||
|
||||
-- SharedLinkRepository.getSharedLinks
|
||||
select
|
||||
"shared_link".*,
|
||||
coalesce(
|
||||
json_agg("assets") filter (
|
||||
where
|
||||
"assets"."id" is not null
|
||||
),
|
||||
'[]'
|
||||
) as "assets"
|
||||
from
|
||||
"shared_link"
|
||||
left join "shared_link_asset" on "shared_link_asset"."sharedLinkId" = "shared_link"."id"
|
||||
left join lateral (
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
inner join lateral (
|
||||
select
|
||||
*
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
"asset_exif"."assetId" = "asset"."id"
|
||||
) as "exifInfo" on true
|
||||
where
|
||||
"asset"."id" = "shared_link_asset"."assetId"
|
||||
) as "assets" on true
|
||||
where
|
||||
"shared_link"."id" = $1
|
||||
group by
|
||||
"shared_link"."id"
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
||||
import {
|
||||
ExpressionBuilder,
|
||||
Insertable,
|
||||
Kysely,
|
||||
NotNull,
|
||||
Selectable,
|
||||
ShallowDehydrateObject,
|
||||
sql,
|
||||
Updateable,
|
||||
} from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns, Exif } from 'src/database';
|
||||
import { columns } from 'src/database';
|
||||
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
|
||||
import { DB } from 'src/schema';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { withDefaultVisibility } from 'src/utils/database';
|
||||
|
||||
export interface AlbumAssetCount {
|
||||
@@ -56,7 +66,9 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) => eb.table('asset_exif').$castTo<Exif>().as('exifInfo'))
|
||||
.select((eb) =>
|
||||
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
|
||||
)
|
||||
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
|
||||
.whereRef('album_asset.albumId', '=', 'album.id')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
|
||||
@@ -9,7 +9,6 @@ import { DB } from 'src/schema';
|
||||
import {
|
||||
anyUuid,
|
||||
asUuid,
|
||||
toJson,
|
||||
withDefaultVisibility,
|
||||
withEdits,
|
||||
withExif,
|
||||
@@ -105,7 +104,7 @@ export class AssetJobRepository {
|
||||
getForMigrationJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.encodedVideoPath'])
|
||||
.select(['asset.id', 'asset.ownerId'])
|
||||
.select(withFiles)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
@@ -269,7 +268,6 @@ export class AssetJobRepository {
|
||||
'asset.libraryId',
|
||||
'asset.ownerId',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.encodedVideoPath',
|
||||
'asset.originalPath',
|
||||
'asset.isOffline',
|
||||
])
|
||||
@@ -296,7 +294,12 @@ export class AssetJobRepository {
|
||||
.as('stack_result'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select((eb) => toJson(eb, 'stack_result').as('stack'))
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.toJson(eb.table('stack_result'))
|
||||
.$castTo<{ id: string; primaryAssetId: string; assets: { id: string }[] } | null>()
|
||||
.as('stack'),
|
||||
)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -306,11 +309,21 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id'])
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.$if(!force, (qb) =>
|
||||
qb
|
||||
.where((eb) => eb.or([eb('asset.encodedVideoPath', 'is', null), eb('asset.encodedVideoPath', '=', '')]))
|
||||
.where('asset.visibility', '!=', AssetVisibility.Hidden),
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select('asset_file.id')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', sql.lit(AssetFileType.EncodedVideo)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where('asset.visibility', '!=', sql.lit(AssetVisibility.Hidden)),
|
||||
)
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.stream();
|
||||
@@ -320,9 +333,10 @@ export class AssetJobRepository {
|
||||
getForVideoConversion(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath', 'asset.encodedVideoPath'])
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
|
||||
.select(withFiles)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@@ -353,6 +367,7 @@ export class AssetJobRepository {
|
||||
'asset.checksum',
|
||||
'asset.originalPath',
|
||||
'asset.isExternal',
|
||||
'asset.visibility',
|
||||
'asset.originalFileName',
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.fileCreatedAt',
|
||||
@@ -367,13 +382,16 @@ export class AssetJobRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForStorageTemplateJob(id: string) {
|
||||
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
|
||||
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) {
|
||||
return this.storageTemplateAssetQuery()
|
||||
.where('asset.id', '=', id)
|
||||
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamForStorageTemplateJob() {
|
||||
return this.storageTemplateAssetQuery().stream();
|
||||
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
import {
|
||||
ExpressionBuilder,
|
||||
Insertable,
|
||||
Kysely,
|
||||
NotNull,
|
||||
Selectable,
|
||||
SelectQueryBuilder,
|
||||
ShallowDehydrateObject,
|
||||
sql,
|
||||
Updateable,
|
||||
UpdateResult,
|
||||
} from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -25,6 +36,7 @@ import {
|
||||
withExif,
|
||||
withFaces,
|
||||
withFacesAndPeople,
|
||||
withFilePath,
|
||||
withFiles,
|
||||
withLibrary,
|
||||
withOwner,
|
||||
@@ -36,6 +48,13 @@ import { globToSqlPattern } from 'src/utils/misc';
|
||||
|
||||
export type AssetStats = Record<AssetType, number>;
|
||||
|
||||
export interface BoundingBox {
|
||||
west: number;
|
||||
south: number;
|
||||
east: number;
|
||||
north: number;
|
||||
}
|
||||
|
||||
interface AssetStatsOptions {
|
||||
isFavorite?: boolean;
|
||||
isTrashed?: boolean;
|
||||
@@ -64,6 +83,7 @@ interface AssetBuilderOptions {
|
||||
assetType?: AssetType;
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
bbox?: BoundingBox;
|
||||
}
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
@@ -120,6 +140,34 @@ interface GetByIdsRelations {
|
||||
const distinctLocked = <T extends LockableProperty[] | null>(eb: ExpressionBuilder<DB, 'asset_exif'>, columns: T) =>
|
||||
sql<T>`nullif(array(select distinct unnest(${eb.ref('asset_exif.lockedProperties')} || ${columns})), '{}')`;
|
||||
|
||||
const getBoundingCircle = (bbox: BoundingBox) => {
|
||||
const { west, south, east, north } = bbox;
|
||||
const eastUnwrapped = west <= east ? east : east + 360;
|
||||
const centerLongitude = (((west + eastUnwrapped) / 2 + 540) % 360) - 180;
|
||||
const centerLatitude = (south + north) / 2;
|
||||
const radius = sql<number>`greatest(
|
||||
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${west})),
|
||||
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${south}, ${east})),
|
||||
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${west})),
|
||||
earth_distance(ll_to_earth_public(${centerLatitude}, ${centerLongitude}), ll_to_earth_public(${north}, ${east}))
|
||||
)`;
|
||||
|
||||
return { centerLatitude, centerLongitude, radius };
|
||||
};
|
||||
|
||||
const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T>, bbox: BoundingBox) => {
|
||||
const { west, south, east, north } = bbox;
|
||||
const withLatitude = qb.where('asset_exif.latitude', '>=', south).where('asset_exif.latitude', '<=', north);
|
||||
|
||||
if (west <= east) {
|
||||
return withLatitude.where('asset_exif.longitude', '>=', west).where('asset_exif.longitude', '<=', east);
|
||||
}
|
||||
|
||||
return withLatitude.where((eb) =>
|
||||
eb.or([eb('asset_exif.longitude', '>=', west), eb('asset_exif.longitude', '<=', east)]),
|
||||
);
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AssetRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
@@ -358,7 +406,7 @@ export class AssetRepository {
|
||||
(qb) =>
|
||||
qb
|
||||
.selectFrom('asset')
|
||||
.selectAll('asset')
|
||||
.select(['asset.id', 'asset.localDateTime'])
|
||||
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
|
||||
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
|
||||
.where('asset.ownerId', '=', anyUuid(ownerIds))
|
||||
@@ -377,9 +425,7 @@ export class AssetRepository {
|
||||
.as('a'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.innerJoin('asset_exif', 'a.id', 'asset_exif.assetId')
|
||||
.selectAll('a')
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')),
|
||||
.selectAll('a'),
|
||||
)
|
||||
.selectFrom('res')
|
||||
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
|
||||
@@ -510,7 +556,11 @@ export class AssetRepository {
|
||||
eb
|
||||
.selectFrom('asset as stacked')
|
||||
.selectAll('stack')
|
||||
.select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets'))
|
||||
.select((eb) =>
|
||||
eb
|
||||
.fn<ShallowDehydrateObject<Selectable<AssetTable>>>('array_agg', [eb.table('stacked')])
|
||||
.as('assets'),
|
||||
)
|
||||
.whereRef('stacked.stackId', '=', 'stack.id')
|
||||
.whereRef('stacked.id', '!=', 'stack.primaryAssetId')
|
||||
.where('stacked.deletedAt', 'is', null)
|
||||
@@ -519,7 +569,7 @@ export class AssetRepository {
|
||||
.as('stacked_assets'),
|
||||
(join) => join.on('stack.id', 'is not', null),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack')),
|
||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack')),
|
||||
),
|
||||
)
|
||||
.$if(!!files, (qb) => qb.select(withFiles))
|
||||
@@ -651,6 +701,20 @@ export class AssetRepository {
|
||||
.select(truncatedDate<Date>().as('timeBucket'))
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(!!options.bbox, (qb) => {
|
||||
const bbox = options.bbox!;
|
||||
const circle = getBoundingCircle(bbox);
|
||||
|
||||
const withBoundingCircle = qb
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.where(
|
||||
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
|
||||
'@>',
|
||||
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
|
||||
);
|
||||
|
||||
return withBoundingBox(withBoundingCircle, bbox);
|
||||
})
|
||||
.$if(options.visibility === undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
|
||||
.$if(!!options.albumId, (qb) =>
|
||||
@@ -686,6 +750,7 @@ export class AssetRepository {
|
||||
params: [DummyValue.TIME_BUCKET, { withStacked: true }, { user: { id: DummyValue.UUID } }],
|
||||
})
|
||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
|
||||
const order = options.order ?? 'desc';
|
||||
const query = this.db
|
||||
.with('cte', (qb) =>
|
||||
qb
|
||||
@@ -725,6 +790,18 @@ export class AssetRepository {
|
||||
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
|
||||
.$if(!!options.bbox, (qb) => {
|
||||
const bbox = options.bbox!;
|
||||
const circle = getBoundingCircle(bbox);
|
||||
|
||||
const withBoundingCircle = qb.where(
|
||||
sql`earth_box(ll_to_earth_public(${circle.centerLatitude}, ${circle.centerLongitude}), ${circle.radius})`,
|
||||
'@>',
|
||||
sql`ll_to_earth_public(asset_exif.latitude, asset_exif.longitude)`,
|
||||
);
|
||||
|
||||
return withBoundingBox(withBoundingCircle, bbox);
|
||||
})
|
||||
.where(truncatedDate(), '=', timeBucket.replace(/^[+-]/, ''))
|
||||
.$if(!!options.albumId, (qb) =>
|
||||
qb.where((eb) =>
|
||||
@@ -771,7 +848,8 @@ export class AssetRepository {
|
||||
)
|
||||
.$if(!!options.isTrashed, (qb) => qb.where('asset.status', '!=', AssetStatus.Deleted))
|
||||
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
|
||||
.orderBy('asset.fileCreatedAt', options.order ?? 'desc'),
|
||||
.orderBy(sql`(asset."localDateTime" AT TIME ZONE 'UTC')::date`, order)
|
||||
.orderBy('asset.fileCreatedAt', order),
|
||||
)
|
||||
.with('agg', (qb) =>
|
||||
qb
|
||||
@@ -942,8 +1020,21 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteFile({ assetId, type }: { assetId: string; type: AssetFileType }): Promise<void> {
|
||||
await this.db.deleteFrom('asset_file').where('assetId', '=', asUuid(assetId)).where('type', '=', type).execute();
|
||||
async deleteFile({
|
||||
assetId,
|
||||
type,
|
||||
edited,
|
||||
}: {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
edited?: boolean;
|
||||
}): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('asset_file')
|
||||
.where('assetId', '=', asUuid(assetId))
|
||||
.where('type', '=', type)
|
||||
.$if(edited !== undefined, (qb) => qb.where('isEdited', '=', edited!))
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteFiles(files: Pick<Selectable<AssetFileTable>, 'id'>[]): Promise<void> {
|
||||
@@ -1062,7 +1153,8 @@ export class AssetRepository {
|
||||
async getForVideo(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.encodedVideoPath', 'asset.originalPath'])
|
||||
.select(['asset.originalPath'])
|
||||
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -22,6 +22,7 @@ import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import 'src/schema'; // make sure all schema definitions are imported for schemaFromCode
|
||||
import { DB } from 'src/schema';
|
||||
import { immich_uuid_v7 } from 'src/schema/functions';
|
||||
import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types';
|
||||
import { vectorIndexQuery } from 'src/utils/database';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
@@ -288,7 +289,11 @@ export class DatabaseRepository {
|
||||
}
|
||||
|
||||
async getSchemaDrift() {
|
||||
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
|
||||
const source = schemaFromCode({
|
||||
overrides: true,
|
||||
namingStrategy: 'default',
|
||||
uuidFunction: (version) => (version === 7 ? `${immich_uuid_v7.name}()` : 'uuid_generate_v4()'),
|
||||
});
|
||||
const { database } = this.configRepository.getEnv();
|
||||
const target = await schemaFromDatabase({ connection: database.config });
|
||||
|
||||
@@ -426,7 +431,6 @@ export class DatabaseRepository {
|
||||
.updateTable('asset')
|
||||
.set((eb) => ({
|
||||
originalPath: eb.fn('REGEXP_REPLACE', ['originalPath', source, target]),
|
||||
encodedVideoPath: eb.fn('REGEXP_REPLACE', ['encodedVideoPath', source, target]),
|
||||
}))
|
||||
.execute();
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, NotNull, sql } from 'kysely';
|
||||
import { Kysely, NotNull, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, VectorIndex } from 'src/enum';
|
||||
import { probes } from 'src/repositories/database.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { anyUuid, asUuid, withDefaultVisibility } from 'src/utils/database';
|
||||
|
||||
interface DuplicateSearch {
|
||||
@@ -39,15 +39,15 @@ export class DuplicateRepository {
|
||||
qb
|
||||
.selectFrom('asset_exif')
|
||||
.selectAll('asset')
|
||||
.select((eb) => eb.table('asset_exif').as('exifInfo'))
|
||||
.select((eb) =>
|
||||
eb.table('asset_exif').$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>().as('exifInfo'),
|
||||
)
|
||||
.whereRef('asset_exif.assetId', '=', 'asset.id')
|
||||
.as('asset2'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select('asset.duplicateId')
|
||||
.select((eb) =>
|
||||
eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'),
|
||||
)
|
||||
.select((eb) => eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').as('assets'))
|
||||
.where('asset.ownerId', '=', asUuid(userId))
|
||||
.where('asset.duplicateId', 'is not', null)
|
||||
.$narrowType<{ duplicateId: NotNull }>()
|
||||
|
||||
@@ -162,6 +162,7 @@ export class EmailRepository {
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
tls: { rejectUnauthorized: !options.ignoreCert },
|
||||
secure: options.secure,
|
||||
auth:
|
||||
options.username || options.password
|
||||
? {
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MediaRepository {
|
||||
ExposureTime: tags.exposureTime,
|
||||
ProfileDescription: tags.profileDescription,
|
||||
ColorSpace: tags.colorspace,
|
||||
Rating: tags.rating,
|
||||
Rating: tags.rating === null ? 0 : tags.rating,
|
||||
// specially convert Orientation to numeric Orientation# for exiftool
|
||||
'Orientation#': tags.orientation ? Number(tags.orientation) : undefined,
|
||||
};
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||
|
||||
AndroidMake?: string;
|
||||
AndroidModel?: string;
|
||||
DeviceManufacturer?: string;
|
||||
DeviceModelName?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -70,7 +70,16 @@ export class OAuthRepository {
|
||||
|
||||
try {
|
||||
const tokens = await authorizationCodeGrant(client, new URL(url), { expectedState, pkceCodeVerifier });
|
||||
const profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
||||
|
||||
let profile: OAuthProfile;
|
||||
const tokenClaims = tokens.claims();
|
||||
if (tokenClaims && 'email' in tokenClaims) {
|
||||
this.logger.debug('Using ID token claims instead of userinfo endpoint');
|
||||
profile = tokenClaims as OAuthProfile;
|
||||
} else {
|
||||
profile = await fetchUserInfo(client, tokens.access_token, oidc.skipSubjectCheck);
|
||||
}
|
||||
|
||||
if (!profile.sub) {
|
||||
throw new Error('Unexpected profile response, no `sub`');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely, OrderByDirection, Selectable, sql } from 'kysely';
|
||||
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
@@ -433,7 +433,7 @@ export class SearchRepository {
|
||||
.select((eb) =>
|
||||
eb
|
||||
.fn('to_jsonb', [eb.table('asset_exif')])
|
||||
.$castTo<Selectable<AssetExifTable>>()
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
|
||||
.as('exifInfo'),
|
||||
)
|
||||
.orderBy('asset_exif.city')
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
|
||||
import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Album, columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
|
||||
export type SharedLinkSearchOptions = {
|
||||
@@ -106,11 +107,15 @@ export class SharedLinkRepository {
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
|
||||
.$castTo<MapAsset[]>()
|
||||
.$castTo<
|
||||
(ShallowDehydrateObject<Selectable<AssetTable>> & {
|
||||
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
|
||||
})[]
|
||||
>()
|
||||
.as('assets'),
|
||||
)
|
||||
.groupBy(['shared_link.id', sql`"album".*`])
|
||||
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
|
||||
.select((eb) => eb.fn.toJson(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
|
||||
.where('shared_link.id', '=', id)
|
||||
.where('shared_link.userId', '=', userId)
|
||||
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
||||
@@ -124,19 +129,18 @@ export class SharedLinkRepository {
|
||||
.selectFrom('shared_link')
|
||||
.selectAll('shared_link')
|
||||
.where('shared_link.userId', '=', userId)
|
||||
.leftJoin('shared_link_asset', 'shared_link_asset.sharedLinkId', 'shared_link.id')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset')
|
||||
.select((eb) => eb.fn.jsonAgg('asset').as('assets'))
|
||||
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
|
||||
.selectFrom('shared_link_asset')
|
||||
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
|
||||
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.as('assets'),
|
||||
(join) => join.onTrue(),
|
||||
.selectAll('asset')
|
||||
.orderBy('asset.fileCreatedAt', 'asc')
|
||||
.limit(1),
|
||||
).as('assets'),
|
||||
)
|
||||
.select('assets.assets')
|
||||
.$narrowType<{ assets: NotNull }>()
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
eb
|
||||
@@ -174,12 +178,11 @@ export class SharedLinkRepository {
|
||||
.as('album'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
|
||||
.select((eb) => eb.fn.toJson('album').$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
|
||||
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
|
||||
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
|
||||
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
|
||||
.orderBy('shared_link.createdAt', 'desc')
|
||||
.distinctOn(['shared_link.createdAt'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -246,6 +249,21 @@ export class SharedLinkRepository {
|
||||
await this.db.deleteFrom('shared_link').where('shared_link.id', '=', id).execute();
|
||||
}
|
||||
|
||||
@ChunkedArray({ paramIndex: 1 })
|
||||
async addAssets(id: string, assetIds: string[]) {
|
||||
if (assetIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await this.db
|
||||
.insertInto('shared_link_asset')
|
||||
.values(assetIds.map((assetId) => ({ assetId, sharedLinkId: id })))
|
||||
.onConflict((oc) => oc.doNothing())
|
||||
.returning(['shared_link_asset.assetId'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
private getSharedLinks(id: string) {
|
||||
return this.db
|
||||
.selectFrom('shared_link')
|
||||
@@ -269,7 +287,11 @@ export class SharedLinkRepository {
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`)
|
||||
.$castTo<MapAsset[]>()
|
||||
.$castTo<
|
||||
(ShallowDehydrateObject<Selectable<AssetTable>> & {
|
||||
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
|
||||
})[]
|
||||
>()
|
||||
.as('assets'),
|
||||
)
|
||||
.groupBy('shared_link.id')
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`UPDATE "asset_exif" SET "rating" = NULL WHERE "rating" = 0;`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// not supported
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE INDEX "IDX_asset_exif_gist_earthcoord" ON "asset_exif" USING gist (ll_to_earth_public(latitude, longitude));`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_IDX_asset_exif_gist_earthcoord', '{"type":"index","name":"IDX_asset_exif_gist_earthcoord","sql":"CREATE INDEX \\"IDX_asset_exif_gist_earthcoord\\" ON \\"asset_exif\\" USING gist (ll_to_earth_public(latitude, longitude));"}'::jsonb);`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP INDEX "IDX_asset_exif_gist_earthcoord";`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_IDX_asset_exif_gist_earthcoord';`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION asset_edit_delete()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE asset
|
||||
SET "isEdited" = false
|
||||
FROM deleted_edit
|
||||
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
|
||||
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"asset_edit_delete","sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\"\\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION public.asset_edit_delete()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
UPDATE asset
|
||||
SET "isEdited" = false
|
||||
FROM deleted_edit
|
||||
WHERE asset.id = deleted_edit."assetId" AND asset."isEdited"
|
||||
AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit."assetId" = asset.id);
|
||||
RETURN NULL;
|
||||
END
|
||||
$function$
|
||||
`.execute(db);
|
||||
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION asset_edit_delete()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE asset\\n SET \\"isEdited\\" = false\\n FROM deleted_edit\\n WHERE asset.id = deleted_edit.\\"assetId\\" AND asset.\\"isEdited\\" \\n AND NOT EXISTS (SELECT FROM asset_edit edit WHERE edit.\\"assetId\\" = asset.id);\\n RETURN NULL;\\n END\\n $$;","name":"asset_edit_delete","type":"function"}'::jsonb WHERE "name" = 'function_asset_edit_delete';`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,acceptedAudioCodecs}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem = 'libopus' THEN 'opus'
|
||||
ELSE elem
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
||||
)
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'libopus';
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,targetAudioCodec}',
|
||||
'"opus"'::jsonb
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->>'targetAudioCodec' = 'libopus';
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,acceptedAudioCodecs}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE
|
||||
WHEN elem = 'opus' THEN 'libopus'
|
||||
ELSE elem
|
||||
END
|
||||
)
|
||||
FROM jsonb_array_elements_text(value->'ffmpeg'->'acceptedAudioCodecs') elem
|
||||
)
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->'acceptedAudioCodecs' ? 'opus';
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
UPDATE system_metadata
|
||||
SET value = jsonb_set(
|
||||
value,
|
||||
'{ffmpeg,targetAudioCodec}',
|
||||
'"libopus"'::jsonb
|
||||
)
|
||||
WHERE key = 'system-config'
|
||||
AND value->'ffmpeg'->>'targetAudioCodec' = 'opus';
|
||||
`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`
|
||||
INSERT INTO "asset_file" ("assetId", "type", "path")
|
||||
SELECT "id", 'encoded_video', "encodedVideoPath"
|
||||
FROM "asset"
|
||||
WHERE "encodedVideoPath" IS NOT NULL AND "encodedVideoPath" != '';
|
||||
`.execute(db);
|
||||
|
||||
await sql`ALTER TABLE "asset" DROP COLUMN "encodedVideoPath";`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`ALTER TABLE "asset" ADD "encodedVideoPath" character varying DEFAULT '';`.execute(db);
|
||||
|
||||
await sql`
|
||||
UPDATE "asset"
|
||||
SET "encodedVideoPath" = af."path"
|
||||
FROM "asset_file" af
|
||||
WHERE "asset"."id" = af."assetId"
|
||||
AND af."type" = 'encoded_video'
|
||||
AND af."isEdited" = false;
|
||||
`.execute(db);
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import { Column, ForeignKeyColumn, Generated, Int8, Table, Timestamp, UpdateDateColumn } from '@immich/sql-tools';
|
||||
import {
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
Int8,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from '@immich/sql-tools';
|
||||
import { LockableProperty } from 'src/database';
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@Table('asset_exif')
|
||||
@Index({
|
||||
name: 'IDX_asset_exif_gist_earthcoord',
|
||||
using: 'gist',
|
||||
expression: 'll_to_earth_public(latitude, longitude)',
|
||||
})
|
||||
@UpdatedAtTrigger('asset_exif_updatedAt')
|
||||
export class AssetExifTable {
|
||||
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
|
||||
|
||||
@@ -92,9 +92,6 @@ export class AssetTable {
|
||||
@Column({ type: 'character varying', nullable: true })
|
||||
duration!: string | null;
|
||||
|
||||
@Column({ type: 'character varying', nullable: true, default: '' })
|
||||
encodedVideoPath!: string | null;
|
||||
|
||||
@Column({ type: 'bytea', index: true })
|
||||
checksum!: Buffer; // sha1 checksum
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ReactionType } from 'src/dtos/activity.dto';
|
||||
import { ActivityService } from 'src/services/activity.service';
|
||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
||||
import { ActivityFactory } from 'test/factories/activity.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { getForActivity } from 'test/mappers';
|
||||
import { newUuid, newUuids } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(ActivityService.name, () => {
|
||||
@@ -23,7 +26,7 @@ describe(ActivityService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId })).resolves.toEqual([]);
|
||||
await expect(sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId })).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: undefined });
|
||||
});
|
||||
@@ -35,7 +38,7 @@ describe(ActivityService.name, () => {
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
sut.getAll(factory.auth({ user: { id: userId } }), { assetId, albumId, type: ReactionType.LIKE }),
|
||||
sut.getAll(AuthFactory.create({ id: userId }), { assetId, albumId, type: ReactionType.LIKE }),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: true });
|
||||
@@ -47,7 +50,9 @@ describe(ActivityService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.getAll(factory.auth(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual([]);
|
||||
await expect(sut.getAll(AuthFactory.create(), { assetId, albumId, type: ReactionType.COMMENT })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
|
||||
expect(mocks.activity.search).toHaveBeenCalledWith({ assetId, albumId, isLiked: false });
|
||||
});
|
||||
@@ -60,7 +65,10 @@ describe(ActivityService.name, () => {
|
||||
mocks.activity.getStatistics.mockResolvedValue({ comments: 1, likes: 3 });
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
|
||||
await expect(sut.getStatistics(factory.auth(), { assetId, albumId })).resolves.toEqual({ comments: 1, likes: 3 });
|
||||
await expect(sut.getStatistics(AuthFactory.create(), { assetId, albumId })).resolves.toEqual({
|
||||
comments: 1,
|
||||
likes: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,18 +77,18 @@ describe(ActivityService.name, () => {
|
||||
const [albumId, assetId] = newUuids();
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a comment', async () => {
|
||||
const [albumId, assetId, userId] = newUuids();
|
||||
const activity = factory.activity({ albumId, assetId, userId });
|
||||
const activity = ActivityFactory.create({ albumId, assetId, userId });
|
||||
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.create.mockResolvedValue(activity);
|
||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||
|
||||
await sut.create(factory.auth({ user: { id: userId } }), {
|
||||
await sut.create(AuthFactory.create({ id: userId }), {
|
||||
albumId,
|
||||
assetId,
|
||||
type: ReactionType.COMMENT,
|
||||
@@ -98,38 +106,38 @@ describe(ActivityService.name, () => {
|
||||
|
||||
it('should fail because activity is disabled for the album', async () => {
|
||||
const [albumId, assetId] = newUuids();
|
||||
const activity = factory.activity({ albumId, assetId });
|
||||
const activity = ActivityFactory.create({ albumId, assetId });
|
||||
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.create.mockResolvedValue(activity);
|
||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
const [albumId, assetId, userId] = newUuids();
|
||||
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
|
||||
const activity = ActivityFactory.create({ userId, albumId, assetId, isLiked: true });
|
||||
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.create.mockResolvedValue(activity);
|
||||
mocks.activity.create.mockResolvedValue(getForActivity(activity));
|
||||
mocks.activity.search.mockResolvedValue([]);
|
||||
|
||||
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
|
||||
await sut.create(AuthFactory.create({ id: userId }), { albumId, assetId, type: ReactionType.LIKE });
|
||||
|
||||
expect(mocks.activity.create).toHaveBeenCalledWith({ userId: activity.userId, albumId, assetId, isLiked: true });
|
||||
});
|
||||
|
||||
it('should skip if like exists', async () => {
|
||||
const [albumId, assetId] = newUuids();
|
||||
const activity = factory.activity({ albumId, assetId, isLiked: true });
|
||||
const activity = ActivityFactory.create({ albumId, assetId, isLiked: true });
|
||||
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
|
||||
mocks.activity.search.mockResolvedValue([activity]);
|
||||
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
|
||||
|
||||
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });
|
||||
await sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.LIKE });
|
||||
|
||||
expect(mocks.activity.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -137,29 +145,29 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.delete(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.activity.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should let the activity owner delete a comment', async () => {
|
||||
const activity = factory.activity();
|
||||
const activity = ActivityFactory.create();
|
||||
|
||||
mocks.access.activity.checkOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
||||
mocks.activity.delete.mockResolvedValue();
|
||||
|
||||
await sut.delete(factory.auth(), activity.id);
|
||||
await sut.delete(AuthFactory.create(), activity.id);
|
||||
|
||||
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
||||
});
|
||||
|
||||
it('should let the album owner delete a comment', async () => {
|
||||
const activity = factory.activity();
|
||||
const activity = ActivityFactory.create();
|
||||
|
||||
mocks.access.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set([activity.id]));
|
||||
mocks.activity.delete.mockResolvedValue();
|
||||
|
||||
await sut.delete(factory.auth(), activity.id);
|
||||
await sut.delete(AuthFactory.create(), activity.id);
|
||||
|
||||
expect(mocks.activity.delete).toHaveBeenCalledWith(activity.id);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
@@ -9,6 +8,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAlbum } from 'test/mappers';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -45,7 +45,7 @@ describe(AlbumService.name, () => {
|
||||
it('gets list of albums for auth user', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build();
|
||||
mocks.album.getOwned.mockResolvedValue([album, sharedWithUserAlbum]);
|
||||
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -70,8 +70,13 @@ describe(AlbumService.name, () => {
|
||||
});
|
||||
|
||||
it('gets list of albums that have a specific asset', async () => {
|
||||
const album = AlbumFactory.from().owner({ isAdmin: true }).albumUser().asset().asset().build();
|
||||
mocks.album.getByAssetId.mockResolvedValue([album]);
|
||||
const album = AlbumFactory.from()
|
||||
.owner({ isAdmin: true })
|
||||
.albumUser()
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -90,7 +95,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('gets list of albums that are shared', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
mocks.album.getShared.mockResolvedValue([album]);
|
||||
mocks.album.getShared.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -109,7 +114,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('gets list of albums that are NOT shared', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getNotShared.mockResolvedValue([album]);
|
||||
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -129,7 +134,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('counts assets correctly', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getOwned.mockResolvedValue([album]);
|
||||
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
albumId: album.id,
|
||||
@@ -155,7 +160,7 @@ describe(AlbumService.name, () => {
|
||||
.albumUser(albumUser)
|
||||
.build();
|
||||
|
||||
mocks.album.create.mockResolvedValue(album);
|
||||
mocks.album.create.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user));
|
||||
mocks.user.getMetadata.mockResolvedValue([]);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
@@ -192,7 +197,7 @@ describe(AlbumService.name, () => {
|
||||
.asset({ id: assetId }, (asset) => asset.exif())
|
||||
.albumUser(albumUser)
|
||||
.build();
|
||||
mocks.album.create.mockResolvedValue(album);
|
||||
mocks.album.create.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
|
||||
mocks.user.getMetadata.mockResolvedValue([
|
||||
{
|
||||
@@ -250,7 +255,7 @@ describe(AlbumService.name, () => {
|
||||
.albumUser()
|
||||
.build();
|
||||
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
|
||||
mocks.album.create.mockResolvedValue(album);
|
||||
mocks.album.create.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.getMetadata.mockResolvedValue([]);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
|
||||
@@ -316,7 +321,7 @@ describe(AlbumService.name, () => {
|
||||
it('should require a valid thumbnail asset id', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -330,8 +335,8 @@ describe(AlbumService.name, () => {
|
||||
it('should allow the owner to update the album', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.update.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
|
||||
|
||||
@@ -352,7 +357,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should not let a shared user delete the album', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -363,7 +368,7 @@ describe(AlbumService.name, () => {
|
||||
it('should let the owner delete an album', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await sut.delete(AuthFactory.create(album.owner), album.id);
|
||||
|
||||
@@ -387,7 +392,7 @@ describe(AlbumService.name, () => {
|
||||
const userId = newUuid();
|
||||
const album = AlbumFactory.from().albumUser({ userId }).build();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
await expect(
|
||||
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -398,7 +403,7 @@ describe(AlbumService.name, () => {
|
||||
it('should throw an error if the userId does not exist', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue(void 0);
|
||||
await expect(
|
||||
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
|
||||
@@ -410,7 +415,7 @@ describe(AlbumService.name, () => {
|
||||
it('should throw an error if the userId is the ownerId', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
await expect(
|
||||
sut.addUsers(AuthFactory.create(album.owner), album.id, {
|
||||
albumUsers: [{ userId: album.owner.id }],
|
||||
@@ -424,8 +429,8 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.create();
|
||||
const user = UserFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.update.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.update.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
|
||||
|
||||
@@ -456,7 +461,7 @@ describe(AlbumService.name, () => {
|
||||
const userId = newUuid();
|
||||
const album = AlbumFactory.from().albumUser({ userId }).build();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.albumUser.delete.mockResolvedValue();
|
||||
|
||||
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined();
|
||||
@@ -470,7 +475,7 @@ describe(AlbumService.name, () => {
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -483,7 +488,7 @@ describe(AlbumService.name, () => {
|
||||
it('should allow a shared user to remove themselves', async () => {
|
||||
const user1 = UserFactory.create();
|
||||
const album = AlbumFactory.from().albumUser({ userId: user1.id }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.albumUser.delete.mockResolvedValue();
|
||||
|
||||
await sut.removeUser(AuthFactory.create(user1), album.id, user1.id);
|
||||
@@ -495,7 +500,7 @@ describe(AlbumService.name, () => {
|
||||
it('should allow a shared user to remove themselves using "me"', async () => {
|
||||
const user = UserFactory.create();
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.albumUser.delete.mockResolvedValue();
|
||||
|
||||
await sut.removeUser(AuthFactory.create(user), album.id, 'me');
|
||||
@@ -506,7 +511,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should not allow the owner to be removed', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -517,7 +522,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should throw an error for a user not in the album', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -546,7 +551,7 @@ describe(AlbumService.name, () => {
|
||||
describe('getAlbumInfo', () => {
|
||||
it('should get a shared album', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
@@ -566,7 +571,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should get a shared album via a shared link', async () => {
|
||||
const album = AlbumFactory.from().albumUser().build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
@@ -588,7 +593,7 @@ describe(AlbumService.name, () => {
|
||||
it('should get a shared album via shared with user', async () => {
|
||||
const user = UserFactory.create();
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getMetadataForIds.mockResolvedValue([
|
||||
{
|
||||
@@ -630,7 +635,7 @@ describe(AlbumService.name, () => {
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -654,7 +659,7 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
|
||||
@@ -675,7 +680,7 @@ describe(AlbumService.name, () => {
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -703,7 +708,7 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build();
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set());
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }),
|
||||
@@ -718,7 +723,7 @@ describe(AlbumService.name, () => {
|
||||
const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build();
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(sut.addAssets(auth, album.id, { ids: [asset1.id, asset2.id, asset3.id] })).resolves.toEqual([
|
||||
@@ -742,7 +747,7 @@ describe(AlbumService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
@@ -762,7 +767,7 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id]));
|
||||
|
||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
@@ -776,7 +781,7 @@ describe(AlbumService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
@@ -791,7 +796,7 @@ describe(AlbumService.name, () => {
|
||||
const user = UserFactory.create();
|
||||
const album = AlbumFactory.create();
|
||||
const asset = AssetFactory.create({ ownerId: user.id });
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -804,7 +809,7 @@ describe(AlbumService.name, () => {
|
||||
it('should not allow unauthorized shared link access to the album', async () => {
|
||||
const album = AlbumFactory.create();
|
||||
const asset = AssetFactory.create();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(
|
||||
sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }),
|
||||
@@ -821,7 +826,7 @@ describe(AlbumService.name, () => {
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -859,7 +864,7 @@ describe(AlbumService.name, () => {
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -897,7 +902,7 @@ describe(AlbumService.name, () => {
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -943,7 +948,7 @@ describe(AlbumService.name, () => {
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -965,7 +970,7 @@ describe(AlbumService.name, () => {
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build();
|
||||
@@ -1004,7 +1009,7 @@ describe(AlbumService.name, () => {
|
||||
];
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -1048,7 +1053,7 @@ describe(AlbumService.name, () => {
|
||||
mocks.album.getAssetIds
|
||||
.mockResolvedValueOnce(new Set([asset1.id, asset2.id, asset3.id]))
|
||||
.mockResolvedValueOnce(new Set());
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
|
||||
@@ -1078,7 +1083,7 @@ describe(AlbumService.name, () => {
|
||||
.mockResolvedValueOnce(new Set([album1.id]))
|
||||
.mockResolvedValueOnce(new Set([album2.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
|
||||
|
||||
await expect(
|
||||
@@ -1107,7 +1112,7 @@ describe(AlbumService.name, () => {
|
||||
mocks.access.album.checkSharedAlbumAccess
|
||||
.mockResolvedValueOnce(new Set([album1.id]))
|
||||
.mockResolvedValueOnce(new Set([album2.id]));
|
||||
mocks.album.getById.mockResolvedValueOnce(_.cloneDeep(album1)).mockResolvedValueOnce(_.cloneDeep(album2));
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
|
||||
|
||||
await expect(
|
||||
@@ -1138,7 +1143,7 @@ describe(AlbumService.name, () => {
|
||||
const album1 = AlbumFactory.create();
|
||||
const album2 = AlbumFactory.create();
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(AuthFactory.create(user), {
|
||||
@@ -1160,7 +1165,7 @@ describe(AlbumService.name, () => {
|
||||
const album1 = AlbumFactory.create();
|
||||
const album2 = AlbumFactory.create();
|
||||
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
|
||||
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
|
||||
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
|
||||
|
||||
await expect(
|
||||
sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), {
|
||||
@@ -1182,7 +1187,7 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
||||
|
||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
@@ -1196,7 +1201,7 @@ describe(AlbumService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
@@ -1210,7 +1215,7 @@ describe(AlbumService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const album = AlbumFactory.create();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
||||
|
||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
@@ -1224,7 +1229,7 @@ describe(AlbumService.name, () => {
|
||||
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id]));
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
|
||||
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Permission } from 'src/enum';
|
||||
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
import { asDateString } from 'src/utils/date';
|
||||
import { getPreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
@@ -64,11 +65,11 @@ export class AlbumService extends BaseService {
|
||||
return albums.map((album) => ({
|
||||
...mapAlbumWithoutAssets(album),
|
||||
sharedLinks: undefined,
|
||||
startDate: albumMetadata[album.id]?.startDate ?? undefined,
|
||||
endDate: albumMetadata[album.id]?.endDate ?? undefined,
|
||||
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
|
||||
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
|
||||
assetCount: albumMetadata[album.id]?.assetCount ?? 0,
|
||||
// lastModifiedAssetTimestamp is only used in mobile app, please remove if not need
|
||||
lastModifiedAssetTimestamp: albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined,
|
||||
lastModifiedAssetTimestamp: asDateString(albumMetadata[album.id]?.lastModifiedAssetTimestamp ?? undefined),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -85,10 +86,10 @@ export class AlbumService extends BaseService {
|
||||
|
||||
return {
|
||||
...mapAlbum(album, withAssets, auth),
|
||||
startDate: albumMetadataForIds?.startDate ?? undefined,
|
||||
endDate: albumMetadataForIds?.endDate ?? undefined,
|
||||
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
|
||||
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
|
||||
assetCount: albumMetadataForIds?.assetCount ?? 0,
|
||||
lastModifiedAssetTimestamp: albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined,
|
||||
lastModifiedAssetTimestamp: asDateString(albumMetadataForIds?.lastModifiedAssetTimestamp ?? undefined),
|
||||
contributorCounts: isShared ? await this.albumRepository.getContributorCounts(album.id) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ApiKeyService } from 'src/services/api-key.service';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(ApiKeyService.name, () => {
|
||||
@@ -14,8 +17,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new key', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.All] });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.All] });
|
||||
const key = 'super-secret';
|
||||
|
||||
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
||||
@@ -34,8 +37,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should not require a name', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
const key = 'super-secret';
|
||||
|
||||
mocks.crypto.randomBytesAsText.mockReturnValue(key);
|
||||
@@ -54,7 +57,9 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the api key does not have sufficient permissions', async () => {
|
||||
const auth = factory.auth({ apiKey: { permissions: [Permission.AssetRead] } });
|
||||
const auth = AuthFactory.from()
|
||||
.apiKey({ permissions: [Permission.AssetRead] })
|
||||
.build();
|
||||
|
||||
await expect(sut.create(auth, { permissions: [Permission.AssetUpdate] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -65,7 +70,7 @@ describe(ApiKeyService.name, () => {
|
||||
describe('update', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const id = newUuid();
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
@@ -77,8 +82,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should update a key', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
const newName = 'New name';
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
@@ -93,8 +98,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should update permissions', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
const newPermissions = [Permission.ActivityCreate, Permission.ActivityRead, Permission.ActivityUpdate];
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
@@ -111,8 +116,8 @@ describe(ApiKeyService.name, () => {
|
||||
describe('api key auth', () => {
|
||||
it('should prevent adding Permission.all', async () => {
|
||||
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
||||
const auth = factory.auth({ apiKey: { permissions } });
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
|
||||
const auth = AuthFactory.from().apiKey({ permissions }).build();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -125,8 +130,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
it('should prevent adding a new permission', async () => {
|
||||
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
|
||||
const auth = factory.auth({ apiKey: { permissions } });
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
|
||||
const auth = AuthFactory.from().apiKey({ permissions }).build();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -138,8 +143,10 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow removing permissions', async () => {
|
||||
const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } });
|
||||
const apiKey = factory.apiKey({
|
||||
const auth = AuthFactory.from()
|
||||
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] })
|
||||
.build();
|
||||
const apiKey = ApiKeyFactory.create({
|
||||
userId: auth.user.id,
|
||||
permissions: [Permission.AssetRead, Permission.AssetDelete],
|
||||
});
|
||||
@@ -158,10 +165,10 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow adding new permissions', async () => {
|
||||
const auth = factory.auth({
|
||||
apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] },
|
||||
});
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] });
|
||||
const auth = AuthFactory.from()
|
||||
.apiKey({ permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] })
|
||||
.build();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id, permissions: [Permission.AssetRead] });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
mocks.apiKey.update.mockResolvedValue(apiKey);
|
||||
@@ -183,7 +190,7 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
const id = newUuid();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
@@ -194,8 +201,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete a key', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
mocks.apiKey.delete.mockResolvedValue();
|
||||
@@ -208,8 +215,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('getMine', () => {
|
||||
it('should not work with a session token', async () => {
|
||||
const session = factory.session();
|
||||
const auth = factory.auth({ session });
|
||||
const session = SessionFactory.create();
|
||||
const auth = AuthFactory.from().session(session).build();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
@@ -219,8 +226,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const apiKey = factory.authApiKey();
|
||||
const auth = factory.auth({ apiKey });
|
||||
const apiKey = ApiKeyFactory.create();
|
||||
const auth = AuthFactory.from().apiKey(apiKey).build();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
|
||||
@@ -230,8 +237,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -243,7 +250,7 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('getById', () => {
|
||||
it('should throw an error if the key is not found', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
const id = newUuid();
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(void 0);
|
||||
@@ -254,8 +261,8 @@ describe(ApiKeyService.name, () => {
|
||||
});
|
||||
|
||||
it('should get a key by id', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getById.mockResolvedValue(apiKey);
|
||||
|
||||
@@ -267,8 +274,8 @@ describe(ApiKeyService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all the keys for a user', async () => {
|
||||
const auth = factory.auth();
|
||||
const apiKey = factory.apiKey({ userId: auth.user.id });
|
||||
const auth = AuthFactory.create();
|
||||
const apiKey = ApiKeyFactory.create({ userId: auth.user.id });
|
||||
|
||||
mocks.apiKey.getByUserId.mockResolvedValue([apiKey]);
|
||||
|
||||
|
||||
@@ -4,13 +4,12 @@ import {
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||
import { AssetFileType, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadBody } from 'src/types';
|
||||
@@ -22,6 +21,7 @@ import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { getForAsset } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
@@ -152,13 +152,6 @@ const createDto = Object.freeze({
|
||||
duration: '0:00:00.000000',
|
||||
}) as AssetMediaCreateDto;
|
||||
|
||||
const replaceDto = Object.freeze({
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
}) as AssetMediaReplaceDto;
|
||||
|
||||
const assetEntity = Object.freeze({
|
||||
id: 'id_1',
|
||||
ownerId: 'user_id_1',
|
||||
@@ -170,7 +163,6 @@ const assetEntity = Object.freeze({
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
encodedVideoPath: '',
|
||||
duration: '0:00:00.000000',
|
||||
files: [] as AssetFile[],
|
||||
exifInfo: {
|
||||
@@ -180,25 +172,6 @@ const assetEntity = Object.freeze({
|
||||
livePhotoVideoId: null,
|
||||
} as MapAsset);
|
||||
|
||||
const existingAsset = Object.freeze({
|
||||
...assetEntity,
|
||||
duration: null,
|
||||
type: AssetType.Image,
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
}) as MapAsset;
|
||||
|
||||
const sidecarAsset = Object.freeze({
|
||||
...existingAsset,
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
}) as MapAsset;
|
||||
|
||||
const copiedAsset = Object.freeze({
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
}) as MapAsset;
|
||||
|
||||
describe(AssetMediaService.name, () => {
|
||||
let sut: AssetMediaService;
|
||||
let mocks: ServiceMocks;
|
||||
@@ -434,7 +407,7 @@ describe(AssetMediaService.name, () => {
|
||||
.owner(authStub.user1.user)
|
||||
.build();
|
||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||
mocks.asset.create.mockResolvedValueOnce(asset);
|
||||
|
||||
await expect(
|
||||
@@ -451,7 +424,7 @@ describe(AssetMediaService.name, () => {
|
||||
it('should hide the linked motion asset', async () => {
|
||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
|
||||
const asset = AssetFactory.create();
|
||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||
mocks.asset.create.mockResolvedValueOnce(asset);
|
||||
|
||||
await expect(
|
||||
@@ -470,7 +443,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
it('should handle a sidecar file', async () => {
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
|
||||
mocks.asset.getById.mockResolvedValueOnce(asset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||
mocks.asset.create.mockResolvedValueOnce(asset);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
|
||||
@@ -737,13 +710,18 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should return the encoded video path if available', async () => {
|
||||
const asset = AssetFactory.create({ encodedVideoPath: '/path/to/encoded/video.mp4' });
|
||||
const asset = AssetFactory.from()
|
||||
.file({ type: AssetFileType.EncodedVideo, path: '/path/to/encoded/video.mp4' })
|
||||
.build();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(asset);
|
||||
mocks.asset.getForVideo.mockResolvedValue({
|
||||
originalPath: asset.originalPath,
|
||||
encodedVideoPath: asset.files[0].path,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: asset.encodedVideoPath!,
|
||||
path: '/path/to/encoded/video.mp4',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'video/mp4',
|
||||
}),
|
||||
@@ -753,7 +731,10 @@ describe(AssetMediaService.name, () => {
|
||||
it('should fall back to the original path', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getForVideo.mockResolvedValue(asset);
|
||||
mocks.asset.getForVideo.mockResolvedValue({
|
||||
originalPath: asset.originalPath,
|
||||
encodedVideoPath: null,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
@@ -776,177 +757,6 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceAsset', () => {
|
||||
it('should fail the auth check when update photo does not exist', async () => {
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow(
|
||||
'Not found or no asset.update access',
|
||||
);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if asset cannot be fetched', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, fileStub.photo)).rejects.toThrow(
|
||||
'Asset not found',
|
||||
);
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a photo with no sidecar to photo with no sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingAsset.id,
|
||||
originalFileName: 'photo1.jpeg',
|
||||
originalPath: 'fake_path/photo1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const sidecarFile = fileStub.photoSidecar;
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(
|
||||
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a photo with a sidecar to photo with no sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the copy call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
|
||||
deletedAt: expect.any(Date),
|
||||
status: AssetStatus.Trashed,
|
||||
});
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
assetId: existingAsset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
}),
|
||||
);
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(mocks.storage.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a photo with sidecar to duplicate photo ', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const error = new Error('unique key violation');
|
||||
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
mocks.asset.update.mockRejectedValue(error);
|
||||
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
mocks.asset.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
id: sidecarAsset.id,
|
||||
});
|
||||
|
||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [updatedFile.originalPath, undefined] },
|
||||
});
|
||||
expect(mocks.user.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUploadCheck', () => {
|
||||
it('should accept hex and base64 checksums', async () => {
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
@@ -151,6 +151,10 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [asset.id]);
|
||||
}
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
@@ -341,6 +345,11 @@ export class AssetMediaService extends BaseService {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
|
||||
if (auth.sharedLink) {
|
||||
await this.sharedLinkRepository.addAssets(auth.sharedLink.id, [duplicateId]);
|
||||
}
|
||||
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -71,7 +73,7 @@ describe(AssetService.name, () => {
|
||||
describe('getRandom', () => {
|
||||
it('should get own random assets', async () => {
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
||||
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||
|
||||
await sut.getRandom(authStub.admin, 1);
|
||||
|
||||
@@ -79,11 +81,11 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should not include partner assets if not in timeline', async () => {
|
||||
const partner = factory.partner({ inTimeline: false });
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
const partner = PartnerFactory.create({ inTimeline: false });
|
||||
const auth = AuthFactory.create({ id: partner.sharedWithId });
|
||||
|
||||
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
|
||||
await sut.getRandom(auth, 1);
|
||||
|
||||
@@ -91,11 +93,11 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should include partner assets if in timeline', async () => {
|
||||
const partner = factory.partner({ inTimeline: true });
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
const partner = PartnerFactory.create({ inTimeline: true });
|
||||
const auth = AuthFactory.create({ id: partner.sharedWithId });
|
||||
|
||||
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
|
||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
|
||||
await sut.getRandom(auth, 1);
|
||||
|
||||
@@ -107,7 +109,7 @@ describe(AssetService.name, () => {
|
||||
it('should allow owner access', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
await sut.get(authStub.admin, asset.id);
|
||||
|
||||
@@ -121,7 +123,7 @@ describe(AssetService.name, () => {
|
||||
it('should allow shared link access', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
await sut.get(authStub.adminSharedLink, asset.id);
|
||||
|
||||
@@ -134,7 +136,7 @@ describe(AssetService.name, () => {
|
||||
it('should strip metadata for shared link if exif is disabled', async () => {
|
||||
const asset = AssetFactory.from().exif({ description: 'foo' }).build();
|
||||
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
const result = await sut.get(
|
||||
{ ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
|
||||
@@ -152,7 +154,7 @@ describe(AssetService.name, () => {
|
||||
it('should allow partner sharing access', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
await sut.get(authStub.admin, asset.id);
|
||||
|
||||
@@ -162,7 +164,7 @@ describe(AssetService.name, () => {
|
||||
it('should allow shared album access', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
await sut.get(authStub.admin, asset.id);
|
||||
|
||||
@@ -204,8 +206,8 @@ describe(AssetService.name, () => {
|
||||
it('should update the asset', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.update.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
mocks.asset.update.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
await sut.update(authStub.admin, asset.id, { isFavorite: true });
|
||||
|
||||
@@ -215,8 +217,8 @@ describe(AssetService.name, () => {
|
||||
it('should update the exif description', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.update.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
mocks.asset.update.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
await sut.update(authStub.admin, asset.id, { description: 'Test description' });
|
||||
|
||||
@@ -229,8 +231,8 @@ describe(AssetService.name, () => {
|
||||
it('should update the exif rating', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(asset);
|
||||
mocks.asset.update.mockResolvedValueOnce(asset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||
mocks.asset.update.mockResolvedValueOnce(getForAsset(asset));
|
||||
|
||||
await sut.update(authStub.admin, asset.id, { rating: 3 });
|
||||
|
||||
@@ -274,7 +276,7 @@ describe(AssetService.name, () => {
|
||||
const motionAsset = AssetFactory.from().owner(auth.user).build();
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(asset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
|
||||
|
||||
await expect(
|
||||
sut.update(authStub.admin, asset.id, {
|
||||
@@ -301,7 +303,7 @@ describe(AssetService.name, () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video });
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValue(motionAsset);
|
||||
mocks.asset.getById.mockResolvedValue(getForAsset(motionAsset));
|
||||
|
||||
await expect(
|
||||
sut.update(auth, asset.id, {
|
||||
@@ -327,9 +329,9 @@ describe(AssetService.name, () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
|
||||
const stillAsset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(stillAsset);
|
||||
mocks.asset.update.mockResolvedValue(stillAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(stillAsset));
|
||||
mocks.asset.update.mockResolvedValue(getForAsset(stillAsset));
|
||||
const auth = AuthFactory.from(motionAsset.owner).build();
|
||||
|
||||
await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id });
|
||||
@@ -354,9 +356,9 @@ describe(AssetService.name, () => {
|
||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||
const unlinkedAsset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getById.mockResolvedValueOnce(asset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
|
||||
mocks.asset.update.mockResolvedValueOnce(unlinkedAsset);
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
|
||||
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
|
||||
mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset));
|
||||
|
||||
await sut.update(auth, asset.id, { livePhotoVideoId: null });
|
||||
|
||||
@@ -532,7 +534,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should immediately queue assets for deletion if trash is disabled', async () => {
|
||||
const asset = factory.asset({ isOffline: false });
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: false } });
|
||||
@@ -546,7 +548,7 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue assets for deletion after trash duration', async () => {
|
||||
const asset = factory.asset({ isOffline: false });
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.assetJob.streamForDeletedJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ trash: { enabled: true, days: 7 } });
|
||||
@@ -566,8 +568,10 @@ describe(AssetService.name, () => {
|
||||
.file({ type: AssetFileType.Thumbnail })
|
||||
.file({ type: AssetFileType.Preview })
|
||||
.file({ type: AssetFileType.FullSize })
|
||||
.file({ type: AssetFileType.Preview, isEdited: true })
|
||||
.file({ type: AssetFileType.Thumbnail, isEdited: true })
|
||||
.build();
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
@@ -581,7 +585,7 @@ describe(AssetService.name, () => {
|
||||
},
|
||||
],
|
||||
]);
|
||||
expect(mocks.asset.remove).toHaveBeenCalledWith(asset);
|
||||
expect(mocks.asset.remove).toHaveBeenCalledWith(getForAssetDeletion(asset));
|
||||
});
|
||||
|
||||
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
|
||||
@@ -589,11 +593,7 @@ describe(AssetService.name, () => {
|
||||
.stack({}, (builder) => builder.asset())
|
||||
.build();
|
||||
mocks.stack.delete.mockResolvedValue();
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
|
||||
...asset,
|
||||
// TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually
|
||||
stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) },
|
||||
});
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
@@ -603,7 +603,7 @@ describe(AssetService.name, () => {
|
||||
it('should delete a live photo', async () => {
|
||||
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
|
||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
|
||||
|
||||
await sut.handleAssetDeletion({
|
||||
@@ -620,7 +620,7 @@ describe(AssetService.name, () => {
|
||||
it('should not delete a live motion part if it is being used by another asset', async () => {
|
||||
const asset = AssetFactory.create({ livePhotoVideoId: newUuid() });
|
||||
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
|
||||
@@ -631,7 +631,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
it('should update usage', async () => {
|
||||
const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
|
||||
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
|
||||
expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000);
|
||||
});
|
||||
@@ -737,7 +737,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('upsertMetadata', () => {
|
||||
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
||||
const asset = factory.asset();
|
||||
const asset = AssetFactory.create();
|
||||
const items = [
|
||||
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
@@ -755,7 +755,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
describe('upsertBulkMetadata', () => {
|
||||
it('should throw a bad request exception if duplicate keys are sent', async () => {
|
||||
const asset = factory.asset();
|
||||
const asset = AssetFactory.create();
|
||||
const items = [
|
||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
{ assetId: asset.id, key: AssetMetadataKey.MobileApp, value: { iCloudId: 'id1' } },
|
||||
|
||||
@@ -370,7 +370,7 @@ export class AssetService extends BaseService {
|
||||
assetFiles.editedFullsizeFile?.path,
|
||||
assetFiles.editedPreviewFile?.path,
|
||||
assetFiles.editedThumbnailFile?.path,
|
||||
asset.encodedVideoPath,
|
||||
assetFiles.encodedVideoFile?.path,
|
||||
];
|
||||
|
||||
if (deleteOnDisk && !asset.isOffline) {
|
||||
@@ -516,7 +516,7 @@ export class AssetService extends BaseService {
|
||||
dateTimeOriginal?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
rating?: number;
|
||||
rating?: number | null;
|
||||
}) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
||||
const writes = _.omitBy(
|
||||
|
||||
@@ -6,9 +6,13 @@ import { AuthDto, SignUpDto } from 'src/dtos/auth.dto';
|
||||
import { AuthType, Permission } from 'src/enum';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const oauthResponse = ({
|
||||
@@ -91,8 +95,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should successfully log the user in', async () => {
|
||||
const user = { ...(factory.user() as UserAdmin), password: 'immich_password' };
|
||||
const session = factory.session();
|
||||
const user = UserFactory.create({ password: 'immich_password' });
|
||||
const session = SessionFactory.create();
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(session);
|
||||
|
||||
@@ -113,8 +117,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should change the password', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
||||
@@ -132,8 +136,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw when password does not match existing password', async () => {
|
||||
const user = factory.user();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.crypto.compareBcrypt.mockReturnValue(false);
|
||||
@@ -144,8 +148,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw when user does not have a password', async () => {
|
||||
const user = factory.user();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password' };
|
||||
|
||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: '' });
|
||||
@@ -154,8 +158,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should change the password and logout other sessions', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { password: 'old-password', newPassword: 'new-password', invalidateSessions: true };
|
||||
|
||||
mocks.user.getForChangePassword.mockResolvedValue({ id: user.id, password: 'hash-password' });
|
||||
@@ -175,7 +179,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('logout', () => {
|
||||
it('should return the end session endpoint', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
|
||||
@@ -186,7 +190,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should return the default redirect', async () => {
|
||||
const auth = factory.auth();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
await expect(sut.logout(auth, AuthType.Password)).resolves.toEqual({
|
||||
successful: true,
|
||||
@@ -262,11 +266,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should validate using authorization header', async () => {
|
||||
const session = factory.session();
|
||||
const session = SessionFactory.create();
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
@@ -340,7 +344,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a base64url key', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
@@ -361,7 +365,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a hex key', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
const sharedLink = { ...sharedLinkStub.valid, user } as any;
|
||||
|
||||
mocks.sharedLink.getByKey.mockResolvedValue(sharedLink);
|
||||
@@ -396,7 +400,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a valid slug', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
const sharedLink = { ...sharedLinkStub.valid, slug: 'slug-123', user } as any;
|
||||
|
||||
mocks.sharedLink.getBySlug.mockResolvedValue(sharedLink);
|
||||
@@ -428,11 +432,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
const session = factory.session();
|
||||
const session = SessionFactory.create();
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
};
|
||||
@@ -455,11 +459,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw if admin route and not an admin', async () => {
|
||||
const session = factory.session();
|
||||
const session = SessionFactory.create();
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
@@ -477,11 +481,11 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should update when access time exceeds an hour', async () => {
|
||||
const session = factory.session({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
|
||||
const session = SessionFactory.create({ updatedAt: DateTime.now().minus({ hours: 2 }).toJSDate() });
|
||||
const sessionWithToken = {
|
||||
id: session.id,
|
||||
updatedAt: session.updatedAt,
|
||||
user: factory.authUser(),
|
||||
user: UserFactory.create(),
|
||||
isPendingSyncReset: false,
|
||||
pinExpiresAt: null,
|
||||
appVersion: null,
|
||||
@@ -517,8 +521,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if api key has insufficient permissions', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.create({ permissions: [] });
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
|
||||
@@ -533,8 +537,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should default to requiring the all permission when omitted', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.AssetRead] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.create({ permissions: [Permission.AssetRead] });
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
|
||||
@@ -548,10 +552,12 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not require any permission when metadata is set to `false`', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.ActivityRead] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.ActivityRead] })
|
||||
.user(authUser)
|
||||
.build();
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
|
||||
|
||||
const result = sut.authenticate({
|
||||
headers: { 'x-api-key': 'auth_token' },
|
||||
@@ -562,10 +568,12 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should return an auth dto', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [Permission.All] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.from({ permissions: [Permission.All] })
|
||||
.user(authUser)
|
||||
.build();
|
||||
|
||||
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
|
||||
mocks.apiKey.getKey.mockResolvedValue(authApiKey);
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
@@ -629,12 +637,12 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should link an existing user', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -649,7 +657,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not link to a user with a different oauth sub', async () => {
|
||||
const user = factory.userAdmin({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.user.getByEmail.mockResolvedValueOnce(user);
|
||||
@@ -669,13 +677,13 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should allow auto registering by default', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -690,13 +698,13 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
|
||||
const user = factory.userAdmin({ isAdmin: true });
|
||||
const user = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
|
||||
|
||||
await expect(
|
||||
@@ -717,11 +725,11 @@ describe(AuthService.name, () => {
|
||||
'app.immich:///oauth-callback?code=abc123',
|
||||
]) {
|
||||
it(`should use the mobile redirect override for a url of ${url}`, async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
|
||||
|
||||
@@ -735,13 +743,13 @@ describe(AuthService.name, () => {
|
||||
}
|
||||
|
||||
it('should use the default quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -755,14 +763,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore an invalid storage quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -776,14 +784,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should ignore a negative quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
|
||||
mocks.user.getAdmin.mockResolvedValue(user);
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -797,14 +805,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should set quota for 0 quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -825,15 +833,15 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should use a valid storage quota', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -855,7 +863,7 @@ describe(AuthService.name, () => {
|
||||
|
||||
it('should sync the profile picture', async () => {
|
||||
const fileId = newUuid();
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
@@ -871,7 +879,7 @@ describe(AuthService.name, () => {
|
||||
data: new Uint8Array([1, 2, 3, 4, 5]).buffer,
|
||||
});
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -889,7 +897,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not sync the profile picture if the user already has one', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
|
||||
mocks.oauth.getProfile.mockResolvedValue({
|
||||
@@ -899,7 +907,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
mocks.user.getByOAuthId.mockResolvedValue(user);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -914,15 +922,15 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should only allow "admin" and "user" for the role claim', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getAdmin.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -943,14 +951,14 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should create an admin user if the role claim is set to admin', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
|
||||
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -971,7 +979,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a custom role claim', async () => {
|
||||
const user = factory.userAdmin({ oauthId: 'oauth-id' });
|
||||
const user = UserFactory.create({ oauthId: 'oauth-id' });
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
|
||||
@@ -980,7 +988,7 @@ describe(AuthService.name, () => {
|
||||
mocks.user.getByEmail.mockResolvedValue(void 0);
|
||||
mocks.user.getByOAuthId.mockResolvedValue(void 0);
|
||||
mocks.user.create.mockResolvedValue(user);
|
||||
mocks.session.create.mockResolvedValue(factory.session());
|
||||
mocks.session.create.mockResolvedValue(SessionFactory.create());
|
||||
|
||||
await expect(
|
||||
sut.callback(
|
||||
@@ -1003,8 +1011,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('link', () => {
|
||||
it('should link an account', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ apiKey: { permissions: [] }, user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
@@ -1019,8 +1027,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should not link an already linked oauth.sub', async () => {
|
||||
const authUser = factory.authUser();
|
||||
const authApiKey = factory.authApiKey({ permissions: [] });
|
||||
const authUser = UserFactory.create();
|
||||
const authApiKey = ApiKeyFactory.create({ permissions: [] });
|
||||
const auth = { user: authUser, apiKey: authApiKey };
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
@@ -1036,8 +1044,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('unlink', () => {
|
||||
it('should unlink an account', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user, apiKey: { permissions: [] } });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
|
||||
mocks.user.update.mockResolvedValue(user);
|
||||
@@ -1050,8 +1058,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('setupPinCode', () => {
|
||||
it('should setup a PIN code', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { pinCode: '123456' };
|
||||
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: null, password: '' });
|
||||
@@ -1065,8 +1073,8 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail if the user already has a PIN code', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
|
||||
@@ -1076,8 +1084,8 @@ describe(AuthService.name, () => {
|
||||
|
||||
describe('changePinCode', () => {
|
||||
it('should change the PIN code', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
const dto = { pinCode: '123456', newPinCode: '012345' };
|
||||
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
@@ -1091,37 +1099,37 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
it('should fail if the PIN code does not match', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||
|
||||
await expect(
|
||||
sut.changePinCode(factory.auth({ user }), { pinCode: '000000', newPinCode: '012345' }),
|
||||
sut.changePinCode(AuthFactory.create(user), { pinCode: '000000', newPinCode: '012345' }),
|
||||
).rejects.toThrow('Wrong PIN code');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetPinCode', () => {
|
||||
it('should reset the PIN code', async () => {
|
||||
const currentSession = factory.session();
|
||||
const user = factory.userAdmin();
|
||||
const currentSession = SessionFactory.create();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||
mocks.session.lockAll.mockResolvedValue(void 0);
|
||||
mocks.session.update.mockResolvedValue(currentSession);
|
||||
|
||||
await sut.resetPinCode(factory.auth({ user }), { pinCode: '123456' });
|
||||
await sut.resetPinCode(AuthFactory.create(user), { pinCode: '123456' });
|
||||
|
||||
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { pinCode: null });
|
||||
expect(mocks.session.lockAll).toHaveBeenCalledWith(user.id);
|
||||
});
|
||||
|
||||
it('should throw if the PIN code does not match', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getForPinCode.mockResolvedValue({ pinCode: '123456 (hashed)', password: '' });
|
||||
mocks.crypto.compareBcrypt.mockImplementation((a, b) => `${a} (hashed)` === b);
|
||||
|
||||
await expect(sut.resetPinCode(factory.auth({ user }), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
|
||||
await expect(sut.resetPinCode(AuthFactory.create(user), { pinCode: '000000' })).rejects.toThrow('Wrong PIN code');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { jwtVerify } from 'jose';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe, it } from 'vitest';
|
||||
|
||||
@@ -15,7 +15,7 @@ describe(CliService.name, () => {
|
||||
|
||||
describe('listUsers', () => {
|
||||
it('should list users', async () => {
|
||||
mocks.user.getList.mockResolvedValue([factory.userAdmin({ isAdmin: true })]);
|
||||
mocks.user.getList.mockResolvedValue([UserFactory.create({ isAdmin: true })]);
|
||||
await expect(sut.listUsers()).resolves.toEqual([expect.objectContaining({ isAdmin: true })]);
|
||||
expect(mocks.user.getList).toHaveBeenCalledWith({ withDeleted: true });
|
||||
});
|
||||
@@ -32,10 +32,10 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should default to a random password', async () => {
|
||||
const admin = factory.userAdmin({ isAdmin: true });
|
||||
const admin = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(factory.userAdmin({ isAdmin: true }));
|
||||
mocks.user.update.mockResolvedValue(UserFactory.create({ isAdmin: true }));
|
||||
|
||||
const ask = vitest.fn().mockImplementation(() => {});
|
||||
|
||||
@@ -50,7 +50,7 @@ describe(CliService.name, () => {
|
||||
});
|
||||
|
||||
it('should use the supplied password', async () => {
|
||||
const admin = factory.userAdmin({ isAdmin: true });
|
||||
const admin = UserFactory.create({ isAdmin: true });
|
||||
|
||||
mocks.user.getAdmin.mockResolvedValue(admin);
|
||||
mocks.user.update.mockResolvedValue(admin);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForDuplicate } from 'test/mappers';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { beforeEach, vitest } from 'vitest';
|
||||
@@ -39,11 +40,11 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('getDuplicates', () => {
|
||||
it('should get duplicates', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.duplicateRepository.getAll.mockResolvedValue([
|
||||
{
|
||||
duplicateId: 'duplicate-id',
|
||||
assets: [asset, asset],
|
||||
assets: [getForDuplicate(asset), getForDuplicate(asset)],
|
||||
},
|
||||
]);
|
||||
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
|
||||
|
||||
@@ -186,8 +186,8 @@ export class JobService extends BaseService {
|
||||
exifImageHeight: exif.exifImageHeight,
|
||||
fileSizeInByte: exif.fileSizeInByte,
|
||||
orientation: exif.orientation,
|
||||
dateTimeOriginal: exif.dateTimeOriginal,
|
||||
modifyDate: exif.modifyDate,
|
||||
dateTimeOriginal: exif.dateTimeOriginal ? new Date(exif.dateTimeOriginal) : null,
|
||||
modifyDate: exif.modifyDate ? new Date(exif.modifyDate) : null,
|
||||
timeZone: exif.timeZone,
|
||||
latitude: exif.latitude,
|
||||
longitude: exif.longitude,
|
||||
|
||||
@@ -2,8 +2,9 @@ import { MapService } from 'src/services/map.service';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getForAlbum, getForPartner } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(MapService.name, () => {
|
||||
@@ -39,7 +40,7 @@ describe(MapService.name, () => {
|
||||
|
||||
it('should include partner assets', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const partner = factory.partner({ sharedWithId: auth.user.id });
|
||||
const partner = PartnerFactory.create({ sharedWithId: auth.user.id });
|
||||
|
||||
const asset = AssetFactory.from()
|
||||
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
|
||||
@@ -52,7 +53,7 @@ describe(MapService.name, () => {
|
||||
state: asset.exifInfo.state,
|
||||
country: asset.exifInfo.country,
|
||||
};
|
||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||
|
||||
const markers = await sut.getMapMarkers(auth, { withPartners: true });
|
||||
@@ -81,8 +82,10 @@ describe(MapService.name, () => {
|
||||
};
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.map.getMapMarkers.mockResolvedValue([marker]);
|
||||
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
|
||||
mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]);
|
||||
mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]);
|
||||
mocks.album.getShared.mockResolvedValue([
|
||||
getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()),
|
||||
]);
|
||||
|
||||
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { OutputInfo } from 'sharp';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { Exif } from 'src/database';
|
||||
@@ -27,6 +28,7 @@ import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { personThumbnailStub } from 'test/fixtures/person.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { getForGenerateThumbnail } from 'test/mappers';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -367,8 +369,10 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip thumbnail generation if asset type is unknown', async () => {
|
||||
const asset = AssetFactory.create({ type: 'foo' as AssetType });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
const asset = AssetFactory.from({ type: 'foo' as AssetType })
|
||||
.exif()
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
|
||||
expect(mocks.media.probe).not.toHaveBeenCalled();
|
||||
@@ -377,17 +381,17 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip video thumbnail generation if no video stream', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
await expect(sut.handleGenerateThumbnails({ id: asset.id })).rejects.toThrowError();
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.update).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should skip invisible assets', async () => {
|
||||
const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }).exif().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
expect(await sut.handleGenerateThumbnails({ id: asset.id })).toEqual(JobStatus.Skipped);
|
||||
|
||||
@@ -398,7 +402,7 @@ describe(MediaService.name, () => {
|
||||
it('should delete previous preview if different path', async () => {
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -415,7 +419,7 @@ describe(MediaService.name, () => {
|
||||
.exif({ profileDescription: 'Adobe RGB', bitsPerSample: 14 })
|
||||
.files([AssetFileType.Preview, AssetFileType.Thumbnail])
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
@@ -490,9 +494,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate a thumbnail for a video', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||
@@ -532,9 +536,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should tonemap thumbnail for hdr video', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||
@@ -574,12 +578,12 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should always generate video thumbnail in one pass', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { twoPass: true, maxBitrate: '5000k' },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
@@ -600,9 +604,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should not skip intra frames for MTS file', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
@@ -618,9 +622,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should override reserved color metadata', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamReserved);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
@@ -638,10 +642,10 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should use scaling divisible by 2 even when using quick sync', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
@@ -658,7 +662,7 @@ describe(MediaService.name, () => {
|
||||
it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => {
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.${format}`;
|
||||
@@ -708,7 +712,7 @@ describe(MediaService.name, () => {
|
||||
it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => {
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`;
|
||||
@@ -760,7 +764,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: true }, thumbnail: { progressive: false } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -799,7 +803,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -834,12 +838,12 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should never set isProgressive for videos', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { progressive: true }, thumbnail: { progressive: true } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -860,7 +864,7 @@ describe(MediaService.name, () => {
|
||||
it('should delete previous thumbnail if different path', async () => {
|
||||
const asset = AssetFactory.from().exif().file({ type: AssetFileType.Preview }).build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -879,7 +883,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -896,7 +900,7 @@ describe(MediaService.name, () => {
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -910,7 +914,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -925,7 +929,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -941,7 +945,7 @@ describe(MediaService.name, () => {
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -958,7 +962,7 @@ describe(MediaService.name, () => {
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -977,7 +981,7 @@ describe(MediaService.name, () => {
|
||||
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1018,7 +1022,7 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1056,7 +1060,7 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1104,7 +1108,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1156,7 +1160,7 @@ describe(MediaService.name, () => {
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1187,7 +1191,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1219,7 +1223,7 @@ describe(MediaService.name, () => {
|
||||
})
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1264,7 +1268,7 @@ describe(MediaService.name, () => {
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1303,7 +1307,7 @@ describe(MediaService.name, () => {
|
||||
bitsPerSample: 14,
|
||||
})
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
@@ -1338,7 +1342,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should skip videos', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
@@ -1355,7 +1359,7 @@ describe(MediaService.name, () => {
|
||||
])
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
@@ -1377,7 +1381,7 @@ describe(MediaService.name, () => {
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } })
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
@@ -1405,7 +1409,7 @@ describe(MediaService.name, () => {
|
||||
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
|
||||
@@ -1423,7 +1427,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should generate all 3 edited files if an asset has edits', async () => {
|
||||
const asset = AssetFactory.from().exif().edit().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
@@ -1449,7 +1453,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should generate the original thumbhash if no edits exist', async () => {
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' });
|
||||
@@ -1459,7 +1463,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should apply thumbhash if job source is edit and edits exist', async () => {
|
||||
const asset = AssetFactory.from().exif().edit().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = factory.buffer();
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
@@ -2015,6 +2019,13 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transcode when policy bitrate and bitrate lower than max bitrate', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '50M' } });
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should transcode when policy bitrate and bitrate higher than max bitrate', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '30M' } });
|
||||
@@ -2030,19 +2041,18 @@ describe(MediaService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should transcode when max bitrate is not a number', async () => {
|
||||
it('should not transcode when max bitrate is not a number', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: 'foo' } });
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.any(Array),
|
||||
twoPass: false,
|
||||
}),
|
||||
);
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not transcode when max bitrate is 0', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream40Mbps);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Bitrate, maxBitrate: '0' } });
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not scale resolution if no target resolution', async () => {
|
||||
@@ -2244,7 +2254,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete existing transcode if current policy does not require transcoding', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
|
||||
const asset = AssetFactory.from({ type: AssetType.Video })
|
||||
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/video/path.mp4' })
|
||||
.build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
@@ -2254,7 +2266,7 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [asset.encodedVideoPath] },
|
||||
data: { files: ['/encoded/video/path.mp4'] },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2565,6 +2577,50 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('should skip transcoding for accepted audio codecs with optimal policy if video is fine', () => {
|
||||
const acceptedCodecs = [
|
||||
{ codec: 'aac', probeStub: probeStub.audioStreamAac },
|
||||
{ codec: 'mp3', probeStub: probeStub.audioStreamMp3 },
|
||||
{ codec: 'opus', probeStub: probeStub.audioStreamOpus },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: {
|
||||
targetVideoCodec: VideoCodec.Hevc,
|
||||
transcode: TranscodePolicy.Optimal,
|
||||
targetResolution: '1080p',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(acceptedCodecs)('should skip $codec', async ({ probeStub }) => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub);
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should use libopus audio encoder when target audio is opus', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.audioStreamAac);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: {
|
||||
targetAudioCodec: AudioCodec.Opus,
|
||||
transcode: TranscodePolicy.All,
|
||||
},
|
||||
});
|
||||
await sut.handleVideoConversion({ id: 'video-id' });
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.arrayContaining(['-c:a libopus']),
|
||||
twoPass: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should fail if hwaccel is enabled for an unsupported codec', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
@@ -3553,15 +3609,15 @@ describe(MediaService.name, () => {
|
||||
|
||||
describe('isSRGB', () => {
|
||||
it('should return true for srgb colorspace', () => {
|
||||
expect(sut.isSRGB({ colorspace: 'sRGB' } as Exif)).toEqual(true);
|
||||
expect(sut.isSRGB({ colorspace: 'sRGB' } as ShallowDehydrateObject<Exif>)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for srgb profile description', () => {
|
||||
expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as Exif)).toEqual(true);
|
||||
expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as ShallowDehydrateObject<Exif>)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for 8-bit image with no colorspace metadata', () => {
|
||||
expect(sut.isSRGB({ bitsPerSample: 8 } as Exif)).toEqual(true);
|
||||
expect(sut.isSRGB({ bitsPerSample: 8 } as ShallowDehydrateObject<Exif>)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for image with no colorspace or bit depth metadata', () => {
|
||||
@@ -3569,23 +3625,25 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should return false for non-srgb colorspace', () => {
|
||||
expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as Exif)).toEqual(false);
|
||||
expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as ShallowDehydrateObject<Exif>)).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return false for non-srgb profile description', () => {
|
||||
expect(sut.isSRGB({ profileDescription: 'sP3C' } as Exif)).toEqual(false);
|
||||
expect(sut.isSRGB({ profileDescription: 'sP3C' } as ShallowDehydrateObject<Exif>)).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return false for 16-bit image with no colorspace metadata', () => {
|
||||
expect(sut.isSRGB({ bitsPerSample: 16 } as Exif)).toEqual(false);
|
||||
expect(sut.isSRGB({ bitsPerSample: 16 } as ShallowDehydrateObject<Exif>)).toEqual(false);
|
||||
});
|
||||
|
||||
it('should return true for 16-bit image with sRGB colorspace', () => {
|
||||
expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true);
|
||||
expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject<Exif>)).toEqual(true);
|
||||
});
|
||||
|
||||
it('should return true for 16-bit image with sRGB profile', () => {
|
||||
expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true);
|
||||
expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject<Exif>)).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
VideoInterfaces,
|
||||
VideoStreamInfo,
|
||||
} from 'src/types';
|
||||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { getAssetFile, getDimensions } from 'src/utils/asset.util';
|
||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -258,7 +258,7 @@ export class MediaService extends BaseService {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) {
|
||||
private async decodeImage(thumbSource: string | Buffer, exifInfo: ThumbnailAsset['exifInfo'], targetSize?: number) {
|
||||
const { image } = await this.getConfig({ withCache: true });
|
||||
const colorspace = this.isSRGB(exifInfo) ? Colorspace.Srgb : image.colorspace;
|
||||
const decodeOptions: DecodeToBufferOptions = {
|
||||
@@ -605,10 +605,11 @@ export class MediaService extends BaseService {
|
||||
let { ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
||||
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
||||
if (asset.encodedVideoPath) {
|
||||
const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||
if (encodedVideo) {
|
||||
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [asset.encodedVideoPath] } });
|
||||
await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } });
|
||||
await this.assetRepository.deleteFiles([encodedVideo]);
|
||||
} else {
|
||||
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
||||
}
|
||||
@@ -656,7 +657,12 @@ export class MediaService extends BaseService {
|
||||
|
||||
this.logger.log(`Successfully encoded ${asset.id}`);
|
||||
|
||||
await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.EncodedVideo,
|
||||
path: output,
|
||||
isEdited: false,
|
||||
});
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -717,7 +723,8 @@ export class MediaService extends BaseService {
|
||||
const scalingEnabled = ffmpegConfig.targetResolution !== 'original';
|
||||
const targetRes = Number.parseInt(ffmpegConfig.targetResolution);
|
||||
const isLargerThanTargetRes = scalingEnabled && Math.min(stream.height, stream.width) > targetRes;
|
||||
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
||||
const maxBitrate = this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
||||
const isLargerThanTargetBitrate = maxBitrate > 0 && stream.bitrate > maxBitrate;
|
||||
|
||||
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
|
||||
const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p');
|
||||
@@ -753,7 +760,15 @@ export class MediaService extends BaseService {
|
||||
return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name);
|
||||
}
|
||||
|
||||
isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean {
|
||||
isSRGB({
|
||||
colorspace,
|
||||
profileDescription,
|
||||
bitsPerSample,
|
||||
}: {
|
||||
colorspace: string | null;
|
||||
profileDescription: string | null;
|
||||
bitsPerSample: number | null;
|
||||
}): boolean {
|
||||
if (colorspace || profileDescription) {
|
||||
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
|
||||
} else if (bitsPerSample) {
|
||||
@@ -769,6 +784,7 @@ export class MediaService extends BaseService {
|
||||
const bitrateValue = Number.parseInt(bitrateString);
|
||||
|
||||
if (Number.isNaN(bitrateValue)) {
|
||||
this.logger.log(`Maximum bitrate '${bitrateString} is not a number and will be ignored.`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { MemoryService } from 'src/services/memory.service';
|
||||
import { OnThisDayData } from 'src/types';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { MemoryFactory } from 'test/factories/memory.factory';
|
||||
import { getForMemory } from 'test/mappers';
|
||||
import { factory, newUuid, newUuids } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -27,11 +30,11 @@ describe(MemoryService.name, () => {
|
||||
describe('search', () => {
|
||||
it('should search memories', async () => {
|
||||
const [userId] = newUuids();
|
||||
const asset = factory.asset();
|
||||
const memory1 = factory.memory({ ownerId: userId, assets: [asset] });
|
||||
const memory2 = factory.memory({ ownerId: userId });
|
||||
const asset = AssetFactory.create();
|
||||
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
|
||||
const memory2 = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.search.mockResolvedValue([memory1, memory2]);
|
||||
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
|
||||
|
||||
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -64,9 +67,9 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should get a memory by id', async () => {
|
||||
const userId = newUuid();
|
||||
const memory = factory.memory({ ownerId: userId });
|
||||
const memory = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
mocks.memory.get.mockResolvedValue(getForMemory(memory));
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
|
||||
await expect(sut.get(factory.auth({ user: { id: userId } }), memory.id)).resolves.toMatchObject({
|
||||
@@ -81,9 +84,9 @@ describe(MemoryService.name, () => {
|
||||
describe('create', () => {
|
||||
it('should skip assets the user does not have access to', async () => {
|
||||
const [assetId, userId] = newUuids();
|
||||
const memory = factory.memory({ ownerId: userId });
|
||||
const memory = MemoryFactory.create({ ownerId: userId });
|
||||
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
mocks.memory.create.mockResolvedValue(getForMemory(memory));
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth({ user: { id: userId } }), {
|
||||
@@ -109,11 +112,11 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should create a memory', async () => {
|
||||
const [assetId, userId] = newUuids();
|
||||
const asset = factory.asset({ id: assetId, ownerId: userId });
|
||||
const memory = factory.memory({ assets: [asset] });
|
||||
const asset = AssetFactory.create({ id: assetId, ownerId: userId });
|
||||
const memory = MemoryFactory.from().asset(asset).build();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
mocks.memory.create.mockResolvedValue(getForMemory(memory));
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth({ user: { id: userId } }), {
|
||||
@@ -131,9 +134,9 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a memory without assets', async () => {
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.memory.create.mockResolvedValue(memory);
|
||||
mocks.memory.create.mockResolvedValue(getForMemory(memory));
|
||||
|
||||
await expect(
|
||||
sut.create(factory.auth(), {
|
||||
@@ -155,10 +158,10 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should update a memory', async () => {
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.memory.update.mockResolvedValue(memory);
|
||||
mocks.memory.update.mockResolvedValue(getForMemory(memory));
|
||||
|
||||
await expect(sut.update(factory.auth(), memory.id, { isSaved: true })).resolves.toBeDefined();
|
||||
|
||||
@@ -198,10 +201,10 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should require asset access', async () => {
|
||||
const assetId = newUuid();
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
mocks.memory.get.mockResolvedValue(getForMemory(memory));
|
||||
mocks.memory.getAssetIds.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([
|
||||
@@ -212,11 +215,11 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip assets already in the memory', async () => {
|
||||
const asset = factory.asset();
|
||||
const memory = factory.memory({ assets: [asset] });
|
||||
const asset = AssetFactory.create();
|
||||
const memory = MemoryFactory.from().asset(asset).build();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
mocks.memory.get.mockResolvedValue(getForMemory(memory));
|
||||
mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
||||
|
||||
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
@@ -228,12 +231,12 @@ describe(MemoryService.name, () => {
|
||||
|
||||
it('should add assets', async () => {
|
||||
const assetId = newUuid();
|
||||
const memory = factory.memory();
|
||||
const memory = MemoryFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.memory.get.mockResolvedValue(memory);
|
||||
mocks.memory.update.mockResolvedValue(memory);
|
||||
mocks.memory.get.mockResolvedValue(getForMemory(memory));
|
||||
mocks.memory.update.mockResolvedValue(getForMemory(memory));
|
||||
mocks.memory.getAssetIds.mockResolvedValue(new Set());
|
||||
mocks.memory.addAssetIds.mockResolvedValue();
|
||||
|
||||
@@ -266,14 +269,14 @@ describe(MemoryService.name, () => {
|
||||
});
|
||||
|
||||
it('should remove assets', async () => {
|
||||
const memory = factory.memory();
|
||||
const asset = factory.asset();
|
||||
const memory = MemoryFactory.create();
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.memory.removeAssetIds.mockResolvedValue();
|
||||
mocks.memory.update.mockResolvedValue(memory);
|
||||
mocks.memory.update.mockResolvedValue(getForMemory(memory));
|
||||
|
||||
await expect(sut.removeAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([
|
||||
{ id: asset.id, success: true },
|
||||
|
||||
@@ -19,6 +19,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -176,7 +177,7 @@ describe(MetadataService.name, () => {
|
||||
const originalDate = new Date('2023-11-21T16:13:17.517Z');
|
||||
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -198,7 +199,7 @@ describe(MetadataService.name, () => {
|
||||
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@@ -228,7 +229,7 @@ describe(MetadataService.name, () => {
|
||||
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
|
||||
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: fileModifiedAt,
|
||||
@@ -257,7 +258,7 @@ describe(MetadataService.name, () => {
|
||||
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
|
||||
process.env.TZ = 'America/Los_Angeles';
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -277,7 +278,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle lists of numbers', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: asset.fileModifiedAt,
|
||||
@@ -305,7 +306,7 @@ describe(MetadataService.name, () => {
|
||||
it('should not delete latituide and longitude without reverse geocode', async () => {
|
||||
// regression test for issue 17511
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
@@ -337,7 +338,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should apply reverse geocoding', async () => {
|
||||
const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
|
||||
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
@@ -367,7 +368,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should discard latitude and longitude on null island', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({
|
||||
GPSLatitude: 0,
|
||||
GPSLongitude: 0,
|
||||
@@ -383,7 +384,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from TagsList', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
|
||||
mockReadTags({ TagsList: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -395,7 +396,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from TagsList', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] });
|
||||
mockReadTags({ TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
@@ -417,7 +418,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a string', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
|
||||
mockReadTags({ Keywords: 'Parent' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -429,7 +430,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
|
||||
mockReadTags({ Keywords: ['Parent'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -441,7 +442,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from Keywords as a list with a number', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] });
|
||||
mockReadTags({ Keywords: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -454,7 +455,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchal tags from Keywords', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] });
|
||||
mockReadTags({ Keywords: 'Parent/Child' });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -474,7 +475,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore Keywords when TagsList is present', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Child'] });
|
||||
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -495,7 +496,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract hierarchy from HierarchicalSubject', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'TagA'] });
|
||||
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
@@ -522,7 +523,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] });
|
||||
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -535,7 +536,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] });
|
||||
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
|
||||
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
|
||||
@@ -551,7 +552,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore HierarchicalSubject when TagsList is present', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] });
|
||||
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
|
||||
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
|
||||
@@ -572,7 +573,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should remove existing tags', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -582,7 +583,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should not apply motion photos if asset is video', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -597,7 +598,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle an invalid Directory Item', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({
|
||||
MotionPhoto: 1,
|
||||
ContainerDirectory: [{ Foo: 100 }],
|
||||
@@ -608,7 +609,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract the correct video orientation', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
mockReadTags({});
|
||||
|
||||
@@ -624,7 +625,7 @@ describe(MetadataService.name, () => {
|
||||
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: asset.fileModifiedAt,
|
||||
@@ -686,7 +687,7 @@ describe(MetadataService.name, () => {
|
||||
mtimeMs: asset.fileModifiedAt.valueOf(),
|
||||
birthtimeMs: asset.fileCreatedAt.valueOf(),
|
||||
} as Stats);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
EmbeddedVideoFile: new BinaryField(0, ''),
|
||||
@@ -733,7 +734,7 @@ describe(MetadataService.name, () => {
|
||||
it('should extract the motion photo video from the XMP directory entry ', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.storage.stat.mockResolvedValue({
|
||||
size: 123_456,
|
||||
mtime: asset.fileModifiedAt,
|
||||
@@ -786,7 +787,7 @@ describe(MetadataService.name, () => {
|
||||
it('should delete old motion photo video assets if they do not match what is extracted', async () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@@ -808,7 +809,7 @@ describe(MetadataService.name, () => {
|
||||
it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
||||
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@@ -832,7 +833,7 @@ describe(MetadataService.name, () => {
|
||||
it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video });
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@@ -859,7 +860,7 @@ describe(MetadataService.name, () => {
|
||||
it('should not update storage usage if motion photo is external', async () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
|
||||
const asset = AssetFactory.create({ isExternal: true });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({
|
||||
Directory: 'foo/bar/',
|
||||
MotionPhoto: 1,
|
||||
@@ -904,7 +905,7 @@ describe(MetadataService.name, () => {
|
||||
Rating: 3,
|
||||
};
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags(tags);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -969,7 +970,7 @@ describe(MetadataService.name, () => {
|
||||
DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'),
|
||||
zone: undefined,
|
||||
};
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags(tags);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -984,7 +985,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should extract duration', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@@ -1007,7 +1008,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should only extract duration for videos', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@@ -1029,7 +1030,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should omit duration of zero', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@@ -1052,7 +1053,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should a handle duration of 1 week', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
format: {
|
||||
@@ -1075,7 +1076,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should use Duration from exif', async () => {
|
||||
const asset = AssetFactory.create({ originalFileName: 'file.webp' });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Duration: 123 }, {});
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1086,7 +1087,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should prefer Duration from exif over sidecar', async () => {
|
||||
const asset = AssetFactory.from({ originalFileName: 'file.webp' }).file({ type: AssetFileType.Sidecar }).build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
|
||||
mockReadTags({ Duration: 123 }, { Duration: 456 });
|
||||
|
||||
@@ -1098,7 +1099,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore all Duration tags for definitely static images', async () => {
|
||||
const asset = AssetFactory.from({ originalFileName: 'file.dng' }).build();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Duration: 123 }, { Duration: 456 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1109,7 +1110,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should ignore Duration from exif for videos', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Duration: 123 }, {});
|
||||
mocks.media.probe.mockResolvedValue({
|
||||
...probeStub.videoStreamH264,
|
||||
@@ -1127,7 +1128,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should trim whitespace from description', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Description: '\t \v \f \n \r' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1150,7 +1151,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle a numeric description', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Description: 1000 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1164,7 +1165,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should skip importing metadata when the feature is disabled', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
|
||||
mockReadTags(makeFaceTags({ Name: 'Person 1' }));
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1173,7 +1174,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags();
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1182,7 +1183,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should skip importing faces without name', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags());
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
@@ -1195,7 +1196,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should skip importing faces with empty name', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: '' }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
@@ -1210,7 +1211,7 @@ describe(MetadataService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
@@ -1252,7 +1253,7 @@ describe(MetadataService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]);
|
||||
@@ -1339,7 +1340,7 @@ describe(MetadataService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
@@ -1383,7 +1384,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle invalid modify date', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ ModifyDate: '00:00:00.000' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1397,7 +1398,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle invalid rating value', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Rating: 6 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1411,7 +1412,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle valid rating value', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Rating: 5 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1423,9 +1424,23 @@ describe(MetadataService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle 0 as unrated -> null', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Rating: 0 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
rating: null,
|
||||
}),
|
||||
{ lockedPropertiesBehavior: 'skip' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle valid negative rating value', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ Rating: -1 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1439,7 +1454,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should handle livePhotoCID not set', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
@@ -1454,7 +1469,7 @@ describe(MetadataService.name, () => {
|
||||
it('should handle not finding a match', async () => {
|
||||
const asset = AssetFactory.create({ type: AssetType.Video });
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1476,7 +1491,7 @@ describe(MetadataService.name, () => {
|
||||
it('should link photo and video', async () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video });
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
@@ -1504,7 +1519,7 @@ describe(MetadataService.name, () => {
|
||||
it('should notify clients on live photo link', async () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video });
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
@@ -1519,7 +1534,7 @@ describe(MetadataService.name, () => {
|
||||
it('should search by libraryId', async () => {
|
||||
const motionAsset = AssetFactory.create({ type: AssetType.Video, libraryId: 'library-id' });
|
||||
const asset = AssetFactory.create({ libraryId: 'library-id' });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
@@ -1554,9 +1569,14 @@ describe(MetadataService.name, () => {
|
||||
expected: { make: '1', model: '2' },
|
||||
},
|
||||
{ exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } },
|
||||
{ exif: { DeviceManufacturer: '1', DeviceModelName: '2' }, expected: { make: '1', model: '2' } },
|
||||
{
|
||||
exif: { Make: '1', Model: '2', DeviceManufacturer: '3', DeviceModelName: '4' },
|
||||
expected: { make: '1', model: '2' },
|
||||
},
|
||||
])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags(exif);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1581,7 +1601,7 @@ describe(MetadataService.name, () => {
|
||||
{ exif: { LensID: '' }, expected: null },
|
||||
])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags(exif);
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1595,7 +1615,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should properly set width/height for normal images', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1609,7 +1629,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should properly swap asset width/height for rotated images', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1623,7 +1643,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should not overwrite existing width/height if they already exist', async () => {
|
||||
const asset = AssetFactory.create({ width: 1920, height: 1080 });
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
|
||||
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
@@ -1740,17 +1760,20 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should skip jobs with no metadata', async () => {
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
|
||||
const asset = factory.jobAssets.sidecarWrite();
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build();
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
|
||||
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
|
||||
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should write tags', async () => {
|
||||
const asset = factory.jobAssets.sidecarWrite();
|
||||
const description = 'this is a description';
|
||||
const gps = 12;
|
||||
const date = '2023-11-21T22:56:12.196-06:00';
|
||||
const asset = AssetFactory.from()
|
||||
.file({ type: AssetFileType.Sidecar })
|
||||
.exif({ description, dateTimeOriginal: new Date(date), latitude: gps, longitude: gps })
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([
|
||||
'description',
|
||||
@@ -1759,7 +1782,7 @@ describe(MetadataService.name, () => {
|
||||
'dateTimeOriginal',
|
||||
'timeZone',
|
||||
]);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
|
||||
await expect(
|
||||
sut.handleSidecarWrite({
|
||||
id: asset.id,
|
||||
@@ -1780,6 +1803,28 @@ describe(MetadataService.name, () => {
|
||||
'timeZone',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should write rating', async () => {
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build();
|
||||
asset.exifInfo.rating = 4;
|
||||
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
|
||||
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 });
|
||||
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
|
||||
});
|
||||
|
||||
it('should write null rating as 0', async () => {
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build();
|
||||
asset.exifInfo.rating = null;
|
||||
|
||||
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
|
||||
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
|
||||
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 });
|
||||
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstDateTime', () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { constants } from 'node:fs/promises';
|
||||
import { join, parse } from 'node:path';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset, AssetFace, AssetFile } from 'src/database';
|
||||
import { Asset, AssetFile } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -289,8 +289,10 @@ export class MetadataService extends BaseService {
|
||||
colorspace: exifTags.ColorSpace === undefined ? null : String(exifTags.ColorSpace),
|
||||
|
||||
// camera
|
||||
make: exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? null,
|
||||
model: exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? null,
|
||||
make:
|
||||
exifTags.Make ?? exifTags.Device?.Manufacturer ?? exifTags.AndroidMake ?? (exifTags.DeviceManufacturer || null),
|
||||
model:
|
||||
exifTags.Model ?? exifTags.Device?.ModelName ?? exifTags.AndroidModel ?? (exifTags.DeviceModelName || null),
|
||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||
iso: validate(exifTags.ISO) as number,
|
||||
exposureTime: exifTags.ExposureTime ?? null,
|
||||
@@ -301,7 +303,7 @@ export class MetadataService extends BaseService {
|
||||
// comments
|
||||
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
||||
profileDescription: exifTags.ProfileDescription || null,
|
||||
rating: validateRange(exifTags.Rating, -1, 5),
|
||||
rating: exifTags.Rating === 0 ? null : validateRange(exifTags.Rating, -1, 5),
|
||||
|
||||
// grouping
|
||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||
@@ -447,11 +449,10 @@ export class MetadataService extends BaseService {
|
||||
const { description, dateTimeOriginal, latitude, longitude, rating, tags, timeZone } = _.pick(
|
||||
{
|
||||
description: asset.exifInfo.description,
|
||||
// the kysely type is wrong here; fixed in 0.28.3
|
||||
dateTimeOriginal: asset.exifInfo.dateTimeOriginal as string | null,
|
||||
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
|
||||
latitude: asset.exifInfo.latitude,
|
||||
longitude: asset.exifInfo.longitude,
|
||||
rating: asset.exifInfo.rating,
|
||||
rating: asset.exifInfo.rating ?? 0,
|
||||
tags: asset.exifInfo.tags,
|
||||
timeZone: asset.exifInfo.timeZone,
|
||||
},
|
||||
@@ -829,7 +830,7 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
private async applyTaggedFaces(
|
||||
asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string },
|
||||
asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string },
|
||||
tags: ImmichTags,
|
||||
) {
|
||||
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { notificationStub } from 'test/fixtures/notification.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { getForAlbum } from 'test/mappers';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -269,14 +270,14 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if recipient could not be found', async () => {
|
||||
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
|
||||
|
||||
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip if the recipient has email notifications disabled', async () => {
|
||||
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
metadata: [
|
||||
@@ -292,7 +293,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if the recipient has email notifications for album invite disabled', async () => {
|
||||
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
metadata: [
|
||||
@@ -308,7 +309,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
|
||||
it('should send invite email', async () => {
|
||||
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
metadata: [
|
||||
@@ -331,7 +332,7 @@ describe(NotificationService.name, () => {
|
||||
|
||||
it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => {
|
||||
const album = AlbumFactory.create({ albumThumbnailAssetId: newUuid() });
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
metadata: [
|
||||
@@ -363,7 +364,7 @@ describe(NotificationService.name, () => {
|
||||
it('should send invite email with album thumbnail as jpeg', async () => {
|
||||
const assetFile = AssetFileFactory.create({ type: AssetFileType.Thumbnail });
|
||||
const album = AlbumFactory.create({ albumThumbnailAssetId: assetFile.assetId });
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
metadata: [
|
||||
@@ -394,8 +395,10 @@ describe(NotificationService.name, () => {
|
||||
|
||||
it('should send invite email with album thumbnail and arbitrary extension', async () => {
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build();
|
||||
const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).asset(asset).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id })
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue({
|
||||
...userStub.user1,
|
||||
metadata: [
|
||||
@@ -432,7 +435,7 @@ describe(NotificationService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip if owner could not be found', async () => {
|
||||
mocks.album.getById.mockResolvedValue(AlbumFactory.create({ ownerId: 'non-existent' }));
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create({ ownerId: 'non-existent' })));
|
||||
|
||||
await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped);
|
||||
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
||||
@@ -440,7 +443,7 @@ describe(NotificationService.name, () => {
|
||||
|
||||
it('should skip recipient that could not be looked up', async () => {
|
||||
const album = AlbumFactory.from().albumUser({ userId: 'non-existent' }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValueOnce(album.owner);
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
@@ -459,7 +462,7 @@ describe(NotificationService.name, () => {
|
||||
})
|
||||
.build();
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
@@ -478,7 +481,7 @@ describe(NotificationService.name, () => {
|
||||
})
|
||||
.build();
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
@@ -492,7 +495,7 @@ describe(NotificationService.name, () => {
|
||||
it('should send email', async () => {
|
||||
const user = UserFactory.create();
|
||||
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
|
||||
mocks.album.getById.mockResolvedValue(album);
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { getForPartner } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(PartnerService.name, () => {
|
||||
@@ -18,26 +21,26 @@ describe(PartnerService.name, () => {
|
||||
|
||||
describe('search', () => {
|
||||
it("should return a list of partners with whom I've shared my library", async () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
||||
|
||||
await expect(sut.search(auth, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined();
|
||||
expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id);
|
||||
});
|
||||
|
||||
it('should return a list of partners who have shared their libraries with me', async () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const sharedWithUser2 = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const sharedWithUser1 = PartnerFactory.from().sharedBy(user2).sharedWith(user1).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
|
||||
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
|
||||
expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id);
|
||||
});
|
||||
@@ -45,13 +48,13 @@ describe(PartnerService.name, () => {
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new partner', async () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.get.mockResolvedValue(void 0);
|
||||
mocks.partner.create.mockResolvedValue(partner);
|
||||
mocks.partner.create.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined();
|
||||
|
||||
@@ -62,12 +65,12 @@ describe(PartnerService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error when the partner already exists', async () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.get.mockResolvedValue(partner);
|
||||
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
@@ -77,12 +80,12 @@ describe(PartnerService.name, () => {
|
||||
|
||||
describe('remove', () => {
|
||||
it('should remove a partner', async () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.partner.get.mockResolvedValue(partner);
|
||||
mocks.partner.get.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
await sut.remove(auth, user2.id);
|
||||
|
||||
@@ -90,8 +93,8 @@ describe(PartnerService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error when the partner does not exist', async () => {
|
||||
const user2 = factory.user();
|
||||
const auth = factory.auth();
|
||||
const user2 = UserFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.partner.get.mockResolvedValue(void 0);
|
||||
|
||||
@@ -103,20 +106,20 @@ describe(PartnerService.name, () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('should require access', async () => {
|
||||
const user2 = factory.user();
|
||||
const auth = factory.auth();
|
||||
const user2 = UserFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should update partner', async () => {
|
||||
const user1 = factory.user();
|
||||
const user2 = factory.user();
|
||||
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
|
||||
const auth = factory.auth({ user: { id: user1.id } });
|
||||
const user1 = UserFactory.create();
|
||||
const user2 = UserFactory.create();
|
||||
const partner = PartnerFactory.from().sharedBy(user1).sharedWith(user2).build();
|
||||
const auth = AuthFactory.create({ id: user1.id });
|
||||
|
||||
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
|
||||
mocks.partner.update.mockResolvedValue(partner);
|
||||
mocks.partner.update.mockResolvedValue(getForPartner(partner));
|
||||
|
||||
await expect(sut.update(auth, user2.id, { inTimeline: true })).resolves.toBeDefined();
|
||||
expect(mocks.partner.update).toHaveBeenCalledWith(
|
||||
|
||||
@@ -49,9 +49,8 @@ export class PartnerService extends BaseService {
|
||||
|
||||
private mapPartner(partner: Partner, direction: PartnerDirection): PartnerResponseDto {
|
||||
// this is opposite to return the non-me user of the "partner"
|
||||
const user = mapUser(
|
||||
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,
|
||||
) as PartnerResponseDto;
|
||||
const sharedUser = direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy;
|
||||
const user = mapUser(sharedUser);
|
||||
|
||||
return { ...user, inTimeline: partner.inTimeline };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers';
|
||||
import { getAsDetectedFace, getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob } from 'test/mappers';
|
||||
import { newDate, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -202,16 +202,16 @@ describe(PersonService.name, () => {
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
||||
await expect(sut.update(auth, person.id, { birthDate: '1976-06-30' })).resolves.toEqual({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
birthDate: '1976-06-30',
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') });
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: '1976-06-30' });
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
@@ -319,7 +319,7 @@ describe(PersonService.name, () => {
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||
mocks.person.getFacesByIds.mockResolvedValue([face]);
|
||||
mocks.person.getFacesByIds.mockResolvedValue([getForAssetFace(face)]);
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
@@ -353,15 +353,17 @@ describe(PersonService.name, () => {
|
||||
const face = AssetFaceFactory.create();
|
||||
const asset = AssetFactory.from({ id: face.assetId }).exif().build();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.person.getFaces.mockResolvedValue([face]);
|
||||
mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]);
|
||||
mocks.asset.getForFaces.mockResolvedValue({ edits: [], ...asset.exifInfo });
|
||||
await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([mapFaces(face, auth)]);
|
||||
await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([
|
||||
mapFaces(getForAssetFace(face), auth),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should reject if the user has not access to the asset', async () => {
|
||||
const face = AssetFaceFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.person.getFaces.mockResolvedValue([face]);
|
||||
mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]);
|
||||
await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
@@ -390,7 +392,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||
mocks.person.getFaceById.mockResolvedValue(face);
|
||||
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
|
||||
@@ -400,7 +402,7 @@ describe(PersonService.name, () => {
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
updatedAt: expect.any(Date),
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
@@ -412,7 +414,7 @@ describe(PersonService.name, () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.person.getFaceById.mockResolvedValue(face);
|
||||
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(
|
||||
@@ -735,18 +737,18 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip when no resize path', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||
await sut.handleDetectFaces({ id: asset.id });
|
||||
expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle no results', async () => {
|
||||
const start = Date.now();
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build();
|
||||
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] });
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||
await sut.handleDetectFaces({ id: asset.id });
|
||||
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
|
||||
asset.files[0].path,
|
||||
@@ -764,12 +766,12 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should create a face with no person and queue recognition job', async () => {
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build();
|
||||
const face = AssetFaceFactory.create({ assetId: asset.id });
|
||||
mocks.crypto.randomUUID.mockReturnValue(face.id);
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
|
||||
mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleDetectFaces({ id: asset.id });
|
||||
@@ -788,9 +790,9 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete an existing face not among the new detected faces', async () => {
|
||||
const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build();
|
||||
const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).exif().build();
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 });
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||
|
||||
await sut.handleDetectFaces({ id: asset.id });
|
||||
|
||||
@@ -809,9 +811,9 @@ describe(PersonService.name, () => {
|
||||
boundingBoxY1: 200,
|
||||
boundingBoxY2: 300,
|
||||
});
|
||||
const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).build();
|
||||
const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).exif().build();
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||
mocks.crypto.randomUUID.mockReturnValue(face.id);
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
|
||||
@@ -832,9 +834,9 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should add embedding to matching metadata face', async () => {
|
||||
const face = AssetFaceFactory.create({ sourceType: SourceType.Exif });
|
||||
const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).build();
|
||||
const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).exif().build();
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleDetectFaces({ id: asset.id });
|
||||
@@ -848,9 +850,9 @@ describe(PersonService.name, () => {
|
||||
it('should not add embedding to non-matching metadata face', async () => {
|
||||
const assetId = newUuid();
|
||||
const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif });
|
||||
const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).build();
|
||||
const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).exif().build();
|
||||
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
|
||||
mocks.crypto.randomUUID.mockReturnValue(face.id);
|
||||
|
||||
await sut.handleDetectFaces({ id: asset.id });
|
||||
@@ -1237,7 +1239,7 @@ describe(PersonService.name, () => {
|
||||
const person = PersonFactory.create({ ownerId: user.id });
|
||||
const face = AssetFaceFactory.from().person(person).build();
|
||||
|
||||
expect(mapFaces(face, auth)).toEqual({
|
||||
expect(mapFaces(getForAssetFace(face), auth)).toEqual({
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY1: 100,
|
||||
@@ -1251,11 +1253,13 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should not map person if person is null', () => {
|
||||
expect(mapFaces(AssetFaceFactory.create(), AuthFactory.create()).person).toBeNull();
|
||||
expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull();
|
||||
});
|
||||
|
||||
it('should not map person if person does not match auth user id', () => {
|
||||
expect(mapFaces(AssetFaceFactory.from().person().build(), AuthFactory.create()).person).toBeNull();
|
||||
expect(
|
||||
mapFaces(getForAssetFace(AssetFaceFactory.from().person().build()), AuthFactory.create()).person,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -491,7 +491,7 @@ export class PersonService extends BaseService {
|
||||
embedding: face.faceSearch.embedding,
|
||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||
numResults: machineLearning.facialRecognition.minFaces,
|
||||
minBirthDate: face.asset.fileCreatedAt ?? undefined,
|
||||
minBirthDate: new Date(face.asset.fileCreatedAt),
|
||||
});
|
||||
|
||||
// `matches` also includes the face itself
|
||||
@@ -519,7 +519,7 @@ export class PersonService extends BaseService {
|
||||
maxDistance: machineLearning.facialRecognition.maxDistance,
|
||||
numResults: 1,
|
||||
hasPerson: true,
|
||||
minBirthDate: face.asset.fileCreatedAt ?? undefined,
|
||||
minBirthDate: new Date(face.asset.fileCreatedAt),
|
||||
});
|
||||
|
||||
if (matchWithPerson.length > 0) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { SearchService } from 'src/services/search.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { beforeEach, vitest } from 'vitest';
|
||||
|
||||
@@ -74,7 +75,9 @@ describe(SearchService.name, () => {
|
||||
items: [{ value: 'city', data: asset.id }],
|
||||
});
|
||||
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
|
||||
const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }];
|
||||
const expectedResponse = [
|
||||
{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] },
|
||||
];
|
||||
|
||||
const result = await sut.getExploreData(auth);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { JobStatus } from 'src/enum';
|
||||
import { SessionService } from 'src/services/session.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { SessionFactory } from 'test/factories/session.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe('SessionService', () => {
|
||||
@@ -25,9 +26,9 @@ describe('SessionService', () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get the devices', async () => {
|
||||
const currentSession = factory.session();
|
||||
const otherSession = factory.session();
|
||||
const auth = factory.auth({ session: currentSession });
|
||||
const currentSession = SessionFactory.create();
|
||||
const otherSession = SessionFactory.create();
|
||||
const auth = AuthFactory.from().session(currentSession).build();
|
||||
|
||||
mocks.session.getByUserId.mockResolvedValue([currentSession, otherSession]);
|
||||
|
||||
@@ -42,8 +43,8 @@ describe('SessionService', () => {
|
||||
|
||||
describe('logoutDevices', () => {
|
||||
it('should logout all devices', async () => {
|
||||
const currentSession = factory.session();
|
||||
const auth = factory.auth({ session: currentSession });
|
||||
const currentSession = SessionFactory.create();
|
||||
const auth = AuthFactory.from().session(currentSession).build();
|
||||
|
||||
mocks.session.invalidate.mockResolvedValue();
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { mapSharedLink } from 'src/dtos/shared-link.dto';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
||||
import { getForSharedLink } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -24,11 +26,13 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all shared links for a user', async () => {
|
||||
mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
|
||||
await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([
|
||||
sharedLinkResponseStub.expired,
|
||||
sharedLinkResponseStub.valid,
|
||||
]);
|
||||
const [sharedLink1, sharedLink2] = [SharedLinkFactory.create(), SharedLinkFactory.create()];
|
||||
mocks.sharedLink.getAll.mockResolvedValue([getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)]);
|
||||
await expect(sut.getAll(authStub.user1, {})).resolves.toEqual(
|
||||
[getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)].map((link) =>
|
||||
mapSharedLink(link, { stripAssetMetadata: false }),
|
||||
),
|
||||
);
|
||||
expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id });
|
||||
});
|
||||
});
|
||||
@@ -41,8 +45,11 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
it('should return the shared link for the public user', async () => {
|
||||
const authDto = authStub.adminSharedLink;
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
const sharedLink = SharedLinkFactory.create();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
await expect(sut.getMine(authDto, [])).resolves.toEqual(
|
||||
mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: false }),
|
||||
);
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
});
|
||||
|
||||
@@ -54,7 +61,13 @@ describe(SharedLinkService.name, () => {
|
||||
allowUpload: true,
|
||||
},
|
||||
});
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
|
||||
mocks.sharedLink.get.mockResolvedValue(
|
||||
getForSharedLink(
|
||||
SharedLinkFactory.from({ showExif: false })
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build(),
|
||||
),
|
||||
);
|
||||
const response = await sut.getMine(authDto, []);
|
||||
expect(response.assets[0]).toMatchObject({ hasMetadata: false });
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
|
||||
@@ -68,7 +81,8 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
it('should accept a valid shared link auth token', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
|
||||
const sharedLink = SharedLinkFactory.create({ password: '123' });
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
const secret = Buffer.from('auth-token-123');
|
||||
mocks.crypto.hashSha256.mockReturnValue(secret);
|
||||
await expect(sut.getMine(authStub.adminSharedLink, [secret.toString('base64')])).resolves.toBeDefined();
|
||||
@@ -90,9 +104,12 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
it('should get a shared link by id', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid);
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
|
||||
const sharedLink = SharedLinkFactory.create();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
await expect(sut.get(authStub.user1, sharedLink.id)).resolves.toEqual(
|
||||
mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: true }),
|
||||
);
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -123,8 +140,9 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
it('should create an album shared link', async () => {
|
||||
const album = AlbumFactory.from().asset().build();
|
||||
const sharedLink = SharedLinkFactory.from().album(album).build();
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
|
||||
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid);
|
||||
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
|
||||
await sut.create(authStub.admin, { type: SharedLinkType.Album, albumId: album.id });
|
||||
|
||||
@@ -145,8 +163,11 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
it('should create an individual shared link', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const sharedLink = SharedLinkFactory.from()
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
type: SharedLinkType.Individual,
|
||||
@@ -178,8 +199,11 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
it('should create a shared link with allowDownload set to false when showMetadata is false', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const sharedLink = SharedLinkFactory.from({ allowDownload: false })
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual);
|
||||
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
|
||||
await sut.create(authStub.admin, {
|
||||
type: SharedLinkType.Individual,
|
||||
@@ -221,8 +245,9 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
it('should update a shared link', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid);
|
||||
const sharedLink = SharedLinkFactory.create();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
|
||||
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
|
||||
|
||||
@@ -247,19 +272,21 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
it('should remove a key', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
const sharedLink = SharedLinkFactory.create();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.sharedLink.remove.mockResolvedValue();
|
||||
|
||||
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
|
||||
await sut.remove(authStub.user1, sharedLink.id);
|
||||
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
|
||||
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid.id);
|
||||
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id);
|
||||
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLink.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAssets', () => {
|
||||
it('should not work on album shared links', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
const sharedLink = SharedLinkFactory.from().album().build();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
|
||||
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -268,11 +295,13 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
it('should add assets to a shared link', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const sharedLink = SharedLinkFactory.from().asset(asset).build();
|
||||
const sharedLink = SharedLinkFactory.from()
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
const newAsset = AssetFactory.create();
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLink);
|
||||
mocks.sharedLink.create.mockResolvedValue(sharedLink);
|
||||
mocks.sharedLink.update.mockResolvedValue(sharedLink);
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([newAsset.id]));
|
||||
|
||||
await expect(
|
||||
@@ -286,7 +315,7 @@ describe(SharedLinkService.name, () => {
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.sharedLink.update).toHaveBeenCalled();
|
||||
expect(mocks.sharedLink.update).toHaveBeenCalledWith({
|
||||
...sharedLink,
|
||||
...getForSharedLink(sharedLink),
|
||||
slug: null,
|
||||
assetIds: [newAsset.id],
|
||||
});
|
||||
@@ -295,19 +324,22 @@ describe(SharedLinkService.name, () => {
|
||||
|
||||
describe('removeAssets', () => {
|
||||
it('should not work on album shared links', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
|
||||
const sharedLink = SharedLinkFactory.from().album().build();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
|
||||
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
await expect(sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should remove assets from a shared link', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const sharedLink = SharedLinkFactory.from().asset(asset).build();
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLink);
|
||||
mocks.sharedLink.create.mockResolvedValue(sharedLink);
|
||||
mocks.sharedLink.update.mockResolvedValue(sharedLink);
|
||||
const sharedLink = SharedLinkFactory.from()
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]);
|
||||
|
||||
await expect(
|
||||
@@ -338,11 +370,14 @@ describe(SharedLinkService.name, () => {
|
||||
});
|
||||
|
||||
it('should return metadata tags', async () => {
|
||||
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual);
|
||||
const sharedLink = SharedLinkFactory.from({ description: null })
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
|
||||
|
||||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||
description: '1 shared photos & videos',
|
||||
imageUrl: `https://my.immich.app/api/assets/${sharedLinkStub.individual.assets[0].id}/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
|
||||
imageUrl: `https://my.immich.app/api/assets/${sharedLink.assets[0].id}/thumbnail?key=${sharedLink.key.toString('base64url')}`,
|
||||
title: 'Public Share',
|
||||
});
|
||||
|
||||
|
||||
@@ -150,6 +150,12 @@ export class SharedLinkService extends BaseService {
|
||||
}
|
||||
|
||||
async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
|
||||
if (auth.sharedLink) {
|
||||
this.logger.deprecate(
|
||||
'Assets uploaded using shared link authentication are now automatically added to the shared link during upload and in the next major release this endpoint will no longer accept shared link authentication',
|
||||
);
|
||||
}
|
||||
|
||||
const sharedLink = await this.findOrFail(auth.user.id, id);
|
||||
|
||||
if (sharedLink.type !== SharedLinkType.Individual) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { StackFactory } from 'test/factories/stack.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForStack } from 'test/mappers';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -22,9 +23,11 @@ describe(StackService.name, () => {
|
||||
describe('search', () => {
|
||||
it('should search stacks', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const asset = AssetFactory.create();
|
||||
const stack = StackFactory.from().primaryAsset(asset).build();
|
||||
mocks.stack.search.mockResolvedValue([stack]);
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
const stack = StackFactory.from()
|
||||
.primaryAsset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
mocks.stack.search.mockResolvedValue([getForStack(stack)]);
|
||||
|
||||
await sut.search(auth, { primaryAssetId: asset.id });
|
||||
expect(mocks.stack.search).toHaveBeenCalledWith({
|
||||
@@ -49,11 +52,14 @@ describe(StackService.name, () => {
|
||||
|
||||
it('should create a stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||
const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()];
|
||||
const stack = StackFactory.from()
|
||||
.primaryAsset(primaryAsset, (builder) => builder.exif())
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id]));
|
||||
mocks.stack.create.mockResolvedValue(stack);
|
||||
mocks.stack.create.mockResolvedValue(getForStack(stack));
|
||||
|
||||
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
|
||||
id: stack.id,
|
||||
@@ -88,11 +94,14 @@ describe(StackService.name, () => {
|
||||
|
||||
it('should get stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||
const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()];
|
||||
const stack = StackFactory.from()
|
||||
.primaryAsset(primaryAsset, (builder) => builder.exif())
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
|
||||
mocks.stack.getById.mockResolvedValue(stack);
|
||||
mocks.stack.getById.mockResolvedValue(getForStack(stack));
|
||||
|
||||
await expect(sut.get(auth, stack.id)).resolves.toEqual({
|
||||
id: stack.id,
|
||||
@@ -125,10 +134,13 @@ describe(StackService.name, () => {
|
||||
|
||||
it('should fail if the provided primary asset id is not in the stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const stack = StackFactory.from().primaryAsset().asset().build();
|
||||
const stack = StackFactory.from()
|
||||
.primaryAsset({}, (builder) => builder.exif())
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
|
||||
mocks.stack.getById.mockResolvedValue(stack);
|
||||
mocks.stack.getById.mockResolvedValue(getForStack(stack));
|
||||
|
||||
await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
@@ -141,12 +153,15 @@ describe(StackService.name, () => {
|
||||
|
||||
it('should update stack', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
|
||||
const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()];
|
||||
const stack = StackFactory.from()
|
||||
.primaryAsset(primaryAsset, (builder) => builder.exif())
|
||||
.asset(asset, (builder) => builder.exif())
|
||||
.build();
|
||||
|
||||
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
|
||||
mocks.stack.getById.mockResolvedValue(stack);
|
||||
mocks.stack.update.mockResolvedValue(stack);
|
||||
mocks.stack.getById.mockResolvedValue(getForStack(stack));
|
||||
mocks.stack.update.mockResolvedValue(getForStack(stack));
|
||||
|
||||
await sut.update(auth, stack.id, { primaryAssetId: asset.id });
|
||||
|
||||
|
||||
@@ -6,9 +6,12 @@ import { AlbumFactory } from 'test/factories/album.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { getForStorageTemplate } from 'test/mappers';
|
||||
import { getForAlbum, getForStorageTemplate } from 'test/mappers';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build();
|
||||
const stillAsset = AssetFactory.from({ livePhotoVideoId: motionAsset.id }).exif().build();
|
||||
|
||||
describe(StorageTemplateService.name, () => {
|
||||
let sut: StorageTemplateService;
|
||||
let mocks: ServiceMocks;
|
||||
@@ -153,10 +156,66 @@ describe(StorageTemplateService.name, () => {
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
||||
});
|
||||
|
||||
it('should migrate live photo motion video alongside the still image using album in path', async () => {
|
||||
const motionAsset = AssetFactory.from({
|
||||
type: AssetType.Video,
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
const stillAsset = AssetFactory.from({
|
||||
livePhotoVideoId: motionAsset.id,
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
|
||||
const album = AlbumFactory.from()
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
||||
sut.onConfigInit({ newConfig: config });
|
||||
|
||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
||||
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
|
||||
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
entityId: stillAsset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: stillAsset.originalPath,
|
||||
newPath: newStillPicturePath,
|
||||
});
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '124',
|
||||
entityId: motionAsset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: motionAsset.originalPath,
|
||||
newPath: newMotionPicturePath,
|
||||
});
|
||||
|
||||
await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
||||
});
|
||||
|
||||
it('should use handlebar if condition for album', async () => {
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from().owner(user).exif().build();
|
||||
const album = AlbumFactory.from().asset().build();
|
||||
const album = AlbumFactory.from()
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
||||
|
||||
@@ -164,7 +223,7 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
||||
mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]);
|
||||
|
||||
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
|
||||
|
||||
@@ -204,7 +263,9 @@ describe(StorageTemplateService.name, () => {
|
||||
it('should handle album startDate', async () => {
|
||||
const user = UserFactory.create();
|
||||
const asset = AssetFactory.from().owner(user).exif().build();
|
||||
const album = AlbumFactory.from().asset().build();
|
||||
const album = AlbumFactory.from()
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template =
|
||||
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
|
||||
@@ -213,7 +274,7 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
|
||||
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
|
||||
mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]);
|
||||
mocks.album.getMetadataForIds.mockResolvedValueOnce([
|
||||
{
|
||||
startDate: asset.fileCreatedAt,
|
||||
@@ -709,12 +770,20 @@ describe(StorageTemplateService.name, () => {
|
||||
})
|
||||
.exif()
|
||||
.build();
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
|
||||
const album = AlbumFactory.from()
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
||||
sut.onConfigInit({ newConfig: config });
|
||||
|
||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
|
||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
@@ -735,11 +804,55 @@ describe(StorageTemplateService.name, () => {
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id);
|
||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
||||
});
|
||||
|
||||
it('should use still photo album info when migrating live photo motion video', async () => {
|
||||
const user = userStub.user1;
|
||||
const album = AlbumFactory.from()
|
||||
.asset({}, (builder) => builder.exif())
|
||||
.build();
|
||||
const config = structuredClone(defaults);
|
||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}';
|
||||
|
||||
sut.onConfigInit({ newConfig: config });
|
||||
|
||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '123',
|
||||
entityId: stillAsset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: stillAsset.originalPath,
|
||||
newPath: `/data/library/${user.id}/2022/${album.albumName}/${stillAsset.originalFileName}`,
|
||||
});
|
||||
|
||||
mocks.move.create.mockResolvedValueOnce({
|
||||
id: '124',
|
||||
entityId: motionAsset.id,
|
||||
pathType: AssetPathType.Original,
|
||||
oldPath: motionAsset.originalPath,
|
||||
newPath: `/data/library/${user.id}/2022/${album.albumName}/${motionAsset.originalFileName}`,
|
||||
});
|
||||
|
||||
await sut.handleMigration();
|
||||
|
||||
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
|
||||
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: stillAsset.id,
|
||||
originalPath: expect.stringContaining(`/${album.albumName}/`),
|
||||
});
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
||||
id: motionAsset.id,
|
||||
originalPath: expect.stringContaining(`/${album.albumName}/`),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('file rename correctness', () => {
|
||||
|
||||
@@ -158,12 +158,14 @@ export class StorageTemplateService extends BaseService {
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
|
||||
includeHidden: true,
|
||||
});
|
||||
if (!livePhotoVideo) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
|
||||
}
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -191,10 +193,12 @@ export class StorageTemplateService extends BaseService {
|
||||
|
||||
// move motion part of live photo
|
||||
if (asset.livePhotoVideoId) {
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
|
||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
|
||||
includeHidden: true,
|
||||
});
|
||||
if (livePhotoVideo) {
|
||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -214,7 +218,7 @@ export class StorageTemplateService extends BaseService {
|
||||
await this.moveRepository.cleanMoveHistorySingle(assetId);
|
||||
}
|
||||
|
||||
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) {
|
||||
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata, stillPhoto?: StorageAsset) {
|
||||
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
|
||||
// External assets are not affected by storage template
|
||||
// TODO: shouldn't this only apply to external assets?
|
||||
@@ -224,7 +228,7 @@ export class StorageTemplateService extends BaseService {
|
||||
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
|
||||
const { id, originalPath, checksum, fileSizeInByte } = asset;
|
||||
const oldPath = originalPath;
|
||||
const newPath = await this.getTemplatePath(asset, metadata);
|
||||
const newPath = await this.getTemplatePath(asset, metadata, stillPhoto);
|
||||
|
||||
if (!fileSizeInByte) {
|
||||
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
|
||||
@@ -255,7 +259,11 @@ export class StorageTemplateService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise<string> {
|
||||
private async getTemplatePath(
|
||||
asset: StorageAsset,
|
||||
metadata: MoveAssetMetadata,
|
||||
stillPhoto?: StorageAsset,
|
||||
): Promise<string> {
|
||||
const { storageLabel, filename } = metadata;
|
||||
|
||||
try {
|
||||
@@ -296,8 +304,12 @@ export class StorageTemplateService extends BaseService {
|
||||
let albumName = null;
|
||||
let albumStartDate = null;
|
||||
let albumEndDate = null;
|
||||
const assetForMetadata = stillPhoto || asset;
|
||||
|
||||
if (this.template.needsAlbum) {
|
||||
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
|
||||
// For motion videos, use the still photo's album information since motion videos
|
||||
// don't have album metadata attached directly
|
||||
const albums = await this.albumRepository.getByAssetId(assetForMetadata.ownerId, assetForMetadata.id);
|
||||
const album = albums?.[0];
|
||||
if (album) {
|
||||
albumName = album.albumName || null;
|
||||
@@ -310,16 +322,18 @@ export class StorageTemplateService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// For motion videos that are part of live photos, use the still photo's date
|
||||
// to ensure both parts end up in the same folder
|
||||
const storagePath = this.render(this.template.compiled, {
|
||||
asset,
|
||||
asset: assetForMetadata,
|
||||
filename: sanitized,
|
||||
extension,
|
||||
albumName,
|
||||
albumStartDate,
|
||||
albumEndDate,
|
||||
make: asset.make,
|
||||
model: asset.model,
|
||||
lensModel: asset.lensModel,
|
||||
make: assetForMetadata.make,
|
||||
model: assetForMetadata.model,
|
||||
lensModel: assetForMetadata.lensModel,
|
||||
});
|
||||
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
||||
let destination = `${fullPath}.${extension}`;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PartnerFactory } from 'test/factories/partner.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset, getForPartner } from 'test/mappers';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -26,10 +28,10 @@ describe(SyncService.name, () => {
|
||||
AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(),
|
||||
AssetFactory.from().owner(authStub.user1.user).build(),
|
||||
];
|
||||
mocks.asset.getAllForUserFullSync.mockResolvedValue([asset1, asset2]);
|
||||
mocks.asset.getAllForUserFullSync.mockResolvedValue([getForAsset(asset1), getForAsset(asset2)]);
|
||||
await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([
|
||||
mapAsset(asset1, mapAssetOpts),
|
||||
mapAsset(asset2, mapAssetOpts),
|
||||
mapAsset(getForAsset(asset1), mapAssetOpts),
|
||||
mapAsset(getForAsset(asset2), mapAssetOpts),
|
||||
]);
|
||||
expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({
|
||||
ownerId: authStub.user1.user.id,
|
||||
@@ -41,10 +43,10 @@ describe(SyncService.name, () => {
|
||||
|
||||
describe('getChangesForDeltaSync', () => {
|
||||
it('should return a response requiring a full sync when partners are out of sync', async () => {
|
||||
const partner = factory.partner();
|
||||
const partner = PartnerFactory.create();
|
||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
||||
|
||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
||||
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
|
||||
|
||||
await expect(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [auth.user.id] }),
|
||||
@@ -66,7 +68,9 @@ describe(SyncService.name, () => {
|
||||
it('should return a response requiring a full sync when there are too many changes', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.asset.getChangedDeltaSync.mockResolvedValue(Array.from<typeof asset>({ length: 10_000 }).fill(asset));
|
||||
mocks.asset.getChangedDeltaSync.mockResolvedValue(
|
||||
Array.from<ReturnType<typeof getForAsset>>({ length: 10_000 }).fill(getForAsset(asset)),
|
||||
);
|
||||
await expect(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
|
||||
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
|
||||
@@ -78,13 +82,13 @@ describe(SyncService.name, () => {
|
||||
const asset = AssetFactory.create({ ownerId: authStub.user1.user.id });
|
||||
const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
|
||||
mocks.partner.getAll.mockResolvedValue([]);
|
||||
mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]);
|
||||
mocks.asset.getChangedDeltaSync.mockResolvedValue([getForAsset(asset)]);
|
||||
mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]);
|
||||
await expect(
|
||||
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
|
||||
).resolves.toEqual({
|
||||
needsFullSync: false,
|
||||
upserted: [mapAsset(asset, mapAssetOpts)],
|
||||
upserted: [mapAsset(getForAsset(asset), mapAssetOpts)],
|
||||
deleted: [deletedAsset.id],
|
||||
});
|
||||
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -55,7 +55,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
threads: 0,
|
||||
preset: 'ultrafast',
|
||||
targetAudioCodec: AudioCodec.Aac,
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.Opus],
|
||||
targetResolution: '720',
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
|
||||
@@ -23,6 +23,24 @@ describe(TimelineService.name, () => {
|
||||
userIds: [authStub.admin.user.id],
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass bbox options to repository when all bbox fields are provided', async () => {
|
||||
mocks.asset.getTimeBuckets.mockResolvedValue([{ timeBucket: 'bucket', count: 1 }]);
|
||||
|
||||
await sut.getTimeBuckets(authStub.admin, {
|
||||
bbox: {
|
||||
west: -70,
|
||||
south: -30,
|
||||
east: 120,
|
||||
north: 55,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.asset.getTimeBuckets).toHaveBeenCalledWith({
|
||||
userIds: [authStub.admin.user.id],
|
||||
bbox: { west: -70, south: -30, east: 120, north: 55 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTimeBucket', () => {
|
||||
|
||||
@@ -2,9 +2,10 @@ import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { JobName, UserStatus } from 'src/enum';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
import { describe } from 'vitest';
|
||||
|
||||
@@ -126,8 +127,8 @@ describe(UserAdminService.name, () => {
|
||||
});
|
||||
|
||||
it('should not allow deleting own account', async () => {
|
||||
const user = factory.userAdmin({ isAdmin: false });
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create({ isAdmin: false });
|
||||
const auth = AuthFactory.create(user);
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
await expect(sut.delete(auth, user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@ import { UserAdmin } from 'src/database';
|
||||
import { CacheControl, JobName, UserMetadataKey } from 'src/enum';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const makeDeletedAt = (daysAgo: number) => {
|
||||
@@ -28,8 +29,8 @@ describe(UserService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('admin should get all users', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
|
||||
@@ -39,8 +40,8 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('non-admin should get all users when publicUsers enabled', async () => {
|
||||
const user = factory.userAdmin();
|
||||
const auth = factory.auth({ user });
|
||||
const user = UserFactory.create();
|
||||
const auth = AuthFactory.create(user);
|
||||
|
||||
mocks.user.getList.mockResolvedValue([user]);
|
||||
|
||||
@@ -105,7 +106,7 @@ describe(UserService.name, () => {
|
||||
|
||||
it('should throw an error if the user profile could not be updated with the new image', async () => {
|
||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
mocks.user.update.mockRejectedValue(new InternalServerErrorException('mocked error'));
|
||||
|
||||
@@ -113,7 +114,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete the previous profile image', async () => {
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const file = { path: '/profile/path' } as Express.Multer.File;
|
||||
const files = [user.profileImagePath];
|
||||
|
||||
@@ -149,7 +150,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should delete the profile image if user has one', async () => {
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const files = [user.profileImagePath];
|
||||
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
@@ -178,7 +179,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should return the profile picture', async () => {
|
||||
const user = factory.userAdmin({ profileImagePath: '/path/to/profile.jpg' });
|
||||
const user = UserFactory.create({ profileImagePath: '/path/to/profile.jpg' });
|
||||
mocks.user.get.mockResolvedValue(user);
|
||||
|
||||
await expect(sut.getProfileImage(user.id)).resolves.toEqual(
|
||||
@@ -205,7 +206,7 @@ describe(UserService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue user ready for deletion', async () => {
|
||||
const user = factory.user();
|
||||
const user = UserFactory.create();
|
||||
mocks.user.getDeletedAfter.mockResolvedValue([{ id: user.id }]);
|
||||
|
||||
await sut.handleUserDeleteCheck();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { getForAsset } from 'test/mappers';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(ViewService.name, () => {
|
||||
@@ -37,7 +38,7 @@ describe(ViewService.name, () => {
|
||||
|
||||
const mockAssets = [asset1, asset2];
|
||||
|
||||
const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin }));
|
||||
const mockAssetReponseDto = mockAssets.map((asset) => mapAsset(getForAsset(asset), { auth: authStub.admin }));
|
||||
|
||||
mocks.view.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ShallowDehydrateObject } from 'kysely';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
import { Asset, AssetFile } from 'src/database';
|
||||
@@ -548,3 +549,5 @@ export interface UserMetadata extends Record<UserMetadataKey, Record<string, any
|
||||
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
|
||||
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
|
||||
}
|
||||
|
||||
export type MaybeDehydrated<T> = T | ShallowDehydrateObject<T>;
|
||||
|
||||
@@ -25,7 +25,9 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
||||
|
||||
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
|
||||
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
|
||||
editedThumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: true }),
|
||||
|
||||
encodedVideoFile: getAssetFile(files, AssetFileType.EncodedVideo, { isEdited: false }),
|
||||
});
|
||||
|
||||
export const addAssets = async (
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { applyDecorators } from '@nestjs/common';
|
||||
import { ApiPropertyOptions } from '@nestjs/swagger';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsNotEmpty, ValidateNested } from 'class-validator';
|
||||
import { Property } from 'src/decorators';
|
||||
import { BBoxDto } from 'src/dtos/bbox.dto';
|
||||
import { Optional } from 'src/validation';
|
||||
|
||||
type BBoxOptions = { optional?: boolean };
|
||||
export const ValidateBBox = (options: BBoxOptions & ApiPropertyOptions = {}) => {
|
||||
const { optional, ...apiPropertyOptions } = options;
|
||||
|
||||
return applyDecorators(
|
||||
Transform(({ value }) => {
|
||||
if (typeof value !== 'string') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const [west, south, east, north] = value.split(',', 4).map(Number);
|
||||
return Object.assign(new BBoxDto(), { west, south, east, north });
|
||||
}),
|
||||
Type(() => BBoxDto),
|
||||
ValidateNested(),
|
||||
Property({
|
||||
type: 'string',
|
||||
description: 'Bounding box coordinates as west,south,east,north (WGS84)',
|
||||
example: '11.075683,49.416711,11.117589,49.454875',
|
||||
...apiPropertyOptions,
|
||||
}),
|
||||
optional ? Optional({}) : IsNotEmpty(),
|
||||
);
|
||||
};
|
||||
@@ -4,23 +4,23 @@ import {
|
||||
DeduplicateJoinsPlugin,
|
||||
Expression,
|
||||
ExpressionBuilder,
|
||||
ExpressionWrapper,
|
||||
Kysely,
|
||||
KyselyConfig,
|
||||
Nullable,
|
||||
NotNull,
|
||||
Selectable,
|
||||
SelectQueryBuilder,
|
||||
Simplify,
|
||||
ShallowDehydrateObject,
|
||||
sql,
|
||||
} from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { Notice, PostgresError } from 'postgres';
|
||||
import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database';
|
||||
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { VectorExtension } from 'src/types';
|
||||
|
||||
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
|
||||
@@ -70,28 +70,6 @@ export const removeUndefinedKeys = <T extends object>(update: T, template: unkno
|
||||
return update;
|
||||
};
|
||||
|
||||
/** Modifies toJson return type to not set all properties as nullable */
|
||||
export function toJson<DB, TB extends keyof DB & string, T extends TB | Expression<unknown>>(
|
||||
eb: ExpressionBuilder<DB, TB>,
|
||||
table: T,
|
||||
) {
|
||||
return eb.fn.toJson<T>(table) as ExpressionWrapper<
|
||||
DB,
|
||||
TB,
|
||||
Simplify<
|
||||
T extends TB
|
||||
? Selectable<DB[T]> extends Nullable<infer N>
|
||||
? N | null
|
||||
: Selectable<DB[T]>
|
||||
: T extends Expression<infer O>
|
||||
? O extends Nullable<infer N>
|
||||
? N | null
|
||||
: O
|
||||
: never
|
||||
>
|
||||
>;
|
||||
}
|
||||
|
||||
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
|
||||
|
||||
export const isAssetChecksumConstraint = (error: unknown) => {
|
||||
@@ -106,19 +84,25 @@ export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>)
|
||||
export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo<Exif | null>().as('exifInfo'));
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.toJson(eb.table('asset_exif'))
|
||||
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
|
||||
.as('exifInfo'),
|
||||
);
|
||||
}
|
||||
|
||||
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo<Exif>().as('exifInfo'));
|
||||
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo'))
|
||||
.$narrowType<{ exifInfo: NotNull }>();
|
||||
}
|
||||
|
||||
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb
|
||||
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
|
||||
.select((eb) => toJson(eb, 'smart_search').as('smartSearch'));
|
||||
.select((eb) => jsonObjectFrom(eb.table('smart_search')).as('smartSearch'));
|
||||
}
|
||||
|
||||
export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withHidden?: boolean, withDeletedFace?: boolean) {
|
||||
@@ -164,7 +148,7 @@ export function withFacesAndPeople(
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.selectAll('asset_face')
|
||||
.select((eb) => eb.table('person').$castTo<Person>().as('person'))
|
||||
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
|
||||
.whereRef('asset_face.assetId', '=', 'asset.id')
|
||||
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
|
||||
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
|
||||
@@ -371,7 +355,16 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
|
||||
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
|
||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(!!options.encodedVideoPath, (qb) => qb.where('asset.encodedVideoPath', '=', options.encodedVideoPath!))
|
||||
.$if(!!options.encodedVideoPath, (qb) =>
|
||||
qb
|
||||
.innerJoin('asset_file', (join) =>
|
||||
join
|
||||
.onRef('asset.id', '=', 'asset_file.assetId')
|
||||
.on('asset_file.type', '=', AssetFileType.EncodedVideo)
|
||||
.on('asset_file.isEdited', '=', false),
|
||||
)
|
||||
.where('asset_file.path', '=', options.encodedVideoPath!),
|
||||
)
|
||||
.$if(!!options.originalPath, (qb) =>
|
||||
qb.where(sql`f_unaccent(asset."originalPath")`, 'ilike', sql`'%' || f_unaccent(${options.originalPath}) || '%'`),
|
||||
)
|
||||
@@ -396,7 +389,15 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||
.$if(options.isOffline !== undefined, (qb) => qb.where('asset.isOffline', '=', options.isOffline!))
|
||||
.$if(options.isEncoded !== undefined, (qb) =>
|
||||
qb.where('asset.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
|
||||
qb.where((eb) => {
|
||||
const exists = eb.exists((eb) =>
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.whereRef('assetId', '=', 'asset.id')
|
||||
.where('type', '=', AssetFileType.EncodedVideo),
|
||||
);
|
||||
return options.isEncoded ? exists : eb.not(exists);
|
||||
}),
|
||||
)
|
||||
.$if(options.isMotion !== undefined, (qb) =>
|
||||
qb.where('asset.livePhotoVideoId', options.isMotion ? 'is not' : 'is', null),
|
||||
@@ -404,6 +405,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
|
||||
.$if(!!options.isNotInAlbum && (!options.albumIds || options.albumIds.length === 0), (qb) =>
|
||||
qb.where((eb) => eb.not(eb.exists((eb) => eb.selectFrom('album_asset').whereRef('assetId', '=', 'asset.id')))),
|
||||
)
|
||||
.$if(options.withStacked === false, (qb) => qb.where('asset.stackId', 'is', null))
|
||||
.$if(!!options.withExif, withExifInner)
|
||||
.$if(!!(options.withFaces || options.withPeople), (qb) => qb.select(withFacesAndPeople))
|
||||
.$if(!options.withDeleted, (qb) => qb.where('asset.deletedAt', 'is', null));
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
export const asDateString = (x: Date | string | null): string | null => {
|
||||
export const asDateString = <T extends Date | string | undefined | null>(x: T) => {
|
||||
return x instanceof Date ? x.toISOString() : (x as Exclude<T, Date>);
|
||||
};
|
||||
|
||||
export const asBirthDateString = (x: Date | string | null): string | null => {
|
||||
return x instanceof Date ? x.toISOString().split('T')[0] : x;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AUDIO_ENCODER } from 'src/constants';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
|
||||
import {
|
||||
@@ -117,7 +118,7 @@ export 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 audioCodec = [TranscodeTarget.All, TranscodeTarget.Audio].includes(target) ? this.getAudioEncoder() : 'copy';
|
||||
|
||||
const options = [
|
||||
`-c:v ${videoCodec}`,
|
||||
@@ -305,8 +306,8 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return [options];
|
||||
}
|
||||
|
||||
getAudioCodec(): string {
|
||||
return this.config.targetAudioCodec;
|
||||
getAudioEncoder(): string {
|
||||
return AUDIO_ENCODER[this.config.targetAudioCodec];
|
||||
}
|
||||
|
||||
getVideoCodec(): string {
|
||||
|
||||
@@ -427,3 +427,25 @@ export function IsIPRange(options: IsIPRangeOptions, validationOptions?: Validat
|
||||
validationOptions,
|
||||
);
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'isGreaterThanOrEqualTo' })
|
||||
export class IsGreaterThanOrEqualToConstraint implements ValidatorConstraintInterface {
|
||||
validate(value: unknown, args: ValidationArguments) {
|
||||
const relatedPropertyName = args.constraints?.[0] as string;
|
||||
const relatedValue = (args.object as Record<string, unknown>)[relatedPropertyName];
|
||||
if (!Number.isFinite(value) || !Number.isFinite(relatedValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Number(value) >= Number(relatedValue);
|
||||
}
|
||||
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
const relatedPropertyName = args.constraints?.[0] as string;
|
||||
return `${args.property} must be greater than or equal to ${relatedPropertyName}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const IsGreaterThanOrEqualTo = (property: string, validationOptions?: ValidationOptions) => {
|
||||
return Validate(IsGreaterThanOrEqualToConstraint, [property], validationOptions);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { ActivityLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class ActivityFactory {
|
||||
#user!: UserFactory;
|
||||
|
||||
private constructor(private value: Selectable<ActivityTable>) {}
|
||||
|
||||
static create(dto: ActivityLike = {}) {
|
||||
return ActivityFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: ActivityLike = {}) {
|
||||
const userId = dto.userId ?? newUuid();
|
||||
return new ActivityFactory({
|
||||
albumId: newUuid(),
|
||||
assetId: null,
|
||||
comment: null,
|
||||
createdAt: newDate(),
|
||||
id: newUuid(),
|
||||
isLiked: false,
|
||||
userId,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
}).user({ id: userId });
|
||||
}
|
||||
|
||||
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#user = build(UserFactory.from(dto), builder);
|
||||
this.value.userId = this.#user.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, user: this.#user.build() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { ApiKeyLike, FactoryBuilder, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class ApiKeyFactory {
|
||||
#user!: UserFactory;
|
||||
|
||||
private constructor(private value: Selectable<ApiKeyTable>) {}
|
||||
|
||||
static create(dto: ApiKeyLike = {}) {
|
||||
return ApiKeyFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: ApiKeyLike = {}) {
|
||||
const userId = dto.userId ?? newUuid();
|
||||
return new ApiKeyFactory({
|
||||
createdAt: newDate(),
|
||||
id: newUuid(),
|
||||
key: Buffer.from('api-key-buffer'),
|
||||
name: 'API Key',
|
||||
permissions: [Permission.All],
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
userId,
|
||||
...dto,
|
||||
}).user({ id: userId });
|
||||
}
|
||||
|
||||
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#user = build(UserFactory.from(dto), builder);
|
||||
this.value.userId = this.#user.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, user: this.#user.build() };
|
||||
}
|
||||
}
|
||||
@@ -55,7 +55,6 @@ export class AssetFactory {
|
||||
deviceId: '',
|
||||
duplicateId: null,
|
||||
duration: null,
|
||||
encodedVideoPath: null,
|
||||
fileCreatedAt: newDate(),
|
||||
fileModifiedAt: newDate(),
|
||||
isExternal: false,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiKeyFactory } from 'test/factories/api-key.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
|
||||
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
||||
import { ApiKeyLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newUuid } from 'test/small.factory';
|
||||
|
||||
export class AuthFactory {
|
||||
#user: UserFactory;
|
||||
#sharedLink?: SharedLinkFactory;
|
||||
#apiKey?: ApiKeyFactory;
|
||||
#session?: AuthDto['session'];
|
||||
|
||||
private constructor(user: UserFactory) {
|
||||
this.#user = user;
|
||||
@@ -20,8 +24,8 @@ export class AuthFactory {
|
||||
return new AuthFactory(UserFactory.from(dto));
|
||||
}
|
||||
|
||||
apiKey() {
|
||||
// TODO
|
||||
apiKey(dto: ApiKeyLike = {}, builder?: FactoryBuilder<ApiKeyFactory>) {
|
||||
this.#apiKey = build(ApiKeyFactory.from(dto), builder);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -30,6 +34,11 @@ export class AuthFactory {
|
||||
return this;
|
||||
}
|
||||
|
||||
session(dto: Partial<AuthDto['session']> = {}) {
|
||||
this.#session = { id: newUuid(), hasElevatedPermission: false, ...dto };
|
||||
return this;
|
||||
}
|
||||
|
||||
build(): AuthDto {
|
||||
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
|
||||
|
||||
@@ -43,6 +52,8 @@ export class AuthFactory {
|
||||
quotaSizeInBytes,
|
||||
},
|
||||
sharedLink: this.#sharedLink?.build(),
|
||||
apiKey: this.#apiKey?.build(),
|
||||
session: this.#session,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { MemoryType } from 'src/enum';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { AssetLike, FactoryBuilder, MemoryLike } from 'test/factories/types';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class MemoryFactory {
|
||||
#assets: AssetFactory[] = [];
|
||||
|
||||
private constructor(private readonly value: Selectable<MemoryTable>) {}
|
||||
|
||||
static create(dto: MemoryLike = {}) {
|
||||
return MemoryFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: MemoryLike = {}) {
|
||||
return new MemoryFactory({
|
||||
id: newUuid(),
|
||||
createdAt: newDate(),
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
deletedAt: null,
|
||||
ownerId: newUuid(),
|
||||
type: MemoryType.OnThisDay,
|
||||
data: { year: 2024 },
|
||||
isSaved: false,
|
||||
memoryAt: newDate(),
|
||||
seenAt: null,
|
||||
showAt: newDate(),
|
||||
hideAt: newDate(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
asset(asset: AssetLike, builder?: FactoryBuilder<AssetFactory>) {
|
||||
this.#assets.push(build(AssetFactory.from(asset), builder));
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, assets: this.#assets.map((asset) => asset.build()) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||
import { build } from 'test/factories/builder.factory';
|
||||
import { FactoryBuilder, PartnerLike, UserLike } from 'test/factories/types';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class PartnerFactory {
|
||||
#sharedWith!: UserFactory;
|
||||
#sharedBy!: UserFactory;
|
||||
|
||||
private constructor(private value: Selectable<PartnerTable>) {}
|
||||
|
||||
static create(dto: PartnerLike = {}) {
|
||||
return PartnerFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: PartnerLike = {}) {
|
||||
const sharedById = dto.sharedById ?? newUuid();
|
||||
const sharedWithId = dto.sharedWithId ?? newUuid();
|
||||
return new PartnerFactory({
|
||||
createdAt: newDate(),
|
||||
createId: newUuidV7(),
|
||||
inTimeline: true,
|
||||
sharedById,
|
||||
sharedWithId,
|
||||
updatedAt: newDate(),
|
||||
updateId: newUuidV7(),
|
||||
...dto,
|
||||
})
|
||||
.sharedBy({ id: sharedById })
|
||||
.sharedWith({ id: sharedWithId });
|
||||
}
|
||||
|
||||
sharedWith(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#sharedWith = build(UserFactory.from(dto), builder);
|
||||
this.value.sharedWithId = this.#sharedWith.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
sharedBy(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
|
||||
this.#sharedBy = build(UserFactory.from(dto), builder);
|
||||
this.value.sharedById = this.#sharedBy.build().id;
|
||||
return this;
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value, sharedWith: this.#sharedWith.build(), sharedBy: this.#sharedBy.build() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { SessionLike } from 'test/factories/types';
|
||||
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
|
||||
|
||||
export class SessionFactory {
|
||||
private constructor(private value: Selectable<SessionTable>) {}
|
||||
|
||||
static create(dto: SessionLike = {}) {
|
||||
return SessionFactory.from(dto).build();
|
||||
}
|
||||
|
||||
static from(dto: SessionLike = {}) {
|
||||
return new SessionFactory({
|
||||
appVersion: null,
|
||||
createdAt: newDate(),
|
||||
deviceOS: 'android',
|
||||
deviceType: 'mobile',
|
||||
expiresAt: null,
|
||||
id: newUuid(),
|
||||
isPendingSyncReset: false,
|
||||
parentId: null,
|
||||
pinExpiresAt: null,
|
||||
token: Buffer.from('abc123'),
|
||||
updateId: newUuidV7(),
|
||||
updatedAt: newDate(),
|
||||
userId: newUuid(),
|
||||
...dto,
|
||||
});
|
||||
}
|
||||
|
||||
build() {
|
||||
return { ...this.value };
|
||||
}
|
||||
}
|
||||
@@ -51,12 +51,14 @@ export class SharedLinkFactory {
|
||||
|
||||
album(dto: AlbumLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
|
||||
this.#album = build(AlbumFactory.from(dto), builder);
|
||||
this.value.type = SharedLinkType.Album;
|
||||
return this;
|
||||
}
|
||||
|
||||
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
|
||||
const asset = build(AssetFactory.from(dto), builder);
|
||||
this.#assets.push(asset);
|
||||
this.value.type = SharedLinkType.Individual;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Selectable } from 'kysely';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { ApiKeyTable } from 'src/schema/tables/api-key.table';
|
||||
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { PartnerTable } from 'src/schema/tables/partner.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { SessionTable } from 'src/schema/tables/session.table';
|
||||
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
|
||||
import { StackTable } from 'src/schema/tables/stack.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
@@ -24,3 +29,8 @@ export type UserLike = Partial<Selectable<UserTable>>;
|
||||
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
|
||||
export type PersonLike = Partial<Selectable<PersonTable>>;
|
||||
export type StackLike = Partial<Selectable<StackTable>>;
|
||||
export type MemoryLike = Partial<Selectable<MemoryTable>>;
|
||||
export type PartnerLike = Partial<Selectable<PartnerTable>>;
|
||||
export type ActivityLike = Partial<Selectable<ActivityTable>>;
|
||||
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
|
||||
export type SessionLike = Partial<Selectable<SessionTable>>;
|
||||
|
||||
Vendored
+9
-1
@@ -112,7 +112,7 @@ export const probeStub = {
|
||||
}),
|
||||
videoStream40Mbps: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000 }],
|
||||
videoStreams: [{ ...probeStubDefaultVideoStream[0], bitrate: 40_000_000, codecName: 'h264' }],
|
||||
}),
|
||||
videoStreamMTS: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
@@ -221,6 +221,14 @@ export const probeStub = {
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'aac', bitrate: 100 }],
|
||||
}),
|
||||
audioStreamMp3: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'mp3', bitrate: 100 }],
|
||||
}),
|
||||
audioStreamOpus: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [{ index: 1, codecName: 'opus', bitrate: 100 }],
|
||||
}),
|
||||
audioStreamUnknown: Object.freeze<VideoInfo>({
|
||||
...probeStubDefault,
|
||||
audioStreams: [
|
||||
|
||||
+2
-82
@@ -1,7 +1,6 @@
|
||||
import { UserAdmin } from 'src/database';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
|
||||
import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
@@ -83,86 +82,7 @@ export const sharedLinkStub = {
|
||||
showExif: false,
|
||||
description: null,
|
||||
password: null,
|
||||
assets: [
|
||||
{
|
||||
id: 'id_1',
|
||||
status: AssetStatus.Active,
|
||||
owner: undefined as unknown as UserAdmin,
|
||||
ownerId: 'user_id_1',
|
||||
deviceAssetId: 'device_asset_id_1',
|
||||
deviceId: 'device_id_1',
|
||||
type: AssetType.Video,
|
||||
originalPath: 'fake_path/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
fileModifiedAt: today,
|
||||
fileCreatedAt: today,
|
||||
localDateTime: today,
|
||||
createdAt: today,
|
||||
updatedAt: today,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
isExternal: false,
|
||||
isOffline: false,
|
||||
files: [],
|
||||
thumbhash: null,
|
||||
encodedVideoPath: '',
|
||||
duration: null,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
originalFileName: 'asset_1.jpeg',
|
||||
exifInfo: {
|
||||
projectionType: null,
|
||||
livePhotoCID: null,
|
||||
assetId: 'id_1',
|
||||
description: 'description',
|
||||
exifImageWidth: 500,
|
||||
exifImageHeight: 500,
|
||||
fileSizeInByte: 100,
|
||||
orientation: 'orientation',
|
||||
dateTimeOriginal: today,
|
||||
modifyDate: today,
|
||||
timeZone: 'America/Los_Angeles',
|
||||
latitude: 100,
|
||||
longitude: 100,
|
||||
city: 'city',
|
||||
state: 'state',
|
||||
country: 'country',
|
||||
make: 'camera-make',
|
||||
model: 'camera-model',
|
||||
lensModel: 'fancy',
|
||||
fNumber: 100,
|
||||
focalLength: 100,
|
||||
iso: 100,
|
||||
exposureTime: '1/16',
|
||||
fps: 100,
|
||||
profileDescription: 'sRGB',
|
||||
bitsPerSample: 8,
|
||||
colorspace: 'sRGB',
|
||||
autoStackId: null,
|
||||
rating: 3,
|
||||
updatedAt: today,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: 500,
|
||||
height: 500,
|
||||
tags: [],
|
||||
},
|
||||
sharedLinks: [],
|
||||
faces: [],
|
||||
sidecarPath: null,
|
||||
deletedAt: null,
|
||||
duplicateId: null,
|
||||
updateId: '42',
|
||||
libraryId: null,
|
||||
stackId: null,
|
||||
visibility: AssetVisibility.Timeline,
|
||||
width: 500,
|
||||
height: 500,
|
||||
isEdited: false,
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
albumId: null,
|
||||
album: null,
|
||||
slug: null,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user