mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' into feat/panorama-tiles
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
24.11.1
|
||||
24.13.1
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202511261514@sha256:cbcca5851fd11042463f09797e6d6068d94adbb108749e62aa69159df59c0591 AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -52,7 +52,7 @@ FROM builder AS plugins
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
|
||||
COPY --from=ghcr.io/jdx/mise:2026.1.1@sha256:a55c391f7582f34c58bce1a85090cd526596402ba77fc32b06c49b8404ef9c14 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./plugins/mise.toml ./plugins/
|
||||
@@ -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:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
|
||||
FROM ghcr.io/immich-app/base-server-prod:202601131104@sha256:c649c5838b6348836d27db6d49cadbbc6157feae7a1a237180c3dec03577ba8f
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
+13
-15
@@ -1,21 +1,21 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202511261514@sha256:cbcca5851fd11042463f09797e6d6068d94adbb108749e62aa69159df59c0591 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e034fd0060ea68c01854d92fcc9debc6b868b98f888ba7 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 && \
|
||||
corepack enable pnpm && \
|
||||
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc
|
||||
echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \
|
||||
echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \
|
||||
echo "fetch-retries=5" >> /usr/local/etc/npmrc && \
|
||||
mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \
|
||||
chmod -R o+rw /buildcache
|
||||
|
||||
COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/
|
||||
COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/
|
||||
COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/
|
||||
COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/
|
||||
WORKDIR /tmp/create-dep-cache
|
||||
RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \
|
||||
@@ -27,16 +27,14 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
|
||||
FROM dev AS dev-container-server
|
||||
|
||||
RUN apt-get update --allow-releaseinfo-change && \
|
||||
apt-get install sudo inetutils-ping openjdk-21-jre-headless \
|
||||
apt-get install inetutils-ping openjdk-21-jre-headless \
|
||||
vim nano curl \
|
||||
-y --no-install-recommends --fix-missing
|
||||
|
||||
RUN usermod -aG sudo node && \
|
||||
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
mkdir -p /workspaces/immich
|
||||
RUN mkdir -p /workspaces && \
|
||||
ln -s /usr/src/app /workspaces/immich
|
||||
|
||||
RUN chown node:node -R /workspaces
|
||||
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
|
||||
WORKDIR /workspaces/immich
|
||||
|
||||
|
||||
+22
-4
@@ -1,5 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
echo "Initializing Immich $IMMICH_SOURCE_REF"
|
||||
|
||||
# Quiet mode suppresses informational output (enabled for immich-admin)
|
||||
QUIET=false
|
||||
if [ "$1" = "immich-admin" ]; then
|
||||
QUIET=true
|
||||
fi
|
||||
|
||||
# Helper function that only logs when not in quiet mode
|
||||
log_message() {
|
||||
if [ "$QUIET" = "false" ]; then
|
||||
echo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
log_message "Initializing Immich $IMMICH_SOURCE_REF"
|
||||
|
||||
# TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed
|
||||
# lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
|
||||
@@ -30,16 +44,20 @@ read_file_and_export "DB_PASSWORD_FILE" "DB_PASSWORD"
|
||||
read_file_and_export "REDIS_PASSWORD_FILE" "REDIS_PASSWORD"
|
||||
|
||||
if CPU_CORES="${CPU_CORES:=$(get-cpus.sh 2>/dev/null)}"; then
|
||||
echo "Detected CPU Cores: $CPU_CORES"
|
||||
log_message "Detected CPU Cores: $CPU_CORES"
|
||||
if [ "$CPU_CORES" -gt 4 ]; then
|
||||
export UV_THREADPOOL_SIZE=$CPU_CORES
|
||||
fi
|
||||
else
|
||||
echo "skipping get-cpus.sh - not found in PATH or failed: using default UV_THREADPOOL_SIZE"
|
||||
log_message "skipping get-cpus.sh - not found in PATH or failed: using default UV_THREADPOOL_SIZE"
|
||||
fi
|
||||
|
||||
if [ -f "${SERVER_HOME}/dist/main.js" ]; then
|
||||
exec node "${SERVER_HOME}/dist/main.js" "$@"
|
||||
if [ "$QUIET" = "true" ]; then
|
||||
exec node --no-warnings "${SERVER_HOME}/dist/main.js" "$@"
|
||||
else
|
||||
exec node "${SERVER_HOME}/dist/main.js" "$@"
|
||||
fi
|
||||
else
|
||||
echo "Error: ${SERVER_HOME}/dist/main.js not found"
|
||||
if [ "$IMMICH_ENV" = "development" ]; then
|
||||
|
||||
+31
-29
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.4.0",
|
||||
"version": "2.5.6",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -9,32 +9,33 @@
|
||||
"build": "nest build",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"start": "npm run start:dev",
|
||||
"start": "pnpm run start:dev",
|
||||
"nest": "nest",
|
||||
"start:dev": "nest start --watch --",
|
||||
"start:debug": "nest start --debug 0.0.0.0:9230 --watch --",
|
||||
"lint": "eslint \"src/**/*.ts\" \"test/**/*.ts\" --max-warnings 0",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"lint:fix": "pnpm run lint --fix",
|
||||
"check": "tsc --noEmit",
|
||||
"check:code": "npm run format && npm run lint && npm run check",
|
||||
"check:all": "npm run check:code && npm run test:cov",
|
||||
"check:code": "pnpm run format && pnpm run lint && pnpm run check",
|
||||
"check:all": "pnpm run check:code && pnpm run test:cov",
|
||||
"test": "vitest --config test/vitest.config.mjs",
|
||||
"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;'",
|
||||
"schema:reset": "npm run schema:drop && npm run migrations:run",
|
||||
"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",
|
||||
"email:dev": "email dev -p 3050 --dir src/emails"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/sql-tools": "^0.3.2",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
@@ -45,14 +46,14 @@
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.208.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.208.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.56.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.55.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.61.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/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.208.0",
|
||||
"@opentelemetry/sdk-node": "^0.212.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
@@ -69,8 +70,8 @@
|
||||
"compression": "^1.8.0",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "4.3.5",
|
||||
"exiftool-vendored": "^34.0.0",
|
||||
"cron": "4.4.0",
|
||||
"exiftool-vendored": "^35.0.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
@@ -96,7 +97,7 @@
|
||||
"pg": "^8.11.3",
|
||||
"pg-connection-string": "^2.9.1",
|
||||
"picomatch": "^4.0.2",
|
||||
"postgres": "3.4.7",
|
||||
"postgres": "3.4.8",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-email": "^4.0.0",
|
||||
@@ -110,12 +111,13 @@
|
||||
"socket.io": "^4.8.1",
|
||||
"tailwindcss-preset-email": "^1.4.0",
|
||||
"thumbhash": "^0.1.1",
|
||||
"transformation-matrix": "^3.1.0",
|
||||
"ua-parser-js": "^2.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"validator": "^13.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@eslint/js": "^10.0.0",
|
||||
"@nestjs/cli": "^11.0.2",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.4",
|
||||
@@ -128,13 +130,13 @@
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/fluent-ffmpeg": "^2.1.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.3",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
@@ -145,11 +147,11 @@
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/validator": "^13.15.2",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint": "^10.0.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^62.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"eslint-plugin-unicorn": "^63.0.0",
|
||||
"globals": "^17.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"node-gyp": "^12.0.0",
|
||||
"pngjs": "^7.0.0",
|
||||
@@ -162,11 +164,11 @@
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"unplugin-swc": "^1.4.5",
|
||||
"vite-tsconfig-paths": "^5.0.0",
|
||||
"vite-tsconfig-paths": "^6.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.11.1"
|
||||
"node": "24.13.1"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.34.5"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IWorker } from 'src/constants';
|
||||
import { controllers } from 'src/controllers';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
@@ -21,14 +22,18 @@ import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
|
||||
import { repositories } from 'src/repositories';
|
||||
import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { services } from 'src/services';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { QueueService } from 'src/services/queue.service';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
@@ -103,9 +108,14 @@ export class ApiModule extends BaseModule {}
|
||||
providers: [
|
||||
ConfigRepository,
|
||||
LoggingRepository,
|
||||
StorageRepository,
|
||||
ProcessRepository,
|
||||
DatabaseRepository,
|
||||
SystemMetadataRepository,
|
||||
AppRepository,
|
||||
MaintenanceHealthRepository,
|
||||
MaintenanceWebsocketRepository,
|
||||
DatabaseBackupService,
|
||||
MaintenanceWorkerService,
|
||||
...commonMiddleware,
|
||||
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard },
|
||||
@@ -116,9 +126,14 @@ export class MaintenanceModule {
|
||||
constructor(
|
||||
@Inject(IWorker) private worker: ImmichWorker,
|
||||
logger: LoggingRepository,
|
||||
private maintenanceWorkerService: MaintenanceWorkerService,
|
||||
) {
|
||||
logger.setAppName(this.worker);
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.maintenanceWorkerService.init();
|
||||
}
|
||||
}
|
||||
|
||||
@Module({
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
process.env.DB_URL = process.env.DB_URL || 'postgres://postgres:postgres@localhost:5432/immich';
|
||||
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, extname, join } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
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 { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
|
||||
import { asPostgresConnectionConfig, 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 db = postgres(asPostgresConnectionConfig(database.config));
|
||||
|
||||
const source = schemaFromCode({ overrides: true, namingStrategy: 'default' });
|
||||
const target = await schemaFromDatabase(db, {});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
||||
import { SchemaCheck } from 'src/commands/schema-check';
|
||||
import { VersionCommand } from 'src/commands/version.command';
|
||||
|
||||
export const commandsAndQuestions = [
|
||||
@@ -28,4 +29,5 @@ export const commandsAndQuestions = [
|
||||
ChangeMediaLocationCommand,
|
||||
PromptMediaLocationQuestions,
|
||||
PromptConfirmMoveQuestions,
|
||||
SchemaCheck,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { asHuman } from '@immich/sql-tools';
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
import { ErrorMessages } from 'src/constants';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
@Command({
|
||||
name: 'schema-check',
|
||||
description: 'Verify database migrations and check for schema drift',
|
||||
})
|
||||
export class SchemaCheck extends CommandRunner {
|
||||
constructor(private service: CliService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const { migrations, drift } = await this.service.schemaReport();
|
||||
|
||||
if (migrations.every((item) => item.status === 'applied')) {
|
||||
console.log('Migrations are up to date');
|
||||
} else {
|
||||
console.log('Migration issues detected:');
|
||||
for (const migration of migrations) {
|
||||
switch (migration.status) {
|
||||
case 'deleted': {
|
||||
console.log(` - ${migration.name} was applied, but the file no longer exists on disk`);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'missing': {
|
||||
console.log(` - ${migration.name} exists, but has not been applied to the database`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (drift.items.length === 0) {
|
||||
console.log('\nNo schema drift detected');
|
||||
} else {
|
||||
console.log(`\n${ErrorMessages.SchemaDrift}`);
|
||||
for (const item of drift.items) {
|
||||
console.log(` - ${item.type}: ` + asHuman(item));
|
||||
}
|
||||
|
||||
console.log(`
|
||||
|
||||
The below SQL is automatically generated and may be helpful for resolving drift. ** Use at your own risk! **
|
||||
|
||||
\`\`\`sql
|
||||
${drift.asSql().join('\n')}
|
||||
\`\`\`
|
||||
`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Unable to debug migrations');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from 'src/enum';
|
||||
import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types';
|
||||
|
||||
export interface SystemConfig {
|
||||
export type SystemConfig = {
|
||||
backup: {
|
||||
database: {
|
||||
enabled: boolean;
|
||||
@@ -187,7 +187,7 @@ export interface SystemConfig {
|
||||
user: {
|
||||
deleteDelay: number;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
||||
|
||||
@@ -236,6 +236,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
[QueueName.Notification]: { concurrency: 5 },
|
||||
[QueueName.Ocr]: { concurrency: 1 },
|
||||
[QueueName.Workflow]: { concurrency: 5 },
|
||||
[QueueName.Editor]: { concurrency: 2 },
|
||||
},
|
||||
logging: {
|
||||
enabled: true,
|
||||
@@ -318,11 +319,13 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
format: ImageFormat.Webp,
|
||||
size: 250,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
preview: {
|
||||
format: ImageFormat.Jpeg,
|
||||
size: 1440,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
colorspace: Colorspace.P3,
|
||||
extractEmbedded: false,
|
||||
@@ -330,6 +333,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
enabled: false,
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 80,
|
||||
progressive: false,
|
||||
},
|
||||
},
|
||||
newVersionCheck: {
|
||||
|
||||
@@ -4,8 +4,15 @@ import { dirname, join } from 'node:path';
|
||||
import { SemVer } from 'semver';
|
||||
import { ApiTag, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
|
||||
|
||||
export const ErrorMessages = {
|
||||
InconsistentMediaLocation:
|
||||
'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location',
|
||||
SchemaDrift: `Detected schema drift. For more information, see https://docs.immich.app/errors#schema-drift`,
|
||||
TypeOrmUpgrade: 'Invalid upgrade path. For more information, see https://docs.immich.app/errors/#typeorm-upgrade',
|
||||
};
|
||||
|
||||
export const POSTGRES_VERSION_RANGE = '>=14.0.0';
|
||||
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <0.6';
|
||||
export const VECTORCHORD_VERSION_RANGE = '>=0.3 <2';
|
||||
export const VECTORS_VERSION_RANGE = '>=0.2 <0.4';
|
||||
export const VECTOR_VERSION_RANGE = '>=0.5 <1';
|
||||
|
||||
@@ -142,6 +149,7 @@ export const endpointTags: Record<ApiTag, string> = {
|
||||
[ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.',
|
||||
[ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.',
|
||||
[ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.',
|
||||
[ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.',
|
||||
[ApiTag.Deprecated]: 'Deprecated endpoints that are planned for removal in the next major release.',
|
||||
[ApiTag.Download]: 'Endpoints for downloading assets or collections of assets.',
|
||||
[ApiTag.Duplicates]: 'Endpoints for managing and identifying duplicate assets.',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
@@ -63,6 +64,16 @@ export class AssetMediaController {
|
||||
required: false,
|
||||
})
|
||||
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Asset is a duplicate',
|
||||
type: AssetMediaResponseDto,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: 'Asset uploaded successfully',
|
||||
type: AssetMediaResponseDto,
|
||||
})
|
||||
@Endpoint({
|
||||
summary: 'Upload asset',
|
||||
description: 'Uploads a new asset to the server.',
|
||||
@@ -95,15 +106,21 @@ export class AssetMediaController {
|
||||
async downloadAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: AssetDownloadOriginalDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger);
|
||||
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
|
||||
}
|
||||
|
||||
@Put(':id/original')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Asset replaced successfully',
|
||||
type: AssetMediaResponseDto,
|
||||
})
|
||||
@Endpoint({
|
||||
summary: 'Replace asset',
|
||||
description: 'Replace the asset with new file, without changing its id.',
|
||||
@@ -131,7 +148,8 @@ export class AssetMediaController {
|
||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'View asset thumbnail',
|
||||
description: 'Retrieve the thumbnail image for the specified asset.',
|
||||
description:
|
||||
'Retrieve the thumbnail image for the specified asset. Viewing the fullsize thumbnail might redirect to downloadAsset, which requires a different permission.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
async viewAsset(
|
||||
@@ -206,7 +224,7 @@ export class AssetMediaController {
|
||||
}
|
||||
|
||||
@Post('exist')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@Endpoint({
|
||||
summary: 'Check existing assets',
|
||||
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
|
||||
|
||||
@@ -24,6 +24,34 @@ describe(AssetController.name, () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets`)
|
||||
.send({ ids: ['123'] });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['each value in ids must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require duplicateId to be a string', async () => {
|
||||
const id = factory.uuid();
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets`)
|
||||
.send({ ids: [id], duplicateId: true });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['duplicateId must be a string']));
|
||||
});
|
||||
|
||||
it('should accept a null duplicateId', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer())
|
||||
.put(`/assets`)
|
||||
.send({ ids: [id], duplicateId: null });
|
||||
|
||||
expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ duplicateId: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets', () => {
|
||||
@@ -79,6 +107,74 @@ describe(AssetController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test', value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), value: {} }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } }] });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/assets/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid assetId', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: '123', key: 'test' }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['items.0.assetId must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid() }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.delete('/assets/metadata')
|
||||
.send({ items: [{ assetId: factory.uuid(), key: AssetMetadataKey.MobileApp }] });
|
||||
expect(status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/123`);
|
||||
@@ -111,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', () => {
|
||||
@@ -169,12 +281,10 @@ describe(AssetController.name, () => {
|
||||
it('should require each item to have a valid key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/metadata`)
|
||||
.send({ items: [{ key: 'someKey' }] });
|
||||
.send({ items: [{ value: { some: 'value' } }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
|
||||
),
|
||||
factory.responses.badRequest(['items.0.key must be a string', 'items.0.key should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -224,16 +334,89 @@ describe(AssetController.name, () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id/edits', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/edits`).send({ edits: [] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept valid edits and pass to service correctly', async () => {
|
||||
const edits = [
|
||||
{
|
||||
action: 'crop',
|
||||
parameters: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer()).put(`/assets/${assetId}/edits`).send({
|
||||
edits,
|
||||
});
|
||||
|
||||
expect(service.editAsset).toHaveBeenCalledWith(undefined, assetId, { edits });
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/123/edits`)
|
||||
.send({
|
||||
edits: [
|
||||
{
|
||||
action: 'crop',
|
||||
parameters: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should check the action and parameters discriminator', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
.send({
|
||||
edits: [
|
||||
{
|
||||
action: 'rotate',
|
||||
parameters: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it('should require a valid key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([expect.stringContaining('key must be one of the following value')]),
|
||||
expect.arrayContaining([expect.stringContaining('parameters.angle must be one of the following values')]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require at least one edit', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/edits`)
|
||||
.send({ edits: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['edits must contain at least 1 elements']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets/:id/metadata/:key', () => {
|
||||
@@ -247,13 +430,5 @@ describe(AssetController.name, () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['id must be a UUID']));
|
||||
});
|
||||
|
||||
it('should require a valid key', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/invalid`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest([expect.stringContaining('key must be one of the following values')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import {
|
||||
AssetBulkUpdateDto,
|
||||
AssetCopyDto,
|
||||
AssetJobsDto,
|
||||
AssetMetadataBulkDeleteDto,
|
||||
AssetMetadataBulkResponseDto,
|
||||
AssetMetadataBulkUpsertDto,
|
||||
AssetMetadataResponseDto,
|
||||
AssetMetadataRouteParams,
|
||||
AssetMetadataUpsertDto,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
UpdateAssetDto,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditsCreateDto, AssetEditsResponseDto } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
@@ -62,7 +66,7 @@ export class AssetController {
|
||||
}
|
||||
|
||||
@Post('jobs')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.JobCreate })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Run an asset job',
|
||||
@@ -120,6 +124,32 @@ export class AssetController {
|
||||
return this.service.copy(auth, dto);
|
||||
}
|
||||
|
||||
@Put('metadata')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@Endpoint({
|
||||
summary: 'Upsert asset metadata',
|
||||
description: 'Upsert metadata key-value pairs for multiple assets.',
|
||||
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
|
||||
})
|
||||
updateBulkAssetMetadata(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: AssetMetadataBulkUpsertDto,
|
||||
): Promise<AssetMetadataBulkResponseDto[]> {
|
||||
return this.service.upsertBulkMetadata(auth, dto);
|
||||
}
|
||||
|
||||
@Delete('metadata')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Delete asset metadata',
|
||||
description: 'Delete metadata key-value pairs for multiple assets.',
|
||||
history: new HistoryBuilder().added('v1').beta('v2.5.0'),
|
||||
})
|
||||
deleteBulkAssetMetadata(@Auth() auth: AuthDto, @Body() dto: AssetMetadataBulkDeleteDto): Promise<void> {
|
||||
return this.service.deleteBulkMetadata(auth, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@Endpoint({
|
||||
@@ -197,4 +227,42 @@ export class AssetController {
|
||||
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
|
||||
return this.service.deleteMetadataByKey(auth, id, key);
|
||||
}
|
||||
|
||||
@Get(':id/edits')
|
||||
@Authenticated({ permission: Permission.AssetEditGet })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve edits for an existing asset',
|
||||
description: 'Retrieve a series of edit actions (crop, rotate, mirror) associated with the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
getAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetEditsResponseDto> {
|
||||
return this.service.getAssetEdits(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id/edits')
|
||||
@Authenticated({ permission: Permission.AssetEditCreate })
|
||||
@Endpoint({
|
||||
summary: 'Apply edits to an existing asset',
|
||||
description: 'Apply a series of edit actions (crop, rotate, mirror) to the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
editAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: AssetEditsCreateDto,
|
||||
): Promise<AssetEditsResponseDto> {
|
||||
return this.service.editAsset(auth, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id/edits')
|
||||
@Authenticated({ permission: Permission.AssetEditDelete })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Endpoint({
|
||||
summary: 'Remove edits from an existing asset',
|
||||
description: 'Removes all edit actions (crop, rotate, mirror) associated with the specified asset.',
|
||||
history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0'),
|
||||
})
|
||||
removeAssetEdits(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
|
||||
return this.service.removeAssetEdits(auth, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export class AuthController {
|
||||
summary: 'Retrieve auth status',
|
||||
description:
|
||||
'Get information about the current session, including whether the user has a password, and if the session can access locked assets.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
getAuthStatus(@Auth() auth: AuthDto): Promise<AuthStatusResponseDto> {
|
||||
return this.service.getAuthStatus(auth);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import {
|
||||
DatabaseBackupDeleteDto,
|
||||
DatabaseBackupListResponseDto,
|
||||
DatabaseBackupUploadDto,
|
||||
} from 'src/dtos/database-backup.dto';
|
||||
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||
import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { FilenameParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.DatabaseBackups)
|
||||
@Controller('admin/database-backups')
|
||||
export class DatabaseBackupController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: DatabaseBackupService,
|
||||
private maintenanceService: MaintenanceService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Endpoint({
|
||||
summary: 'List database backups',
|
||||
description: 'Get the list of the successful and failed backups',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
return this.service.listBackups();
|
||||
}
|
||||
|
||||
@Get(':filename')
|
||||
@FileResponse()
|
||||
@Endpoint({
|
||||
summary: 'Download database backup',
|
||||
description: 'Downloads the database backup file',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.BackupDownload, admin: true })
|
||||
async downloadDatabaseBackup(
|
||||
@Param() { filename }: FilenameParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
): Promise<void> {
|
||||
await sendFile(res, next, () => this.service.downloadBackup(filename), this.logger);
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@Endpoint({
|
||||
summary: 'Delete database backup',
|
||||
description: 'Delete a backup by its filename',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.BackupDelete, admin: true })
|
||||
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
|
||||
return this.service.deleteBackup(dto.backups);
|
||||
}
|
||||
|
||||
@Post('start-restore')
|
||||
@Endpoint({
|
||||
summary: 'Start database backup restore flow',
|
||||
description: 'Put Immich into maintenance mode to restore a backup (Immich must not be configured)',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
async startDatabaseRestoreFlow(
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<void> {
|
||||
const { jwt } = await this.maintenanceService.startRestoreFlow();
|
||||
return respondWithCookie(res, undefined, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||
});
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@Authenticated({ permission: Permission.BackupUpload, admin: true })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ description: 'Backup Upload', type: DatabaseBackupUploadDto })
|
||||
@Endpoint({
|
||||
summary: 'Upload database backup',
|
||||
description: 'Uploads .sql/.sql.gz file to restore backup from',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
return this.service.uploadBackup(file);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
@@ -36,7 +35,7 @@ export class DownloadController {
|
||||
'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
|
||||
downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise<StreamableFile> {
|
||||
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
import { DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||
import { DownloadController } from 'src/controllers/download.controller';
|
||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
import { FaceController } from 'src/controllers/face.controller';
|
||||
@@ -46,6 +47,7 @@ export const controllers = [
|
||||
AssetMediaController,
|
||||
AuthController,
|
||||
AuthAdminController,
|
||||
DatabaseBackupController,
|
||||
DownloadController,
|
||||
DuplicateController,
|
||||
FaceController,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
||||
import { MaintenanceAction } from 'src/enum';
|
||||
import { MaintenanceService } from 'src/services/maintenance.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(MaintenanceController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(MaintenanceService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(MaintenanceController, [{ provide: MaintenanceService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /admin/maintenance', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/maintenance').send();
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a backup file when action is restore', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/admin/maintenance').send({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest(['restoreBackupFilename must be a string', 'restoreBackupFilename should not be empty']),
|
||||
);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,15 @@
|
||||
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
|
||||
import { BadRequestException, Body, Controller, Get, Post, Res } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||
import {
|
||||
MaintenanceAuthDto,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceLoginDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
@@ -15,6 +21,27 @@ import { respondWithCookie } from 'src/utils/response';
|
||||
export class MaintenanceController {
|
||||
constructor(private service: MaintenanceService) {}
|
||||
|
||||
@Get('status')
|
||||
@Endpoint({
|
||||
summary: 'Get maintenance mode status',
|
||||
description: 'Fetch information about the currently running maintenance action.',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
getMaintenanceStatus(): MaintenanceStatusResponseDto {
|
||||
return this.service.getMaintenanceStatus();
|
||||
}
|
||||
|
||||
@Get('detect-install')
|
||||
@Endpoint({
|
||||
summary: 'Detect existing install',
|
||||
description: 'Collect integrity checks and other heuristics about local data.',
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@Authenticated({ permission: Permission.Maintenance, admin: true })
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return this.service.detectPriorInstall();
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Endpoint({
|
||||
summary: 'Log into maintenance mode',
|
||||
@@ -38,8 +65,8 @@ export class MaintenanceController {
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<void> {
|
||||
if (dto.action === MaintenanceAction.Start) {
|
||||
const { jwt } = await this.service.startMaintenance(auth.user.name);
|
||||
if (dto.action !== MaintenanceAction.End) {
|
||||
const { jwt } = await this.service.startMaintenance(dto, auth.user.name);
|
||||
return respondWithCookie(res, undefined, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
MapReverseGeocodeDto,
|
||||
MapReverseGeocodeResponseDto,
|
||||
} from 'src/dtos/map.dto';
|
||||
import { ApiTag } from 'src/enum';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { MapService } from 'src/services/map.service';
|
||||
|
||||
@@ -18,7 +18,7 @@ export class MapController {
|
||||
constructor(private service: MapService) {}
|
||||
|
||||
@Get('markers')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.MapRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve map markers',
|
||||
description: 'Retrieve a list of latitude and longitude coordinates for every asset with location data.',
|
||||
@@ -28,8 +28,8 @@ export class MapController {
|
||||
return this.service.getMapMarkers(auth, options);
|
||||
}
|
||||
|
||||
@Authenticated()
|
||||
@Get('reverse-geocode')
|
||||
@Authenticated({ permission: Permission.MapSearch })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Endpoint({
|
||||
summary: 'Reverse geocode coordinates',
|
||||
|
||||
@@ -51,6 +51,20 @@ describe(MemoryController.name, () => {
|
||||
errorDto.badRequest(['data.year must be a positive number', 'data.year must be an integer number']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should accept showAt and hideAt', async () => {
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/memories')
|
||||
.send({
|
||||
type: 'on_this_day',
|
||||
data: { year: 2020 },
|
||||
memoryAt: new Date(2021).toISOString(),
|
||||
showAt: new Date(2022).toISOString(),
|
||||
hideAt: new Date(2023).toISOString(),
|
||||
});
|
||||
|
||||
expect(status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /memories/statistics', () => {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
|
||||
import { NotificationAdminService } from 'src/services/notification-admin.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(NotificationAdminController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(NotificationAdminService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(NotificationAdminController, [
|
||||
{ provide: NotificationAdminService, useValue: service },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /admin/notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a null readAt', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post(`/admin/notifications`)
|
||||
.send({ title: 'Test', userId: factory.uuid(), readAt: null });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ readAt: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -37,9 +37,33 @@ describe(NotificationController.name, () => {
|
||||
|
||||
describe('PUT /notifications', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/notifications');
|
||||
await request(ctx.getHttpServer()).put('/notifications');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('ids', () => {
|
||||
it('should require a list', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/notifications`).send({ ids: true });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['ids must be an array'])));
|
||||
});
|
||||
|
||||
it('should require uuids', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/notifications`)
|
||||
.send({ ids: [true] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['each value in ids must be a UUID']));
|
||||
});
|
||||
|
||||
it('should accept valid uuids', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer())
|
||||
.put(`/notifications`)
|
||||
.send({ ids: [id] });
|
||||
expect(service.updateAll).toHaveBeenCalledWith(undefined, expect.objectContaining({ ids: [id] }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /notifications/:id', () => {
|
||||
@@ -60,5 +84,11 @@ describe(NotificationController.name, () => {
|
||||
await request(ctx.getHttpServer()).put(`/notifications/${factory.uuid()}`).send({ readAt: factory.date() });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept a null readAt', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/notifications/${id}`).send({ readAt: null });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ readAt: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,6 +58,11 @@ describe(PersonController.name, () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ birthDate: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { birthDate: null });
|
||||
});
|
||||
|
||||
it('should map an empty color to null', async () => {
|
||||
await request(ctx.getHttpServer()).post('/people').send({ color: '' });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, { color: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /people', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { PluginResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { PluginResponseDto, PluginTriggerResponseDto } from 'src/dtos/plugin.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Authenticated } from 'src/middleware/auth.guard';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
@@ -12,6 +12,17 @@ import { UUIDParamDto } from 'src/validation';
|
||||
export class PluginController {
|
||||
constructor(private service: PluginService) {}
|
||||
|
||||
@Get('triggers')
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
summary: 'List all plugin triggers',
|
||||
description: 'Retrieve a list of all available plugin triggers.',
|
||||
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
|
||||
})
|
||||
getPluginTriggers(): PluginTriggerResponseDto[] {
|
||||
return this.service.getTriggers();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.PluginRead })
|
||||
@Endpoint({
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SharedLinkController } from 'src/controllers/shared-link.controller';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import request from 'supertest';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(SharedLinkController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(SharedLinkService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(SharedLinkController, [{ provide: SharedLinkService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('POST /shared-links', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/shared-links');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow an null expiresAt', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.post('/shared-links')
|
||||
.send({ expiresAt: null, type: SharedLinkType.Individual });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ expiresAt: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,21 +22,39 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
SharedLinkCreateDto,
|
||||
SharedLinkEditDto,
|
||||
SharedLinkLoginDto,
|
||||
SharedLinkPasswordDto,
|
||||
SharedLinkResponseDto,
|
||||
SharedLinkSearchDto,
|
||||
} from 'src/dtos/shared-link.dto';
|
||||
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { SharedLinkService } from 'src/services/shared-link.service';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
const getAuthTokens = (cookies: Record<string, string> | undefined) => {
|
||||
return cookies?.[ImmichCookie.SharedLinkToken]?.split(',') || [];
|
||||
};
|
||||
|
||||
const merge = (cookies: Record<string, string> | undefined, token: string) => {
|
||||
const authTokens = getAuthTokens(cookies);
|
||||
if (!authTokens.includes(token)) {
|
||||
authTokens.push(token);
|
||||
}
|
||||
|
||||
return authTokens.join(',');
|
||||
};
|
||||
|
||||
@ApiTags(ApiTag.SharedLinks)
|
||||
@Controller('shared-links')
|
||||
export class SharedLinkController {
|
||||
constructor(private service: SharedLinkService) {}
|
||||
constructor(
|
||||
private service: SharedLinkService,
|
||||
private logger: LoggingRepository,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.SharedLinkRead })
|
||||
@@ -49,6 +67,28 @@ export class SharedLinkController {
|
||||
return this.service.getAll(auth, dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Endpoint({
|
||||
summary: 'Shared link login',
|
||||
description: 'Login to a password protected shared link',
|
||||
history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'),
|
||||
})
|
||||
async sharedLinkLogin(
|
||||
@Auth() auth: AuthDto,
|
||||
@Body() dto: SharedLinkLoginDto,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
const { sharedLink, token } = await this.service.login(auth, dto);
|
||||
|
||||
return respondWithCookie(res, sharedLink, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: [{ key: ImmichCookie.SharedLinkToken, value: merge(req.cookies, token) }],
|
||||
});
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@Authenticated({ sharedLink: true })
|
||||
@Endpoint({
|
||||
@@ -59,19 +99,19 @@ export class SharedLinkController {
|
||||
async getMySharedLink(
|
||||
@Auth() auth: AuthDto,
|
||||
@Query() dto: SharedLinkPasswordDto,
|
||||
@Req() request: Request,
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
@GetLoginDetails() loginDetails: LoginDetails,
|
||||
): Promise<SharedLinkResponseDto> {
|
||||
const sharedLinkToken = request.cookies?.[ImmichCookie.SharedLinkToken];
|
||||
if (sharedLinkToken) {
|
||||
dto.token = sharedLinkToken;
|
||||
if (dto.password) {
|
||||
this.logger.deprecate(
|
||||
'Passing shared link password via query parameters is deprecated and will be removed in the next major release. Please use POST /shared-links/login instead.',
|
||||
);
|
||||
|
||||
return this.sharedLinkLogin(auth, { password: dto.password }, req, res, loginDetails);
|
||||
}
|
||||
const body = await this.service.getMine(auth, dto);
|
||||
return respondWithCookie(res, body, {
|
||||
isSecure: loginDetails.isSecure,
|
||||
values: body.token ? [{ key: ImmichCookie.SharedLinkToken, value: body.token }] : [],
|
||||
});
|
||||
|
||||
return this.service.getMine(auth, getAuthTokens(req.cookies));
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -70,5 +70,33 @@ describe(SystemConfigController.name, () => {
|
||||
expect(body).toEqual(errorDto.badRequest(['nightlyTasks.databaseCleanup must be a boolean value']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('image', () => {
|
||||
it('should accept config without optional progressive property', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
delete config.image.thumbnail.progressive;
|
||||
delete config.image.preview.progressive;
|
||||
delete config.image.fullsize.progressive;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should accept config with progressive set to true', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
config.image.thumbnail.progressive = true;
|
||||
config.image.preview.progressive = true;
|
||||
config.image.fullsize.progressive = true;
|
||||
const { status } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should reject invalid progressive value', async () => {
|
||||
const config = _.cloneDeep(defaults);
|
||||
(config.image.thumbnail.progressive as any) = 'invalid';
|
||||
const { status, body } = await request(ctx.getHttpServer()).put('/system-config').send(config);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['image.thumbnail.progressive must be a boolean value']));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TagController } from 'src/controllers/tag.controller';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(TagController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(TagService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(TagController, [{ provide: TagService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should a null parentId', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/tags`).send({ name: 'tag', parentId: null });
|
||||
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ parentId: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tags', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put('/tags');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /tags/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/tags/123`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('id must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /tags/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/tags/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow setting a null color via an empty string', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/tags/${id}`).send({ color: '' });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ color: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -31,12 +31,55 @@ describe(UserAdminController.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /admin/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/admin/users');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow a null pinCode', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/admin/users`).send({
|
||||
name: 'Test user',
|
||||
email: 'test@immich.cloud',
|
||||
password: 'password',
|
||||
pinCode: null,
|
||||
});
|
||||
expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ pinCode: null }));
|
||||
});
|
||||
|
||||
it('should allow a null avatarColor', async () => {
|
||||
await request(ctx.getHttpServer()).post(`/admin/users`).send({
|
||||
name: 'Test user',
|
||||
email: 'test@immich.cloud',
|
||||
password: 'password',
|
||||
avatarColor: null,
|
||||
});
|
||||
expect(service.create).toHaveBeenCalledWith(expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
|
||||
it(`should `, async () => {
|
||||
const dto: UserAdminCreateDto = {
|
||||
email: 'user@immich.app',
|
||||
password: 'test',
|
||||
name: 'Test User',
|
||||
quotaSizeInBytes: 1.2,
|
||||
};
|
||||
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/admin/users`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send(dto);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||
});
|
||||
|
||||
it(`should not allow decimal quota`, async () => {
|
||||
const dto: UserAdminCreateDto = {
|
||||
email: 'user@immich.app',
|
||||
@@ -75,5 +118,17 @@ describe(UserAdminController.name, () => {
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||
});
|
||||
|
||||
it('should allow a null pinCode', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ pinCode: null });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ pinCode: null }));
|
||||
});
|
||||
|
||||
it('should allow a null avatarColor', async () => {
|
||||
const id = factory.uuid();
|
||||
await request(ctx.getHttpServer()).put(`/admin/users/${id}`).send({ avatarColor: null });
|
||||
expect(service.update).toHaveBeenCalledWith(undefined, id, expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +54,14 @@ describe(UserController.name, () => {
|
||||
expect(body).toEqual(errorDto.badRequest());
|
||||
});
|
||||
}
|
||||
|
||||
it('should allow an empty avatarColor', async () => {
|
||||
await request(ctx.getHttpServer())
|
||||
.put(`/users/me`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ avatarColor: null });
|
||||
expect(service.updateMe).toHaveBeenCalledWith(undefined, expect.objectContaining({ avatarColor: null }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiTag } from 'src/enum';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { ViewService } from 'src/services/view.service';
|
||||
|
||||
@@ -13,7 +13,7 @@ export class ViewController {
|
||||
constructor(private service: ViewService) {}
|
||||
|
||||
@Get('folder/unique-paths')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.FolderRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve unique paths',
|
||||
description: 'Retrieve a list of unique folder paths from asset original paths.',
|
||||
@@ -24,7 +24,7 @@ export class ViewController {
|
||||
}
|
||||
|
||||
@Get('folder')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.FolderRead })
|
||||
@Endpoint({
|
||||
summary: 'Retrieve assets by original path',
|
||||
description: 'Retrieve assets that are children of a specific folder.',
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { StorageAsset } from 'src/database';
|
||||
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
ImageFormat,
|
||||
PathType,
|
||||
PersonPathType,
|
||||
RawExtractedFormat,
|
||||
StorageFolder,
|
||||
TilesFormat,
|
||||
} from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
@@ -33,6 +42,8 @@ export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat | TilesFormat; isEdited: boolean };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
||||
let mediaLocation: string | undefined;
|
||||
@@ -109,8 +120,12 @@ export class StorageCore {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||
}
|
||||
|
||||
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp' | 'dz') {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`);
|
||||
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
||||
return StorageCore.getNestedPath(
|
||||
StorageFolder.Thumbnails,
|
||||
asset.ownerId,
|
||||
`${asset.id}_${fileType}${isEdited ? '_edited' : ''}.${format}`,
|
||||
);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
@@ -135,14 +150,14 @@ export class StorageCore {
|
||||
return normalizedPath.startsWith(normalizedAppMediaLocation);
|
||||
}
|
||||
|
||||
async moveAssetImage(asset: StorageAsset, pathType: GeneratedImageType, format: ImageFormat) {
|
||||
async moveAssetImage(asset: StorageAsset, fileType: AssetFileType, format: ImageFormat) {
|
||||
const { id: entityId, files } = asset;
|
||||
const oldFile = getAssetFile(files, pathType);
|
||||
const oldFile = getAssetFile(files, fileType, { isEdited: false });
|
||||
return this.moveFile({
|
||||
entityId,
|
||||
pathType,
|
||||
pathType: fileType,
|
||||
oldPath: oldFile?.path || null,
|
||||
newPath: StorageCore.getImagePath(asset, pathType, format),
|
||||
newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,19 +311,19 @@ export class StorageCore {
|
||||
case AssetPathType.Original: {
|
||||
return this.assetRepository.update({ id, originalPath: newPath });
|
||||
}
|
||||
case AssetPathType.FullSize: {
|
||||
case AssetFileType.FullSize: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
|
||||
}
|
||||
case AssetPathType.Preview: {
|
||||
case AssetFileType.Preview: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
|
||||
}
|
||||
case AssetPathType.Thumbnail: {
|
||||
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 AssetPathType.Sidecar: {
|
||||
case AssetFileType.Sidecar: {
|
||||
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
|
||||
}
|
||||
case PersonPathType.Face: {
|
||||
|
||||
+25
-1
@@ -39,6 +39,7 @@ export type AssetFile = {
|
||||
id: string;
|
||||
type: AssetFileType;
|
||||
path: string;
|
||||
isEdited: boolean;
|
||||
};
|
||||
|
||||
export type Library = {
|
||||
@@ -272,6 +273,7 @@ export type AssetFace = {
|
||||
person?: Person | null;
|
||||
updatedAt: Date;
|
||||
updateId: string;
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
export type Plugin = Selectable<PluginTable>;
|
||||
@@ -340,8 +342,18 @@ export const columns = {
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.type',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
assetFilesForThumbnail: [
|
||||
'asset_file.id',
|
||||
'asset_file.path',
|
||||
'asset_file.type',
|
||||
'asset_file.isEdited',
|
||||
'asset_file.isProgressive',
|
||||
'asset_file.isTransparent',
|
||||
],
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
|
||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||
@@ -390,6 +402,9 @@ export const columns = {
|
||||
'asset.livePhotoVideoId',
|
||||
'asset.stackId',
|
||||
'asset.libraryId',
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
'asset.isEdited',
|
||||
],
|
||||
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
|
||||
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
|
||||
@@ -422,6 +437,13 @@ export const columns = {
|
||||
'asset_exif.rating',
|
||||
'asset_exif.fps',
|
||||
],
|
||||
syncAssetEdit: [
|
||||
'asset_edit.id',
|
||||
'asset_edit.assetId',
|
||||
'asset_edit.sequence',
|
||||
'asset_edit.action',
|
||||
'asset_edit.parameters',
|
||||
],
|
||||
exif: [
|
||||
'asset_exif.assetId',
|
||||
'asset_exif.autoStackId',
|
||||
@@ -451,6 +473,7 @@ export const columns = {
|
||||
'asset_exif.projectionType',
|
||||
'asset_exif.rating',
|
||||
'asset_exif.state',
|
||||
'asset_exif.tags',
|
||||
'asset_exif.timeZone',
|
||||
],
|
||||
plugin: [
|
||||
@@ -474,4 +497,5 @@ export const lockableProperties = [
|
||||
'longitude',
|
||||
'rating',
|
||||
'timeZone',
|
||||
'tags',
|
||||
] as const;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { BeforeUpdateTrigger, Column, ColumnOptions } from '@immich/sql-tools';
|
||||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
||||
import { ApiOperation, ApiOperationOptions, ApiProperty, ApiPropertyOptions, ApiTags } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { ApiCustomExtension, ApiTag, ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
|
||||
import { EmitEvent } from 'src/repositories/event.repository';
|
||||
import { immich_uuid_v7, updated_at } from 'src/schema/functions';
|
||||
import { BeforeUpdateTrigger, Column, ColumnOptions } from 'src/sql-tools';
|
||||
import { setUnion } from 'src/utils/set';
|
||||
|
||||
const GeneratedUuidV7Column = (options: Omit<ColumnOptions, 'type' | 'default' | 'nullable'> = {}) =>
|
||||
@@ -171,7 +171,7 @@ export const Endpoint = ({ history, ...options }: EndpointOptions) => {
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
|
||||
export type PropertyOptions = ApiPropertyOptions & { history?: HistoryBuilder };
|
||||
export const Property = ({ history, ...options }: PropertyOptions) => {
|
||||
const extensions = history?.getExtensions() ?? {};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, ValidateIf } from 'class-validator';
|
||||
import { Activity } from 'src/database';
|
||||
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
|
||||
@@ -17,48 +17,55 @@ export enum ReactionLevel {
|
||||
export type MaybeDuplicate<T> = { duplicate: boolean; value: T };
|
||||
|
||||
export class ActivityResponseDto {
|
||||
@ApiProperty({ description: 'Activity ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Creation date', format: 'date-time' })
|
||||
createdAt!: Date;
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType' })
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type' })
|
||||
type!: ReactionType;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
user!: UserResponseDto;
|
||||
@ApiProperty({ description: 'Asset ID (if activity is for an asset)' })
|
||||
assetId!: string | null;
|
||||
@ApiPropertyOptional({ description: 'Comment text (for comment activities)' })
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
export class ActivityStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of comments' })
|
||||
comments!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of likes' })
|
||||
likes!: number;
|
||||
}
|
||||
|
||||
export class ActivityDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Asset ID (if activity is for an asset)' })
|
||||
assetId?: string;
|
||||
}
|
||||
|
||||
export class ActivitySearchDto extends ActivityDto {
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', optional: true })
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Filter by activity type', optional: true })
|
||||
type?: ReactionType;
|
||||
|
||||
@ValidateEnum({ enum: ReactionLevel, name: 'ReactionLevel', optional: true })
|
||||
@ValidateEnum({ enum: ReactionLevel, name: 'ReactionLevel', description: 'Filter by activity level', optional: true })
|
||||
level?: ReactionLevel;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const isComment = (dto: ActivityCreateDto) => dto.type === ReactionType.COMMENT;
|
||||
|
||||
export class ActivityCreateDto extends ActivityDto {
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType' })
|
||||
@ValidateEnum({ enum: ReactionType, name: 'ReactionType', description: 'Activity type (like or comment)' })
|
||||
type!: ReactionType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Comment text (required if type is comment)' })
|
||||
@ValidateIf(isComment)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { mapAlbum } from 'src/dtos/album.dto';
|
||||
import { albumStub } from 'test/fixtures/album.stub';
|
||||
import { AlbumFactory } from 'test/factories/album.factory';
|
||||
|
||||
describe('mapAlbum', () => {
|
||||
it('should set start and end dates', () => {
|
||||
const dto = mapAlbum(albumStub.twoAssets, false);
|
||||
expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z'));
|
||||
expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z'));
|
||||
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);
|
||||
});
|
||||
|
||||
it('should not set start and end dates for empty assets', () => {
|
||||
const dto = mapAlbum(albumStub.empty, false);
|
||||
const dto = mapAlbum(AlbumFactory.create(), false);
|
||||
expect(dto.startDate).toBeUndefined();
|
||||
expect(dto.endDate).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
|
||||
import _ from 'lodash';
|
||||
@@ -11,156 +11,181 @@ import { AlbumUserRole, AssetOrder } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class AlbumInfoDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Exclude assets from response' })
|
||||
withoutAssets?: boolean;
|
||||
}
|
||||
|
||||
export class AlbumUserAddDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'User ID' })
|
||||
userId!: string;
|
||||
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', default: AlbumUserRole.Editor })
|
||||
@ValidateEnum({
|
||||
enum: AlbumUserRole,
|
||||
name: 'AlbumUserRole',
|
||||
description: 'Album user role',
|
||||
default: AlbumUserRole.Editor,
|
||||
})
|
||||
role?: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class AddUsersDto {
|
||||
@ApiProperty({ description: 'Album users to add' })
|
||||
@ArrayNotEmpty()
|
||||
albumUsers!: AlbumUserAddDto[];
|
||||
}
|
||||
|
||||
export class AlbumUserCreateDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'User ID' })
|
||||
userId!: string;
|
||||
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole' })
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class CreateAlbumDto {
|
||||
@ApiProperty({ description: 'Album name' })
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
albumName!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Album description' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Album users' })
|
||||
@Optional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AlbumUserCreateDto)
|
||||
albumUsers?: AlbumUserCreateDto[];
|
||||
|
||||
@ValidateUUID({ optional: true, each: true })
|
||||
@ValidateUUID({ optional: true, each: true, description: 'Initial asset IDs' })
|
||||
assetIds?: string[];
|
||||
}
|
||||
|
||||
export class AlbumsAddAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Album IDs' })
|
||||
albumIds!: string[];
|
||||
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class AlbumsAddAssetsResponseDto {
|
||||
@ApiProperty({ description: 'Operation success' })
|
||||
success!: boolean;
|
||||
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', optional: true })
|
||||
@ValidateEnum({ enum: BulkIdErrorReason, name: 'BulkIdErrorReason', description: 'Error reason', optional: true })
|
||||
error?: BulkIdErrorReason;
|
||||
}
|
||||
|
||||
export class UpdateAlbumDto {
|
||||
@ApiPropertyOptional({ description: 'Album name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
albumName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Album description' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Album thumbnail asset ID' })
|
||||
albumThumbnailAssetId?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Enable activity feed' })
|
||||
isActivityEnabled?: boolean;
|
||||
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||
order?: AssetOrder;
|
||||
}
|
||||
|
||||
export class GetAlbumsDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
/**
|
||||
* true: only shared albums
|
||||
* false: only non-shared own albums
|
||||
* undefined: shared and owned albums
|
||||
*/
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
description: 'Filter by shared status: true = only shared, false = not shared, undefined = all owned albums',
|
||||
})
|
||||
shared?: boolean;
|
||||
|
||||
/**
|
||||
* Only returns albums that contain the asset
|
||||
* Ignores the shared parameter
|
||||
* undefined: get all albums
|
||||
*/
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter albums containing this asset ID (ignores shared parameter)' })
|
||||
assetId?: string;
|
||||
}
|
||||
|
||||
export class AlbumStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of owned albums' })
|
||||
owned!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of shared albums' })
|
||||
shared!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of non-shared albums' })
|
||||
notShared!: number;
|
||||
}
|
||||
|
||||
export class UpdateAlbumUserDto {
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole' })
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class AlbumUserResponseDto {
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
user!: UserResponseDto;
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole' })
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class ContributorCountResponseDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets contributed' })
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
export class AlbumResponseDto {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
@ApiProperty({ description: 'Album name' })
|
||||
albumName!: string;
|
||||
@ApiProperty({ description: 'Album description' })
|
||||
description!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Thumbnail asset ID' })
|
||||
albumThumbnailAssetId!: string | null;
|
||||
@ApiProperty({ description: 'Is shared album' })
|
||||
shared!: boolean;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
albumUsers!: AlbumUserResponseDto[];
|
||||
@ApiProperty({ description: 'Has shared link' })
|
||||
hasSharedLink!: boolean;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
assets!: AssetResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
owner!: UserResponseDto;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@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;
|
||||
@ApiProperty({ description: 'Activity feed enabled' })
|
||||
isActivityEnabled!: boolean;
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Asset sort order', optional: true })
|
||||
order?: AssetOrder;
|
||||
|
||||
// Optional per-user contribution counts for shared albums
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Type(() => ContributorCountResponseDto)
|
||||
@ApiProperty({ type: [ContributorCountResponseDto], required: false })
|
||||
contributorCounts?: ContributorCountResponseDto[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,55 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Optional, ValidateEnum } from 'src/validation';
|
||||
|
||||
export class APIKeyCreateDto {
|
||||
@ApiPropertyOptional({ description: 'API key name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
name?: string;
|
||||
|
||||
@ValidateEnum({ enum: Permission, name: 'Permission', each: true })
|
||||
@ValidateEnum({ enum: Permission, name: 'Permission', each: true, description: 'List of permissions' })
|
||||
@ArrayMinSize(1)
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
export class APIKeyUpdateDto {
|
||||
@ApiPropertyOptional({ description: 'API key name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ValidateEnum({ enum: Permission, name: 'Permission', each: true, optional: true })
|
||||
@ValidateEnum({
|
||||
enum: Permission,
|
||||
name: 'Permission',
|
||||
description: 'List of permissions',
|
||||
each: true,
|
||||
optional: true,
|
||||
})
|
||||
@ArrayMinSize(1)
|
||||
permissions?: Permission[];
|
||||
}
|
||||
|
||||
export class APIKeyCreateResponseDto {
|
||||
secret!: string;
|
||||
apiKey!: APIKeyResponseDto;
|
||||
}
|
||||
|
||||
export class APIKeyResponseDto {
|
||||
@ApiProperty({ description: 'API key ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'API key name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ValidateEnum({ enum: Permission, name: 'Permission', each: true })
|
||||
@ValidateEnum({ enum: Permission, name: 'Permission', each: true, description: 'List of permissions' })
|
||||
permissions!: Permission[];
|
||||
}
|
||||
|
||||
export class APIKeyCreateResponseDto {
|
||||
@ApiProperty({ description: 'API key secret (only shown once)' })
|
||||
secret!: string;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
apiKey!: APIKeyResponseDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ValidateUUID } from 'src/validation';
|
||||
|
||||
/** @deprecated Use `BulkIdResponseDto` instead */
|
||||
@@ -9,8 +10,11 @@ export enum AssetIdErrorReason {
|
||||
|
||||
/** @deprecated Use `BulkIdResponseDto` instead */
|
||||
export class AssetIdsResponseDto {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
@ApiProperty({ description: 'Whether operation succeeded' })
|
||||
success!: boolean;
|
||||
@ApiPropertyOptional({ description: 'Error reason if failed', enum: AssetIdErrorReason })
|
||||
error?: AssetIdErrorReason;
|
||||
}
|
||||
|
||||
@@ -22,12 +26,15 @@ export enum BulkIdErrorReason {
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'IDs to process' })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkIdResponseDto {
|
||||
@ApiProperty({ description: 'ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Whether operation succeeded' })
|
||||
success!: boolean;
|
||||
@ApiPropertyOptional({ description: 'Error reason if failed', enum: BulkIdErrorReason })
|
||||
error?: BulkIdErrorReason;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export enum AssetMediaStatus {
|
||||
@@ -6,8 +7,9 @@ export enum AssetMediaStatus {
|
||||
DUPLICATE = 'duplicate',
|
||||
}
|
||||
export class AssetMediaResponseDto {
|
||||
@ValidateEnum({ enum: AssetMediaStatus, name: 'AssetMediaStatus' })
|
||||
@ValidateEnum({ enum: AssetMediaStatus, name: 'AssetMediaStatus', description: 'Upload status' })
|
||||
status!: AssetMediaStatus;
|
||||
@ApiProperty({ description: 'Asset media ID' })
|
||||
id!: string;
|
||||
}
|
||||
|
||||
@@ -22,17 +24,24 @@ export enum AssetRejectReason {
|
||||
}
|
||||
|
||||
export class AssetBulkUploadCheckResult {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Upload action', enum: AssetUploadAction })
|
||||
action!: AssetUploadAction;
|
||||
@ApiPropertyOptional({ description: 'Rejection reason if rejected', enum: AssetRejectReason })
|
||||
reason?: AssetRejectReason;
|
||||
@ApiPropertyOptional({ description: 'Existing asset ID if duplicate' })
|
||||
assetId?: string;
|
||||
@ApiPropertyOptional({ description: 'Whether existing asset is trashed' })
|
||||
isTrashed?: boolean;
|
||||
}
|
||||
|
||||
export class AssetBulkUploadCheckResponseDto {
|
||||
@ApiProperty({ description: 'Upload check results' })
|
||||
results!: AssetBulkUploadCheckResult[];
|
||||
}
|
||||
|
||||
export class CheckExistingAssetsResponseDto {
|
||||
@ApiProperty({ description: 'Existing asset IDs' })
|
||||
existingIds!: string[];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
|
||||
@@ -7,6 +7,7 @@ import { AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetMediaSize {
|
||||
Original = 'original',
|
||||
/**
|
||||
* An full-sized image extracted/converted from non-web-friendly formats like RAW/HIF.
|
||||
* or otherwise the original image itself.
|
||||
@@ -17,8 +18,11 @@ export enum AssetMediaSize {
|
||||
}
|
||||
|
||||
export class AssetMediaOptionsDto {
|
||||
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', optional: true })
|
||||
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
|
||||
size?: AssetMediaSize;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export enum UploadFieldName {
|
||||
@@ -28,44 +32,49 @@ export enum UploadFieldName {
|
||||
}
|
||||
|
||||
class AssetMediaBase {
|
||||
@ApiProperty({ description: 'Device asset ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
|
||||
@ValidateDate()
|
||||
@ValidateDate({ description: 'File creation date' })
|
||||
fileCreatedAt!: Date;
|
||||
|
||||
@ValidateDate()
|
||||
@ValidateDate({ description: 'File modification date' })
|
||||
fileModifiedAt!: Date;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Duration (for videos)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
duration?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filename' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
filename?: string;
|
||||
|
||||
// The properties below are added to correctly generate the API docs
|
||||
// and client SDKs. Validation should be handled in the controller.
|
||||
@ApiProperty({ type: 'string', format: 'binary' })
|
||||
@ApiProperty({ type: 'string', format: 'binary', description: 'Asset file data' })
|
||||
[UploadFieldName.ASSET_DATA]!: any;
|
||||
}
|
||||
|
||||
export class AssetMediaCreateDto extends AssetMediaBase {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true })
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility', optional: true })
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Live photo video ID' })
|
||||
livePhotoVideoId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Asset metadata items' })
|
||||
@Transform(({ value }) => {
|
||||
try {
|
||||
const json = JSON.parse(value);
|
||||
@@ -78,26 +87,28 @@ export class AssetMediaCreateDto extends AssetMediaBase {
|
||||
@Optional()
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
metadata!: AssetMetadataUpsertItemDto[];
|
||||
metadata?: AssetMetadataUpsertItemDto[];
|
||||
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false, description: 'Sidecar file data' })
|
||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||
}
|
||||
|
||||
export class AssetMediaReplaceDto extends AssetMediaBase {}
|
||||
|
||||
export class AssetBulkUploadCheckItem {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
id!: string;
|
||||
|
||||
/** base64 or hex encoded sha1 hash */
|
||||
@ApiProperty({ description: 'Base64 or hex encoded SHA1 hash' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
checksum!: string;
|
||||
}
|
||||
|
||||
export class AssetBulkUploadCheckDto {
|
||||
@ApiProperty({ description: 'Assets to check' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetBulkUploadCheckItem)
|
||||
@@ -105,11 +116,13 @@ export class AssetBulkUploadCheckDto {
|
||||
}
|
||||
|
||||
export class CheckExistingAssetsDto {
|
||||
@ApiProperty({ description: 'Device asset IDs to check' })
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
deviceAssetIds!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
@IsNotEmpty()
|
||||
deviceId!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
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';
|
||||
|
||||
describe('mapAsset', () => {
|
||||
describe('peopleWithFaces', () => {
|
||||
it('should transform all faces when a person has multiple faces in the same image', () => {
|
||||
const person = PersonFactory.create();
|
||||
const face1 = {
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const face2 = {
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 400,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 500,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const asset = AssetFactory.from()
|
||||
.face(face1, (builder) => builder.person(person))
|
||||
.face(face2, (builder) => builder.person(person))
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.edit({
|
||||
action: AssetEditAction.Crop,
|
||||
parameters: {
|
||||
width: 1512,
|
||||
height: 1152,
|
||||
x: 216,
|
||||
y: 1512,
|
||||
},
|
||||
})
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
expect(result.people![0].faces).toHaveLength(2);
|
||||
|
||||
// Verify that both faces have been transformed (bounding boxes adjusted for crop)
|
||||
const firstFace = result.people![0].faces[0];
|
||||
const secondFace = result.people![0].faces[1];
|
||||
|
||||
// After crop (x: 216, y: 1512), the coordinates should be adjusted
|
||||
// Faces outside the crop area will be clamped
|
||||
expect(firstFace.boundingBoxX1).toBe(-116); // 100 - 216 = -116
|
||||
expect(firstFace.boundingBoxY1).toBe(-1412); // 100 - 1512 = -1412
|
||||
expect(firstFace.boundingBoxX2).toBe(-16); // 200 - 216 = -16
|
||||
expect(firstFace.boundingBoxY2).toBe(-1312); // 200 - 1512 = -1312
|
||||
|
||||
expect(secondFace.boundingBoxX1).toBe(84); // 300 - 216
|
||||
expect(secondFace.boundingBoxY1).toBe(-1112); // 400 - 1512 = -1112
|
||||
expect(secondFace.boundingBoxX2).toBe(184); // 400 - 216
|
||||
expect(secondFace.boundingBoxY2).toBe(-1012); // 500 - 1512 = -1012
|
||||
});
|
||||
|
||||
it('should transform unassigned faces with edits and dimensions', () => {
|
||||
const unassignedFace = AssetFaceFactory.create({
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
});
|
||||
|
||||
const asset = AssetFactory.from()
|
||||
.face(unassignedFace)
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.unassignedFaces).toBeDefined();
|
||||
expect(result.unassignedFaces).toHaveLength(1);
|
||||
|
||||
// Verify that unassigned face has been transformed
|
||||
const face = result.unassignedFaces![0];
|
||||
expect(face.boundingBoxX1).toBe(50); // 100 - 50
|
||||
expect(face.boundingBoxY1).toBe(50); // 100 - 50
|
||||
expect(face.boundingBoxX2).toBe(150); // 200 - 50
|
||||
expect(face.boundingBoxY2).toBe(150); // 200 - 50
|
||||
});
|
||||
|
||||
it('should handle multiple people each with multiple faces', () => {
|
||||
const person1Face1 = {
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person1Face2 = {
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 300,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 400,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person2Face1 = {
|
||||
boundingBoxX1: 500,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 600,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person = PersonFactory.create({ id: 'person-1' });
|
||||
|
||||
const asset = AssetFactory.from()
|
||||
.face(person1Face1, (builder) => builder.person(person))
|
||||
.face(person1Face2, (builder) => builder.person(person))
|
||||
.face(person2Face1, (builder) => builder.person({ id: 'person-2' }))
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(2);
|
||||
|
||||
const person1 = result.people!.find((p) => p.id === 'person-1');
|
||||
const person2 = result.people!.find((p) => p.id === 'person-2');
|
||||
|
||||
expect(person1).toBeDefined();
|
||||
expect(person1!.faces).toHaveLength(2);
|
||||
// No edits, so coordinates should be unchanged
|
||||
expect(person1!.faces[0].boundingBoxX1).toBe(100);
|
||||
expect(person1!.faces[0].boundingBoxY1).toBe(100);
|
||||
expect(person1!.faces[1].boundingBoxX1).toBe(300);
|
||||
expect(person1!.faces[1].boundingBoxY1).toBe(300);
|
||||
|
||||
expect(person2).toBeDefined();
|
||||
expect(person2!.faces).toHaveLength(1);
|
||||
expect(person2!.faces[0].boundingBoxX1).toBe(500);
|
||||
expect(person2!.faces[0].boundingBoxY1).toBe(100);
|
||||
});
|
||||
|
||||
it('should combine faces of the same person into a single entry', () => {
|
||||
const face1 = {
|
||||
boundingBoxX1: 100,
|
||||
boundingBoxY1: 100,
|
||||
boundingBoxX2: 200,
|
||||
boundingBoxY2: 200,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const face2 = {
|
||||
boundingBoxX1: 300,
|
||||
boundingBoxY1: 300,
|
||||
boundingBoxX2: 400,
|
||||
boundingBoxY2: 400,
|
||||
imageWidth: 1000,
|
||||
imageHeight: 800,
|
||||
};
|
||||
|
||||
const person = PersonFactory.create();
|
||||
|
||||
const asset = AssetFactory.from()
|
||||
.face(face1, (builder) => builder.person(person))
|
||||
.face(face2, (builder) => builder.person(person))
|
||||
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
|
||||
.build();
|
||||
|
||||
const result = mapAsset(asset);
|
||||
|
||||
expect(result.people).toBeDefined();
|
||||
expect(result.people).toHaveLength(1);
|
||||
|
||||
expect(result.people![0].id).toBe(person.id);
|
||||
expect(result.people![0].faces).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Selectable } 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';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
||||
import {
|
||||
AssetFaceWithoutPersonResponseDto,
|
||||
@@ -13,15 +14,23 @@ 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 { getDimensions } from 'src/utils/asset.util';
|
||||
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class SanitizedAssetResponseDto {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum' })
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
|
||||
type!: AssetType;
|
||||
@ApiProperty({
|
||||
description:
|
||||
'Thumbhash for thumbnail generation (base64) also used as the c query param for thumbnail cache busting.',
|
||||
})
|
||||
thumbhash!: string | null;
|
||||
@ApiPropertyOptional({ description: 'Original MIME type' })
|
||||
originalMimeType?: string;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
@@ -31,9 +40,16 @@ export class SanitizedAssetResponseDto {
|
||||
example: '2024-01-15T14:30:00.000Z',
|
||||
})
|
||||
localDateTime!: Date;
|
||||
@ApiProperty({ description: 'Video duration (for videos)' })
|
||||
duration!: string;
|
||||
@ApiPropertyOptional({ description: 'Live photo video ID' })
|
||||
livePhotoVideoId?: string | null;
|
||||
@ApiProperty({ description: 'Whether asset has metadata' })
|
||||
hasMetadata!: boolean;
|
||||
@ApiProperty({ description: 'Asset width' })
|
||||
width!: number | null;
|
||||
@ApiProperty({ description: 'Asset height' })
|
||||
height!: number | null;
|
||||
}
|
||||
|
||||
export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
@@ -44,13 +60,24 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
example: '2024-01-15T20:30:00.000Z',
|
||||
})
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Device asset ID' })
|
||||
deviceAssetId!: string;
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
deviceId!: string;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
owner?: UserResponseDto;
|
||||
@Property({ history: new HistoryBuilder().added('v1').deprecated('v1') })
|
||||
@ValidateUUID({
|
||||
nullable: true,
|
||||
description: 'Library ID',
|
||||
history: new HistoryBuilder().added('v1').deprecated('v1'),
|
||||
})
|
||||
libraryId?: string | null;
|
||||
@ApiProperty({ description: 'Original file path' })
|
||||
originalPath!: string;
|
||||
@ApiProperty({ description: 'Original file name' })
|
||||
originalFileName!: string;
|
||||
@ApiProperty({
|
||||
type: 'string',
|
||||
@@ -76,23 +103,40 @@ export class AssetResponseDto extends SanitizedAssetResponseDto {
|
||||
example: '2024-01-16T12:45:30.000Z',
|
||||
})
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
@ApiProperty({ description: 'Is archived' })
|
||||
isArchived!: boolean;
|
||||
@ApiProperty({ description: 'Is trashed' })
|
||||
isTrashed!: boolean;
|
||||
@ApiProperty({ description: 'Is offline' })
|
||||
isOffline!: boolean;
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility' })
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility' })
|
||||
visibility!: AssetVisibility;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
exifInfo?: ExifResponseDto;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
tags?: TagResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
people?: PersonWithFacesResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
||||
/**base64 encoded sha1 hash */
|
||||
@ApiProperty({ description: 'Base64 encoded SHA1 hash' })
|
||||
checksum!: string;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
stack?: AssetStackResponseDto | null;
|
||||
@ApiPropertyOptional({ description: 'Duplicate group ID' })
|
||||
duplicateId?: string | null;
|
||||
|
||||
@Property({ history: new HistoryBuilder().added('v1').deprecated('v1.113.0') })
|
||||
@Property({ description: 'Is resized', history: new HistoryBuilder().added('v1').deprecated('v1.113.0') })
|
||||
resized?: boolean;
|
||||
@Property({ description: 'Is edited', history: new HistoryBuilder().added('v2.5.0').beta('v2.5.0') })
|
||||
isEdited!: boolean;
|
||||
}
|
||||
|
||||
export type MapAsset = {
|
||||
@@ -107,6 +151,7 @@ export type MapAsset = {
|
||||
deviceId: string;
|
||||
duplicateId: string | null;
|
||||
duration: string | null;
|
||||
edits?: AssetEditActionItem[];
|
||||
encodedVideoPath: string | null;
|
||||
exifInfo?: Selectable<Exif> | null;
|
||||
faces?: AssetFace[];
|
||||
@@ -129,14 +174,19 @@ export type MapAsset = {
|
||||
tags?: Tag[];
|
||||
thumbhash: Buffer<ArrayBufferLike> | null;
|
||||
type: AssetType;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
isEdited: boolean;
|
||||
};
|
||||
|
||||
export class AssetStackResponseDto {
|
||||
@ApiProperty({ description: 'Stack ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ description: 'Primary asset ID' })
|
||||
primaryAssetId!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets in stack' })
|
||||
assetCount!: number;
|
||||
}
|
||||
|
||||
@@ -146,23 +196,30 @@ export type AssetMapOptions = {
|
||||
auth?: AuthDto;
|
||||
};
|
||||
|
||||
// TODO: this is inefficient
|
||||
const peopleWithFaces = (faces?: AssetFace[]): PersonWithFacesResponseDto[] => {
|
||||
const result: PersonWithFacesResponseDto[] = [];
|
||||
if (faces) {
|
||||
for (const face of faces) {
|
||||
if (face.person) {
|
||||
const existingPersonEntry = result.find((item) => item.id === face.person!.id);
|
||||
if (existingPersonEntry) {
|
||||
existingPersonEntry.faces.push(face);
|
||||
} else {
|
||||
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face)] });
|
||||
}
|
||||
}
|
||||
}
|
||||
const peopleWithFaces = (
|
||||
faces?: AssetFace[],
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): PersonWithFacesResponseDto[] => {
|
||||
if (!faces) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result;
|
||||
const peopleFaces: Map<string, PersonWithFacesResponseDto> = new Map();
|
||||
|
||||
for (const face of faces) {
|
||||
if (!face.person) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!peopleFaces.has(face.person.id)) {
|
||||
peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] });
|
||||
}
|
||||
const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions);
|
||||
peopleFaces.get(face.person.id)!.faces.push(mappedFace);
|
||||
}
|
||||
|
||||
return [...peopleFaces.values()];
|
||||
};
|
||||
|
||||
const mapStack = (entity: { stack?: Stack | null }) => {
|
||||
@@ -190,10 +247,14 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
duration: entity.duration ?? '0:00:00.00000',
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
hasMetadata: false,
|
||||
width: entity.width,
|
||||
height: entity.height,
|
||||
};
|
||||
return sanitizedAssetResponse as AssetResponseDto;
|
||||
}
|
||||
|
||||
const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined;
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
createdAt: entity.createdAt,
|
||||
@@ -219,13 +280,18 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
||||
livePhotoVideoId: entity.livePhotoVideoId,
|
||||
tags: entity.tags?.map((tag) => mapTag(tag)),
|
||||
people: peopleWithFaces(entity.faces),
|
||||
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
|
||||
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
|
||||
unassignedFaces: entity.faces
|
||||
?.filter((face) => !face.person)
|
||||
.map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)),
|
||||
checksum: hexOrBufferToBase64(entity.checksum)!,
|
||||
stack: withStack ? mapStack(entity) : undefined,
|
||||
isOffline: entity.isOffline,
|
||||
hasMetadata: true,
|
||||
duplicateId: entity.duplicateId,
|
||||
resized: true,
|
||||
width: entity.width,
|
||||
height: entity.height,
|
||||
isEdited: entity.isEdited,
|
||||
};
|
||||
}
|
||||
|
||||
+100
-37
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
@@ -16,12 +16,14 @@ import {
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DeviceIdDto {
|
||||
@ApiProperty({ description: 'Device ID' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
@@ -32,49 +34,63 @@ const hasGPS = (o: { latitude: undefined; longitude: undefined }) =>
|
||||
const ValidateGPS = () => ValidateIf(hasGPS);
|
||||
|
||||
export class UpdateAssetBase {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true })
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true, description: 'Asset visibility' })
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ApiProperty({ description: 'Original date and time' })
|
||||
@Optional()
|
||||
@IsDateString()
|
||||
dateTimeOriginal?: string;
|
||||
|
||||
@ApiProperty({ description: 'Latitude coordinate' })
|
||||
@ValidateGPS()
|
||||
@IsLatitude()
|
||||
@IsNotEmpty()
|
||||
latitude?: number;
|
||||
|
||||
@ApiProperty({ description: 'Longitude coordinate' })
|
||||
@ValidateGPS()
|
||||
@IsLongitude()
|
||||
@IsNotEmpty()
|
||||
longitude?: number;
|
||||
|
||||
@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()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class AssetBulkUpdateDto extends UpdateAssetBase {
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs to update' })
|
||||
ids!: string[];
|
||||
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, nullable: true, description: 'Duplicate ID' })
|
||||
duplicateId?: string | null;
|
||||
|
||||
@ApiProperty({ description: 'Relative time offset in seconds' })
|
||||
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||
@Optional()
|
||||
@IsInt()
|
||||
dateTimeRelative?: number;
|
||||
|
||||
@ApiProperty({ description: 'Time zone (IANA timezone)' })
|
||||
@IsNotSiblingOf(['dateTimeOriginal'])
|
||||
@IsTimeZone()
|
||||
@Optional()
|
||||
@@ -82,11 +98,12 @@ export class AssetBulkUpdateDto extends UpdateAssetBase {
|
||||
}
|
||||
|
||||
export class UpdateAssetDto extends UpdateAssetBase {
|
||||
@ValidateUUID({ optional: true, nullable: true })
|
||||
@ValidateUUID({ optional: true, nullable: true, description: 'Live photo video ID' })
|
||||
livePhotoVideoId?: string | null;
|
||||
}
|
||||
|
||||
export class RandomAssetsDto {
|
||||
@ApiProperty({ description: 'Number of random assets to return' })
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@@ -95,12 +112,12 @@ export class RandomAssetsDto {
|
||||
}
|
||||
|
||||
export class AssetBulkDeleteDto extends BulkIdsDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Force delete even if in use' })
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class AssetIdsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
@@ -112,41 +129,42 @@ export enum AssetJobName {
|
||||
}
|
||||
|
||||
export class AssetJobsDto extends AssetIdsDto {
|
||||
@ValidateEnum({ enum: AssetJobName, name: 'AssetJobName' })
|
||||
@ValidateEnum({ enum: AssetJobName, name: 'AssetJobName', description: 'Job name' })
|
||||
name!: AssetJobName;
|
||||
}
|
||||
|
||||
export class AssetStatsDto {
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true })
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Filter by visibility', optional: true })
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by favorite status' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by trash status' })
|
||||
isTrashed?: boolean;
|
||||
}
|
||||
|
||||
export class AssetStatsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ description: 'Number of images', type: 'integer' })
|
||||
images!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ description: 'Number of videos', type: 'integer' })
|
||||
videos!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ description: 'Total number of assets', type: 'integer' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export class AssetMetadataRouteParams {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertDto {
|
||||
@ApiProperty({ description: 'Metadata items to upsert' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataUpsertItemDto)
|
||||
@@ -154,49 +172,94 @@ export class AssetMetadataUpsertDto {
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertItemDto {
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ description: 'Metadata value (object)' })
|
||||
@IsObject()
|
||||
value!: object;
|
||||
}
|
||||
|
||||
export class AssetMetadataMobileAppDto {
|
||||
@IsString()
|
||||
@Optional()
|
||||
iCloudId?: string;
|
||||
export class AssetMetadataBulkUpsertDto {
|
||||
@ApiProperty({ description: 'Metadata items to upsert' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataBulkUpsertItemDto)
|
||||
items!: AssetMetadataBulkUpsertItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkUpsertItemDto {
|
||||
@ValidateUUID({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ description: 'Metadata value (object)' })
|
||||
@IsObject()
|
||||
value!: object;
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkDeleteDto {
|
||||
@ApiProperty({ description: 'Metadata items to delete' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataBulkDeleteItemDto)
|
||||
items!: AssetMetadataBulkDeleteItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkDeleteItemDto {
|
||||
@ValidateUUID({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
}
|
||||
|
||||
export class AssetMetadataResponseDto {
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
@ValidateString({ description: 'Metadata key' })
|
||||
key!: string;
|
||||
|
||||
@ApiProperty({ description: 'Metadata value (object)' })
|
||||
value!: object;
|
||||
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export class AssetMetadataBulkResponseDto extends AssetMetadataResponseDto {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class AssetCopyDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Source asset ID' })
|
||||
sourceId!: string;
|
||||
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Target asset ID' })
|
||||
targetId!: string;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Copy shared links', default: true })
|
||||
sharedLinks?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Copy album associations', default: true })
|
||||
albums?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Copy sidecar file', default: true })
|
||||
sidecar?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Copy stack association', default: true })
|
||||
stack?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Copy favorite status', default: true })
|
||||
favorite?: boolean;
|
||||
}
|
||||
|
||||
export class AssetDownloadOriginalDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||
return {
|
||||
images: stats[AssetType.Image],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
import { AuthApiKey, AuthSession, AuthSharedLink, AuthUser, UserAdmin } from 'src/database';
|
||||
@@ -12,34 +12,46 @@ export type CookieResponse = {
|
||||
};
|
||||
|
||||
export class AuthDto {
|
||||
@ApiProperty({ description: 'Authenticated user' })
|
||||
user!: AuthUser;
|
||||
|
||||
@ApiPropertyOptional({ description: 'API key (if authenticated via API key)' })
|
||||
apiKey?: AuthApiKey;
|
||||
@ApiPropertyOptional({ description: 'Shared link (if authenticated via shared link)' })
|
||||
sharedLink?: AuthSharedLink;
|
||||
@ApiPropertyOptional({ description: 'Session (if authenticated via session)' })
|
||||
session?: AuthSession;
|
||||
}
|
||||
|
||||
export class LoginCredentialDto {
|
||||
@ApiProperty({ example: 'testuser@email.com', description: 'User email' })
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(toEmail)
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'testuser@email.com' })
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: 'password', description: 'User password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class LoginResponseDto {
|
||||
@ApiProperty({ description: 'Access token' })
|
||||
accessToken!: string;
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
@ApiProperty({ description: 'User email' })
|
||||
userEmail!: string;
|
||||
@ApiProperty({ description: 'User name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Profile image path' })
|
||||
profileImagePath!: string;
|
||||
@ApiProperty({ description: 'Is admin user' })
|
||||
isAdmin!: boolean;
|
||||
@ApiProperty({ description: 'Should change password' })
|
||||
shouldChangePassword!: boolean;
|
||||
@ApiProperty({ description: 'Is onboarded' })
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
@@ -61,42 +73,47 @@ export function mapLoginResponse(entity: UserAdmin, accessToken: string): LoginR
|
||||
}
|
||||
|
||||
export class LogoutResponseDto {
|
||||
@ApiProperty({ description: 'Logout successful' })
|
||||
successful!: boolean;
|
||||
@ApiProperty({ description: 'Redirect URI' })
|
||||
redirectUri!: string;
|
||||
}
|
||||
|
||||
export class SignUpDto extends LoginCredentialDto {
|
||||
@ApiProperty({ example: 'Admin', description: 'User name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'Admin' })
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class ChangePasswordDto {
|
||||
@ApiProperty({ example: 'password', description: 'Current password' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ example: 'password', description: 'New password (min 8 characters)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
@ApiProperty({ example: 'password' })
|
||||
newPassword!: string;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: false })
|
||||
@ValidateBoolean({ optional: true, default: false, description: 'Invalidate all other sessions' })
|
||||
invalidateSessions?: boolean;
|
||||
}
|
||||
|
||||
export class PinCodeSetupDto {
|
||||
@ApiProperty({ description: 'PIN code (4-6 digits)' })
|
||||
@PinCode()
|
||||
pinCode!: string;
|
||||
}
|
||||
|
||||
export class PinCodeResetDto {
|
||||
@ApiPropertyOptional({ description: 'New PIN code (4-6 digits)' })
|
||||
@PinCode({ optional: true })
|
||||
pinCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User password (required if PIN code is not provided)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -106,51 +123,64 @@ export class PinCodeResetDto {
|
||||
export class SessionUnlockDto extends PinCodeResetDto {}
|
||||
|
||||
export class PinCodeChangeDto extends PinCodeResetDto {
|
||||
@ApiProperty({ description: 'New PIN code (4-6 digits)' })
|
||||
@PinCode()
|
||||
newPinCode!: string;
|
||||
}
|
||||
|
||||
export class ValidateAccessTokenResponseDto {
|
||||
@ApiProperty({ description: 'Authentication status' })
|
||||
authStatus!: boolean;
|
||||
}
|
||||
|
||||
export class OAuthCallbackDto {
|
||||
@ApiProperty({ description: 'OAuth callback URL' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
url!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth state parameter' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth code verifier (PKCE)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
codeVerifier?: string;
|
||||
}
|
||||
|
||||
export class OAuthConfigDto {
|
||||
@ApiProperty({ description: 'OAuth redirect URI' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
redirectUri!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth state parameter' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'OAuth code challenge (PKCE)' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
codeChallenge?: string;
|
||||
}
|
||||
|
||||
export class OAuthAuthorizeResponseDto {
|
||||
@ApiProperty({ description: 'OAuth authorization URL' })
|
||||
url!: string;
|
||||
}
|
||||
|
||||
export class AuthStatusResponseDto {
|
||||
@ApiProperty({ description: 'Has PIN code set' })
|
||||
pinCode!: boolean;
|
||||
@ApiProperty({ description: 'Has password set' })
|
||||
password!: boolean;
|
||||
@ApiProperty({ description: 'Is elevated session' })
|
||||
isElevated!: boolean;
|
||||
@ApiPropertyOptional({ description: 'Session expiration date' })
|
||||
expiresAt?: string;
|
||||
@ApiPropertyOptional({ description: 'PIN expiration date' })
|
||||
pinExpiresAt?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DatabaseBackupDto {
|
||||
filename!: string;
|
||||
filesize!: number;
|
||||
}
|
||||
|
||||
export class DatabaseBackupListResponseDto {
|
||||
backups!: DatabaseBackupDto[];
|
||||
}
|
||||
|
||||
export class DatabaseBackupUploadDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
file?: any;
|
||||
}
|
||||
|
||||
export class DatabaseBackupDeleteDto {
|
||||
@IsString({ each: true })
|
||||
backups!: string[];
|
||||
}
|
||||
@@ -1,32 +1,40 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsPositive } from 'class-validator';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DownloadInfoDto {
|
||||
@ValidateUUID({ each: true, optional: true })
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Asset IDs to download' })
|
||||
assetIds?: string[];
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Album ID to download' })
|
||||
albumId?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'User ID to download assets from' })
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: 'integer', description: 'Archive size limit in bytes' })
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@Optional()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
archiveSize?: number;
|
||||
}
|
||||
|
||||
export class DownloadResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total size in bytes' })
|
||||
totalSize!: number;
|
||||
@ApiProperty({ description: 'Archive information' })
|
||||
archives!: DownloadArchiveInfo[];
|
||||
}
|
||||
|
||||
export class DownloadArchiveInfo {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Archive size in bytes' })
|
||||
size!: number;
|
||||
@ApiProperty({ description: 'Asset IDs in this archive' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class DownloadArchiveDto extends AssetIdsDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Download edited asset if available' })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
|
||||
export class DuplicateResponseDto {
|
||||
@ApiProperty({ description: 'Duplicate group ID' })
|
||||
duplicateId!: string;
|
||||
@ApiProperty({ description: 'Duplicate assets' })
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { ApiExtraModels, ApiProperty, getSchemaPath } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsEnum, IsInt, Min, ValidateNested } from 'class-validator';
|
||||
import { IsAxisAlignedRotation, IsUniqueEditActions, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetEditAction {
|
||||
Crop = 'crop',
|
||||
Rotate = 'rotate',
|
||||
Mirror = 'mirror',
|
||||
}
|
||||
|
||||
export enum MirrorAxis {
|
||||
Horizontal = 'horizontal',
|
||||
Vertical = 'vertical',
|
||||
}
|
||||
|
||||
export class CropParameters {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ description: 'Top-Left X coordinate of crop' })
|
||||
x!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ description: 'Top-Left Y coordinate of crop' })
|
||||
y!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@ApiProperty({ description: 'Width of the crop' })
|
||||
width!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@ApiProperty({ description: 'Height of the crop' })
|
||||
height!: number;
|
||||
}
|
||||
|
||||
export class RotateParameters {
|
||||
@IsAxisAlignedRotation()
|
||||
@ApiProperty({ description: 'Rotation angle in degrees' })
|
||||
angle!: number;
|
||||
}
|
||||
|
||||
export class MirrorParameters {
|
||||
@IsEnum(MirrorAxis)
|
||||
@ApiProperty({ enum: MirrorAxis, enumName: 'MirrorAxis', description: 'Axis to mirror along' })
|
||||
axis!: MirrorAxis;
|
||||
}
|
||||
|
||||
export type AssetEditParameters = CropParameters | RotateParameters | MirrorParameters;
|
||||
export type AssetEditActionItem =
|
||||
| {
|
||||
action: AssetEditAction.Crop;
|
||||
parameters: CropParameters;
|
||||
}
|
||||
| {
|
||||
action: AssetEditAction.Rotate;
|
||||
parameters: RotateParameters;
|
||||
}
|
||||
| {
|
||||
action: AssetEditAction.Mirror;
|
||||
parameters: MirrorParameters;
|
||||
};
|
||||
|
||||
@ApiExtraModels(CropParameters, RotateParameters, MirrorParameters)
|
||||
export class AssetEditActionItemDto {
|
||||
@ValidateEnum({ name: 'AssetEditAction', enum: AssetEditAction, description: 'Type of edit action to perform' })
|
||||
action!: AssetEditAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of edit actions to apply (crop, rotate, or mirror)',
|
||||
anyOf: [CropParameters, RotateParameters, MirrorParameters].map((type) => ({
|
||||
$ref: getSchemaPath(type),
|
||||
})),
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type((options) => actionParameterMap[options?.object.action as keyof AssetEditActionParameter])
|
||||
parameters!: AssetEditActionItem['parameters'];
|
||||
}
|
||||
|
||||
export class AssetEditActionItemResponseDto extends AssetEditActionItemDto {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export type AssetEditActionParameter = typeof actionParameterMap;
|
||||
const actionParameterMap = {
|
||||
[AssetEditAction.Crop]: CropParameters,
|
||||
[AssetEditAction.Rotate]: RotateParameters,
|
||||
[AssetEditAction.Mirror]: MirrorParameters,
|
||||
};
|
||||
|
||||
export class AssetEditsCreateDto {
|
||||
@ArrayMinSize(1)
|
||||
@IsUniqueEditActions()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetEditActionItemDto)
|
||||
@ApiProperty({ description: 'List of edit actions to apply (crop, rotate, or mirror)' })
|
||||
edits!: AssetEditActionItemDto[];
|
||||
}
|
||||
|
||||
export class AssetEditsResponseDto {
|
||||
@ValidateUUID({ description: 'Asset ID these edits belong to' })
|
||||
assetId!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'List of edit actions applied to the asset',
|
||||
})
|
||||
edits!: AssetEditActionItemResponseDto[];
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
||||
import { DatabaseSslMode, ImmichEnvironment, LogLevel } from 'src/enum';
|
||||
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
|
||||
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
||||
|
||||
// TODO import from sql-tools once the swagger plugin supports external enums
|
||||
enum DatabaseSslMode {
|
||||
Disable = 'disable',
|
||||
Allow = 'allow',
|
||||
Prefer = 'prefer',
|
||||
Require = 'require',
|
||||
VerifyFull = 'verify-full',
|
||||
}
|
||||
|
||||
export class EnvDto {
|
||||
@IsInt()
|
||||
@Optional()
|
||||
@@ -48,6 +57,10 @@ export class EnvDto {
|
||||
@Optional()
|
||||
IMMICH_LOG_LEVEL?: LogLevel;
|
||||
|
||||
@IsEnum(LogFormat)
|
||||
@Optional()
|
||||
IMMICH_LOG_FORMAT?: LogFormat;
|
||||
|
||||
@Optional()
|
||||
@Matches(/^\//, { message: 'IMMICH_MEDIA_LOCATION must be an absolute path' })
|
||||
IMMICH_MEDIA_LOCATION?: string;
|
||||
@@ -58,7 +71,7 @@ export class EnvDto {
|
||||
IMMICH_MICROSERVICES_METRICS_PORT?: number;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_PLUGINS_ENABLED?: boolean;
|
||||
IMMICH_ALLOW_EXTERNAL_PLUGINS?: boolean;
|
||||
|
||||
@Optional()
|
||||
@Matches(/^\//, { message: 'IMMICH_PLUGINS_INSTALL_FOLDER must be an absolute path' })
|
||||
@@ -113,6 +126,9 @@ export class EnvDto {
|
||||
@Optional()
|
||||
IMMICH_THIRD_PARTY_SUPPORT_URL?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
IMMICH_ALLOW_SETUP?: boolean;
|
||||
|
||||
@IsIPRange({ requireCIDR: false }, { each: true })
|
||||
@Transform(({ value }) =>
|
||||
value && typeof value === 'string'
|
||||
|
||||
@@ -1,30 +1,51 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Exif } from 'src/database';
|
||||
|
||||
export class ExifResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Camera make' })
|
||||
make?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Camera model' })
|
||||
model?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Image width in pixels' })
|
||||
exifImageWidth?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Image height in pixels' })
|
||||
exifImageHeight?: number | null = null;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'File size in bytes' })
|
||||
fileSizeInByte?: number | null = null;
|
||||
@ApiPropertyOptional({ description: 'Image orientation' })
|
||||
orientation?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Original date/time', format: 'date-time' })
|
||||
dateTimeOriginal?: Date | null = null;
|
||||
@ApiPropertyOptional({ description: 'Modification date/time', format: 'date-time' })
|
||||
modifyDate?: Date | null = null;
|
||||
@ApiPropertyOptional({ description: 'Time zone' })
|
||||
timeZone?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Lens model' })
|
||||
lensModel?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'F-number (aperture)' })
|
||||
fNumber?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Focal length in mm' })
|
||||
focalLength?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'ISO sensitivity' })
|
||||
iso?: number | null = null;
|
||||
@ApiPropertyOptional({ description: 'Exposure time' })
|
||||
exposureTime?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'GPS latitude' })
|
||||
latitude?: number | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'GPS longitude' })
|
||||
longitude?: number | null = null;
|
||||
@ApiPropertyOptional({ description: 'City name' })
|
||||
city?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'State/province name' })
|
||||
state?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Country name' })
|
||||
country?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Image description' })
|
||||
description?: string | null = null;
|
||||
@ApiPropertyOptional({ description: 'Projection type' })
|
||||
projectionType?: string | null = null;
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Rating' })
|
||||
rating?: number | null = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@ import { ManualJobName } from 'src/enum';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export class JobCreateDto {
|
||||
@ValidateEnum({ enum: ManualJobName, name: 'ManualJobName' })
|
||||
@ValidateEnum({ enum: ManualJobName, name: 'ManualJobName', description: 'Job name' })
|
||||
name!: ManualJobName;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Library } from 'src/database';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class CreateLibraryDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Library name' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Import paths (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@@ -19,6 +21,7 @@ export class CreateLibraryDto {
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@@ -28,11 +31,13 @@ export class CreateLibraryDto {
|
||||
}
|
||||
|
||||
export class UpdateLibraryDto {
|
||||
@ApiPropertyOptional({ description: 'Library name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Import paths (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@@ -40,6 +45,7 @@ export class UpdateLibraryDto {
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
|
||||
@Optional()
|
||||
@IsNotEmpty({ each: true })
|
||||
@IsString({ each: true })
|
||||
@@ -59,6 +65,7 @@ export interface WalkOptionsDto extends CrawlOptionsDto {
|
||||
}
|
||||
|
||||
export class ValidateLibraryDto {
|
||||
@ApiPropertyOptional({ description: 'Import paths to validate (max 128)' })
|
||||
@Optional()
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
@@ -66,6 +73,7 @@ export class ValidateLibraryDto {
|
||||
@ArrayMaxSize(128)
|
||||
importPaths?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Exclusion patterns (max 128)' })
|
||||
@Optional()
|
||||
@IsNotEmpty({ each: true })
|
||||
@IsString({ each: true })
|
||||
@@ -75,48 +83,60 @@ export class ValidateLibraryDto {
|
||||
}
|
||||
|
||||
export class ValidateLibraryResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Validation results for import paths' })
|
||||
importPaths?: ValidateLibraryImportPathResponseDto[];
|
||||
}
|
||||
|
||||
export class ValidateLibraryImportPathResponseDto {
|
||||
@ApiProperty({ description: 'Import path' })
|
||||
importPath!: string;
|
||||
@ApiProperty({ description: 'Is valid' })
|
||||
isValid: boolean = false;
|
||||
@ApiPropertyOptional({ description: 'Validation message' })
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export class LibrarySearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class LibraryResponseDto {
|
||||
@ApiProperty({ description: 'Library ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
@ApiProperty({ description: 'Library name' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets' })
|
||||
assetCount!: number;
|
||||
|
||||
@ApiProperty({ description: 'Import paths' })
|
||||
importPaths!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Exclusion patterns' })
|
||||
exclusionPatterns!: string[];
|
||||
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Last refresh date' })
|
||||
refreshedAt!: Date | null;
|
||||
}
|
||||
|
||||
export class LibraryStatsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of photos' })
|
||||
photos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of videos' })
|
||||
videos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of assets' })
|
||||
total = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
|
||||
usage = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Matches } from 'class-validator';
|
||||
|
||||
export class LicenseKeyDto {
|
||||
@ApiProperty({ description: 'License key (format: IM(SV|CL)(-XXXX){8})' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/IM(SV|CL)(-[\dA-Za-z]{4}){8}/)
|
||||
licenseKey!: string;
|
||||
|
||||
@ApiProperty({ description: 'Activation key' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
activationKey!: string;
|
||||
}
|
||||
|
||||
export class LicenseResponseDto extends LicenseKeyDto {
|
||||
@ApiProperty({ description: 'Activation date' })
|
||||
activatedAt!: Date;
|
||||
}
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
import { MaintenanceAction } from 'src/enum';
|
||||
import { ValidateEnum, ValidateString } from 'src/validation';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ValidateIf } from 'class-validator';
|
||||
import { MaintenanceAction, StorageFolder } from 'src/enum';
|
||||
import { ValidateBoolean, ValidateEnum, ValidateString } from 'src/validation';
|
||||
|
||||
export class SetMaintenanceModeDto {
|
||||
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
|
||||
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction', description: 'Maintenance action' })
|
||||
action!: MaintenanceAction;
|
||||
|
||||
@ValidateIf((o) => o.action === MaintenanceAction.RestoreDatabase)
|
||||
@ValidateString({ description: 'Restore backup filename' })
|
||||
restoreBackupFilename?: string;
|
||||
}
|
||||
|
||||
export class MaintenanceLoginDto {
|
||||
@ValidateString({ optional: true })
|
||||
@ValidateString({ optional: true, description: 'Maintenance token' })
|
||||
token?: string;
|
||||
}
|
||||
|
||||
export class MaintenanceAuthDto {
|
||||
@ApiProperty({ description: 'Maintenance username' })
|
||||
username!: string;
|
||||
}
|
||||
|
||||
export class MaintenanceStatusResponseDto {
|
||||
active!: boolean;
|
||||
|
||||
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction', description: 'Maintenance action' })
|
||||
action!: MaintenanceAction;
|
||||
|
||||
progress?: number;
|
||||
task?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class MaintenanceDetectInstallStorageFolderDto {
|
||||
@ValidateEnum({ enum: StorageFolder, name: 'StorageFolder', description: 'Storage folder' })
|
||||
folder!: StorageFolder;
|
||||
@ValidateBoolean({ description: 'Whether the folder is readable' })
|
||||
readable!: boolean;
|
||||
@ValidateBoolean({ description: 'Whether the folder is writable' })
|
||||
writable!: boolean;
|
||||
@ApiProperty({ description: 'Number of files in the folder' })
|
||||
files!: number;
|
||||
}
|
||||
|
||||
export class MaintenanceDetectInstallResponseDto {
|
||||
storage!: MaintenanceDetectInstallStorageFolderDto[];
|
||||
}
|
||||
|
||||
+17
-17
@@ -4,64 +4,64 @@ import { IsLatitude, IsLongitude } from 'class-validator';
|
||||
import { ValidateBoolean, ValidateDate } from 'src/validation';
|
||||
|
||||
export class MapReverseGeocodeDto {
|
||||
@ApiProperty({ format: 'double' })
|
||||
@ApiProperty({ format: 'double', description: 'Latitude (-90 to 90)' })
|
||||
@Type(() => Number)
|
||||
@IsLatitude({ message: ({ property }) => `${property} must be a number between -90 and 90` })
|
||||
lat!: number;
|
||||
|
||||
@ApiProperty({ format: 'double' })
|
||||
@ApiProperty({ format: 'double', description: 'Longitude (-180 to 180)' })
|
||||
@Type(() => Number)
|
||||
@IsLongitude({ message: ({ property }) => `${property} must be a number between -180 and 180` })
|
||||
lon!: number;
|
||||
}
|
||||
|
||||
export class MapReverseGeocodeResponseDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'City name' })
|
||||
city!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'State/Province name' })
|
||||
state!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'Country name' })
|
||||
country!: string | null;
|
||||
}
|
||||
|
||||
export class MapMarkerDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by archived status' })
|
||||
isArchived?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by favorite status' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter assets created after this date' })
|
||||
fileCreatedAfter?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter assets created before this date' })
|
||||
fileCreatedBefore?: Date;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include partner assets' })
|
||||
withPartners?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include shared album assets' })
|
||||
withSharedAlbums?: boolean;
|
||||
}
|
||||
|
||||
export class MapMarkerResponseDto {
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ format: 'double' })
|
||||
@ApiProperty({ format: 'double', description: 'Latitude' })
|
||||
lat!: number;
|
||||
|
||||
@ApiProperty({ format: 'double' })
|
||||
@ApiProperty({ format: 'double', description: 'Longitude' })
|
||||
lon!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'City name' })
|
||||
city!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'State/Province name' })
|
||||
state!: string | null;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiProperty({ description: 'Country name' })
|
||||
country!: string | null;
|
||||
}
|
||||
|
||||
@@ -2,30 +2,31 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
|
||||
import { Memory } from 'src/database';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetOrderWithRandom, MemoryType } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
class MemoryBaseDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Is memory saved' })
|
||||
isSaved?: boolean;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Date when memory was seen' })
|
||||
seenAt?: Date;
|
||||
}
|
||||
|
||||
export class MemorySearchDto {
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', optional: true })
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type', optional: true })
|
||||
type?: MemoryType;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by date' })
|
||||
for?: Date;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include trashed memories' })
|
||||
isTrashed?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by saved status' })
|
||||
isSaved?: boolean;
|
||||
|
||||
@IsInt()
|
||||
@@ -35,11 +36,12 @@ export class MemorySearchDto {
|
||||
@ApiProperty({ type: 'integer', description: 'Number of memories to return' })
|
||||
size?: number;
|
||||
|
||||
@ValidateEnum({ enum: AssetOrderWithRandom, name: 'MemorySearchOrder', optional: true })
|
||||
@ValidateEnum({ enum: AssetOrderWithRandom, name: 'MemorySearchOrder', description: 'Sort order', optional: true })
|
||||
order?: AssetOrderWithRandom;
|
||||
}
|
||||
|
||||
class OnThisDayDto {
|
||||
@ApiProperty({ type: 'number', description: 'Year for on this day memory', minimum: 1 })
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
year!: number;
|
||||
@@ -48,14 +50,16 @@ class OnThisDayDto {
|
||||
type MemoryData = OnThisDayDto;
|
||||
|
||||
export class MemoryUpdateDto extends MemoryBaseDto {
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Memory date' })
|
||||
memoryAt?: Date;
|
||||
}
|
||||
|
||||
export class MemoryCreateDto extends MemoryBaseDto {
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType' })
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type' })
|
||||
type!: MemoryType;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type((options) => {
|
||||
@@ -71,32 +75,60 @@ export class MemoryCreateDto extends MemoryBaseDto {
|
||||
})
|
||||
data!: MemoryData;
|
||||
|
||||
@ValidateDate()
|
||||
@ValidateDate({ description: 'Memory date' })
|
||||
memoryAt!: Date;
|
||||
|
||||
@ValidateUUID({ optional: true, each: true })
|
||||
@ValidateDate({
|
||||
optional: true,
|
||||
description: 'Date when memory should be shown',
|
||||
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
|
||||
})
|
||||
showAt?: Date;
|
||||
|
||||
@ValidateDate({
|
||||
optional: true,
|
||||
description: 'Date when memory should be hidden',
|
||||
history: new HistoryBuilder().added('v2.6.0').stable('v2.6.0'),
|
||||
})
|
||||
hideAt?: Date;
|
||||
|
||||
@ValidateUUID({ optional: true, each: true, description: 'Asset IDs to associate with memory' })
|
||||
assetIds?: string[];
|
||||
}
|
||||
|
||||
export class MemoryStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of memories' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export class MemoryResponseDto {
|
||||
@ApiProperty({ description: 'Memory ID' })
|
||||
id!: string;
|
||||
@ValidateDate({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ValidateDate({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ValidateDate({ optional: true, description: 'Deletion date' })
|
||||
deletedAt?: Date;
|
||||
@ValidateDate({ description: 'Memory date' })
|
||||
memoryAt!: Date;
|
||||
@ValidateDate({ optional: true, description: 'Date when memory was seen' })
|
||||
seenAt?: Date;
|
||||
@ValidateDate({ optional: true, description: 'Date when memory should be shown' })
|
||||
showAt?: Date;
|
||||
@ValidateDate({ optional: true, description: 'Date when memory should be hidden' })
|
||||
hideAt?: Date;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType' })
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type' })
|
||||
type!: MemoryType;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
data!: MemoryData;
|
||||
@ApiProperty({ description: 'Is memory saved' })
|
||||
isSaved!: boolean;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import { IsNotEmpty, IsNumber, IsString, Max, Min } from 'class-validator';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class TaskConfig {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Whether the task is enabled' })
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
export class ModelConfig extends TaskConfig {
|
||||
@ApiProperty({ description: 'Name of the model to use' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
modelName!: string;
|
||||
@@ -21,7 +22,11 @@ export class DuplicateDetectionConfig extends TaskConfig {
|
||||
@Min(0.001)
|
||||
@Max(0.1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({
|
||||
type: 'number',
|
||||
format: 'double',
|
||||
description: 'Maximum distance threshold for duplicate detection',
|
||||
})
|
||||
maxDistance!: number;
|
||||
}
|
||||
|
||||
@@ -30,20 +35,24 @@ export class FacialRecognitionConfig extends ModelConfig {
|
||||
@Min(0.1)
|
||||
@Max(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'Minimum confidence score for face detection' })
|
||||
minScore!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.1)
|
||||
@Max(2)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({
|
||||
type: 'number',
|
||||
format: 'double',
|
||||
description: 'Maximum distance threshold for face recognition',
|
||||
})
|
||||
maxDistance!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Minimum number of faces required for recognition' })
|
||||
minFaces!: number;
|
||||
}
|
||||
|
||||
@@ -51,20 +60,24 @@ export class OcrConfig extends ModelConfig {
|
||||
@IsNumber()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Maximum resolution for OCR processing' })
|
||||
maxResolution!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.1)
|
||||
@Max(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'Minimum confidence score for text detection' })
|
||||
minDetectionScore!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.1)
|
||||
@Max(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({
|
||||
type: 'number',
|
||||
format: 'double',
|
||||
description: 'Minimum confidence score for text recognition',
|
||||
})
|
||||
minRecognitionScore!: number;
|
||||
}
|
||||
|
||||
@@ -1,86 +1,114 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayMinSize, IsString } from 'class-validator';
|
||||
import { NotificationLevel, NotificationType } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TestEmailResponseDto {
|
||||
@ApiProperty({ description: 'Email message ID' })
|
||||
messageId!: string;
|
||||
}
|
||||
export class TemplateResponseDto {
|
||||
@ApiProperty({ description: 'Template name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Template HTML content' })
|
||||
html!: string;
|
||||
}
|
||||
|
||||
export class TemplateDto {
|
||||
@ApiProperty({ description: 'Template name' })
|
||||
@IsString()
|
||||
template!: string;
|
||||
}
|
||||
|
||||
export class NotificationDto {
|
||||
@ApiProperty({ description: 'Notification ID' })
|
||||
id!: string;
|
||||
@ValidateDate()
|
||||
@ValidateDate({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ValidateEnum({ enum: NotificationLevel, name: 'NotificationLevel' })
|
||||
@ValidateEnum({ enum: NotificationLevel, name: 'NotificationLevel', description: 'Notification level' })
|
||||
level!: NotificationLevel;
|
||||
@ValidateEnum({ enum: NotificationType, name: 'NotificationType' })
|
||||
@ValidateEnum({ enum: NotificationType, name: 'NotificationType', description: 'Notification type' })
|
||||
type!: NotificationType;
|
||||
@ApiProperty({ description: 'Notification title' })
|
||||
title!: string;
|
||||
@ApiPropertyOptional({ description: 'Notification description' })
|
||||
description?: string;
|
||||
@ApiPropertyOptional({ description: 'Additional notification data' })
|
||||
data?: any;
|
||||
@ApiPropertyOptional({ description: 'Date when notification was read', format: 'date-time' })
|
||||
readAt?: Date;
|
||||
}
|
||||
|
||||
export class NotificationSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter by notification ID' })
|
||||
id?: string;
|
||||
|
||||
@ValidateEnum({ enum: NotificationLevel, name: 'NotificationLevel', optional: true })
|
||||
@ValidateEnum({
|
||||
enum: NotificationLevel,
|
||||
name: 'NotificationLevel',
|
||||
optional: true,
|
||||
description: 'Filter by notification level',
|
||||
})
|
||||
level?: NotificationLevel;
|
||||
|
||||
@ValidateEnum({ enum: NotificationType, name: 'NotificationType', optional: true })
|
||||
@ValidateEnum({
|
||||
enum: NotificationType,
|
||||
name: 'NotificationType',
|
||||
optional: true,
|
||||
description: 'Filter by notification type',
|
||||
})
|
||||
type?: NotificationType;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by unread status' })
|
||||
unread?: boolean;
|
||||
}
|
||||
|
||||
export class NotificationCreateDto {
|
||||
@ValidateEnum({ enum: NotificationLevel, name: 'NotificationLevel', optional: true })
|
||||
@ValidateEnum({
|
||||
enum: NotificationLevel,
|
||||
name: 'NotificationLevel',
|
||||
optional: true,
|
||||
description: 'Notification level',
|
||||
})
|
||||
level?: NotificationLevel;
|
||||
|
||||
@ValidateEnum({ enum: NotificationType, name: 'NotificationType', optional: true })
|
||||
@ValidateEnum({ enum: NotificationType, name: 'NotificationType', optional: true, description: 'Notification type' })
|
||||
type?: NotificationType;
|
||||
|
||||
@IsString()
|
||||
@ValidateString({ description: 'Notification title' })
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@Optional({ nullable: true })
|
||||
@ValidateString({ optional: true, nullable: true, description: 'Notification description' })
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Additional notification data' })
|
||||
@Optional({ nullable: true })
|
||||
data?: any;
|
||||
|
||||
@ValidateDate({ optional: true, nullable: true })
|
||||
@ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' })
|
||||
readAt?: Date | null;
|
||||
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'User ID to send notification to' })
|
||||
userId!: string;
|
||||
}
|
||||
|
||||
export class NotificationUpdateDto {
|
||||
@ValidateDate({ optional: true, nullable: true })
|
||||
@ValidateDate({ optional: true, nullable: true, description: 'Date when notification was read' })
|
||||
readAt?: Date | null;
|
||||
}
|
||||
|
||||
export class NotificationUpdateAllDto {
|
||||
@ValidateUUID({ each: true, optional: true })
|
||||
@ValidateUUID({ each: true, description: 'Notification IDs to update' })
|
||||
@ArrayMinSize(1)
|
||||
ids!: string[];
|
||||
|
||||
@ValidateDate({ optional: true, nullable: true })
|
||||
@ValidateDate({ optional: true, nullable: true, description: 'Date when notifications were read' })
|
||||
readAt?: Date | null;
|
||||
}
|
||||
|
||||
export class NotificationDeleteAllDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Notification IDs to delete' })
|
||||
@ArrayMinSize(1)
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class OnboardingDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Is user onboarded' })
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class PartnerCreateDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'User ID to share with' })
|
||||
sharedWithId!: string;
|
||||
}
|
||||
|
||||
export class PartnerUpdateDto {
|
||||
@ApiProperty({ description: 'Show partner assets in timeline' })
|
||||
@IsNotEmpty()
|
||||
inTimeline!: boolean;
|
||||
}
|
||||
|
||||
export class PartnerSearchDto {
|
||||
@ValidateEnum({ enum: PartnerDirection, name: 'PartnerDirection' })
|
||||
@ValidateEnum({ enum: PartnerDirection, name: 'PartnerDirection', description: 'Partner direction' })
|
||||
direction!: PartnerDirection;
|
||||
}
|
||||
|
||||
export class PartnerResponseDto extends UserResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Show in timeline' })
|
||||
inTimeline?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,12 @@ import { DateTime } from 'luxon';
|
||||
import { AssetFace, Person } from 'src/database';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
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 { transformFaceBoundingBox } from 'src/utils/transform';
|
||||
import {
|
||||
IsDateStringFormat,
|
||||
MaxDateString,
|
||||
@@ -20,46 +23,37 @@ import {
|
||||
} from 'src/validation';
|
||||
|
||||
export class PersonCreateDto {
|
||||
/**
|
||||
* Person name.
|
||||
*/
|
||||
@ApiPropertyOptional({ description: 'Person name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* Person date of birth.
|
||||
* Note: the mobile app cannot currently set the birth date to null.
|
||||
*/
|
||||
@ApiProperty({ format: 'date' })
|
||||
// Note: the mobile app cannot currently set the birth date to null.
|
||||
@ApiProperty({ format: 'date', description: 'Person date of birth', required: false })
|
||||
@MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
|
||||
@IsDateStringFormat('yyyy-MM-dd')
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
birthDate?: Date | null;
|
||||
|
||||
/**
|
||||
* Person visibility
|
||||
*/
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Person visibility (hidden)' })
|
||||
isHidden?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Mark as favorite' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Person color (hex)' })
|
||||
@Optional({ emptyToNull: true, nullable: true })
|
||||
@ValidateHexColor()
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class PersonUpdateDto extends PersonCreateDto {
|
||||
/**
|
||||
* Asset is used to get the feature face thumbnail.
|
||||
*/
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Asset ID used for feature face thumbnail' })
|
||||
featureFaceAssetId?: string;
|
||||
}
|
||||
|
||||
export class PeopleUpdateDto {
|
||||
@ApiProperty({ description: 'People to update' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PeopleUpdateItem)
|
||||
@@ -67,36 +61,32 @@ export class PeopleUpdateDto {
|
||||
}
|
||||
|
||||
export class PeopleUpdateItem extends PersonUpdateDto {
|
||||
/**
|
||||
* Person id.
|
||||
*/
|
||||
@ApiProperty({ description: 'Person ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export class MergePersonDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Person IDs to merge' })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class PersonSearchDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include hidden people' })
|
||||
withHidden?: boolean;
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Closest person ID for similarity search' })
|
||||
closestPersonId?: string;
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Closest asset ID for similarity search' })
|
||||
closestAssetId?: string;
|
||||
|
||||
/** Page number for pagination */
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ description: 'Page number for pagination', default: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
page: number = 1;
|
||||
|
||||
/** Number of items per page */
|
||||
@ApiPropertyOptional()
|
||||
@ApiPropertyOptional({ description: 'Number of items per page', default: 500 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
@@ -105,48 +95,55 @@ export class PersonSearchDto {
|
||||
}
|
||||
|
||||
export class PersonResponseDto {
|
||||
@ApiProperty({ description: 'Person ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Person name' })
|
||||
name!: string;
|
||||
@ApiProperty({ format: 'date' })
|
||||
@ApiProperty({ format: 'date', description: 'Person date of birth' })
|
||||
birthDate!: string | null;
|
||||
@ApiProperty({ description: 'Thumbnail path' })
|
||||
thumbnailPath!: string;
|
||||
@ApiProperty({ description: 'Is hidden' })
|
||||
isHidden!: boolean;
|
||||
@Property({ history: new HistoryBuilder().added('v1.107.0').stable('v2') })
|
||||
@Property({ description: 'Last update date', history: new HistoryBuilder().added('v1.107.0').stable('v2') })
|
||||
updatedAt?: Date;
|
||||
@Property({ history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||
@Property({ description: 'Is favorite', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||
isFavorite?: boolean;
|
||||
@Property({ history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||
@Property({ description: 'Person color (hex)', history: new HistoryBuilder().added('v1.126.0').stable('v2') })
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class PersonWithFacesResponseDto extends PersonResponseDto {
|
||||
@ApiProperty({ description: 'Face detections' })
|
||||
faces!: AssetFaceWithoutPersonResponseDto[];
|
||||
}
|
||||
|
||||
export class AssetFaceWithoutPersonResponseDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Face ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Image height in pixels' })
|
||||
imageHeight!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Image width in pixels' })
|
||||
imageWidth!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Bounding box X1 coordinate' })
|
||||
boundingBoxX1!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Bounding box X2 coordinate' })
|
||||
boundingBoxX2!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Bounding box Y1 coordinate' })
|
||||
boundingBoxY1!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Bounding box Y2 coordinate' })
|
||||
boundingBoxY2!: number;
|
||||
@ValidateEnum({ enum: SourceType, name: 'SourceType' })
|
||||
@ValidateEnum({ enum: SourceType, name: 'SourceType', optional: true, description: 'Face detection source type' })
|
||||
sourceType?: SourceType;
|
||||
}
|
||||
|
||||
export class AssetFaceResponseDto extends AssetFaceWithoutPersonResponseDto {
|
||||
@ApiProperty({ description: 'Person associated with face' })
|
||||
person!: PersonResponseDto | null;
|
||||
}
|
||||
|
||||
export class AssetFaceUpdateDto {
|
||||
@ApiProperty({ description: 'Face update items' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetFaceUpdateItem)
|
||||
@@ -154,69 +151,74 @@ export class AssetFaceUpdateDto {
|
||||
}
|
||||
|
||||
export class FaceDto {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Face ID' })
|
||||
id!: string;
|
||||
}
|
||||
|
||||
export class AssetFaceUpdateItem {
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Person ID' })
|
||||
personId!: string;
|
||||
|
||||
@ValidateUUID()
|
||||
@ValidateUUID({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
export class AssetFaceCreateDto extends AssetFaceUpdateItem {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Image width in pixels' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
imageWidth!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Image height in pixels' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
imageHeight!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Face bounding box X coordinate' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
x!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Face bounding box Y coordinate' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
y!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Face bounding box width' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
width!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Face bounding box height' })
|
||||
@IsNotEmpty()
|
||||
@IsNumber()
|
||||
height!: number;
|
||||
}
|
||||
|
||||
export class AssetFaceDeleteDto {
|
||||
@ApiProperty({ description: 'Force delete even if person has other faces' })
|
||||
@IsNotEmpty()
|
||||
force!: boolean;
|
||||
}
|
||||
|
||||
export class PersonStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets' })
|
||||
assets!: number;
|
||||
}
|
||||
|
||||
export class PeopleResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of people' })
|
||||
total!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of hidden people' })
|
||||
hidden!: number;
|
||||
@ApiProperty({ description: 'List of people' })
|
||||
people!: PersonResponseDto[];
|
||||
|
||||
// TODO: make required after a few versions
|
||||
@Property({ history: new HistoryBuilder().added('v1.110.0').stable('v2') })
|
||||
@Property({
|
||||
description: 'Whether there are more pages',
|
||||
history: new HistoryBuilder().added('v1.110.0').stable('v2'),
|
||||
})
|
||||
hasNextPage?: boolean;
|
||||
}
|
||||
|
||||
@@ -233,29 +235,37 @@ export function mapPerson(person: Person): PersonResponseDto {
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFacesWithoutPerson(face: Selectable<AssetFaceTable>): AssetFaceWithoutPersonResponseDto {
|
||||
export function mapFacesWithoutPerson(
|
||||
face: Selectable<AssetFaceTable>,
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): AssetFaceWithoutPersonResponseDto {
|
||||
return {
|
||||
id: face.id,
|
||||
imageHeight: face.imageHeight,
|
||||
imageWidth: face.imageWidth,
|
||||
boundingBoxX1: face.boundingBoxX1,
|
||||
boundingBoxX2: face.boundingBoxX2,
|
||||
boundingBoxY1: face.boundingBoxY1,
|
||||
boundingBoxY2: face.boundingBoxY2,
|
||||
...transformFaceBoundingBox(
|
||||
{
|
||||
boundingBoxX1: face.boundingBoxX1,
|
||||
boundingBoxY1: face.boundingBoxY1,
|
||||
boundingBoxX2: face.boundingBoxX2,
|
||||
boundingBoxY2: face.boundingBoxY2,
|
||||
imageWidth: face.imageWidth,
|
||||
imageHeight: face.imageHeight,
|
||||
},
|
||||
edits ?? [],
|
||||
assetDimensions ?? { width: face.imageWidth, height: face.imageHeight },
|
||||
),
|
||||
sourceType: face.sourceType,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapFaces(face: AssetFace, auth: AuthDto): AssetFaceResponseDto {
|
||||
export function mapFaces(
|
||||
face: AssetFace,
|
||||
auth: AuthDto,
|
||||
edits?: AssetEditActionItem[],
|
||||
assetDimensions?: ImageDimensions,
|
||||
): AssetFaceResponseDto {
|
||||
return {
|
||||
id: face.id,
|
||||
imageHeight: face.imageHeight,
|
||||
imageWidth: face.imageWidth,
|
||||
boundingBoxX1: face.boundingBoxX1,
|
||||
boundingBoxX2: face.boundingBoxX2,
|
||||
boundingBoxY1: face.boundingBoxY1,
|
||||
boundingBoxY2: face.boundingBoxY2,
|
||||
sourceType: face.sourceType,
|
||||
...mapFacesWithoutPerson(face, edits, assetDimensions),
|
||||
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
@@ -16,58 +17,68 @@ import { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
class PluginManifestWasmDto {
|
||||
@ApiProperty({ description: 'WASM file path' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
path!: string;
|
||||
}
|
||||
|
||||
class PluginManifestFilterDto {
|
||||
@ApiProperty({ description: 'Filter method name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
methodName!: string;
|
||||
|
||||
@ApiProperty({ description: 'Filter title' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: 'Filter description' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({ description: 'Supported contexts', enum: PluginContext, isArray: true })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsEnum(PluginContext, { each: true })
|
||||
supportedContexts!: PluginContext[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter schema' })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
schema?: JSONSchema;
|
||||
}
|
||||
|
||||
class PluginManifestActionDto {
|
||||
@ApiProperty({ description: 'Action method name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
methodName!: string;
|
||||
|
||||
@ApiProperty({ description: 'Action title' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: 'Action description' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description!: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true })
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext', each: true, description: 'Supported contexts' })
|
||||
supportedContexts!: PluginContext[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Action schema' })
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
schema?: JSONSchema;
|
||||
}
|
||||
|
||||
export class PluginManifestDto {
|
||||
@ApiProperty({ description: 'Plugin name (lowercase, numbers, hyphens only)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Matches(/^[a-z0-9-]+[a-z0-9]$/, {
|
||||
@@ -75,33 +86,40 @@ export class PluginManifestDto {
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Plugin version (semver)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsSemVer()
|
||||
version!: string;
|
||||
|
||||
@ApiProperty({ description: 'Plugin title' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ description: 'Plugin description' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
description!: string;
|
||||
|
||||
@ApiProperty({ description: 'Plugin author' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
author!: string;
|
||||
|
||||
@ApiProperty({ description: 'WASM configuration' })
|
||||
@ValidateNested()
|
||||
@Type(() => PluginManifestWasmDto)
|
||||
wasm!: PluginManifestWasmDto;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Plugin filters' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PluginManifestFilterDto)
|
||||
@IsOptional()
|
||||
filters?: PluginManifestFilterDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Plugin actions' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PluginManifestActionDto)
|
||||
|
||||
@@ -1,47 +1,78 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { PluginAction, PluginFilter } from 'src/database';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import { PluginContext as PluginContextType, PluginTriggerType } from 'src/enum';
|
||||
import type { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
|
||||
export class PluginTriggerResponseDto {
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Trigger type' })
|
||||
type!: PluginTriggerType;
|
||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', description: 'Context type' })
|
||||
contextType!: PluginContextType;
|
||||
}
|
||||
|
||||
export class PluginResponseDto {
|
||||
@ApiProperty({ description: 'Plugin ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Plugin name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Plugin title' })
|
||||
title!: string;
|
||||
@ApiProperty({ description: 'Plugin description' })
|
||||
description!: string;
|
||||
@ApiProperty({ description: 'Plugin author' })
|
||||
author!: string;
|
||||
@ApiProperty({ description: 'Plugin version' })
|
||||
version!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: string;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: string;
|
||||
@ApiProperty({ description: 'Plugin filters' })
|
||||
filters!: PluginFilterResponseDto[];
|
||||
@ApiProperty({ description: 'Plugin actions' })
|
||||
actions!: PluginActionResponseDto[];
|
||||
}
|
||||
|
||||
export class PluginFilterResponseDto {
|
||||
@ApiProperty({ description: 'Filter ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Plugin ID' })
|
||||
pluginId!: string;
|
||||
@ApiProperty({ description: 'Method name' })
|
||||
methodName!: string;
|
||||
@ApiProperty({ description: 'Filter title' })
|
||||
title!: string;
|
||||
@ApiProperty({ description: 'Filter description' })
|
||||
description!: string;
|
||||
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||
supportedContexts!: PluginContext[];
|
||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' })
|
||||
supportedContexts!: PluginContextType[];
|
||||
@ApiProperty({ description: 'Filter schema' })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
export class PluginActionResponseDto {
|
||||
@ApiProperty({ description: 'Action ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Plugin ID' })
|
||||
pluginId!: string;
|
||||
@ApiProperty({ description: 'Method name' })
|
||||
methodName!: string;
|
||||
@ApiProperty({ description: 'Action title' })
|
||||
title!: string;
|
||||
@ApiProperty({ description: 'Action description' })
|
||||
description!: string;
|
||||
|
||||
@ValidateEnum({ enum: PluginContext, name: 'PluginContext' })
|
||||
supportedContexts!: PluginContext[];
|
||||
@ValidateEnum({ enum: PluginContextType, name: 'PluginContextType', each: true, description: 'Supported contexts' })
|
||||
supportedContexts!: PluginContextType[];
|
||||
@ApiProperty({ description: 'Action schema' })
|
||||
schema!: JSONSchema | null;
|
||||
}
|
||||
|
||||
export class PluginInstallDto {
|
||||
@ApiProperty({ description: 'Path to plugin manifest file' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
manifestPath!: string;
|
||||
|
||||
@@ -3,15 +3,19 @@ import { QueueResponseDto, QueueStatisticsDto } from 'src/dtos/queue.dto';
|
||||
import { QueueName } from 'src/enum';
|
||||
|
||||
export class QueueStatusLegacyDto {
|
||||
@ApiProperty({ description: 'Whether the queue is currently active (has running jobs)' })
|
||||
isActive!: boolean;
|
||||
@ApiProperty({ description: 'Whether the queue is paused' })
|
||||
isPaused!: boolean;
|
||||
}
|
||||
|
||||
export class QueueResponseLegacyDto {
|
||||
@ApiProperty({ type: QueueStatusLegacyDto })
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
queueStatus!: QueueStatusLegacyDto;
|
||||
|
||||
@ApiProperty({ type: QueueStatisticsDto })
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
jobCounts!: QueueStatisticsDto;
|
||||
}
|
||||
|
||||
@@ -66,6 +70,9 @@ export class QueuesResponseLegacyDto implements Record<QueueName, QueueResponseL
|
||||
|
||||
@ApiProperty({ type: QueueResponseLegacyDto })
|
||||
[QueueName.Workflow]!: QueueResponseLegacyDto;
|
||||
|
||||
@ApiProperty({ type: QueueResponseLegacyDto })
|
||||
[QueueName.Editor]!: QueueResponseLegacyDto;
|
||||
}
|
||||
|
||||
export const mapQueueLegacy = (response: QueueResponseDto): QueueResponseLegacyDto => {
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { HistoryBuilder } from 'src/decorators';
|
||||
import { JobName, QueueCommand, QueueJobStatus, QueueName } from 'src/enum';
|
||||
import { ValidateBoolean, ValidateEnum } from 'src/validation';
|
||||
|
||||
export class QueueNameParamDto {
|
||||
@ValidateEnum({ enum: QueueName, name: 'QueueName' })
|
||||
@ValidateEnum({ enum: QueueName, name: 'QueueName', description: 'Queue name' })
|
||||
name!: QueueName;
|
||||
}
|
||||
|
||||
export class QueueCommandDto {
|
||||
@ValidateEnum({ enum: QueueCommand, name: 'QueueCommand' })
|
||||
@ValidateEnum({ enum: QueueCommand, name: 'QueueCommand', description: 'Queue command to execute' })
|
||||
command!: QueueCommand;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Force the command execution (if applicable)' })
|
||||
force?: boolean; // TODO: this uses undefined as a third state, which should be refactored to be more explicit
|
||||
}
|
||||
|
||||
export class QueueUpdateDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether to pause the queue' })
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
export class QueueDeleteDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@Property({
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
description: 'If true, will also remove failed jobs from the queue.',
|
||||
history: new HistoryBuilder().added('v2.4.0').alpha('v2.4.0'),
|
||||
})
|
||||
@@ -31,42 +31,52 @@ export class QueueDeleteDto {
|
||||
}
|
||||
|
||||
export class QueueJobSearchDto {
|
||||
@ValidateEnum({ enum: QueueJobStatus, name: 'QueueJobStatus', optional: true, each: true })
|
||||
@ValidateEnum({
|
||||
enum: QueueJobStatus,
|
||||
name: 'QueueJobStatus',
|
||||
optional: true,
|
||||
each: true,
|
||||
description: 'Filter jobs by status',
|
||||
})
|
||||
status?: QueueJobStatus[];
|
||||
}
|
||||
export class QueueJobResponseDto {
|
||||
@ApiPropertyOptional({ description: 'Job ID' })
|
||||
id?: string;
|
||||
|
||||
@ValidateEnum({ enum: JobName, name: 'JobName' })
|
||||
@ValidateEnum({ enum: JobName, name: 'JobName', description: 'Job name' })
|
||||
name!: JobName;
|
||||
|
||||
@ApiProperty({ description: 'Job data payload', type: Object })
|
||||
data!: object;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Job creation timestamp' })
|
||||
timestamp!: number;
|
||||
}
|
||||
|
||||
export class QueueResponseDto {
|
||||
@ValidateEnum({ enum: QueueName, name: 'QueueName' })
|
||||
name!: QueueName;
|
||||
|
||||
@ValidateBoolean()
|
||||
isPaused!: boolean;
|
||||
|
||||
statistics!: QueueStatisticsDto;
|
||||
}
|
||||
|
||||
export class QueueStatisticsDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of active jobs' })
|
||||
active!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of completed jobs' })
|
||||
completed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of failed jobs' })
|
||||
failed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of delayed jobs' })
|
||||
delayed!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of waiting jobs' })
|
||||
waiting!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of paused jobs' })
|
||||
paused!: number;
|
||||
}
|
||||
|
||||
export class QueueResponseDto {
|
||||
@ValidateEnum({ enum: QueueName, name: 'QueueName', description: 'Queue name' })
|
||||
name!: QueueName;
|
||||
|
||||
@ValidateBoolean({ description: 'Whether the queue is paused' })
|
||||
isPaused!: boolean;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
statistics!: QueueStatisticsDto;
|
||||
}
|
||||
|
||||
+117
-47
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
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';
|
||||
@@ -9,99 +9,117 @@ import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
class BaseSearchDto {
|
||||
@ValidateUUID({ optional: true, nullable: true })
|
||||
@ValidateUUID({ optional: true, nullable: true, description: 'Library ID to filter by' })
|
||||
libraryId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Device ID to filter by' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
deviceId?: string;
|
||||
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', optional: true })
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', optional: true, description: 'Asset type filter' })
|
||||
type?: AssetType;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by encoded status' })
|
||||
isEncoded?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by favorite status' })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by motion photo status' })
|
||||
isMotion?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter by offline status' })
|
||||
isOffline?: boolean;
|
||||
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true })
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', optional: true, description: 'Filter by visibility' })
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by creation date (before)' })
|
||||
createdBefore?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by creation date (after)' })
|
||||
createdAfter?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by update date (before)' })
|
||||
updatedBefore?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by update date (after)' })
|
||||
updatedAfter?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by trash date (before)' })
|
||||
trashedBefore?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by trash date (after)' })
|
||||
trashedAfter?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by taken date (before)' })
|
||||
takenBefore?: Date;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
@ValidateDate({ optional: true, description: 'Filter by taken date (after)' })
|
||||
takenAfter?: Date;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by city name' })
|
||||
@IsString()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
city?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by state/province name' })
|
||||
@IsString()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
state?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by country name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
country?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by camera make' })
|
||||
@IsString()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
make?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by camera model' })
|
||||
@IsString()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
model?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by lens model' })
|
||||
@IsString()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
lensModel?: string | null;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Filter assets not in any album' })
|
||||
isNotInAlbum?: boolean;
|
||||
|
||||
@ValidateUUID({ each: true, optional: true })
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Filter by person IDs' })
|
||||
personIds?: string[];
|
||||
|
||||
@ValidateUUID({ each: true, optional: true, nullable: true })
|
||||
@ValidateUUID({ each: true, optional: true, nullable: true, description: 'Filter by tag IDs' })
|
||||
tagIds?: string[] | null;
|
||||
|
||||
@ValidateUUID({ each: true, optional: true })
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Filter by album IDs' })
|
||||
albumIds?: string[];
|
||||
|
||||
@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()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@@ -109,12 +127,13 @@ class BaseSearchDto {
|
||||
}
|
||||
|
||||
class BaseSearchWithResultsDto extends BaseSearchDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include deleted assets' })
|
||||
withDeleted?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include EXIF data in response' })
|
||||
withExif?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Number of results to return', minimum: 1, maximum: 1000 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
@@ -124,65 +143,78 @@ class BaseSearchWithResultsDto extends BaseSearchDto {
|
||||
}
|
||||
|
||||
export class RandomSearchDto extends BaseSearchWithResultsDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
|
||||
withStacked?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
|
||||
withPeople?: boolean;
|
||||
}
|
||||
|
||||
export class LargeAssetSearchDto extends BaseSearchWithResultsDto {
|
||||
@ApiPropertyOptional({ type: 'integer', description: 'Minimum file size in bytes', minimum: 0 })
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
minFileSize?: number;
|
||||
}
|
||||
|
||||
export class MetadataSearchDto extends RandomSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter by asset ID' })
|
||||
id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by device asset ID' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
deviceAssetId?: string;
|
||||
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
@ValidateString({ optional: true, trim: true, description: 'Filter by description text' })
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by file checksum' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
checksum?: string;
|
||||
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
@ValidateString({ optional: true, trim: true, description: 'Filter by original file name' })
|
||||
originalFileName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by original file path' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
originalPath?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by preview file path' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
previewPath?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by thumbnail file path' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
thumbnailPath?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by encoded video file path' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
encodedVideoPath?: string;
|
||||
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true, default: AssetOrder.Desc })
|
||||
@ValidateEnum({
|
||||
enum: AssetOrder,
|
||||
name: 'AssetOrder',
|
||||
optional: true,
|
||||
default: AssetOrder.Desc,
|
||||
description: 'Sort order',
|
||||
})
|
||||
order?: AssetOrder;
|
||||
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Page number', minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@@ -191,23 +223,24 @@ export class MetadataSearchDto extends RandomSearchDto {
|
||||
}
|
||||
|
||||
export class StatisticsSearchDto extends BaseSearchDto {
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
@ValidateString({ optional: true, trim: true, description: 'Filter by description text' })
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class SmartSearchDto extends BaseSearchWithResultsDto {
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
@ValidateString({ optional: true, trim: true, description: 'Natural language search query' })
|
||||
query?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@Optional()
|
||||
@ValidateUUID({ optional: true, description: 'Asset ID to use as search reference' })
|
||||
queryAssetId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Search language code' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Page number', minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@@ -216,25 +249,32 @@ export class SmartSearchDto extends BaseSearchWithResultsDto {
|
||||
}
|
||||
|
||||
export class SearchPlacesDto {
|
||||
@ApiProperty({ description: 'Place name to search for' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
}
|
||||
|
||||
export class SearchPeopleDto {
|
||||
@ApiProperty({ description: 'Person name to search for' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include hidden people' })
|
||||
withHidden?: boolean;
|
||||
}
|
||||
|
||||
export class PlacesResponseDto {
|
||||
@ApiProperty({ description: 'Place name' })
|
||||
name!: string;
|
||||
@ApiProperty({ type: 'number', description: 'Latitude coordinate' })
|
||||
latitude!: number;
|
||||
@ApiProperty({ type: 'number', description: 'Longitude coordinate' })
|
||||
longitude!: number;
|
||||
@ApiPropertyOptional({ description: 'Administrative level 1 name (state/province)' })
|
||||
admin1name?: string;
|
||||
@ApiPropertyOptional({ description: 'Administrative level 2 name (county/district)' })
|
||||
admin2name?: string;
|
||||
}
|
||||
|
||||
@@ -258,96 +298,126 @@ export enum SearchSuggestionType {
|
||||
}
|
||||
|
||||
export class SearchSuggestionRequestDto {
|
||||
@ValidateEnum({ enum: SearchSuggestionType, name: 'SearchSuggestionType' })
|
||||
@ValidateEnum({ enum: SearchSuggestionType, name: 'SearchSuggestionType', description: 'Suggestion type' })
|
||||
type!: SearchSuggestionType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by country' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
country?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by state/province' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
state?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by camera make' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
make?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by camera model' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
model?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by lens model' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
lensModel?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@Property({ history: new HistoryBuilder().added('v1.111.0').stable('v2') })
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
description: 'Include null values in suggestions',
|
||||
history: new HistoryBuilder().added('v1.111.0').stable('v2'),
|
||||
})
|
||||
includeNull?: boolean;
|
||||
}
|
||||
|
||||
class SearchFacetCountResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets with this facet value' })
|
||||
count!: number;
|
||||
@ApiProperty({ description: 'Facet value' })
|
||||
value!: string;
|
||||
}
|
||||
|
||||
class SearchFacetResponseDto {
|
||||
@ApiProperty({ description: 'Facet field name' })
|
||||
fieldName!: string;
|
||||
@ApiProperty({ description: 'Facet counts' })
|
||||
counts!: SearchFacetCountResponseDto[];
|
||||
}
|
||||
|
||||
class SearchAlbumResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of matching albums' })
|
||||
total!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of albums in this page' })
|
||||
count!: number;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
items!: AlbumResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
facets!: SearchFacetResponseDto[];
|
||||
}
|
||||
|
||||
class SearchAssetResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of matching assets' })
|
||||
total!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets in this page' })
|
||||
count!: number;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
items!: AssetResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
facets!: SearchFacetResponseDto[];
|
||||
@ApiProperty({ description: 'Next page token' })
|
||||
nextPage!: string | null;
|
||||
}
|
||||
|
||||
export class SearchResponseDto {
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
albums!: SearchAlbumResponseDto;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
assets!: SearchAssetResponseDto;
|
||||
}
|
||||
|
||||
export class SearchStatisticsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of matching assets' })
|
||||
total!: number;
|
||||
}
|
||||
|
||||
class SearchExploreItem {
|
||||
@ApiProperty({ description: 'Explore value' })
|
||||
value!: string;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
data!: AssetResponseDto;
|
||||
}
|
||||
|
||||
export class SearchExploreResponseDto {
|
||||
@ApiProperty({ description: 'Explore field name' })
|
||||
fieldName!: string;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
items!: SearchExploreItem[];
|
||||
}
|
||||
|
||||
export class MemoryLaneDto {
|
||||
@ApiProperty({ type: 'integer', description: 'Day of month' })
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@Max(31)
|
||||
@Min(1)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
day!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Month' })
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
@Max(12)
|
||||
@Min(1)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
month!: number;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional, ApiResponseProperty } from '@nestjs/swagger';
|
||||
import { SemVer } from 'semver';
|
||||
import { SystemConfigThemeDto } from 'src/dtos/system-config.dto';
|
||||
|
||||
@@ -8,66 +8,94 @@ export class ServerPingResponse {
|
||||
}
|
||||
|
||||
export class ServerAboutResponseDto {
|
||||
@ApiProperty({ description: 'Server version' })
|
||||
version!: string;
|
||||
@ApiProperty({ description: 'URL to version information' })
|
||||
versionUrl!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Repository name' })
|
||||
repository?: string;
|
||||
@ApiPropertyOptional({ description: 'Repository URL' })
|
||||
repositoryUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Source reference (branch/tag)' })
|
||||
sourceRef?: string;
|
||||
@ApiPropertyOptional({ description: 'Source commit hash' })
|
||||
sourceCommit?: string;
|
||||
@ApiPropertyOptional({ description: 'Source URL' })
|
||||
sourceUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Build identifier' })
|
||||
build?: string;
|
||||
@ApiPropertyOptional({ description: 'Build URL' })
|
||||
buildUrl?: string;
|
||||
@ApiPropertyOptional({ description: 'Build image name' })
|
||||
buildImage?: string;
|
||||
@ApiPropertyOptional({ description: 'Build image URL' })
|
||||
buildImageUrl?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Node.js version' })
|
||||
nodejs?: string;
|
||||
@ApiPropertyOptional({ description: 'FFmpeg version' })
|
||||
ffmpeg?: string;
|
||||
@ApiPropertyOptional({ description: 'ImageMagick version' })
|
||||
imagemagick?: string;
|
||||
@ApiPropertyOptional({ description: 'libvips version' })
|
||||
libvips?: string;
|
||||
@ApiPropertyOptional({ description: 'ExifTool version' })
|
||||
exiftool?: string;
|
||||
|
||||
@ApiProperty({ description: 'Whether the server is licensed' })
|
||||
licensed!: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Third-party source URL' })
|
||||
thirdPartySourceUrl?: string;
|
||||
@ApiPropertyOptional({ description: 'Third-party bug/feature URL' })
|
||||
thirdPartyBugFeatureUrl?: string;
|
||||
@ApiPropertyOptional({ description: 'Third-party documentation URL' })
|
||||
thirdPartyDocumentationUrl?: string;
|
||||
@ApiPropertyOptional({ description: 'Third-party support URL' })
|
||||
thirdPartySupportUrl?: string;
|
||||
}
|
||||
|
||||
export class ServerApkLinksDto {
|
||||
@ApiProperty({ description: 'APK download link for ARM64 v8a architecture' })
|
||||
arm64v8a!: string;
|
||||
@ApiProperty({ description: 'APK download link for ARM EABI v7a architecture' })
|
||||
armeabiv7a!: string;
|
||||
@ApiProperty({ description: 'APK download link for universal architecture' })
|
||||
universal!: string;
|
||||
@ApiProperty({ description: 'APK download link for x86_64 architecture' })
|
||||
x86_64!: string;
|
||||
}
|
||||
|
||||
export class ServerStorageResponseDto {
|
||||
@ApiProperty({ description: 'Total disk size (human-readable format)' })
|
||||
diskSize!: string;
|
||||
@ApiProperty({ description: 'Used disk space (human-readable format)' })
|
||||
diskUse!: string;
|
||||
@ApiProperty({ description: 'Available disk space (human-readable format)' })
|
||||
diskAvailable!: string;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Total disk size in bytes' })
|
||||
diskSizeRaw!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Used disk space in bytes' })
|
||||
diskUseRaw!: number;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Available disk space in bytes' })
|
||||
diskAvailableRaw!: number;
|
||||
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'Disk usage percentage (0-100)' })
|
||||
diskUsagePercentage!: number;
|
||||
}
|
||||
|
||||
export class ServerVersionResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Major version number' })
|
||||
major!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Minor version number' })
|
||||
minor!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Patch version number' })
|
||||
patch!: number;
|
||||
|
||||
static fromSemVer(value: SemVer) {
|
||||
@@ -76,44 +104,52 @@ export class ServerVersionResponseDto {
|
||||
}
|
||||
|
||||
export class ServerVersionHistoryResponseDto {
|
||||
@ApiProperty({ description: 'Version history entry ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'When this version was first seen', format: 'date-time' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Version string' })
|
||||
version!: string;
|
||||
}
|
||||
|
||||
export class UsageByUserDto {
|
||||
@ApiProperty({ type: 'string' })
|
||||
@ApiProperty({ type: 'string', description: 'User ID' })
|
||||
userId!: string;
|
||||
@ApiProperty({ type: 'string' })
|
||||
@ApiProperty({ type: 'string', description: 'User name' })
|
||||
userName!: string;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of photos' })
|
||||
photos!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of videos' })
|
||||
videos!: number;
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Total storage usage in bytes' })
|
||||
usage!: number;
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for photos in bytes' })
|
||||
usagePhotos!: number;
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for videos in bytes' })
|
||||
usageVideos!: number;
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({
|
||||
type: 'integer',
|
||||
format: 'int64',
|
||||
nullable: true,
|
||||
description: 'User quota size in bytes (null if unlimited)',
|
||||
})
|
||||
quotaSizeInBytes!: number | null;
|
||||
}
|
||||
|
||||
export class ServerStatsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of photos' })
|
||||
photos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Total number of videos' })
|
||||
videos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Total storage usage in bytes' })
|
||||
usage = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for photos in bytes' })
|
||||
usagePhotos = 0;
|
||||
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage for videos in bytes' })
|
||||
usageVideos = 0;
|
||||
|
||||
@ApiProperty({
|
||||
@@ -134,44 +170,71 @@ export class ServerStatsResponseDto {
|
||||
}
|
||||
|
||||
export class ServerMediaTypesResponseDto {
|
||||
@ApiProperty({ description: 'Supported video MIME types' })
|
||||
video!: string[];
|
||||
@ApiProperty({ description: 'Supported image MIME types' })
|
||||
image!: string[];
|
||||
@ApiProperty({ description: 'Supported sidecar MIME types' })
|
||||
sidecar!: string[];
|
||||
}
|
||||
|
||||
export class ServerThemeDto extends SystemConfigThemeDto {}
|
||||
|
||||
export class ServerConfigDto {
|
||||
@ApiProperty({ description: 'OAuth button text' })
|
||||
oauthButtonText!: string;
|
||||
@ApiProperty({ description: 'Login page message' })
|
||||
loginPageMessage!: string;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of days before trashed assets are permanently deleted' })
|
||||
trashDays!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Delay in days before deleted users are permanently removed' })
|
||||
userDeleteDelay!: number;
|
||||
@ApiProperty({ description: 'Whether the server has been initialized' })
|
||||
isInitialized!: boolean;
|
||||
@ApiProperty({ description: 'Whether the admin has completed onboarding' })
|
||||
isOnboarded!: boolean;
|
||||
@ApiProperty({ description: 'External domain URL' })
|
||||
externalDomain!: string;
|
||||
@ApiProperty({ description: 'Whether public user registration is enabled' })
|
||||
publicUsers!: boolean;
|
||||
@ApiProperty({ description: 'Map dark style URL' })
|
||||
mapDarkStyleUrl!: string;
|
||||
@ApiProperty({ description: 'Map light style URL' })
|
||||
mapLightStyleUrl!: string;
|
||||
@ApiProperty({ description: 'Whether maintenance mode is active' })
|
||||
maintenanceMode!: boolean;
|
||||
}
|
||||
|
||||
export class ServerFeaturesDto {
|
||||
@ApiProperty({ description: 'Whether smart search is enabled' })
|
||||
smartSearch!: boolean;
|
||||
@ApiProperty({ description: 'Whether duplicate detection is enabled' })
|
||||
duplicateDetection!: boolean;
|
||||
@ApiProperty({ description: 'Whether config file is available' })
|
||||
configFile!: boolean;
|
||||
@ApiProperty({ description: 'Whether facial recognition is enabled' })
|
||||
facialRecognition!: boolean;
|
||||
@ApiProperty({ description: 'Whether map feature is enabled' })
|
||||
map!: boolean;
|
||||
@ApiProperty({ description: 'Whether trash feature is enabled' })
|
||||
trash!: boolean;
|
||||
@ApiProperty({ description: 'Whether reverse geocoding is enabled' })
|
||||
reverseGeocoding!: boolean;
|
||||
@ApiProperty({ description: 'Whether face import is enabled' })
|
||||
importFaces!: boolean;
|
||||
@ApiProperty({ description: 'Whether OAuth is enabled' })
|
||||
oauth!: boolean;
|
||||
@ApiProperty({ description: 'Whether OAuth auto-launch is enabled' })
|
||||
oauthAutoLaunch!: boolean;
|
||||
@ApiProperty({ description: 'Whether password login is enabled' })
|
||||
passwordLogin!: boolean;
|
||||
@ApiProperty({ description: 'Whether sidecar files are supported' })
|
||||
sidecar!: boolean;
|
||||
@ApiProperty({ description: 'Whether search is enabled' })
|
||||
search!: boolean;
|
||||
@ApiProperty({ description: 'Whether email notifications are enabled' })
|
||||
email!: boolean;
|
||||
@ApiProperty({ description: 'Whether OCR is enabled' })
|
||||
ocr!: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,44 +1,55 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Equals, IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { Session } from 'src/database';
|
||||
import { Optional, ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SessionCreateDto {
|
||||
/**
|
||||
* session duration, in seconds
|
||||
*/
|
||||
@ApiPropertyOptional({ type: 'number', description: 'Session duration in seconds' })
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@Optional()
|
||||
duration?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Device type' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
deviceType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Device OS' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
deviceOS?: string;
|
||||
}
|
||||
|
||||
export class SessionUpdateDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Reset pending sync state' })
|
||||
@Equals(true)
|
||||
isPendingSyncReset?: true;
|
||||
}
|
||||
|
||||
export class SessionResponseDto {
|
||||
@ApiProperty({ description: 'Session ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: string;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: string;
|
||||
@ApiPropertyOptional({ description: 'Expiration date' })
|
||||
expiresAt?: string;
|
||||
@ApiProperty({ description: 'Is current session' })
|
||||
current!: boolean;
|
||||
@ApiProperty({ description: 'Device type' })
|
||||
deviceType!: string;
|
||||
@ApiProperty({ description: 'Device OS' })
|
||||
deviceOS!: string;
|
||||
@ApiProperty({ description: 'App version' })
|
||||
appVersion!: string | null;
|
||||
@ApiProperty({ description: 'Is pending sync reset' })
|
||||
isPendingSyncReset!: boolean;
|
||||
}
|
||||
|
||||
export class SessionCreateResponseDto extends SessionResponseDto {
|
||||
@ApiProperty({ description: 'Session token' })
|
||||
token!: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,122 +1,160 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
import _ from 'lodash';
|
||||
import { SharedLink } from 'src/database';
|
||||
import { HistoryBuilder, Property } from 'src/decorators';
|
||||
import { AlbumResponseDto, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { SharedLinkType } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class SharedLinkSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter by album ID' })
|
||||
albumId?: string;
|
||||
|
||||
@ValidateUUID({
|
||||
optional: true,
|
||||
description: 'Filter by shared link ID',
|
||||
history: new HistoryBuilder().added('v2.5.0'),
|
||||
})
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export class SharedLinkCreateDto {
|
||||
@ValidateEnum({ enum: SharedLinkType, name: 'SharedLinkType' })
|
||||
@ValidateEnum({ enum: SharedLinkType, name: 'SharedLinkType', description: 'Shared link type' })
|
||||
type!: SharedLinkType;
|
||||
|
||||
@ValidateUUID({ each: true, optional: true })
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Asset IDs (for individual assets)' })
|
||||
assetIds?: string[];
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Album ID (for album sharing)' })
|
||||
albumId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Link description' })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Link password' })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
password?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Custom URL slug' })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
slug?: string | null;
|
||||
|
||||
@ValidateDate({ optional: true, nullable: true })
|
||||
@ValidateDate({ optional: true, nullable: true, description: 'Expiration date' })
|
||||
expiresAt?: Date | null = null;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Allow uploads' })
|
||||
allowUpload?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Allow downloads', default: true })
|
||||
allowDownload?: boolean = true;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Show metadata', default: true })
|
||||
showMetadata?: boolean = true;
|
||||
}
|
||||
|
||||
export class SharedLinkEditDto {
|
||||
@ApiPropertyOptional({ description: 'Link description' })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
description?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Link password' })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
password?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Custom URL slug' })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@IsString()
|
||||
slug?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Expiration date' })
|
||||
@Optional({ nullable: true })
|
||||
expiresAt?: Date | null;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Allow uploads' })
|
||||
allowUpload?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Allow downloads' })
|
||||
allowDownload?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Show metadata' })
|
||||
showMetadata?: boolean;
|
||||
|
||||
/**
|
||||
* Few clients cannot send null to set the expiryTime to never.
|
||||
* Setting this flag and not sending expiryAt is considered as null instead.
|
||||
* Clients that can send null values can ignore this.
|
||||
*/
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
description:
|
||||
'Whether to change the expiry time. Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this.',
|
||||
})
|
||||
changeExpiryTime?: boolean;
|
||||
}
|
||||
|
||||
export class SharedLinkLoginDto {
|
||||
@ValidateString({ description: 'Shared link password', example: 'password' })
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class SharedLinkPasswordDto {
|
||||
@ApiPropertyOptional({ example: 'password', description: 'Link password' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
@ApiProperty({ example: 'password' })
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Access token' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
token?: string;
|
||||
}
|
||||
export class SharedLinkResponseDto {
|
||||
@ApiProperty({ description: 'Shared link ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Link description' })
|
||||
description!: string | null;
|
||||
@ApiProperty({ description: 'Has password' })
|
||||
password!: string | null;
|
||||
@Property({
|
||||
description: 'Access token',
|
||||
history: new HistoryBuilder().added('v1').stable('v2').deprecated('v2.6.0'),
|
||||
})
|
||||
token?: string | null;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
userId!: string;
|
||||
@ApiProperty({ description: 'Encryption key (base64url)' })
|
||||
key!: string;
|
||||
|
||||
@ValidateEnum({ enum: SharedLinkType, name: 'SharedLinkType' })
|
||||
@ValidateEnum({ enum: SharedLinkType, name: 'SharedLinkType', description: 'Shared link type' })
|
||||
type!: SharedLinkType;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Expiration date' })
|
||||
expiresAt!: Date | null;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
assets!: AssetResponseDto[];
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
album?: AlbumResponseDto;
|
||||
@ApiProperty({ description: 'Allow uploads' })
|
||||
allowUpload!: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Allow downloads' })
|
||||
allowDownload!: boolean;
|
||||
@ApiProperty({ description: 'Show metadata' })
|
||||
showMetadata!: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Custom URL slug' })
|
||||
slug!: string | null;
|
||||
}
|
||||
|
||||
export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
|
||||
const linkAssets = sharedLink.assets || [];
|
||||
export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetadata: boolean }): SharedLinkResponseDto {
|
||||
const assets = sharedLink.assets || [];
|
||||
|
||||
return {
|
||||
const response = {
|
||||
id: sharedLink.id,
|
||||
description: sharedLink.description,
|
||||
password: sharedLink.password,
|
||||
@@ -125,35 +163,19 @@ export function mapSharedLink(sharedLink: SharedLink): SharedLinkResponseDto {
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: linkAssets.map((asset) => mapAsset(asset)),
|
||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showMetadata: sharedLink.showExif,
|
||||
slug: sharedLink.slug,
|
||||
};
|
||||
}
|
||||
|
||||
export function mapSharedLinkWithoutMetadata(sharedLink: SharedLink): SharedLinkResponseDto {
|
||||
const linkAssets = sharedLink.assets || [];
|
||||
const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
|
||||
|
||||
const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
|
||||
|
||||
return {
|
||||
id: sharedLink.id,
|
||||
description: sharedLink.description,
|
||||
password: sharedLink.password,
|
||||
userId: sharedLink.userId,
|
||||
key: sharedLink.key.toString('base64url'),
|
||||
type: sharedLink.type,
|
||||
createdAt: sharedLink.createdAt,
|
||||
expiresAt: sharedLink.expiresAt,
|
||||
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: true })),
|
||||
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
|
||||
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
|
||||
allowUpload: sharedLink.allowUpload,
|
||||
allowDownload: sharedLink.allowDownload,
|
||||
showMetadata: sharedLink.showExif,
|
||||
slug: sharedLink.slug,
|
||||
};
|
||||
|
||||
// unless we select sharedLink.album.sharedLinks this will be wrong
|
||||
if (response.album) {
|
||||
response.album.hasSharedLink = true;
|
||||
response.album.shared = true;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMinSize } from 'class-validator';
|
||||
import { Stack } from 'src/database';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
@@ -5,25 +6,27 @@ import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ValidateUUID } from 'src/validation';
|
||||
|
||||
export class StackCreateDto {
|
||||
/** first asset becomes the primary */
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs (first becomes primary, min 2)' })
|
||||
@ArrayMinSize(2)
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class StackSearchDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter by primary asset ID' })
|
||||
primaryAssetId?: string;
|
||||
}
|
||||
|
||||
export class StackUpdateDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Primary asset ID' })
|
||||
primaryAssetId?: string;
|
||||
}
|
||||
|
||||
export class StackResponseDto {
|
||||
@ApiProperty({ description: 'Stack ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Primary asset ID' })
|
||||
primaryAssetId!: string;
|
||||
@ApiProperty({ description: 'Stack assets' })
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
|
||||
|
||||
+192
-33
@@ -2,9 +2,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetMetadataKey,
|
||||
AssetOrder,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
@@ -18,32 +18,35 @@ import { UserMetadata } from 'src/types';
|
||||
import { ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class AssetFullSyncDto {
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Last asset ID (pagination)' })
|
||||
lastId?: string;
|
||||
|
||||
@ValidateDate()
|
||||
@ValidateDate({ description: 'Sync assets updated until this date' })
|
||||
updatedUntil!: Date;
|
||||
|
||||
@ApiProperty({ type: 'integer', description: 'Maximum number of assets to return' })
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
limit!: number;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'Filter by user ID' })
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export class AssetDeltaSyncDto {
|
||||
@ValidateDate()
|
||||
@ValidateDate({ description: 'Sync assets updated after this date' })
|
||||
updatedAfter!: Date;
|
||||
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'User IDs to sync' })
|
||||
userIds!: string[];
|
||||
}
|
||||
|
||||
export class AssetDeltaSyncResponseDto {
|
||||
@ApiProperty({ description: 'Whether full sync is needed' })
|
||||
needsFullSync!: boolean;
|
||||
@ApiProperty({ description: 'Upserted assets' })
|
||||
upserted!: AssetResponseDto[];
|
||||
@ApiProperty({ description: 'Deleted asset IDs' })
|
||||
deleted!: string[];
|
||||
}
|
||||
|
||||
@@ -58,21 +61,31 @@ export const ExtraModel = (): ClassDecorator => {
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncUserV1 {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'User name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'User email' })
|
||||
email!: string;
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', nullable: true })
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'User avatar color' })
|
||||
avatarColor!: UserAvatarColor | null;
|
||||
@ApiProperty({ description: 'User deleted at' })
|
||||
deletedAt!: Date | null;
|
||||
@ApiProperty({ description: 'User has profile image' })
|
||||
hasProfileImage!: boolean;
|
||||
@ApiProperty({ description: 'User profile changed at' })
|
||||
profileChangedAt!: Date;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAuthUserV1 extends SyncUserV1 {
|
||||
@ApiProperty({ description: 'User is admin' })
|
||||
isAdmin!: boolean;
|
||||
@ApiProperty({ description: 'User pin code' })
|
||||
pinCode!: string | null;
|
||||
@ApiProperty({ description: 'User OAuth ID' })
|
||||
oauthId!: string;
|
||||
@ApiProperty({ description: 'User storage label' })
|
||||
storageLabel!: string | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
quotaSizeInBytes!: number | null;
|
||||
@@ -82,131 +95,207 @@ export class SyncAuthUserV1 extends SyncUserV1 {
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncUserDeleteV1 {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncPartnerV1 {
|
||||
@ApiProperty({ description: 'Shared by ID' })
|
||||
sharedById!: string;
|
||||
@ApiProperty({ description: 'Shared with ID' })
|
||||
sharedWithId!: string;
|
||||
@ApiProperty({ description: 'In timeline' })
|
||||
inTimeline!: boolean;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncPartnerDeleteV1 {
|
||||
@ApiProperty({ description: 'Shared by ID' })
|
||||
sharedById!: string;
|
||||
@ApiProperty({ description: 'Shared with ID' })
|
||||
sharedWithId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetV1 {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Owner ID' })
|
||||
ownerId!: string;
|
||||
@ApiProperty({ description: 'Original file name' })
|
||||
originalFileName!: string;
|
||||
@ApiProperty({ description: 'Thumbhash' })
|
||||
thumbhash!: string | null;
|
||||
@ApiProperty({ description: 'Checksum' })
|
||||
checksum!: string;
|
||||
@ApiProperty({ description: 'File created at' })
|
||||
fileCreatedAt!: Date | null;
|
||||
@ApiProperty({ description: 'File modified at' })
|
||||
fileModifiedAt!: Date | null;
|
||||
@ApiProperty({ description: 'Local date time' })
|
||||
localDateTime!: Date | null;
|
||||
@ApiProperty({ description: 'Duration' })
|
||||
duration!: string | null;
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum' })
|
||||
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum', description: 'Asset type' })
|
||||
type!: AssetType;
|
||||
@ApiProperty({ description: 'Deleted at' })
|
||||
deletedAt!: Date | null;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility' })
|
||||
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility', description: 'Asset visibility' })
|
||||
visibility!: AssetVisibility;
|
||||
@ApiProperty({ description: 'Live photo video ID' })
|
||||
livePhotoVideoId!: string | null;
|
||||
@ApiProperty({ description: 'Stack ID' })
|
||||
stackId!: string | null;
|
||||
@ApiProperty({ description: 'Library ID' })
|
||||
libraryId!: string | null;
|
||||
@ApiProperty({ type: 'integer', description: 'Asset width' })
|
||||
width!: number | null;
|
||||
@ApiProperty({ type: 'integer', description: 'Asset height' })
|
||||
height!: number | null;
|
||||
@ApiProperty({ description: 'Is edited' })
|
||||
isEdited!: boolean;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetDeleteV1 {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetExifV1 {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
@ApiProperty({ description: 'Description' })
|
||||
description!: string | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Exif image width' })
|
||||
exifImageWidth!: number | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Exif image height' })
|
||||
exifImageHeight!: number | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'File size in byte' })
|
||||
fileSizeInByte!: number | null;
|
||||
@ApiProperty({ description: 'Orientation' })
|
||||
orientation!: string | null;
|
||||
@ApiProperty({ description: 'Date time original' })
|
||||
dateTimeOriginal!: Date | null;
|
||||
@ApiProperty({ description: 'Modify date' })
|
||||
modifyDate!: Date | null;
|
||||
@ApiProperty({ description: 'Time zone' })
|
||||
timeZone!: string | null;
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'Latitude' })
|
||||
latitude!: number | null;
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'Longitude' })
|
||||
longitude!: number | null;
|
||||
@ApiProperty({ description: 'Projection type' })
|
||||
projectionType!: string | null;
|
||||
@ApiProperty({ description: 'City' })
|
||||
city!: string | null;
|
||||
@ApiProperty({ description: 'State' })
|
||||
state!: string | null;
|
||||
@ApiProperty({ description: 'Country' })
|
||||
country!: string | null;
|
||||
@ApiProperty({ description: 'Make' })
|
||||
make!: string | null;
|
||||
@ApiProperty({ description: 'Model' })
|
||||
model!: string | null;
|
||||
@ApiProperty({ description: 'Lens model' })
|
||||
lensModel!: string | null;
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'F number' })
|
||||
fNumber!: number | null;
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'Focal length' })
|
||||
focalLength!: number | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'ISO' })
|
||||
iso!: number | null;
|
||||
@ApiProperty({ description: 'Exposure time' })
|
||||
exposureTime!: string | null;
|
||||
@ApiProperty({ description: 'Profile description' })
|
||||
profileDescription!: string | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Rating' })
|
||||
rating!: number | null;
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ type: 'number', format: 'double', description: 'FPS' })
|
||||
fps!: number | null;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetMetadataV1 {
|
||||
export class SyncAssetEditV1 {
|
||||
id!: string;
|
||||
assetId!: string;
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
|
||||
@ValidateEnum({ enum: AssetEditAction, name: 'AssetEditAction' })
|
||||
action!: AssetEditAction;
|
||||
parameters!: object;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
sequence!: number;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetEditDeleteV1 {
|
||||
editId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetMetadataV1 {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
@ApiProperty({ description: 'Key' })
|
||||
key!: string;
|
||||
@ApiProperty({ description: 'Value' })
|
||||
value!: object;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetMetadataDeleteV1 {
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
@ApiProperty({ description: 'Key' })
|
||||
key!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumDeleteV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumUserDeleteV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumUserV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole' })
|
||||
@ValidateEnum({ enum: AlbumUserRole, name: 'AlbumUserRole', description: 'Album user role' })
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Owner ID' })
|
||||
ownerId!: string;
|
||||
@ApiProperty({ description: 'Album name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Album description' })
|
||||
description!: string;
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Updated at' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Thumbnail asset ID' })
|
||||
thumbnailAssetId!: string | null;
|
||||
@ApiProperty({ description: 'Is activity enabled' })
|
||||
isActivityEnabled!: boolean;
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder' })
|
||||
order!: AssetOrder;
|
||||
@@ -214,87 +303,127 @@ export class SyncAlbumV1 {
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumToAssetV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumToAssetDeleteV1 {
|
||||
@ApiProperty({ description: 'Album ID' })
|
||||
albumId!: string;
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncMemoryV1 {
|
||||
@ApiProperty({ description: 'Memory ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Updated at' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Deleted at' })
|
||||
deletedAt!: Date | null;
|
||||
@ApiProperty({ description: 'Owner ID' })
|
||||
ownerId!: string;
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType' })
|
||||
@ValidateEnum({ enum: MemoryType, name: 'MemoryType', description: 'Memory type' })
|
||||
type!: MemoryType;
|
||||
@ApiProperty({ description: 'Data' })
|
||||
data!: object;
|
||||
@ApiProperty({ description: 'Is saved' })
|
||||
isSaved!: boolean;
|
||||
@ApiProperty({ description: 'Memory at' })
|
||||
memoryAt!: Date;
|
||||
@ApiProperty({ description: 'Seen at' })
|
||||
seenAt!: Date | null;
|
||||
@ApiProperty({ description: 'Show at' })
|
||||
showAt!: Date | null;
|
||||
@ApiProperty({ description: 'Hide at' })
|
||||
hideAt!: Date | null;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncMemoryDeleteV1 {
|
||||
@ApiProperty({ description: 'Memory ID' })
|
||||
memoryId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncMemoryAssetV1 {
|
||||
@ApiProperty({ description: 'Memory ID' })
|
||||
memoryId!: string;
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncMemoryAssetDeleteV1 {
|
||||
@ApiProperty({ description: 'Memory ID' })
|
||||
memoryId!: string;
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncStackV1 {
|
||||
@ApiProperty({ description: 'Stack ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Updated at' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Primary asset ID' })
|
||||
primaryAssetId!: string;
|
||||
@ApiProperty({ description: 'Owner ID' })
|
||||
ownerId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncStackDeleteV1 {
|
||||
@ApiProperty({ description: 'Stack ID' })
|
||||
stackId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncPersonV1 {
|
||||
@ApiProperty({ description: 'Person ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Created at' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Updated at' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'Owner ID' })
|
||||
ownerId!: string;
|
||||
@ApiProperty({ description: 'Person name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Birth date' })
|
||||
birthDate!: Date | null;
|
||||
@ApiProperty({ description: 'Is hidden' })
|
||||
isHidden!: boolean;
|
||||
@ApiProperty({ description: 'Is favorite' })
|
||||
isFavorite!: boolean;
|
||||
@ApiProperty({ description: 'Color' })
|
||||
color!: string | null;
|
||||
@ApiProperty({ description: 'Face asset ID' })
|
||||
faceAssetId!: string | null;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncPersonDeleteV1 {
|
||||
@ApiProperty({ description: 'Person ID' })
|
||||
personId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetFaceV1 {
|
||||
@ApiProperty({ description: 'Asset face ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Asset ID' })
|
||||
assetId!: string;
|
||||
@ApiProperty({ description: 'Person ID' })
|
||||
personId!: string | null;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
imageWidth!: number;
|
||||
@@ -308,26 +437,45 @@ export class SyncAssetFaceV1 {
|
||||
boundingBoxX2!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
boundingBoxY2!: number;
|
||||
@ApiProperty({ description: 'Source type' })
|
||||
sourceType!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetFaceV2 extends SyncAssetFaceV1 {
|
||||
@ApiProperty({ description: 'Face deleted at' })
|
||||
deletedAt!: Date | null;
|
||||
@ApiProperty({ description: 'Is the face visible in the asset' })
|
||||
isVisible!: boolean;
|
||||
}
|
||||
|
||||
export function syncAssetFaceV2ToV1(faceV2: SyncAssetFaceV2): SyncAssetFaceV1 {
|
||||
const { deletedAt: _, isVisible: __, ...faceV1 } = faceV2;
|
||||
|
||||
return faceV1;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetFaceDeleteV1 {
|
||||
@ApiProperty({ description: 'Asset face ID' })
|
||||
assetFaceId!: string;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncUserMetadataV1 {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey' })
|
||||
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey', description: 'User metadata key' })
|
||||
key!: UserMetadataKey;
|
||||
@ApiProperty({ description: 'User metadata value' })
|
||||
value!: UserMetadata[UserMetadataKey];
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncUserMetadataDeleteV1 {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey' })
|
||||
@ValidateEnum({ enum: UserMetadataKey, name: 'UserMetadataKey', description: 'User metadata key' })
|
||||
key!: UserMetadataKey;
|
||||
}
|
||||
|
||||
@@ -351,6 +499,8 @@ export type SyncItem = {
|
||||
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
|
||||
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
|
||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.AssetEditV1]: SyncAssetEditV1;
|
||||
[SyncEntityType.AssetEditDeleteV1]: SyncAssetEditDeleteV1;
|
||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
||||
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
||||
@@ -382,6 +532,7 @@ export type SyncItem = {
|
||||
[SyncEntityType.PersonV1]: SyncPersonV1;
|
||||
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
|
||||
[SyncEntityType.AssetFaceV1]: SyncAssetFaceV1;
|
||||
[SyncEntityType.AssetFaceV2]: SyncAssetFaceV2;
|
||||
[SyncEntityType.AssetFaceDeleteV1]: SyncAssetFaceDeleteV1;
|
||||
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
|
||||
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
|
||||
@@ -391,26 +542,34 @@ export type SyncItem = {
|
||||
};
|
||||
|
||||
export class SyncStreamDto {
|
||||
@ValidateEnum({ enum: SyncRequestType, name: 'SyncRequestType', each: true })
|
||||
@ValidateEnum({ enum: SyncRequestType, name: 'SyncRequestType', each: true, description: 'Sync request types' })
|
||||
types!: SyncRequestType[];
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Reset sync state' })
|
||||
reset?: boolean;
|
||||
}
|
||||
|
||||
export class SyncAckDto {
|
||||
@ValidateEnum({ enum: SyncEntityType, name: 'SyncEntityType' })
|
||||
@ValidateEnum({ enum: SyncEntityType, name: 'SyncEntityType', description: 'Sync entity type' })
|
||||
type!: SyncEntityType;
|
||||
@ApiProperty({ description: 'Acknowledgment ID' })
|
||||
ack!: string;
|
||||
}
|
||||
|
||||
export class SyncAckSetDto {
|
||||
@ApiProperty({ description: 'Acknowledgment IDs (max 1000)' })
|
||||
@ArrayMaxSize(1000)
|
||||
@IsString({ each: true })
|
||||
acks!: string[];
|
||||
}
|
||||
|
||||
export class SyncAckDeleteDto {
|
||||
@ValidateEnum({ enum: SyncEntityType, name: 'SyncEntityType', optional: true, each: true })
|
||||
@ValidateEnum({
|
||||
enum: SyncEntityType,
|
||||
name: 'SyncEntityType',
|
||||
optional: true,
|
||||
each: true,
|
||||
description: 'Sync entity types to delete acks for',
|
||||
})
|
||||
types?: SyncEntityType[];
|
||||
}
|
||||
|
||||
@@ -40,18 +40,20 @@ const isEmailNotificationEnabled = (config: SystemConfigSmtpDto) => config.enabl
|
||||
const isDatabaseBackupEnabled = (config: DatabaseBackupConfig) => config.enabled;
|
||||
|
||||
export class DatabaseBackupConfig {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isDatabaseBackupEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsCronExpression()
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Cron expression' })
|
||||
cronExpression!: string;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ description: 'Keep last amount' })
|
||||
keepLastAmount!: number;
|
||||
}
|
||||
|
||||
@@ -67,173 +69,187 @@ export class SystemConfigFFmpegDto {
|
||||
@Min(0)
|
||||
@Max(51)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'CRF' })
|
||||
crf!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Threads' })
|
||||
threads!: number;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Preset' })
|
||||
preset!: string;
|
||||
|
||||
@ValidateEnum({ enum: VideoCodec, name: 'VideoCodec' })
|
||||
@ValidateEnum({ enum: VideoCodec, name: 'VideoCodec', description: 'Target video codec' })
|
||||
targetVideoCodec!: VideoCodec;
|
||||
|
||||
@ValidateEnum({ enum: VideoCodec, name: 'VideoCodec', each: true })
|
||||
@ValidateEnum({ enum: VideoCodec, name: 'VideoCodec', each: true, description: 'Accepted video codecs' })
|
||||
acceptedVideoCodecs!: VideoCodec[];
|
||||
|
||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec' })
|
||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', description: 'Target audio codec' })
|
||||
targetAudioCodec!: AudioCodec;
|
||||
|
||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true })
|
||||
@ValidateEnum({ enum: AudioCodec, name: 'AudioCodec', each: true, description: 'Accepted audio codecs' })
|
||||
acceptedAudioCodecs!: AudioCodec[];
|
||||
|
||||
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true })
|
||||
@ValidateEnum({ enum: VideoContainer, name: 'VideoContainer', each: true, description: 'Accepted containers' })
|
||||
acceptedContainers!: VideoContainer[];
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Target resolution' })
|
||||
targetResolution!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Max bitrate' })
|
||||
maxBitrate!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(-1)
|
||||
@Max(16)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'B-frames' })
|
||||
bframes!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'References' })
|
||||
refs!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'GOP size' })
|
||||
gopSize!: number;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Temporal AQ' })
|
||||
temporalAQ!: boolean;
|
||||
|
||||
@ValidateEnum({ enum: CQMode, name: 'CQMode' })
|
||||
@ValidateEnum({ enum: CQMode, name: 'CQMode', description: 'CQ mode' })
|
||||
cqMode!: CQMode;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Two pass' })
|
||||
twoPass!: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Preferred hardware device' })
|
||||
@IsString()
|
||||
preferredHwDevice!: string;
|
||||
|
||||
@ValidateEnum({ enum: TranscodePolicy, name: 'TranscodePolicy' })
|
||||
@ValidateEnum({ enum: TranscodePolicy, name: 'TranscodePolicy', description: 'Transcode policy' })
|
||||
transcode!: TranscodePolicy;
|
||||
|
||||
@ValidateEnum({ enum: TranscodeHardwareAcceleration, name: 'TranscodeHWAccel' })
|
||||
@ValidateEnum({
|
||||
enum: TranscodeHardwareAcceleration,
|
||||
name: 'TranscodeHWAccel',
|
||||
description: 'Transcode hardware acceleration',
|
||||
})
|
||||
accel!: TranscodeHardwareAcceleration;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Accelerated decode' })
|
||||
accelDecode!: boolean;
|
||||
|
||||
@ValidateEnum({ enum: ToneMapping, name: 'ToneMapping' })
|
||||
@ValidateEnum({ enum: ToneMapping, name: 'ToneMapping', description: 'Tone mapping' })
|
||||
tonemap!: ToneMapping;
|
||||
}
|
||||
|
||||
class JobSettingsDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Concurrency' })
|
||||
concurrency!: number;
|
||||
}
|
||||
|
||||
class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto> {
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.ThumbnailGeneration]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.MetadataExtraction]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.VideoConversion]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.SmartSearch]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Migration]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.BackgroundTask]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Search]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.FaceDetection]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Ocr]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Sidecar]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Library]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Notification]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Workflow]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto, description: undefined })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.Editor]!: JobSettingsDto;
|
||||
}
|
||||
|
||||
class SystemConfigLibraryScanDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isLibraryScanEnabled)
|
||||
@@ -244,7 +260,7 @@ class SystemConfigLibraryScanDto {
|
||||
}
|
||||
|
||||
class SystemConfigLibraryWatchDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
@@ -261,7 +277,7 @@ class SystemConfigLibraryDto {
|
||||
}
|
||||
|
||||
class SystemConfigLoggingDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateEnum({ enum: LogLevel, name: 'LogLevel' })
|
||||
@@ -269,7 +285,7 @@ class SystemConfigLoggingDto {
|
||||
}
|
||||
|
||||
class MachineLearningAvailabilityChecksDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@IsInt()
|
||||
@@ -280,7 +296,7 @@ class MachineLearningAvailabilityChecksDto {
|
||||
}
|
||||
|
||||
class SystemConfigMachineLearningDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@IsUrl({ require_tld: false, allow_underscores: true }, { each: true })
|
||||
@@ -326,7 +342,7 @@ export class MapThemeDto {
|
||||
}
|
||||
|
||||
class SystemConfigMapDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
@@ -339,7 +355,7 @@ class SystemConfigMapDto {
|
||||
}
|
||||
|
||||
class SystemConfigNewVersionCheckDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
@@ -347,72 +363,82 @@ class SystemConfigNightlyTasksDto {
|
||||
@IsDateStringFormat('HH:mm', { message: 'startTime must be in HH:mm format' })
|
||||
startTime!: string;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Database cleanup' })
|
||||
databaseCleanup!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Missing thumbnails' })
|
||||
missingThumbnails!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Cluster new faces' })
|
||||
clusterNewFaces!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Generate memories' })
|
||||
generateMemories!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Sync quota usage' })
|
||||
syncQuotaUsage!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigOAuthDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Auto launch' })
|
||||
autoLaunch!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Auto register' })
|
||||
autoRegister!: boolean;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Button text' })
|
||||
buttonText!: string;
|
||||
|
||||
@ValidateIf(isOAuthEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Client ID' })
|
||||
clientId!: string;
|
||||
|
||||
@ValidateIf(isOAuthEnabled)
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Client secret' })
|
||||
clientSecret!: string;
|
||||
|
||||
@ValidateEnum({ enum: OAuthTokenEndpointAuthMethod, name: 'OAuthTokenEndpointAuthMethod' })
|
||||
@ValidateEnum({
|
||||
enum: OAuthTokenEndpointAuthMethod,
|
||||
name: 'OAuthTokenEndpointAuthMethod',
|
||||
description: 'Token endpoint auth method',
|
||||
})
|
||||
tokenEndpointAuthMethod!: OAuthTokenEndpointAuthMethod;
|
||||
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@Optional()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Timeout' })
|
||||
timeout!: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Optional({ nullable: true })
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Default storage quota' })
|
||||
defaultStorageQuota!: number | null;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isOAuthEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Issuer URL' })
|
||||
issuerUrl!: string;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Mobile override enabled' })
|
||||
mobileOverrideEnabled!: boolean;
|
||||
|
||||
@ValidateIf(isOAuthOverrideEnabled)
|
||||
@IsUrl()
|
||||
@ApiProperty({ description: 'Mobile redirect URI' })
|
||||
mobileRedirectUri!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Scope' })
|
||||
scope!: string;
|
||||
|
||||
@IsString()
|
||||
@@ -421,30 +447,34 @@ class SystemConfigOAuthDto {
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@ApiProperty({ description: 'Profile signing algorithm' })
|
||||
profileSigningAlgorithm!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Storage label claim' })
|
||||
storageLabelClaim!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Storage quota claim' })
|
||||
storageQuotaClaim!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Role claim' })
|
||||
roleClaim!: string;
|
||||
}
|
||||
|
||||
class SystemConfigPasswordLoginDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigReverseGeocodingDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigFacesDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Import' })
|
||||
import!: boolean;
|
||||
}
|
||||
|
||||
@@ -458,51 +488,61 @@ class SystemConfigMetadataDto {
|
||||
class SystemConfigServerDto {
|
||||
@ValidateIf((_, value: string) => value !== '')
|
||||
@IsUrl({ require_tld: false, require_protocol: true, protocols: ['http', 'https'] })
|
||||
@ApiProperty({ description: 'External domain' })
|
||||
externalDomain!: string;
|
||||
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Login page message' })
|
||||
loginPageMessage!: string;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Public users' })
|
||||
publicUsers!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigSmtpTransportDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Whether to ignore SSL certificate errors' })
|
||||
ignoreCert!: boolean;
|
||||
|
||||
@ApiProperty({ description: 'SMTP server hostname' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
host!: string;
|
||||
|
||||
@ApiProperty({ description: 'SMTP server port', type: Number, minimum: 0, maximum: 65_535 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(65_535)
|
||||
port!: number;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Whether to use secure connection (TLS/SSL)' })
|
||||
secure!: boolean;
|
||||
|
||||
@ApiProperty({ description: 'SMTP username' })
|
||||
@IsString()
|
||||
username!: string;
|
||||
|
||||
@ApiProperty({ description: 'SMTP password' })
|
||||
@IsString()
|
||||
password!: string;
|
||||
}
|
||||
|
||||
export class SystemConfigSmtpDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Whether SMTP email notifications are enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Email address to send from' })
|
||||
@ValidateIf(isEmailNotificationEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
from!: string;
|
||||
|
||||
@ApiProperty({ description: 'Email address for replies' })
|
||||
@IsString()
|
||||
replyTo!: string;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@ValidateIf(isEmailNotificationEnabled)
|
||||
@Type(() => SystemConfigSmtpTransportDto)
|
||||
@ValidateNested()
|
||||
@@ -536,97 +576,119 @@ class SystemConfigTemplatesDto {
|
||||
}
|
||||
|
||||
class SystemConfigStorageTemplateDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Hash verification enabled' })
|
||||
hashVerificationEnabled!: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
@ApiProperty({ description: 'Template' })
|
||||
template!: string;
|
||||
}
|
||||
|
||||
export class SystemConfigTemplateStorageOptionDto {
|
||||
@ApiProperty({ description: 'Available year format options for storage template' })
|
||||
yearOptions!: string[];
|
||||
@ApiProperty({ description: 'Available month format options for storage template' })
|
||||
monthOptions!: string[];
|
||||
@ApiProperty({ description: 'Available week format options for storage template' })
|
||||
weekOptions!: string[];
|
||||
@ApiProperty({ description: 'Available day format options for storage template' })
|
||||
dayOptions!: string[];
|
||||
@ApiProperty({ description: 'Available hour format options for storage template' })
|
||||
hourOptions!: string[];
|
||||
@ApiProperty({ description: 'Available minute format options for storage template' })
|
||||
minuteOptions!: string[];
|
||||
@ApiProperty({ description: 'Available second format options for storage template' })
|
||||
secondOptions!: string[];
|
||||
@ApiProperty({ description: 'Available preset template options' })
|
||||
presetOptions!: string[];
|
||||
}
|
||||
|
||||
export class SystemConfigThemeDto {
|
||||
@ApiProperty({ description: 'Custom CSS for theming' })
|
||||
@IsString()
|
||||
customCss!: string;
|
||||
}
|
||||
|
||||
class SystemConfigGeneratedImageDto {
|
||||
@ValidateEnum({ enum: ImageFormat, name: 'ImageFormat' })
|
||||
@ValidateEnum({ enum: ImageFormat, name: 'ImageFormat', description: 'Image format' })
|
||||
format!: ImageFormat;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Quality' })
|
||||
quality!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Size' })
|
||||
size!: number;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: false })
|
||||
progressive?: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigGeneratedFullsizeImageDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateEnum({ enum: ImageFormat, name: 'ImageFormat' })
|
||||
@ValidateEnum({ enum: ImageFormat, name: 'ImageFormat', description: 'Image format' })
|
||||
format!: ImageFormat;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Quality' })
|
||||
quality!: number;
|
||||
|
||||
@ValidateBoolean({ optional: true, default: false, description: 'Progressive' })
|
||||
progressive?: boolean;
|
||||
}
|
||||
|
||||
export class SystemConfigImageDto {
|
||||
@Type(() => SystemConfigGeneratedImageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
thumbnail!: SystemConfigGeneratedImageDto;
|
||||
|
||||
@Type(() => SystemConfigGeneratedImageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
preview!: SystemConfigGeneratedImageDto;
|
||||
|
||||
@Type(() => SystemConfigGeneratedFullsizeImageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
fullsize!: SystemConfigGeneratedFullsizeImageDto;
|
||||
|
||||
@ValidateEnum({ enum: Colorspace, name: 'Colorspace' })
|
||||
@ValidateEnum({ enum: Colorspace, name: 'Colorspace', description: 'Colorspace' })
|
||||
colorspace!: Colorspace;
|
||||
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Extract embedded' })
|
||||
extractEmbedded!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigTrashDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Enabled' })
|
||||
enabled!: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Days' })
|
||||
days!: number;
|
||||
}
|
||||
|
||||
@@ -634,111 +696,153 @@ class SystemConfigUserDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Delete delay' })
|
||||
deleteDelay!: number;
|
||||
}
|
||||
|
||||
export class SystemConfigDto implements SystemConfig {
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigBackupsDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
backup!: SystemConfigBackupsDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigFFmpegDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
ffmpeg!: SystemConfigFFmpegDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigLoggingDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
logging!: SystemConfigLoggingDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigMachineLearningDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
machineLearning!: SystemConfigMachineLearningDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigMapDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
map!: SystemConfigMapDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigNewVersionCheckDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
newVersionCheck!: SystemConfigNewVersionCheckDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigNightlyTasksDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
nightlyTasks!: SystemConfigNightlyTasksDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigOAuthDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
oauth!: SystemConfigOAuthDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigPasswordLoginDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
passwordLogin!: SystemConfigPasswordLoginDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigReverseGeocodingDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
reverseGeocoding!: SystemConfigReverseGeocodingDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigMetadataDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
metadata!: SystemConfigMetadataDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigStorageTemplateDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
storageTemplate!: SystemConfigStorageTemplateDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigJobDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
job!: SystemConfigJobDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigImageDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
image!: SystemConfigImageDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigTrashDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
trash!: SystemConfigTrashDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigThemeDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
theme!: SystemConfigThemeDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigLibraryDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
library!: SystemConfigLibraryDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigNotificationsDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
notifications!: SystemConfigNotificationsDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigTemplatesDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
templates!: SystemConfigTemplatesDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigServerDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
server!: SystemConfigServerDto;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
@Type(() => SystemConfigUserDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class AdminOnboardingUpdateDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Is admin onboarded' })
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
export class AdminOnboardingResponseDto {
|
||||
@ValidateBoolean()
|
||||
@ValidateBoolean({ description: 'Is admin onboarded' })
|
||||
isOnboarded!: boolean;
|
||||
}
|
||||
|
||||
export class ReverseGeocodingStateResponseDto {
|
||||
@ApiProperty({ description: 'Last update timestamp' })
|
||||
lastUpdate!: string | null;
|
||||
@ApiProperty({ description: 'Last import file name' })
|
||||
lastImportFileName!: string | null;
|
||||
}
|
||||
|
||||
export class VersionCheckStateResponseDto {
|
||||
@ApiProperty({ description: 'Last check timestamp' })
|
||||
checkedAt!: string | null;
|
||||
@ApiProperty({ description: 'Release version' })
|
||||
releaseVersion!: string | null;
|
||||
}
|
||||
|
||||
@@ -1,53 +1,64 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { Tag } from 'src/database';
|
||||
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class TagCreateDto {
|
||||
@ApiProperty({ description: 'Tag name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@ValidateUUID({ optional: true, nullable: true })
|
||||
@ValidateUUID({ nullable: true, optional: true, description: 'Parent tag ID' })
|
||||
parentId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Tag color (hex)' })
|
||||
@IsHexColor()
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export class TagUpdateDto {
|
||||
@Optional({ emptyToNull: true, nullable: true })
|
||||
@ApiPropertyOptional({ description: 'Tag color (hex)' })
|
||||
@Optional({ nullable: true, emptyToNull: true })
|
||||
@ValidateHexColor()
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export class TagUpsertDto {
|
||||
@ApiProperty({ description: 'Tag names to upsert' })
|
||||
@IsString({ each: true })
|
||||
@IsNotEmpty({ each: true })
|
||||
tags!: string[];
|
||||
}
|
||||
|
||||
export class TagBulkAssetsDto {
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Tag IDs' })
|
||||
tagIds!: string[];
|
||||
|
||||
@ValidateUUID({ each: true })
|
||||
@ValidateUUID({ each: true, description: 'Asset IDs' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class TagBulkAssetsResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of assets tagged' })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
export class TagResponseDto {
|
||||
@ApiProperty({ description: 'Tag ID' })
|
||||
id!: string;
|
||||
@ApiPropertyOptional({ description: 'Parent tag ID' })
|
||||
parentId?: string;
|
||||
@ApiProperty({ description: 'Tag name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'Tag value (full path)' })
|
||||
value!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiPropertyOptional({ description: 'Tag color (hex)' })
|
||||
color?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -132,7 +136,7 @@ export class TimeBucketAssetResponseDto {
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Array of file creation timestamps in UTC (ISO 8601 format, without timezone)',
|
||||
description: 'Array of file creation timestamps in UTC',
|
||||
})
|
||||
fileCreatedAt!: string[];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class TrashResponseDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Number of items in trash' })
|
||||
count!: number;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional, ApiSchema } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDateString, IsInt, IsPositive, ValidateNested } from 'class-validator';
|
||||
import { AssetOrder, UserAvatarColor } from 'src/enum';
|
||||
@@ -6,71 +6,72 @@ import { UserPreferences } from 'src/types';
|
||||
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
|
||||
|
||||
class AvatarUpdate {
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true })
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, description: 'Avatar color' })
|
||||
color?: UserAvatarColor;
|
||||
}
|
||||
|
||||
class MemoriesUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether memories are enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Memory duration in seconds' })
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
class RatingsUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether ratings are enabled' })
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@ApiSchema({ description: 'Album preferences' })
|
||||
class AlbumsUpdate {
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true })
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', optional: true, description: 'Default asset order for albums' })
|
||||
defaultAssetOrder?: AssetOrder;
|
||||
}
|
||||
|
||||
class FoldersUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether folders are enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether folders appear in web sidebar' })
|
||||
sidebarWeb?: boolean;
|
||||
}
|
||||
|
||||
class PeopleUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether people are enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether people appear in web sidebar' })
|
||||
sidebarWeb?: boolean;
|
||||
}
|
||||
|
||||
class SharedLinksUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether shared links are enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether shared links appear in web sidebar' })
|
||||
sidebarWeb?: boolean;
|
||||
}
|
||||
|
||||
class TagsUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether tags are enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether tags appear in web sidebar' })
|
||||
sidebarWeb?: boolean;
|
||||
}
|
||||
|
||||
class EmailNotificationsUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether email notifications are enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether to receive email notifications for album invites' })
|
||||
albumInvite?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether to receive email notifications for album updates' })
|
||||
albumUpdate?: boolean;
|
||||
}
|
||||
|
||||
@@ -78,83 +79,108 @@ class DownloadUpdate implements Partial<DownloadResponse> {
|
||||
@Optional()
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiPropertyOptional({ type: 'integer', description: 'Maximum archive size in bytes' })
|
||||
archiveSize?: number;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether to include embedded videos in downloads' })
|
||||
includeEmbeddedVideos?: boolean;
|
||||
}
|
||||
|
||||
class PurchaseUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether to show support badge' })
|
||||
showSupportBadge?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Date until which to hide buy button' })
|
||||
@IsDateString()
|
||||
@Optional()
|
||||
hideBuyButtonUntil?: string;
|
||||
}
|
||||
|
||||
class CastUpdate {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Whether Google Cast is enabled' })
|
||||
gCastEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class UserPreferencesUpdateDto {
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => AlbumsUpdate)
|
||||
albums?: AlbumsUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => FoldersUpdate)
|
||||
folders?: FoldersUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => MemoriesUpdate)
|
||||
memories?: MemoriesUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => PeopleUpdate)
|
||||
people?: PeopleUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => RatingsUpdate)
|
||||
ratings?: RatingsUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined, required: false })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => SharedLinksUpdate)
|
||||
sharedLinks?: SharedLinksUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => TagsUpdate)
|
||||
tags?: TagsUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => AvatarUpdate)
|
||||
avatar?: AvatarUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmailNotificationsUpdate)
|
||||
emailNotifications?: EmailNotificationsUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => DownloadUpdate)
|
||||
download?: DownloadUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => PurchaseUpdate)
|
||||
purchase?: PurchaseUpdate;
|
||||
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiPropertyOptional({ description: undefined })
|
||||
@Optional()
|
||||
@ValidateNested()
|
||||
@Type(() => CastUpdate)
|
||||
@@ -162,74 +188,113 @@ export class UserPreferencesUpdateDto {
|
||||
}
|
||||
|
||||
class AlbumsResponse {
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder' })
|
||||
@ValidateEnum({ enum: AssetOrder, name: 'AssetOrder', description: 'Default asset order for albums' })
|
||||
defaultAssetOrder: AssetOrder = AssetOrder.Desc;
|
||||
}
|
||||
|
||||
class RatingsResponse {
|
||||
@ApiProperty({ description: 'Whether ratings are enabled' })
|
||||
enabled: boolean = false;
|
||||
}
|
||||
|
||||
class MemoriesResponse {
|
||||
@ApiProperty({ description: 'Whether memories are enabled' })
|
||||
enabled: boolean = true;
|
||||
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Memory duration in seconds' })
|
||||
duration: number = 5;
|
||||
}
|
||||
|
||||
class FoldersResponse {
|
||||
@ApiProperty({ description: 'Whether folders are enabled' })
|
||||
enabled: boolean = false;
|
||||
@ApiProperty({ description: 'Whether folders appear in web sidebar' })
|
||||
sidebarWeb: boolean = false;
|
||||
}
|
||||
|
||||
class PeopleResponse {
|
||||
@ApiProperty({ description: 'Whether people are enabled' })
|
||||
enabled: boolean = true;
|
||||
@ApiProperty({ description: 'Whether people appear in web sidebar' })
|
||||
sidebarWeb: boolean = false;
|
||||
}
|
||||
|
||||
class TagsResponse {
|
||||
@ApiProperty({ description: 'Whether tags are enabled' })
|
||||
enabled: boolean = true;
|
||||
@ApiProperty({ description: 'Whether tags appear in web sidebar' })
|
||||
sidebarWeb: boolean = true;
|
||||
}
|
||||
|
||||
class SharedLinksResponse {
|
||||
@ApiProperty({ description: 'Whether shared links are enabled' })
|
||||
enabled: boolean = true;
|
||||
@ApiProperty({ description: 'Whether shared links appear in web sidebar' })
|
||||
sidebarWeb: boolean = false;
|
||||
}
|
||||
|
||||
class EmailNotificationsResponse {
|
||||
@ApiProperty({ description: 'Whether email notifications are enabled' })
|
||||
enabled!: boolean;
|
||||
@ApiProperty({ description: 'Whether to receive email notifications for album invites' })
|
||||
albumInvite!: boolean;
|
||||
@ApiProperty({ description: 'Whether to receive email notifications for album updates' })
|
||||
albumUpdate!: boolean;
|
||||
}
|
||||
|
||||
class DownloadResponse {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@ApiProperty({ type: 'integer', description: 'Maximum archive size in bytes' })
|
||||
archiveSize!: number;
|
||||
|
||||
@ApiProperty({ description: 'Whether to include embedded videos in downloads' })
|
||||
includeEmbeddedVideos: boolean = false;
|
||||
}
|
||||
|
||||
class PurchaseResponse {
|
||||
@ApiProperty({ description: 'Whether to show support badge' })
|
||||
showSupportBadge!: boolean;
|
||||
@ApiProperty({ description: 'Date until which to hide buy button' })
|
||||
hideBuyButtonUntil!: string;
|
||||
}
|
||||
|
||||
class CastResponse {
|
||||
@ApiProperty({ description: 'Whether Google Cast is enabled' })
|
||||
gCastEnabled: boolean = false;
|
||||
}
|
||||
|
||||
export class UserPreferencesResponseDto implements UserPreferences {
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
albums!: AlbumsResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
folders!: FoldersResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
memories!: MemoriesResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
people!: PeopleResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
ratings!: RatingsResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
sharedLinks!: SharedLinksResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
tags!: TagsResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
emailNotifications!: EmailNotificationsResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
download!: DownloadResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
purchase!: PurchaseResponse;
|
||||
// Description lives on schema to avoid duplication
|
||||
@ApiProperty({ description: undefined })
|
||||
cast!: CastResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
|
||||
export class CreateProfileImageDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary' })
|
||||
@ApiProperty({ type: 'string', format: 'binary', description: 'Profile image file' })
|
||||
[UploadFieldName.PROFILE_DATA]!: Express.Multer.File;
|
||||
}
|
||||
|
||||
export class CreateProfileImageResponseDto {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
userId!: string;
|
||||
@ApiProperty({ description: 'Profile image change date', format: 'date-time' })
|
||||
profileChangedAt!: Date;
|
||||
@ApiProperty({ description: 'Profile image file path' })
|
||||
profileImagePath!: string;
|
||||
}
|
||||
|
||||
+68
-18
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||
import { User, UserAdmin } from 'src/database';
|
||||
@@ -7,39 +7,56 @@ import { UserMetadataItem } from 'src/types';
|
||||
import { Optional, PinCode, ValidateBoolean, ValidateEnum, ValidateUUID, toEmail, toSanitized } from 'src/validation';
|
||||
|
||||
export class UserUpdateMeDto {
|
||||
@ApiPropertyOptional({ description: 'User email' })
|
||||
@Optional()
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(toEmail)
|
||||
email?: string;
|
||||
|
||||
// TODO: migrate to the other change password endpoint
|
||||
@ApiPropertyOptional({ description: 'User password (deprecated, use change password endpoint)' })
|
||||
@Optional()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, nullable: true })
|
||||
@ValidateEnum({
|
||||
enum: UserAvatarColor,
|
||||
name: 'UserAvatarColor',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
description: 'Avatar color',
|
||||
})
|
||||
avatarColor?: UserAvatarColor | null;
|
||||
}
|
||||
|
||||
export class UserResponseDto {
|
||||
@ApiProperty({ description: 'User ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'User name' })
|
||||
name!: string;
|
||||
@ApiProperty({ description: 'User email' })
|
||||
email!: string;
|
||||
@ApiProperty({ description: 'Profile image path' })
|
||||
profileImagePath!: string;
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor' })
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', description: 'Avatar color' })
|
||||
avatarColor!: UserAvatarColor;
|
||||
@ApiProperty({ description: 'Profile change date' })
|
||||
profileChangedAt!: Date;
|
||||
}
|
||||
|
||||
export class UserLicense {
|
||||
@ApiProperty({ description: 'License key' })
|
||||
licenseKey!: string;
|
||||
@ApiProperty({ description: 'Activation key' })
|
||||
activationKey!: string;
|
||||
@ApiProperty({ description: 'Activation date' })
|
||||
activatedAt!: Date;
|
||||
}
|
||||
|
||||
@@ -63,108 +80,141 @@ export const mapUser = (entity: User | UserAdmin): UserResponseDto => {
|
||||
};
|
||||
|
||||
export class UserAdminSearchDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Include deleted users' })
|
||||
withDeleted?: boolean;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@ValidateUUID({ optional: true, description: 'User ID filter' })
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export class UserAdminCreateDto {
|
||||
@ApiProperty({ description: 'User email' })
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(toEmail)
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ description: 'User password' })
|
||||
@IsString()
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: 'User name' })
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, nullable: true })
|
||||
@ValidateEnum({
|
||||
enum: UserAvatarColor,
|
||||
name: 'UserAvatarColor',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
description: 'Avatar color',
|
||||
})
|
||||
avatarColor?: UserAvatarColor | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'PIN code' })
|
||||
@PinCode({ optional: true, nullable: true, emptyToNull: true })
|
||||
pinCode?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Storage label' })
|
||||
@Optional({ nullable: true })
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
storageLabel?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: 'integer', format: 'int64', description: 'Storage quota in bytes' })
|
||||
@Optional({ nullable: true })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
quotaSizeInBytes?: number | null;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Require password change on next login' })
|
||||
shouldChangePassword?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Send notification email' })
|
||||
notify?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Grant admin privileges' })
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export class UserAdminUpdateDto {
|
||||
@ApiPropertyOptional({ description: 'User email' })
|
||||
@Optional()
|
||||
@IsEmail({ require_tld: false })
|
||||
@Transform(toEmail)
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User password' })
|
||||
@Optional()
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'PIN code' })
|
||||
@PinCode({ optional: true, nullable: true, emptyToNull: true })
|
||||
pinCode?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'User name' })
|
||||
@Optional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name?: string;
|
||||
|
||||
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', optional: true, nullable: true })
|
||||
@ValidateEnum({
|
||||
enum: UserAvatarColor,
|
||||
name: 'UserAvatarColor',
|
||||
optional: true,
|
||||
nullable: true,
|
||||
description: 'Avatar color',
|
||||
})
|
||||
avatarColor?: UserAvatarColor | null;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Storage label' })
|
||||
@Optional({ nullable: true })
|
||||
@IsString()
|
||||
@Transform(toSanitized)
|
||||
storageLabel?: string | null;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Require password change on next login' })
|
||||
shouldChangePassword?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ type: 'integer', format: 'int64', description: 'Storage quota in bytes' })
|
||||
@Optional({ nullable: true })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
quotaSizeInBytes?: number | null;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Grant admin privileges' })
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export class UserAdminDeleteDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Force delete even if user has assets' })
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export class UserAdminResponseDto extends UserResponseDto {
|
||||
@ApiProperty({ description: 'Storage label' })
|
||||
storageLabel!: string | null;
|
||||
@ApiProperty({ description: 'Require password change on next login' })
|
||||
shouldChangePassword!: boolean;
|
||||
@ApiProperty({ description: 'Is admin user' })
|
||||
isAdmin!: boolean;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: Date;
|
||||
@ApiProperty({ description: 'Deletion date' })
|
||||
deletedAt!: Date | null;
|
||||
@ApiProperty({ description: 'Last update date' })
|
||||
updatedAt!: Date;
|
||||
@ApiProperty({ description: 'OAuth ID' })
|
||||
oauthId!: string;
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage quota in bytes' })
|
||||
quotaSizeInBytes!: number | null;
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
@ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' })
|
||||
quotaUsageInBytes!: number | null;
|
||||
@ValidateEnum({ enum: UserStatus, name: 'UserStatus' })
|
||||
@ValidateEnum({ enum: UserStatus, name: 'UserStatus', description: 'User status' })
|
||||
status!: string;
|
||||
@ApiProperty({ description: 'User license' })
|
||||
license!: UserLicense | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsObject, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { WorkflowAction, WorkflowFilter } from 'src/database';
|
||||
@@ -6,65 +7,85 @@ import type { ActionConfig, FilterConfig } from 'src/types/plugin-schema.types';
|
||||
import { Optional, ValidateBoolean, ValidateEnum } from 'src/validation';
|
||||
|
||||
export class WorkflowFilterItemDto {
|
||||
@ApiProperty({ description: 'Plugin filter ID' })
|
||||
@IsUUID()
|
||||
pluginFilterId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter configuration' })
|
||||
@IsObject()
|
||||
@Optional()
|
||||
filterConfig?: FilterConfig;
|
||||
}
|
||||
|
||||
export class WorkflowActionItemDto {
|
||||
@ApiProperty({ description: 'Plugin action ID' })
|
||||
@IsUUID()
|
||||
pluginActionId!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Action configuration' })
|
||||
@IsObject()
|
||||
@Optional()
|
||||
actionConfig?: ActionConfig;
|
||||
}
|
||||
|
||||
export class WorkflowCreateDto {
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType' })
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' })
|
||||
triggerType!: PluginTriggerType;
|
||||
|
||||
@ApiProperty({ description: 'Workflow name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Workflow description' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Workflow enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@ApiProperty({ description: 'Workflow filters' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowFilterItemDto)
|
||||
filters!: WorkflowFilterItemDto[];
|
||||
|
||||
@ApiProperty({ description: 'Workflow actions' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowActionItemDto)
|
||||
actions!: WorkflowActionItemDto[];
|
||||
}
|
||||
|
||||
export class WorkflowUpdateDto {
|
||||
@ValidateEnum({
|
||||
enum: PluginTriggerType,
|
||||
name: 'PluginTriggerType',
|
||||
optional: true,
|
||||
description: 'Workflow trigger type',
|
||||
})
|
||||
triggerType?: PluginTriggerType;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Workflow name' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Workflow description' })
|
||||
@IsString()
|
||||
@Optional()
|
||||
description?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ValidateBoolean({ optional: true, description: 'Workflow enabled' })
|
||||
enabled?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Workflow filters' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowFilterItemDto)
|
||||
@Optional()
|
||||
filters?: WorkflowFilterItemDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Workflow actions' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WorkflowActionItemDto)
|
||||
@Optional()
|
||||
@@ -72,30 +93,49 @@ export class WorkflowUpdateDto {
|
||||
}
|
||||
|
||||
export class WorkflowResponseDto {
|
||||
@ApiProperty({ description: 'Workflow ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Owner user ID' })
|
||||
ownerId!: string;
|
||||
@ValidateEnum({ enum: PluginTriggerType, name: 'PluginTriggerType', description: 'Workflow trigger type' })
|
||||
triggerType!: PluginTriggerType;
|
||||
@ApiProperty({ description: 'Workflow name' })
|
||||
name!: string | null;
|
||||
@ApiProperty({ description: 'Workflow description' })
|
||||
description!: string;
|
||||
@ApiProperty({ description: 'Creation date' })
|
||||
createdAt!: string;
|
||||
@ApiProperty({ description: 'Workflow enabled' })
|
||||
enabled!: boolean;
|
||||
@ApiProperty({ description: 'Workflow filters' })
|
||||
filters!: WorkflowFilterResponseDto[];
|
||||
@ApiProperty({ description: 'Workflow actions' })
|
||||
actions!: WorkflowActionResponseDto[];
|
||||
}
|
||||
|
||||
export class WorkflowFilterResponseDto {
|
||||
@ApiProperty({ description: 'Filter ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Workflow ID' })
|
||||
workflowId!: string;
|
||||
@ApiProperty({ description: 'Plugin filter ID' })
|
||||
pluginFilterId!: string;
|
||||
@ApiProperty({ description: 'Filter configuration' })
|
||||
filterConfig!: FilterConfig | null;
|
||||
@ApiProperty({ description: 'Filter order', type: 'number' })
|
||||
order!: number;
|
||||
}
|
||||
|
||||
export class WorkflowActionResponseDto {
|
||||
@ApiProperty({ description: 'Action ID' })
|
||||
id!: string;
|
||||
@ApiProperty({ description: 'Workflow ID' })
|
||||
workflowId!: string;
|
||||
@ApiProperty({ description: 'Plugin action ID' })
|
||||
pluginActionId!: string;
|
||||
@ApiProperty({ description: 'Action configuration' })
|
||||
actionConfig!: ActionConfig | null;
|
||||
@ApiProperty({ description: 'Action order', type: 'number' })
|
||||
order!: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,14 +7,22 @@ export const ImmichFooter = () => (
|
||||
<Column align="center" className="w-6/12 sm:w-full">
|
||||
<div>
|
||||
<Link href="https://play.google.com/store/apps/details?id=app.alextran.immich" className="object-contain">
|
||||
<Img className="max-w-full" src={`https://immich.app/img/google-play-badge.png`} />
|
||||
<Img
|
||||
alt="Get it on Google Play"
|
||||
className="max-w-full"
|
||||
src={`https://immich.app/img/google-play-badge.png`}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</Column>
|
||||
<Column align="center" className="w-6/12 sm:w-full">
|
||||
<div className="h-full p-6">
|
||||
<Link href="https://apps.apple.com/sg/app/immich/id1613945652">
|
||||
<Img src={`https://immich.app/img/ios-app-store-badge.png`} alt="Immich" className="max-w-full" />
|
||||
<Img
|
||||
alt="Download on the App Store"
|
||||
className="max-w-full"
|
||||
src={`https://immich.app/img/ios-app-store-badge.png`}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
</Column>
|
||||
|
||||
+36
-10
@@ -108,6 +108,11 @@ export enum Permission {
|
||||
AssetUpload = 'asset.upload',
|
||||
AssetReplace = 'asset.replace',
|
||||
AssetCopy = 'asset.copy',
|
||||
AssetDerive = 'asset.derive',
|
||||
|
||||
AssetEditGet = 'asset.edit.get',
|
||||
AssetEditCreate = 'asset.edit.create',
|
||||
AssetEditDelete = 'asset.edit.delete',
|
||||
|
||||
AlbumCreate = 'album.create',
|
||||
AlbumRead = 'album.read',
|
||||
@@ -130,6 +135,11 @@ export enum Permission {
|
||||
|
||||
ArchiveRead = 'archive.read',
|
||||
|
||||
BackupList = 'backup.list',
|
||||
BackupDownload = 'backup.download',
|
||||
BackupUpload = 'backup.upload',
|
||||
BackupDelete = 'backup.delete',
|
||||
|
||||
DuplicateRead = 'duplicate.read',
|
||||
DuplicateDelete = 'duplicate.delete',
|
||||
|
||||
@@ -138,6 +148,8 @@ export enum Permission {
|
||||
FaceUpdate = 'face.update',
|
||||
FaceDelete = 'face.delete',
|
||||
|
||||
FolderRead = 'folder.read',
|
||||
|
||||
JobCreate = 'job.create',
|
||||
JobRead = 'job.read',
|
||||
|
||||
@@ -152,6 +164,9 @@ export enum Permission {
|
||||
|
||||
Maintenance = 'maintenance',
|
||||
|
||||
MapRead = 'map.read',
|
||||
MapSearch = 'map.search',
|
||||
|
||||
MemoryCreate = 'memory.create',
|
||||
MemoryRead = 'memory.read',
|
||||
MemoryUpdate = 'memory.update',
|
||||
@@ -364,7 +379,6 @@ export enum AssetPathType {
|
||||
/** Folder structure containing tiles of the image */
|
||||
Tiles = 'tiles',
|
||||
EncodedVideo = 'encoded_video',
|
||||
Sidecar = 'sidecar',
|
||||
}
|
||||
|
||||
export enum PersonPathType {
|
||||
@@ -375,7 +389,7 @@ export enum UserPathType {
|
||||
Profile = 'profile',
|
||||
}
|
||||
|
||||
export type PathType = AssetPathType | PersonPathType | UserPathType;
|
||||
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
|
||||
|
||||
export enum TranscodePolicy {
|
||||
All = 'all',
|
||||
@@ -449,6 +463,10 @@ export enum RawExtractedFormat {
|
||||
Jxl = 'jxl',
|
||||
}
|
||||
|
||||
export enum TilesFormat {
|
||||
Dz = 'dz',
|
||||
}
|
||||
|
||||
export enum LogLevel {
|
||||
Verbose = 'verbose',
|
||||
Debug = 'debug',
|
||||
@@ -458,6 +476,11 @@ export enum LogLevel {
|
||||
Fatal = 'fatal',
|
||||
}
|
||||
|
||||
export enum LogFormat {
|
||||
Console = 'console',
|
||||
Json = 'json',
|
||||
}
|
||||
|
||||
export enum ApiCustomExtension {
|
||||
Permission = 'x-immich-permission',
|
||||
AdminOnly = 'x-immich-admin-only',
|
||||
@@ -554,6 +577,7 @@ export enum QueueName {
|
||||
BackupDatabase = 'backupDatabase',
|
||||
Ocr = 'ocr',
|
||||
Workflow = 'workflow',
|
||||
Editor = 'editor',
|
||||
}
|
||||
|
||||
export enum QueueJobStatus {
|
||||
@@ -572,6 +596,7 @@ export enum JobName {
|
||||
AssetDetectFaces = 'AssetDetectFaces',
|
||||
AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll',
|
||||
AssetDetectDuplicates = 'AssetDetectDuplicates',
|
||||
AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration',
|
||||
AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll',
|
||||
AssetEncodeVideo = 'AssetEncodeVideo',
|
||||
AssetEmptyTrash = 'AssetEmptyTrash',
|
||||
@@ -683,12 +708,15 @@ export enum DatabaseLock {
|
||||
MediaLocation = 700,
|
||||
GetSystemConfig = 69,
|
||||
BackupDatabase = 42,
|
||||
MaintenanceOperation = 621,
|
||||
MemoryCreation = 777,
|
||||
}
|
||||
|
||||
export enum MaintenanceAction {
|
||||
Start = 'start',
|
||||
End = 'end',
|
||||
SelectDatabaseRestore = 'select_database_restore',
|
||||
RestoreDatabase = 'restore_database',
|
||||
}
|
||||
|
||||
export enum ExitCode {
|
||||
@@ -703,6 +731,7 @@ export enum SyncRequestType {
|
||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||
AssetsV1 = 'AssetsV1',
|
||||
AssetExifsV1 = 'AssetExifsV1',
|
||||
AssetEditsV1 = 'AssetEditsV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AuthUsersV1 = 'AuthUsersV1',
|
||||
MemoriesV1 = 'MemoriesV1',
|
||||
@@ -715,6 +744,7 @@ export enum SyncRequestType {
|
||||
UsersV1 = 'UsersV1',
|
||||
PeopleV1 = 'PeopleV1',
|
||||
AssetFacesV1 = 'AssetFacesV1',
|
||||
AssetFacesV2 = 'AssetFacesV2',
|
||||
UserMetadataV1 = 'UserMetadataV1',
|
||||
}
|
||||
|
||||
@@ -727,6 +757,8 @@ export enum SyncEntityType {
|
||||
AssetV1 = 'AssetV1',
|
||||
AssetDeleteV1 = 'AssetDeleteV1',
|
||||
AssetExifV1 = 'AssetExifV1',
|
||||
AssetEditV1 = 'AssetEditV1',
|
||||
AssetEditDeleteV1 = 'AssetEditDeleteV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||
|
||||
@@ -773,6 +805,7 @@ export enum SyncEntityType {
|
||||
PersonDeleteV1 = 'PersonDeleteV1',
|
||||
|
||||
AssetFaceV1 = 'AssetFaceV1',
|
||||
AssetFaceV2 = 'AssetFaceV2',
|
||||
AssetFaceDeleteV1 = 'AssetFaceDeleteV1',
|
||||
|
||||
UserMetadataV1 = 'UserMetadataV1',
|
||||
@@ -804,14 +837,6 @@ export enum OAuthTokenEndpointAuthMethod {
|
||||
ClientSecretBasic = 'client_secret_basic',
|
||||
}
|
||||
|
||||
export enum DatabaseSslMode {
|
||||
Disable = 'disable',
|
||||
Allow = 'allow',
|
||||
Prefer = 'prefer',
|
||||
Require = 'require',
|
||||
VerifyFull = 'verify-full',
|
||||
}
|
||||
|
||||
export enum AssetVisibility {
|
||||
Archive = 'archive',
|
||||
Timeline = 'timeline',
|
||||
@@ -835,6 +860,7 @@ export enum ApiTag {
|
||||
Authentication = 'Authentication',
|
||||
AuthenticationAdmin = 'Authentication (admin)',
|
||||
Assets = 'Assets',
|
||||
DatabaseBackups = 'Database Backups (admin)',
|
||||
Deprecated = 'Deprecated',
|
||||
Download = 'Download',
|
||||
Duplicates = 'Duplicates',
|
||||
|
||||
+34
-9
@@ -1,11 +1,11 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { CommandFactory } from 'nest-commander';
|
||||
import { ChildProcess, fork } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { Worker } from 'node:worker_threads';
|
||||
import { PostgresError } from 'postgres';
|
||||
import { ImmichAdminModule } from 'src/app.module';
|
||||
import { ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
|
||||
import { DatabaseLock, ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { type DB } from 'src/schema';
|
||||
@@ -35,27 +35,26 @@ class Workers {
|
||||
if (isMaintenanceMode) {
|
||||
this.startWorker(ImmichWorker.Maintenance);
|
||||
} else {
|
||||
await this.waitForFreeLock();
|
||||
|
||||
for (const worker of workers) {
|
||||
this.startWorker(worker);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialise a short-lived Nest application to build configuration
|
||||
* @returns System configuration
|
||||
*/
|
||||
private async isMaintenanceMode(): Promise<boolean> {
|
||||
const { database } = new ConfigRepository().getEnv();
|
||||
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
|
||||
const { log: _, ...kyselyConfig } = getKyselyConfig(database.config);
|
||||
const kysely = new Kysely<DB>(kyselyConfig);
|
||||
const systemMetadataRepository = new SystemMetadataRepository(kysely);
|
||||
|
||||
try {
|
||||
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
|
||||
return value?.isMaintenanceMode || false;
|
||||
} catch (error) {
|
||||
} catch (error: Error | any) {
|
||||
// Table doesn't exist (migrations haven't run yet)
|
||||
if (error instanceof PostgresError && error.code === '42P01') {
|
||||
if ((error as PostgresError).code === '42P01') {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -65,6 +64,32 @@ class Workers {
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForFreeLock() {
|
||||
const { database } = new ConfigRepository().getEnv();
|
||||
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
|
||||
|
||||
let locked = false;
|
||||
while (!locked) {
|
||||
locked = await kysely.connection().execute(async (conn) => {
|
||||
const { rows } = await sql<{
|
||||
pg_try_advisory_lock: boolean;
|
||||
}>`SELECT pg_try_advisory_lock(${DatabaseLock.MaintenanceOperation})`.execute(conn);
|
||||
|
||||
const isLocked = rows[0].pg_try_advisory_lock;
|
||||
|
||||
if (isLocked) {
|
||||
await sql`SELECT pg_advisory_unlock(${DatabaseLock.MaintenanceOperation})`.execute(conn);
|
||||
}
|
||||
|
||||
return isLocked;
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
await kysely.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start an individual worker
|
||||
* @param name Worker
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { fork } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceHealthRepository {
|
||||
checkApiHealth(): Promise<void> {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
const basePath = dirname(__filename);
|
||||
const workerFile = join(basePath, '..', 'workers', `api.js`);
|
||||
|
||||
const worker = fork(workerFile, [], {
|
||||
execArgv: process.execArgv.filter((arg) => !arg.startsWith('--inspect')),
|
||||
env: {
|
||||
...process.env,
|
||||
IMMICH_HOST: '127.0.0.1',
|
||||
IMMICH_PORT: '33001',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'ignore', 'ipc'],
|
||||
});
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch('http://127.0.0.1:33001/api/server/config');
|
||||
const { isOnboarded } = await response.json();
|
||||
if (isOnboarded) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Server health check failed, no admin exists.'));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
} finally {
|
||||
if (worker.exitCode === null) {
|
||||
worker.kill('SIGTERM');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let output = '',
|
||||
alive = false;
|
||||
|
||||
worker.stdout?.on('data', (data) => {
|
||||
if (alive) {
|
||||
return;
|
||||
}
|
||||
|
||||
output += data;
|
||||
|
||||
if (output.includes('Immich Server is listening')) {
|
||||
alive = true;
|
||||
void checkHealth();
|
||||
}
|
||||
});
|
||||
|
||||
worker.on('exit', reject);
|
||||
worker.on('error', reject);
|
||||
|
||||
setTimeout(() => {
|
||||
if (worker.exitCode === null) {
|
||||
worker.kill('SIGTERM');
|
||||
}
|
||||
}, 20_000);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,17 +7,24 @@ import {
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { MaintenanceAuthDto, MaintenanceStatusResponseDto } from 'src/dtos/maintenance.dto';
|
||||
import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
export const serverEvents = ['AppRestart'] as const;
|
||||
export type ServerEvents = (typeof serverEvents)[number];
|
||||
|
||||
export interface ClientEventMap {
|
||||
AppRestartV1: [AppRestartEvent];
|
||||
interface ServerEventMap {
|
||||
AppRestart: [AppRestartEvent];
|
||||
MaintenanceStatus: [MaintenanceStatusResponseDto];
|
||||
}
|
||||
|
||||
interface ClientEventMap {
|
||||
AppRestartV1: [AppRestartEvent];
|
||||
MaintenanceStatusV1: [MaintenanceStatusResponseDto];
|
||||
}
|
||||
|
||||
type AuthFn = (client: Socket) => Promise<MaintenanceAuthDto>;
|
||||
type StatusUpdateFn = (status: MaintenanceStatusResponseDto) => void;
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: true,
|
||||
path: '/api/socket.io',
|
||||
@@ -25,8 +32,11 @@ export interface ClientEventMap {
|
||||
})
|
||||
@Injectable()
|
||||
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
|
||||
private authFn?: AuthFn;
|
||||
private statusUpdateFn?: StatusUpdateFn;
|
||||
|
||||
@WebSocketServer()
|
||||
private websocketServer?: Server;
|
||||
private server?: Server;
|
||||
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
@@ -35,25 +45,51 @@ export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGa
|
||||
this.logger.setContext(MaintenanceWebsocketRepository.name);
|
||||
}
|
||||
|
||||
afterInit(websocketServer: Server) {
|
||||
afterInit(server: Server) {
|
||||
this.logger.log('Initialized websocket server');
|
||||
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
|
||||
server.on('MaintenanceStatus', (status) => this.statusUpdateFn?.(status));
|
||||
server.on('AppRestart', (event: ArgsOf<'AppRestart'>, ack?: (ok: 'ok') => void) => {
|
||||
this.logger.log(`Restarting due to event... ${JSON.stringify(event)}`);
|
||||
|
||||
ack?.('ok');
|
||||
this.appRepository.exitApp();
|
||||
});
|
||||
}
|
||||
|
||||
clientSend<T extends keyof ClientEventMap>(event: T, room: string, ...data: ClientEventMap[T]) {
|
||||
this.server?.to(room).emit(event, ...data);
|
||||
}
|
||||
|
||||
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
|
||||
this.websocketServer?.emit(event, ...data);
|
||||
this.server?.emit(event, ...data);
|
||||
}
|
||||
|
||||
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
|
||||
serverSend<T extends keyof ServerEventMap>(event: T, ...args: ServerEventMap[T]): void {
|
||||
this.logger.debug(`Server event: ${event} (send)`);
|
||||
this.websocketServer?.serverSideEmit(event, ...args);
|
||||
this.server?.serverSideEmit(event, ...args);
|
||||
}
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`Websocket Connect: ${client.id}`);
|
||||
async handleConnection(client: Socket) {
|
||||
try {
|
||||
await this.authFn!(client);
|
||||
await client.join('private');
|
||||
this.logger.log(`Websocket Connect: ${client.id} (private)`);
|
||||
} catch {
|
||||
await client.join('public');
|
||||
this.logger.log(`Websocket Connect: ${client.id} (public)`);
|
||||
}
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
async handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Websocket Disconnect: ${client.id}`);
|
||||
await Promise.allSettled([client.leave('private'), client.leave('public')]);
|
||||
}
|
||||
|
||||
setAuthFn(fn: (client: Socket) => Promise<MaintenanceAuthDto>) {
|
||||
this.authFn = fn;
|
||||
}
|
||||
|
||||
setStatusUpdateFn(fn: (status: MaintenanceStatusResponseDto) => void) {
|
||||
this.statusUpdateFn = fn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,116 @@
|
||||
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto } from 'src/dtos/server.dto';
|
||||
import { ImmichCookie, MaintenanceAction } from 'src/enum';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
import {
|
||||
MaintenanceAuthDto,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceLoginDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ImmichCookie } from 'src/enum';
|
||||
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { LoginDetails } from 'src/services/auth.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { respondWithCookie } from 'src/utils/response';
|
||||
import { FilenameParamDto } from 'src/validation';
|
||||
|
||||
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
|
||||
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
|
||||
@Controller()
|
||||
export class MaintenanceWorkerController {
|
||||
constructor(private service: MaintenanceWorkerService) {}
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
private service: MaintenanceWorkerService,
|
||||
private databaseBackupService: DatabaseBackupService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@link _ServerController.getServerConfig }
|
||||
*/
|
||||
@Get('server/config')
|
||||
getServerConfig(): Promise<ServerConfigDto> {
|
||||
getServerConfig(): ServerConfigDto {
|
||||
return this.service.getSystemConfig();
|
||||
}
|
||||
|
||||
@Get('server/version')
|
||||
getServerVersion(): ServerVersionResponseDto {
|
||||
return this.service.getVersion();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.listDatabaseBackups}
|
||||
*/
|
||||
@Get('admin/database-backups')
|
||||
@MaintenanceRoute()
|
||||
listDatabaseBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
return this.databaseBackupService.listBackups();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.downloadDatabaseBackup}
|
||||
*/
|
||||
@Get('admin/database-backups/:filename')
|
||||
@MaintenanceRoute()
|
||||
async downloadDatabaseBackup(
|
||||
@Param() { filename }: FilenameParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.databaseBackupService.downloadBackup(filename), this.logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.deleteDatabaseBackup}
|
||||
*/
|
||||
@Delete('admin/database-backups')
|
||||
@MaintenanceRoute()
|
||||
async deleteDatabaseBackup(@Body() dto: DatabaseBackupDeleteDto): Promise<void> {
|
||||
return this.databaseBackupService.deleteBackup(dto.backups);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _DatabaseBackupController.uploadDatabaseBackup}
|
||||
*/
|
||||
@Post('admin/database-backups/upload')
|
||||
@MaintenanceRoute()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
return this.databaseBackupService.uploadBackup(file);
|
||||
}
|
||||
|
||||
@Get('admin/maintenance/status')
|
||||
maintenanceStatus(@Req() request: Request): Promise<MaintenanceStatusResponseDto> {
|
||||
return this.service.status(request.cookies[ImmichCookie.MaintenanceToken]);
|
||||
}
|
||||
|
||||
@Get('admin/maintenance/detect-install')
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return this.service.detectPriorInstall();
|
||||
}
|
||||
|
||||
@Post('admin/maintenance/login')
|
||||
async maintenanceLogin(
|
||||
@Req() request: Request,
|
||||
@@ -35,9 +128,7 @@ export class MaintenanceWorkerController {
|
||||
|
||||
@Post('admin/maintenance')
|
||||
@MaintenanceRoute()
|
||||
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
|
||||
if (dto.action === MaintenanceAction.End) {
|
||||
await this.service.endMaintenance();
|
||||
}
|
||||
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
|
||||
void this.service.setAction(dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,61 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { SignJWT } from 'jose';
|
||||
import { SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
import { automock, getMocks, ServiceMocks } from 'test/utils';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { automock, AutoMocked, getMocks, ServiceMocks } from 'test/utils';
|
||||
|
||||
describe(MaintenanceWorkerService.name, () => {
|
||||
let sut: MaintenanceWorkerService;
|
||||
let mocks: ServiceMocks;
|
||||
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository;
|
||||
let maintenanceWebsocketRepositoryMock: AutoMocked<MaintenanceWebsocketRepository>;
|
||||
let maintenanceHealthRepositoryMock: AutoMocked<MaintenanceHealthRepository>;
|
||||
let databaseBackupServiceMock: AutoMocked<DatabaseBackupService>;
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = getMocks();
|
||||
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false });
|
||||
maintenanceWebsocketRepositoryMock = automock(MaintenanceWebsocketRepository, {
|
||||
args: [mocks.logger],
|
||||
strict: false,
|
||||
});
|
||||
maintenanceHealthRepositoryMock = automock(MaintenanceHealthRepository, {
|
||||
args: [mocks.logger],
|
||||
strict: false,
|
||||
});
|
||||
databaseBackupServiceMock = automock(DatabaseBackupService, {
|
||||
args: [
|
||||
mocks.logger,
|
||||
mocks.storage,
|
||||
mocks.config,
|
||||
mocks.systemMetadata,
|
||||
mocks.process,
|
||||
mocks.database,
|
||||
mocks.cron,
|
||||
mocks.job,
|
||||
maintenanceHealthRepositoryMock,
|
||||
],
|
||||
strict: false,
|
||||
});
|
||||
|
||||
sut = new MaintenanceWorkerService(
|
||||
mocks.logger as never,
|
||||
mocks.app,
|
||||
mocks.config,
|
||||
mocks.systemMetadata as never,
|
||||
maintenanceWorkerRepositoryMock,
|
||||
maintenanceWebsocketRepositoryMock,
|
||||
maintenanceHealthRepositoryMock,
|
||||
mocks.storage as never,
|
||||
mocks.process,
|
||||
mocks.database as never,
|
||||
databaseBackupServiceMock,
|
||||
);
|
||||
|
||||
sut.mock({
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
});
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -27,14 +63,43 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
|
||||
describe('getSystemConfig', () => {
|
||||
it('should respond the server is in maintenance mode', async () => {
|
||||
await expect(sut.getSystemConfig()).resolves.toMatchObject(
|
||||
it('should respond the server is in maintenance mode', () => {
|
||||
expect(sut.getSystemConfig()).toMatchObject(
|
||||
expect.objectContaining({
|
||||
maintenanceMode: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skip('ssr');
|
||||
describe.skip('detectMediaLocation');
|
||||
|
||||
describe('setStatus', () => {
|
||||
it('should broadcast status', () => {
|
||||
sut.setStatus({
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
task: 'abc',
|
||||
error: 'def',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalled();
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledTimes(2);
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: 'start',
|
||||
task: 'abc',
|
||||
error: 'def',
|
||||
});
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'public', {
|
||||
active: true,
|
||||
action: 'start',
|
||||
task: 'abc',
|
||||
error: 'Something went wrong, see logs!',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +107,14 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
|
||||
|
||||
it('should log a valid login URL', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(sut.logSecret()).resolves.toBeUndefined();
|
||||
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
|
||||
|
||||
@@ -63,7 +135,13 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
|
||||
it('should parse cookie properly', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.authenticate({
|
||||
@@ -73,13 +151,102 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
beforeEach(() => {
|
||||
sut.mock({
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
error: 'secret value!',
|
||||
});
|
||||
});
|
||||
|
||||
it('generates private status', async () => {
|
||||
const jwt = await new SignJWT({ _mockValue: true })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime('4h')
|
||||
.sign(new TextEncoder().encode('secret'));
|
||||
|
||||
await expect(sut.status(jwt)).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
error: 'secret value!',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates public status', async () => {
|
||||
await expect(sut.status()).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
error: 'Something went wrong, see logs!',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPriorInstall', () => {
|
||||
it('generate report about prior installation', async () => {
|
||||
mocks.storage.readdir.mockResolvedValue(['.immich', 'file1', 'file2']);
|
||||
mocks.storage.readFile.mockResolvedValue(undefined as never);
|
||||
mocks.storage.overwriteFile.mockRejectedValue(undefined as never);
|
||||
|
||||
await expect(sut.detectPriorInstall()).resolves.toMatchInlineSnapshot(`
|
||||
{
|
||||
"storage": [
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "encoded-video",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "library",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "upload",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "profile",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "thumbs",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
{
|
||||
"files": 2,
|
||||
"folder": "backups",
|
||||
"readable": true,
|
||||
"writable": false,
|
||||
},
|
||||
],
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should fail without token', async () => {
|
||||
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
|
||||
});
|
||||
|
||||
it('should fail with expired JWT', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
const jwt = await new SignJWT({})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
@@ -91,7 +258,13 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
|
||||
it('should succeed with valid JWT', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
const jwt = await new SignJWT({ _mockValue: true })
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
@@ -107,22 +280,114 @@ describe(MaintenanceWorkerService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('endMaintenance', () => {
|
||||
describe.skip('setAction'); // just calls setStatus+runAction
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
|
||||
describe('action: start', () => {
|
||||
it('should not do anything', async () => {
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.Start,
|
||||
});
|
||||
|
||||
expect(mocks.logger.log).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action: end', () => {
|
||||
it('should set maintenance mode', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
|
||||
await expect(sut.endMaintenance()).resolves.toBeUndefined();
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.End,
|
||||
});
|
||||
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
|
||||
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
|
||||
expect(maintenanceWebsocketRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
|
||||
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
|
||||
expect(maintenanceWebsocketRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
|
||||
isMaintenanceMode: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('action: restore database', () => {
|
||||
beforeEach(() => {
|
||||
mocks.database.tryLock.mockResolvedValueOnce(true);
|
||||
});
|
||||
|
||||
it('should update maintenance mode state', async () => {
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'filename',
|
||||
});
|
||||
|
||||
expect(mocks.database.tryLock).toHaveBeenCalled();
|
||||
expect(mocks.logger.log).toHaveBeenCalledWith('Running maintenance action restore_database');
|
||||
|
||||
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret: 'secret',
|
||||
action: {
|
||||
action: 'start',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should defer to database backup service', async () => {
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'development-filename.sql',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith(
|
||||
'MaintenanceStatusV1',
|
||||
expect.any(String),
|
||||
{
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
task: 'ready',
|
||||
progress: expect.any(Number),
|
||||
},
|
||||
);
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenLastCalledWith(
|
||||
'MaintenanceStatusV1',
|
||||
expect.any(String),
|
||||
{
|
||||
active: true,
|
||||
action: 'end',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward errors from database backup service', async () => {
|
||||
databaseBackupServiceMock.restoreDatabaseBackup.mockRejectedValue('Sample error');
|
||||
|
||||
await sut.runAction({
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
restoreBackupFilename: 'development-filename.sql',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'private', {
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
error: 'Sample error',
|
||||
task: 'error',
|
||||
});
|
||||
|
||||
expect(maintenanceWebsocketRepositoryMock.clientSend).toHaveBeenCalledWith('MaintenanceStatusV1', 'public', {
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
error: 'Something went wrong, see logs!',
|
||||
task: 'error',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,33 @@ import { NextFunction, Request, Response } from 'express';
|
||||
import { jwtVerify } from 'jose';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { IncomingHttpHeaders } from 'node:http';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { ImmichCookie, SystemMetadataKey } from 'src/enum';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import {
|
||||
MaintenanceAuthDto,
|
||||
MaintenanceDetectInstallResponseDto,
|
||||
MaintenanceStatusResponseDto,
|
||||
SetMaintenanceModeDto,
|
||||
} from 'src/dtos/maintenance.dto';
|
||||
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
|
||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||
import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { type ApiService as _ApiService } from 'src/services/api.service';
|
||||
import { type BaseService as _BaseService } from 'src/services/base.service';
|
||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||
import { type ServerService as _ServerService } from 'src/services/server.service';
|
||||
import { type VersionService as _VersionService } from 'src/services/version.service';
|
||||
import { MaintenanceModeState } from 'src/types';
|
||||
import { getConfig } from 'src/utils/config';
|
||||
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
|
||||
import { createMaintenanceLoginUrl, detectPriorInstall } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
|
||||
/**
|
||||
@@ -24,16 +38,55 @@ import { getExternalDomain } from 'src/utils/misc';
|
||||
*/
|
||||
@Injectable()
|
||||
export class MaintenanceWorkerService {
|
||||
#secret: string | null = null;
|
||||
#status: MaintenanceStatusResponseDto = {
|
||||
active: true,
|
||||
action: MaintenanceAction.Start,
|
||||
};
|
||||
|
||||
constructor(
|
||||
protected logger: LoggingRepository,
|
||||
private appRepository: AppRepository,
|
||||
private configRepository: ConfigRepository,
|
||||
private systemMetadataRepository: SystemMetadataRepository,
|
||||
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
|
||||
private maintenanceWebsocketRepository: MaintenanceWebsocketRepository,
|
||||
private maintenanceHealthRepository: MaintenanceHealthRepository,
|
||||
private storageRepository: StorageRepository,
|
||||
private processRepository: ProcessRepository,
|
||||
private databaseRepository: DatabaseRepository,
|
||||
private databaseBackupService: DatabaseBackupService,
|
||||
) {
|
||||
this.logger.setContext(this.constructor.name);
|
||||
}
|
||||
|
||||
mock(status: MaintenanceStatusResponseDto) {
|
||||
this.#secret = 'secret';
|
||||
this.#status = status;
|
||||
}
|
||||
|
||||
async init() {
|
||||
const state = (await this.systemMetadataRepository.get(
|
||||
SystemMetadataKey.MaintenanceMode,
|
||||
)) as MaintenanceModeState & { isMaintenanceMode: true };
|
||||
|
||||
this.#secret = state.secret;
|
||||
this.#status = {
|
||||
active: true,
|
||||
action: state.action?.action ?? MaintenanceAction.Start,
|
||||
};
|
||||
|
||||
StorageCore.setMediaLocation(this.detectMediaLocation());
|
||||
|
||||
this.maintenanceWebsocketRepository.setAuthFn(async (client) => this.authenticate(client.request.headers));
|
||||
this.maintenanceWebsocketRepository.setStatusUpdateFn((status) => (this.#status = status));
|
||||
|
||||
await this.logSecret();
|
||||
|
||||
if (state.action) {
|
||||
void this.runAction(state.action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _BaseService.configRepos}
|
||||
*/
|
||||
@@ -55,22 +108,17 @@ export class MaintenanceWorkerService {
|
||||
/**
|
||||
* {@link _ServerService.getSystemConfig}
|
||||
*/
|
||||
async getSystemConfig() {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
|
||||
getSystemConfig() {
|
||||
return {
|
||||
loginPageMessage: config.server.loginPageMessage,
|
||||
trashDays: config.trash.days,
|
||||
userDeleteDelay: config.user.deleteDelay,
|
||||
oauthButtonText: config.oauth.buttonText,
|
||||
isInitialized: true,
|
||||
isOnboarded: true,
|
||||
externalDomain: config.server.externalDomain,
|
||||
publicUsers: config.server.publicUsers,
|
||||
mapDarkStyleUrl: config.map.darkStyle,
|
||||
mapLightStyleUrl: config.map.lightStyle,
|
||||
maintenanceMode: true,
|
||||
};
|
||||
} as ServerConfigDto;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link _VersionService.getVersion}
|
||||
*/
|
||||
getVersion() {
|
||||
return ServerVersionResponseDto.fromSemVer(serverVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +154,70 @@ export class MaintenanceWorkerService {
|
||||
};
|
||||
}
|
||||
|
||||
private async secret(): Promise<string> {
|
||||
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
|
||||
secret: string;
|
||||
};
|
||||
/**
|
||||
* {@link _StorageService.detectMediaLocation}
|
||||
*/
|
||||
detectMediaLocation(): string {
|
||||
const envData = this.configRepository.getEnv();
|
||||
if (envData.storage.mediaLocation) {
|
||||
return envData.storage.mediaLocation;
|
||||
}
|
||||
|
||||
return state.secret;
|
||||
const targets: string[] = [];
|
||||
const candidates = ['/data', '/usr/src/app/upload'];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const exists = this.storageRepository.existsSync(candidate);
|
||||
if (exists) {
|
||||
targets.push(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
if (targets.length === 1) {
|
||||
return targets[0];
|
||||
}
|
||||
|
||||
return '/usr/src/app/upload';
|
||||
}
|
||||
|
||||
private get secret() {
|
||||
if (!this.#secret) {
|
||||
throw new Error('Secret is not initialised yet.');
|
||||
}
|
||||
|
||||
return this.#secret;
|
||||
}
|
||||
|
||||
private get backupRepos() {
|
||||
return {
|
||||
logger: this.logger,
|
||||
storage: this.storageRepository,
|
||||
config: this.configRepository,
|
||||
process: this.processRepository,
|
||||
database: this.databaseRepository,
|
||||
health: this.maintenanceHealthRepository,
|
||||
};
|
||||
}
|
||||
|
||||
private getStatus(): MaintenanceStatusResponseDto {
|
||||
return this.#status;
|
||||
}
|
||||
|
||||
private getPublicStatus(): MaintenanceStatusResponseDto {
|
||||
const state = structuredClone(this.#status);
|
||||
|
||||
if (state.error) {
|
||||
state.error = 'Something went wrong, see logs!';
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
setStatus(status: MaintenanceStatusResponseDto): void {
|
||||
this.#status = status;
|
||||
this.maintenanceWebsocketRepository.serverSend('MaintenanceStatus', status);
|
||||
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'private', status);
|
||||
this.maintenanceWebsocketRepository.clientSend('MaintenanceStatusV1', 'public', this.getPublicStatus());
|
||||
}
|
||||
|
||||
async logSecret(): Promise<void> {
|
||||
@@ -123,7 +229,7 @@ export class MaintenanceWorkerService {
|
||||
{
|
||||
username: 'immich-admin',
|
||||
},
|
||||
await this.secret(),
|
||||
this.secret,
|
||||
);
|
||||
|
||||
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
|
||||
@@ -134,28 +240,115 @@ export class MaintenanceWorkerService {
|
||||
return this.login(jwtToken);
|
||||
}
|
||||
|
||||
async status(potentiallyJwt?: string): Promise<MaintenanceStatusResponseDto> {
|
||||
try {
|
||||
await this.login(potentiallyJwt);
|
||||
return this.getStatus();
|
||||
} catch {
|
||||
return this.getPublicStatus();
|
||||
}
|
||||
}
|
||||
|
||||
detectPriorInstall(): Promise<MaintenanceDetectInstallResponseDto> {
|
||||
return detectPriorInstall(this.storageRepository);
|
||||
}
|
||||
|
||||
async login(jwt?: string): Promise<MaintenanceAuthDto> {
|
||||
if (!jwt) {
|
||||
throw new UnauthorizedException('Missing JWT Token');
|
||||
}
|
||||
|
||||
const secret = await this.secret();
|
||||
|
||||
try {
|
||||
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret));
|
||||
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(this.secret));
|
||||
return result.payload;
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid JWT Token');
|
||||
}
|
||||
}
|
||||
|
||||
async endMaintenance(): Promise<void> {
|
||||
async setAction(action: SetMaintenanceModeDto) {
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: action.action,
|
||||
});
|
||||
|
||||
await this.runAction(action);
|
||||
}
|
||||
|
||||
async runAction(action: SetMaintenanceModeDto) {
|
||||
switch (action.action) {
|
||||
case MaintenanceAction.Start: {
|
||||
return;
|
||||
}
|
||||
case MaintenanceAction.End: {
|
||||
return this.endMaintenance();
|
||||
}
|
||||
case MaintenanceAction.SelectDatabaseRestore: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
|
||||
if (!lock) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Running maintenance action ${action.action}`);
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, {
|
||||
isMaintenanceMode: true,
|
||||
secret: this.secret,
|
||||
action: {
|
||||
action: MaintenanceAction.Start,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
if (!action.restoreBackupFilename) {
|
||||
throw new Error("Expected restoreBackupFilename but it's missing!");
|
||||
}
|
||||
|
||||
await this.restoreBackup(action.restoreBackupFilename);
|
||||
} catch (error) {
|
||||
this.logger.error(`Encountered error running action: ${error}`);
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: action.action,
|
||||
task: 'error',
|
||||
error: '' + error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async restoreBackup(filename: string): Promise<void> {
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
task: 'ready',
|
||||
progress: 0,
|
||||
});
|
||||
|
||||
await this.databaseBackupService.restoreDatabaseBackup(filename, (task, progress) =>
|
||||
this.setStatus({
|
||||
active: true,
|
||||
action: MaintenanceAction.RestoreDatabase,
|
||||
progress,
|
||||
task,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.setAction({
|
||||
action: MaintenanceAction.End,
|
||||
});
|
||||
}
|
||||
|
||||
private async endMaintenance(): Promise<void> {
|
||||
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
|
||||
|
||||
// => corresponds to notification.service.ts#onAppRestart
|
||||
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state);
|
||||
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
|
||||
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
|
||||
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
||||
this.appRepository.exitApp();
|
||||
}
|
||||
}
|
||||
|
||||
+3
-23
@@ -1,37 +1,17 @@
|
||||
import { PluginContext, PluginTriggerType } from 'src/enum';
|
||||
import { JSONSchema } from 'src/types/plugin-schema.types';
|
||||
|
||||
export type PluginTrigger = {
|
||||
name: string;
|
||||
type: PluginTriggerType;
|
||||
description: string;
|
||||
context: PluginContext;
|
||||
schema: JSONSchema | null;
|
||||
contextType: PluginContext;
|
||||
};
|
||||
|
||||
export const pluginTriggers: PluginTrigger[] = [
|
||||
{
|
||||
name: 'Asset Uploaded',
|
||||
type: PluginTriggerType.AssetCreate,
|
||||
description: 'Triggered when a new asset is uploaded',
|
||||
context: PluginContext.Asset,
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
assetType: {
|
||||
type: 'string',
|
||||
description: 'Type of the asset',
|
||||
default: 'ALL',
|
||||
enum: ['Image', 'Video', 'All'],
|
||||
},
|
||||
},
|
||||
},
|
||||
contextType: PluginContext.Asset,
|
||||
},
|
||||
{
|
||||
name: 'Person Recognized',
|
||||
type: PluginTriggerType.PersonRecognized,
|
||||
description: 'Triggered when a person is detected in an asset',
|
||||
context: PluginContext.Person,
|
||||
schema: null,
|
||||
contextType: PluginContext.Person,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -58,7 +58,7 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
@@ -243,7 +243,7 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
@@ -316,7 +316,7 @@ select
|
||||
from
|
||||
(
|
||||
select
|
||||
*
|
||||
"shared_link".*
|
||||
from
|
||||
"shared_link"
|
||||
where
|
||||
|
||||
@@ -17,8 +17,6 @@ set
|
||||
where
|
||||
"userId" = $2
|
||||
and "albumId" = $3
|
||||
returning
|
||||
*
|
||||
|
||||
-- AlbumUserRepository.delete
|
||||
delete from "album_user"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AssetEditRepository.replaceAll
|
||||
begin
|
||||
delete from "asset_edit"
|
||||
where
|
||||
"assetId" = $1
|
||||
rollback
|
||||
|
||||
-- AssetEditRepository.getAll
|
||||
select
|
||||
"id",
|
||||
"action",
|
||||
"parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"assetId" = $1
|
||||
order by
|
||||
"sequence" asc
|
||||
|
||||
-- AssetEditRepository.getWithSyncInfo
|
||||
select
|
||||
"asset_edit"."id",
|
||||
"asset_edit"."assetId",
|
||||
"asset_edit"."sequence",
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"assetId" = $1
|
||||
order by
|
||||
"sequence" asc
|
||||
@@ -29,7 +29,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -37,20 +38,6 @@ select
|
||||
and "asset_file"."type" = $1
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"tag"."value"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"asset"."id" = "tag_asset"."assetId"
|
||||
) as agg
|
||||
) as "tags",
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
@@ -72,7 +59,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -90,32 +78,97 @@ limit
|
||||
-- AssetJobRepository.streamForThumbnailJob
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."thumbhash",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "files"
|
||||
"asset"."isEdited"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_job_status" on "asset_job_status"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" != $1
|
||||
and "asset"."visibility" != 'hidden'
|
||||
and (
|
||||
"asset_job_status"."previewAt" is null
|
||||
or "asset_job_status"."thumbnailAt" is null
|
||||
not exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "type" = 'thumbnail'
|
||||
)
|
||||
or not exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "type" = 'preview'
|
||||
)
|
||||
or (
|
||||
"asset"."isEdited" = true
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "type" = 'fullsize'
|
||||
and "asset_file"."isEdited" = true
|
||||
)
|
||||
)
|
||||
or "asset"."thumbhash" is null
|
||||
or (
|
||||
not exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "type" = 'fullsize'
|
||||
)
|
||||
and f_unaccent (asset."originalFileName") like any (
|
||||
array[
|
||||
'%.3fr',
|
||||
'%.ari',
|
||||
'%.arw',
|
||||
'%.cap',
|
||||
'%.cin',
|
||||
'%.cr2',
|
||||
'%.cr3',
|
||||
'%.crw',
|
||||
'%.dcr',
|
||||
'%.dng',
|
||||
'%.erf',
|
||||
'%.fff',
|
||||
'%.iiq',
|
||||
'%.k25',
|
||||
'%.kdc',
|
||||
'%.mrw',
|
||||
'%.nef',
|
||||
'%.nrw',
|
||||
'%.orf',
|
||||
'%.ori',
|
||||
'%.pef',
|
||||
'%.psd',
|
||||
'%.raf',
|
||||
'%.raw',
|
||||
'%.rw2',
|
||||
'%.rwl',
|
||||
'%.sr2',
|
||||
'%.srf',
|
||||
'%.srw',
|
||||
'%.x3f',
|
||||
'%.heic',
|
||||
'%.heif',
|
||||
'%.hif',
|
||||
'%.insp',
|
||||
'%.jp2',
|
||||
'%.jpe',
|
||||
'%.jxl',
|
||||
'%.svg',
|
||||
'%.tif',
|
||||
'%.tiff'
|
||||
]::text[]
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
-- AssetJobRepository.getForMigrationJob
|
||||
@@ -131,7 +184,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -160,19 +214,37 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" in ($1, $2, $3)
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits",
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
"asset"."id" = $4
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
@@ -191,6 +263,8 @@ select
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -203,6 +277,7 @@ select
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $1
|
||||
) as agg
|
||||
) as "faces",
|
||||
(
|
||||
@@ -213,18 +288,19 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
and "asset_file"."type" = $2
|
||||
) as agg
|
||||
) as "files"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
"asset"."id" = $3
|
||||
|
||||
-- AssetJobRepository.getLockedPropertiesForMetadataExtraction
|
||||
select
|
||||
@@ -238,7 +314,8 @@ where
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -266,7 +343,14 @@ from
|
||||
where
|
||||
"asset"."visibility" != $1
|
||||
and "asset"."deletedAt" is null
|
||||
and "job_status"."previewAt" is not null
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $2
|
||||
)
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
@@ -287,7 +371,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -326,7 +411,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -381,29 +467,6 @@ select
|
||||
"asset"."originalPath",
|
||||
"asset"."isOffline",
|
||||
to_json("asset_exif") as "exifInfo",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_face".*,
|
||||
"person" as "person"
|
||||
from
|
||||
"asset_face"
|
||||
left join lateral (
|
||||
select
|
||||
"person".*
|
||||
from
|
||||
"person"
|
||||
where
|
||||
"asset_face"."personId" = "person"."id"
|
||||
) as "person" on true
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
) as agg
|
||||
) as "faces",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -412,34 +475,45 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "files",
|
||||
to_json("stacked_assets") as "stack"
|
||||
to_json("stack_result") as "stack"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
left join "stack" on "stack"."id" = "asset"."stackId"
|
||||
left join lateral (
|
||||
select
|
||||
"stack"."id",
|
||||
"stack"."primaryAssetId",
|
||||
array_agg("stacked") as "assets"
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"stack_asset"."id"
|
||||
from
|
||||
"asset" as "stack_asset"
|
||||
where
|
||||
"stack_asset"."stackId" = "stack"."id"
|
||||
and "stack_asset"."id" != "stack"."primaryAssetId"
|
||||
and "stack_asset"."visibility" = $1
|
||||
and "stack_asset"."status" != $2
|
||||
) as agg
|
||||
) as "assets"
|
||||
from
|
||||
"asset" as "stacked"
|
||||
"stack"
|
||||
where
|
||||
"stacked"."deletedAt" is not null
|
||||
and "stacked"."visibility" = $1
|
||||
and "stacked"."stackId" = "stack"."id"
|
||||
group by
|
||||
"stack"."id"
|
||||
) as "stacked_assets" on "stack"."id" is not null
|
||||
"stack"."id" = "asset"."stackId"
|
||||
) as "stack_result" on true
|
||||
where
|
||||
"asset"."id" = $2
|
||||
"asset"."id" = $3
|
||||
|
||||
-- AssetJobRepository.streamForVideoConversion
|
||||
select
|
||||
@@ -488,11 +562,15 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."originalPath",
|
||||
"asset"."isExternal",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset_exif"."timeZone",
|
||||
"asset_exif"."fileSizeInByte",
|
||||
"asset_exif"."make",
|
||||
"asset_exif"."model",
|
||||
"asset_exif"."lensModel",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -501,7 +579,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -515,6 +594,7 @@ from
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."id" = $2
|
||||
and "asset"."visibility" != $3
|
||||
|
||||
-- AssetJobRepository.streamForStorageTemplateJob
|
||||
select
|
||||
@@ -524,11 +604,15 @@ select
|
||||
"asset"."checksum",
|
||||
"asset"."originalPath",
|
||||
"asset"."isExternal",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."fileCreatedAt",
|
||||
"asset_exif"."timeZone",
|
||||
"asset_exif"."fileSizeInByte",
|
||||
"asset_exif"."make",
|
||||
"asset_exif"."model",
|
||||
"asset_exif"."lensModel",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -537,7 +621,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -550,6 +635,7 @@ from
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."deletedAt" is null
|
||||
and "asset"."visibility" != $2
|
||||
|
||||
-- AssetJobRepository.streamForDeletedJob
|
||||
select
|
||||
@@ -585,7 +671,14 @@ from
|
||||
where
|
||||
"asset"."visibility" != $1
|
||||
and "asset"."deletedAt" is null
|
||||
and "job_status"."previewAt" is not null
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $2
|
||||
)
|
||||
order by
|
||||
"asset"."fileCreatedAt" desc
|
||||
|
||||
|
||||
@@ -49,6 +49,23 @@ returning
|
||||
"dateTimeOriginal",
|
||||
"timeZone"
|
||||
|
||||
-- AssetRepository.unlockProperties
|
||||
update "asset_exif"
|
||||
set
|
||||
"lockedProperties" = nullif(
|
||||
array(
|
||||
select distinct
|
||||
property
|
||||
from
|
||||
unnest("asset_exif"."lockedProperties") property
|
||||
where
|
||||
not property = any ($1)
|
||||
),
|
||||
'{}'
|
||||
)
|
||||
where
|
||||
"assetId" = $2
|
||||
|
||||
-- AssetRepository.getMetadata
|
||||
select
|
||||
"key",
|
||||
@@ -76,6 +93,14 @@ where
|
||||
"assetId" = $1
|
||||
and "key" = $2
|
||||
|
||||
-- AssetRepository.deleteBulkMetadata
|
||||
begin
|
||||
delete from "asset_metadata"
|
||||
where
|
||||
"assetId" = $1
|
||||
and "key" = $2
|
||||
commit
|
||||
|
||||
-- AssetRepository.getByDayOfYear
|
||||
with
|
||||
"res" as (
|
||||
@@ -98,19 +123,18 @@ 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"
|
||||
where
|
||||
"asset_job_status"."previewAt" is not null
|
||||
and (asset."localDateTime" at time zone 'UTC')::date = today.date
|
||||
(asset."localDateTime" at time zone 'UTC')::date = today.date
|
||||
and "asset"."ownerId" = any ($4::uuid[])
|
||||
and "asset"."visibility" = $5
|
||||
and exists (
|
||||
@@ -127,7 +151,6 @@ with
|
||||
limit
|
||||
$7
|
||||
) as "a" on true
|
||||
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
|
||||
)
|
||||
select
|
||||
date_part(
|
||||
@@ -174,6 +197,7 @@ select
|
||||
where
|
||||
"asset_face"."assetId" = "asset"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
) as agg
|
||||
) as "faces",
|
||||
(
|
||||
@@ -260,7 +284,8 @@ select
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type"
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -375,14 +400,10 @@ with
|
||||
"asset_exif"."projectionType",
|
||||
coalesce(
|
||||
case
|
||||
when asset_exif."exifImageHeight" = 0
|
||||
or asset_exif."exifImageWidth" = 0 then 1
|
||||
when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
|
||||
asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric,
|
||||
3
|
||||
)
|
||||
when asset."height" = 0
|
||||
or asset."width" = 0 then 1
|
||||
else round(
|
||||
asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric,
|
||||
asset."width"::numeric / asset."height"::numeric,
|
||||
3
|
||||
)
|
||||
end,
|
||||
@@ -562,3 +583,150 @@ where
|
||||
and "libraryId" = $2::uuid
|
||||
and "isExternal" = $3
|
||||
)
|
||||
|
||||
-- AssetRepository.getForOriginal
|
||||
select
|
||||
"asset"."id",
|
||||
"originalFileName",
|
||||
"asset_file"."path" as "editedPath",
|
||||
"originalPath"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."isEdited" = $1
|
||||
and "asset_file"."type" = $2
|
||||
where
|
||||
"asset"."id" in ($3)
|
||||
|
||||
-- AssetRepository.getForOriginals
|
||||
select
|
||||
"asset"."id",
|
||||
"originalFileName",
|
||||
"asset_file"."path" as "editedPath",
|
||||
"originalPath"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."isEdited" = $1
|
||||
and "asset_file"."type" = $2
|
||||
where
|
||||
"asset"."id" in ($3)
|
||||
|
||||
-- AssetRepository.getForThumbnail
|
||||
select
|
||||
"asset"."originalPath",
|
||||
"asset"."originalFileName",
|
||||
"asset_file"."path" as "path"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."type" = $1
|
||||
where
|
||||
"asset"."id" = $2
|
||||
order by
|
||||
"asset_file"."isEdited" desc
|
||||
|
||||
-- AssetRepository.getForVideo
|
||||
select
|
||||
"asset"."encodedVideoPath",
|
||||
"asset"."originalPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = $2
|
||||
|
||||
-- AssetRepository.getForOcr
|
||||
select
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."orientation"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetRepository.getForEdit
|
||||
select
|
||||
"asset"."type",
|
||||
"asset"."livePhotoVideoId",
|
||||
"asset"."originalPath",
|
||||
"asset"."originalFileName",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."orientation",
|
||||
"asset_exif"."projectionType"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetRepository.getForMetadataExtractionTags
|
||||
select
|
||||
"asset_exif"."tags"
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
"asset_exif"."assetId" = $1
|
||||
|
||||
-- AssetRepository.getForFaces
|
||||
select
|
||||
"asset_exif"."exifImageHeight",
|
||||
"asset_exif"."exifImageWidth",
|
||||
"asset_exif"."orientation",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
-- AssetRepository.getForUpdateTags
|
||||
select
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"tag"."value"
|
||||
from
|
||||
"tag"
|
||||
inner join "tag_asset" on "tag"."id" = "tag_asset"."tagId"
|
||||
where
|
||||
"asset"."id" = "tag_asset"."assetId"
|
||||
) as agg
|
||||
) as "tags"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
|
||||
@@ -15,6 +15,7 @@ from
|
||||
"asset_ocr"
|
||||
where
|
||||
"asset_ocr"."assetId" = $1
|
||||
and "asset_ocr"."isVisible" = $2
|
||||
|
||||
-- OcrRepository.upsert
|
||||
with
|
||||
@@ -66,3 +67,12 @@ with
|
||||
)
|
||||
select
|
||||
1 as "dummy"
|
||||
|
||||
-- OcrRepository.updateOcrVisibilities
|
||||
begin
|
||||
update "ocr_search"
|
||||
set
|
||||
"text" = $1
|
||||
where
|
||||
"assetId" = $2
|
||||
commit
|
||||
|
||||
@@ -35,6 +35,7 @@ from
|
||||
where
|
||||
"person"."ownerId" = $1
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
and "person"."isHidden" = $2
|
||||
group by
|
||||
"person"."id"
|
||||
@@ -63,6 +64,7 @@ from
|
||||
left join "asset_face" on "asset_face"."personId" = "person"."id"
|
||||
where
|
||||
"asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
group by
|
||||
"person"."id"
|
||||
having
|
||||
@@ -89,6 +91,7 @@ from
|
||||
where
|
||||
"asset_face"."assetId" = $1
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $2
|
||||
order by
|
||||
"asset_face"."boundingBoxX1" asc
|
||||
|
||||
@@ -173,6 +176,7 @@ select
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = 'preview'
|
||||
and "asset_file"."isEdited" = $1
|
||||
) as "previewPath"
|
||||
from
|
||||
"person"
|
||||
@@ -180,7 +184,7 @@ from
|
||||
inner join "asset" on "asset_face"."assetId" = "asset"."id"
|
||||
left join "asset_exif" on "asset_exif"."assetId" = "asset"."id"
|
||||
where
|
||||
"person"."id" = $1
|
||||
"person"."id" = $2
|
||||
and "asset_face"."deletedAt" is null
|
||||
|
||||
-- PersonRepository.reassignFace
|
||||
@@ -229,6 +233,7 @@ from
|
||||
and "asset"."deletedAt" is null
|
||||
where
|
||||
"asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
|
||||
-- PersonRepository.getNumberOfPeople
|
||||
select
|
||||
@@ -250,6 +255,7 @@ where
|
||||
where
|
||||
"asset_face"."personId" = "person"."id"
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" = $2
|
||||
and exists (
|
||||
select
|
||||
from
|
||||
@@ -260,7 +266,7 @@ where
|
||||
and "asset"."deletedAt" is null
|
||||
)
|
||||
)
|
||||
and "person"."ownerId" = $2
|
||||
and "person"."ownerId" = $3
|
||||
|
||||
-- PersonRepository.refreshFaces
|
||||
with
|
||||
@@ -280,19 +286,6 @@ from
|
||||
-- PersonRepository.getFacesByIds
|
||||
select
|
||||
"asset_face".*,
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset".*
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = "asset_face"."assetId"
|
||||
) as obj
|
||||
) as "asset",
|
||||
(
|
||||
select
|
||||
to_json(obj)
|
||||
@@ -321,6 +314,7 @@ from
|
||||
where
|
||||
"asset_face"."personId" = $1
|
||||
and "asset_face"."deletedAt" is null
|
||||
and "asset_face"."isVisible" is true
|
||||
|
||||
-- PersonRepository.getLatestFaceDate
|
||||
select
|
||||
@@ -348,3 +342,14 @@ from
|
||||
"person"
|
||||
where
|
||||
"id" in ($1)
|
||||
|
||||
-- PersonRepository.getForFeatureFaceUpdate
|
||||
select
|
||||
"asset_face"."id"
|
||||
from
|
||||
"asset_face"
|
||||
inner join "asset" on "asset"."id" = "asset_face"."assetId"
|
||||
and "asset"."isOffline" = $1
|
||||
where
|
||||
"asset_face"."assetId" = $2
|
||||
and "asset_face"."personId" = $3
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user