Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
22.18.0
|
||||
22.20.0
|
||||
|
||||
+9
-91
@@ -1,83 +1,4 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
|
||||
RUN npm install --global corepack@latest && \
|
||||
corepack enable pnpm && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc
|
||||
|
||||
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" \
|
||||
IMMICH_ENV=development \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
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-11-jre-headless \
|
||||
vim nano \
|
||||
-y --no-install-recommends --fix-missing
|
||||
|
||||
RUN usermod -aG sudo node && \
|
||||
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
mkdir -p /workspaces/immich
|
||||
|
||||
RUN chown node:node -R /workspaces
|
||||
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
|
||||
WORKDIR /workspaces/immich
|
||||
|
||||
FROM dev-container-server AS dev-container-mobile
|
||||
USER root
|
||||
# Enable multiarch for arm64 if necessary
|
||||
RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
|
||||
dpkg --add-architecture amd64 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
qemu-user-static \
|
||||
libc6:amd64 \
|
||||
libstdc++6:amd64 \
|
||||
libgcc1:amd64; \
|
||||
fi
|
||||
|
||||
# Flutter SDK
|
||||
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
|
||||
ENV FLUTTER_CHANNEL="stable"
|
||||
ENV FLUTTER_VERSION="3.32.8"
|
||||
ENV FLUTTER_HOME=/flutter
|
||||
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
|
||||
|
||||
# Flutter SDK
|
||||
RUN mkdir -p ${FLUTTER_HOME} \
|
||||
&& curl -C - --output flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz \
|
||||
&& tar -xf flutter.tar.xz --strip-components=1 -C ${FLUTTER_HOME} \
|
||||
&& rm flutter.tar.xz \
|
||||
&& chown -R node ${FLUTTER_HOME}
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
&& wget -qO- https://dcm.dev/pgp-key.public | gpg --dearmor -o /usr/share/keyrings/dcm.gpg \
|
||||
&& echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | tee /etc/apt/sources.list.d/dart_stable.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install dcm -y
|
||||
|
||||
RUN dart --disable-analytics
|
||||
|
||||
# production-builder-base image
|
||||
FROM ghcr.io/immich-app/base-server-dev:202508191104@sha256:0608857ef682099c458f0fb319afdcaf09462bbb5670b6dcd3642029f12eee1c AS prod-builder-base
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
@@ -85,8 +6,7 @@ ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
RUN npm install --global corepack@latest && \
|
||||
corepack enable pnpm
|
||||
|
||||
# server production build
|
||||
FROM prod-builder-base AS server-prod
|
||||
FROM builder AS server
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./package* ./pnpm* .pnpmfile.cjs ./
|
||||
@@ -94,8 +14,7 @@ COPY ./server ./server/
|
||||
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile build && \
|
||||
SHARP_FORCE_GLOBAL_LIBVIPS=true pnpm --filter immich --frozen-lockfile --prod --no-optional deploy /output/server-pruned
|
||||
|
||||
# web production build
|
||||
FROM prod-builder-base AS web-prod
|
||||
FROM builder AS web
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY ./package* ./pnpm* .pnpmfile.cjs ./
|
||||
@@ -105,7 +24,7 @@ COPY ./open-api ./open-api/
|
||||
RUN SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter @immich/sdk --filter immich-web --frozen-lockfile --force install && \
|
||||
pnpm --filter @immich/sdk --filter immich-web build
|
||||
|
||||
FROM prod-builder-base AS cli-prod
|
||||
FROM builder AS cli
|
||||
|
||||
COPY ./package* ./pnpm* .pnpmfile.cjs ./
|
||||
COPY ./cli ./cli/
|
||||
@@ -114,18 +33,17 @@ RUN pnpm --filter @immich/sdk --filter @immich/cli --frozen-lockfile install &&
|
||||
pnpm --filter @immich/sdk --filter @immich/cli build && \
|
||||
pnpm --filter @immich/cli --prod --no-optional deploy /output/cli-pruned
|
||||
|
||||
# prod base image
|
||||
FROM ghcr.io/immich-app/base-server-prod:202508191104@sha256:4cce4119f5555fce5e383b681e4feea31956ceadb94cafcbcbbae2c7b94a1b62
|
||||
FROM ghcr.io/immich-app/base-server-prod:202509210934@sha256:0c7eacf0ba88ca52e1a267cfc62d20d07792ea2c604818c2cbd37dc7dcefdac9
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
COPY --from=server-prod /output/server-pruned ./server
|
||||
COPY --from=web-prod /usr/src/app/web/build /build/www
|
||||
COPY --from=cli-prod /output/cli-pruned ./cli
|
||||
RUN ln -s ./cli/bin/immich server/bin/immich
|
||||
COPY --from=server /output/server-pruned ./server
|
||||
COPY --from=web /usr/src/app/web/build /build/www
|
||||
COPY --from=cli /output/cli-pruned ./cli
|
||||
RUN ln -s ../../cli/bin/immich server/bin/immich
|
||||
COPY LICENSE /licenses/LICENSE.txt
|
||||
COPY LICENSE /LICENSE
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202509210934@sha256:b5ce2d7eaf379d4cf15efd4bab180d8afc8a80d20b36c9800f4091aca6ae267e AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
|
||||
RUN npm install --global corepack@latest && \
|
||||
corepack enable pnpm && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc
|
||||
|
||||
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" \
|
||||
IMMICH_ENV=development \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
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-11-jre-headless \
|
||||
vim nano \
|
||||
-y --no-install-recommends --fix-missing
|
||||
|
||||
RUN usermod -aG sudo node && \
|
||||
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
|
||||
mkdir -p /workspaces/immich
|
||||
|
||||
RUN chown node:node -R /workspaces
|
||||
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
|
||||
|
||||
WORKDIR /workspaces/immich
|
||||
|
||||
FROM dev-container-server AS dev-container-mobile
|
||||
USER root
|
||||
# Enable multiarch for arm64 if necessary
|
||||
RUN if [ "$(dpkg --print-architecture)" = "arm64" ]; then \
|
||||
dpkg --add-architecture amd64 && \
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
qemu-user-static \
|
||||
libc6:amd64 \
|
||||
libstdc++6:amd64 \
|
||||
libgcc1:amd64; \
|
||||
fi
|
||||
|
||||
# Flutter SDK
|
||||
# https://flutter.dev/docs/development/tools/sdk/releases?tab=linux
|
||||
ENV FLUTTER_CHANNEL="stable"
|
||||
ENV FLUTTER_VERSION="3.35.4"
|
||||
ENV FLUTTER_HOME=/flutter
|
||||
ENV PATH=${PATH}:${FLUTTER_HOME}/bin
|
||||
|
||||
# Flutter SDK
|
||||
RUN mkdir -p ${FLUTTER_HOME} \
|
||||
&& curl -C - --output flutter.tar.xz https://storage.googleapis.com/flutter_infra_release/releases/${FLUTTER_CHANNEL}/linux/flutter_linux_${FLUTTER_VERSION}-${FLUTTER_CHANNEL}.tar.xz \
|
||||
&& tar -xf flutter.tar.xz --strip-components=1 -C ${FLUTTER_HOME} \
|
||||
&& rm flutter.tar.xz \
|
||||
&& chown -R node ${FLUTTER_HOME}
|
||||
|
||||
|
||||
RUN apt-get update \
|
||||
&& wget -qO- https://dcm.dev/pgp-key.public | gpg --dearmor -o /usr/share/keyrings/dcm.gpg \
|
||||
&& echo 'deb [signed-by=/usr/share/keyrings/dcm.gpg arch=amd64] https://dcm.dev/debian stable main' | tee /etc/apt/sources.list.d/dart_stable.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install dcm -y
|
||||
|
||||
RUN dart --disable-analytics
|
||||
+8
-7
@@ -1,14 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
echo "Initializing Immich $IMMICH_SOURCE_REF"
|
||||
|
||||
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
|
||||
if [ -f "$lib_path" ]; then
|
||||
export LD_PRELOAD="$lib_path"
|
||||
else
|
||||
echo "skipping libmimalloc - path not found $lib_path"
|
||||
fi
|
||||
# TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed
|
||||
# lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
|
||||
# if [ -f "$lib_path" ]; then
|
||||
# export LD_PRELOAD="$lib_path"
|
||||
# else
|
||||
# echo "skipping libmimalloc - path not found $lib_path"
|
||||
# fi
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
|
||||
SERVER_HOME=/usr/src/app/server
|
||||
SERVER_HOME="$(readlink -f "$(dirname "$0")/..")"
|
||||
|
||||
read_file_and_export() {
|
||||
fname="${!1}"
|
||||
|
||||
+12
-24
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.139.4",
|
||||
"version": "2.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -37,23 +37,21 @@
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
"@nestjs/event-emitter": "^3.0.0",
|
||||
"@nestjs/platform-express": "^11.0.4",
|
||||
"@nestjs/platform-socket.io": "^11.0.4",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "^11.0.2",
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.62.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.203.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.203.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.51.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.49.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.56.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.205.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.205.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.53.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.51.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.58.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.203.0",
|
||||
"@opentelemetry/sdk-node": "^0.205.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
@@ -103,25 +101,21 @@
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "^2.14.0",
|
||||
"semver": "^7.6.2",
|
||||
"sharp": "^0.34.2",
|
||||
"sharp": "^0.34.3",
|
||||
"sirv": "^3.0.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"tailwindcss-preset-email": "^1.4.0",
|
||||
"thumbhash": "^0.1.1",
|
||||
"typeorm": "^0.3.17",
|
||||
"ua-parser-js": "^2.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"validator": "^13.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@nestjs/cli": "^11.0.2",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.4",
|
||||
"@swc/core": "^1.4.14",
|
||||
"@testcontainers/postgresql": "^11.0.0",
|
||||
"@testcontainers/redis": "^11.0.0",
|
||||
"@types/archiver": "^6.0.0",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -135,8 +129,8 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/node": "^22.18.1",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^19.0.0",
|
||||
@@ -146,36 +140,30 @@
|
||||
"@types/ua-parser-js": "^0.7.36",
|
||||
"@types/validator": "^13.15.2",
|
||||
"@vitest/coverage-v8": "^3.0.0",
|
||||
"canvas": "^3.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-unicorn": "^60.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"node-addon-api": "^8.3.1",
|
||||
"node-gyp": "^11.2.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.0.2",
|
||||
"prettier-plugin-organize-imports": "^4.0.0",
|
||||
"rimraf": "^6.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql-formatter": "^15.0.0",
|
||||
"supertest": "^7.1.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"testcontainers": "^11.0.0",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"unplugin-swc": "^1.4.5",
|
||||
"utimes": "^5.2.1",
|
||||
"vite-tsconfig-paths": "^5.0.0",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.18.0"
|
||||
"node": "22.20.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.34.2"
|
||||
"sharp": "^0.34.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { repositories } from 'src/repositories';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
|
||||
import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
@@ -57,7 +58,7 @@ class SqlGenerator {
|
||||
try {
|
||||
await this.setup();
|
||||
for (const Repository of repositories) {
|
||||
if (Repository === LoggingRepository) {
|
||||
if (Repository === LoggingRepository || Repository === MachineLearningRepository) {
|
||||
continue;
|
||||
}
|
||||
await this.process(Repository);
|
||||
|
||||
+13
-1
@@ -54,6 +54,11 @@ export interface SystemConfig {
|
||||
machineLearning: {
|
||||
enabled: boolean;
|
||||
urls: string[];
|
||||
availabilityChecks: {
|
||||
enabled: boolean;
|
||||
timeout: number;
|
||||
interval: number;
|
||||
};
|
||||
clip: {
|
||||
enabled: boolean;
|
||||
modelName: string;
|
||||
@@ -176,6 +181,8 @@ export interface SystemConfig {
|
||||
};
|
||||
}
|
||||
|
||||
export type MachineLearningConfig = SystemConfig['machineLearning'];
|
||||
|
||||
export const defaults = Object.freeze<SystemConfig>({
|
||||
backup: {
|
||||
database: {
|
||||
@@ -191,7 +198,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
targetAudioCodec: AudioCodec.Aac,
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus, AudioCodec.PcmS16le],
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||
acceptedContainers: [VideoContainer.Mov, VideoContainer.Ogg, VideoContainer.Webm],
|
||||
targetResolution: '720',
|
||||
maxBitrate: '0',
|
||||
@@ -227,6 +234,11 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
machineLearning: {
|
||||
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
|
||||
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
|
||||
availabilityChecks: {
|
||||
enabled: true,
|
||||
timeout: Number(process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT) || 2000,
|
||||
interval: 30_000,
|
||||
},
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
|
||||
@@ -51,11 +51,6 @@ export const serverVersion = new SemVer(version);
|
||||
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
|
||||
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
|
||||
|
||||
export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000);
|
||||
export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number(
|
||||
process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000,
|
||||
);
|
||||
|
||||
export const citiesFile = 'cities500.txt';
|
||||
export const reverseGeocodeMaxDistance = 25_000;
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import request from 'supertest';
|
||||
@@ -11,7 +13,7 @@ const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
|
||||
deviceId: 'TEST',
|
||||
fileCreatedAt: new Date().toISOString(),
|
||||
fileModifiedAt: new Date().toISOString(),
|
||||
isFavorite: 'testing',
|
||||
isFavorite: 'false',
|
||||
duration: '0:00:00.000000',
|
||||
};
|
||||
|
||||
@@ -27,16 +29,20 @@ describe(AssetMediaController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const assetData = Buffer.from('123');
|
||||
const filename = 'example.png';
|
||||
const service = mockBaseService(AssetMediaService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AssetMediaController, [
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
{ provide: AssetMediaService, useValue: mockBaseService(AssetMediaService) },
|
||||
{ provide: AssetMediaService, useValue: service },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
service.uploadAsset.mockResolvedValue({ status: AssetMediaStatus.DUPLICATE, id: factory.uuid() });
|
||||
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
@@ -46,13 +52,61 @@ describe(AssetMediaController.name, () => {
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept metadata', async () => {
|
||||
const mobileMetadata = { key: AssetMetadataKey.MobileApp, value: { iCloudId: '123' } };
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({
|
||||
...makeUploadDto(),
|
||||
metadata: JSON.stringify([mobileMetadata]),
|
||||
});
|
||||
|
||||
expect(service.uploadAsset).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({ metadata: [mobileMetadata] }),
|
||||
expect.objectContaining({ originalName: 'example.png' }),
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should handle invalid metadata json', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({
|
||||
...makeUploadDto(),
|
||||
metadata: 'not-a-string-string',
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['metadata must be valid JSON']));
|
||||
});
|
||||
|
||||
it('should validate iCloudId is a string', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({
|
||||
...makeUploadDto(),
|
||||
metadata: JSON.stringify([{ key: AssetMetadataKey.MobileApp, value: { iCloudId: 123 } }]),
|
||||
});
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['metadata.0.value.iCloudId must be a string']));
|
||||
});
|
||||
|
||||
it('should require `deviceAssetId`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['deviceAssetId must be a string', 'deviceAssetId should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require `deviceId`', async () => {
|
||||
@@ -61,7 +115,7 @@ describe(AssetMediaController.name, () => {
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(factory.responses.badRequest(['deviceId must be a string', 'deviceId should not be empty']));
|
||||
});
|
||||
|
||||
it('should require `fileCreatedAt`', async () => {
|
||||
@@ -70,25 +124,20 @@ describe(AssetMediaController.name, () => {
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'fileCreatedAt' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['fileCreatedAt must be a Date instance', 'fileCreatedAt should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require `fileModifiedAt`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'fileModifiedAt' }) });
|
||||
.field(makeUploadDto({ omit: 'fileModifiedAt' }));
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
});
|
||||
|
||||
it('should require `duration`', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto({ omit: 'duration' }) });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(['fileModifiedAt must be a Date instance', 'fileModifiedAt should not be empty']),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw if `isFavorite` is not a boolean', async () => {
|
||||
@@ -97,16 +146,18 @@ describe(AssetMediaController.name, () => {
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto(), isFavorite: 'not-a-boolean' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(factory.responses.badRequest(['isFavorite must be a boolean value']));
|
||||
});
|
||||
|
||||
it('should throw if `visibility` is not an enum', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post('/assets')
|
||||
.attach('assetData', assetData, filename)
|
||||
.field({ ...makeUploadDto(), visibility: 'not-a-boolean' });
|
||||
.field({ ...makeUploadDto(), visibility: 'not-an-option' });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest());
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest([expect.stringContaining('visibility must be one of the following values:')]),
|
||||
);
|
||||
});
|
||||
|
||||
// TODO figure out how to deal with `sendFile`
|
||||
|
||||
@@ -96,8 +96,9 @@ export class AssetMediaController {
|
||||
@Put(':id/original')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@EndpointLifecycle({ addedAt: 'v1.106.0' })
|
||||
@ApiOperation({
|
||||
@EndpointLifecycle({
|
||||
addedAt: 'v1.106.0',
|
||||
deprecatedAt: 'v1.142.0',
|
||||
summary: 'replaceAsset',
|
||||
description: 'Replace the asset with new file, without changing its id',
|
||||
})
|
||||
@@ -188,7 +189,7 @@ export class AssetMediaController {
|
||||
* Checks if assets exist by checksums
|
||||
*/
|
||||
@Post('bulk-upload-check')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: Permission.AssetUpload })
|
||||
@ApiOperation({
|
||||
summary: 'checkBulkUpload',
|
||||
description: 'Checks if assets exist by checksums',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import request from 'supertest';
|
||||
import { factory } from 'test/small.factory';
|
||||
@@ -6,14 +7,16 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'
|
||||
|
||||
describe(AssetController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(AssetService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: mockBaseService(AssetService) }]);
|
||||
ctx = await controllerSetup(AssetController, [{ provide: AssetService, useValue: service }]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
ctx.reset();
|
||||
service.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('PUT /assets', () => {
|
||||
@@ -115,4 +118,120 @@ describe(AssetController.name, () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:id/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /assets/:id/metadata', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({ items: [] });
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/123/metadata`).send({ items: [] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
it('should require items to be an array', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).put(`/assets/${factory.uuid()}/metadata`).send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(['items must be an array']));
|
||||
});
|
||||
|
||||
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' }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(
|
||||
expect.arrayContaining([expect.stringContaining('items.0.key must be one of the following values')]),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should require each item to have a value', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${factory.uuid()}/metadata`)
|
||||
.send({ items: [{ key: 'mobile-app', value: null }] });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
factory.responses.badRequest(expect.arrayContaining([expect.stringContaining('value must be an object')])),
|
||||
);
|
||||
});
|
||||
|
||||
describe(AssetMetadataKey.MobileApp, () => {
|
||||
it('should accept valid data and pass to service correctly', async () => {
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${assetId}/metadata`)
|
||||
.send({ items: [{ key: 'mobile-app', value: { iCloudId: '123' } }] });
|
||||
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
|
||||
items: [{ key: 'mobile-app', value: { iCloudId: '123' } }],
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
|
||||
it('should work without iCloudId', async () => {
|
||||
const assetId = factory.uuid();
|
||||
const { status } = await request(ctx.getHttpServer())
|
||||
.put(`/assets/${assetId}/metadata`)
|
||||
.send({ items: [{ key: 'mobile-app', value: {} }] });
|
||||
expect(service.upsertMetadata).toHaveBeenCalledWith(undefined, assetId, {
|
||||
items: [{ key: 'mobile-app', value: {} }],
|
||||
});
|
||||
expect(status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /assets/:id/metadata/:key', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/assets/123/metadata/mobile-app`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(factory.responses.badRequest(expect.arrayContaining(['id must be a UUID'])));
|
||||
});
|
||||
|
||||
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')]),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /assets/:id/metadata/:key', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/assets/${factory.uuid()}/metadata/mobile-app`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a valid id', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).delete(`/assets/123/metadata/mobile-app`);
|
||||
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')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
AssetJobsDto,
|
||||
AssetMetadataResponseDto,
|
||||
AssetMetadataRouteParams,
|
||||
AssetMetadataUpsertDto,
|
||||
AssetStatsDto,
|
||||
AssetStatsResponseDto,
|
||||
DeviceIdDto,
|
||||
@@ -85,4 +88,36 @@ export class AssetController {
|
||||
): Promise<AssetResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/metadata')
|
||||
@Authenticated({ permission: Permission.AssetRead })
|
||||
getAssetMetadata(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AssetMetadataResponseDto[]> {
|
||||
return this.service.getMetadata(auth, id);
|
||||
}
|
||||
|
||||
@Put(':id/metadata')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
updateAssetMetadata(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: AssetMetadataUpsertDto,
|
||||
): Promise<AssetMetadataResponseDto[]> {
|
||||
return this.service.upsertMetadata(auth, id, dto);
|
||||
}
|
||||
|
||||
@Get(':id/metadata/:key')
|
||||
@Authenticated({ permission: Permission.AssetRead })
|
||||
getAssetMetadataByKey(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id, key }: AssetMetadataRouteParams,
|
||||
): Promise<AssetMetadataResponseDto> {
|
||||
return this.service.getMetadataByKey(auth, id, key);
|
||||
}
|
||||
|
||||
@Delete(':id/metadata/:key')
|
||||
@Authenticated({ permission: Permission.AssetUpdate })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteAssetMetadata(@Auth() auth: AuthDto, @Param() { id, key }: AssetMetadataRouteParams): Promise<void> {
|
||||
return this.service.deleteMetadataByKey(auth, id, key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Post('validateToken')
|
||||
@Authenticated()
|
||||
@Authenticated({ permission: false })
|
||||
@HttpCode(HttpStatus.OK)
|
||||
validateAccessToken(): ValidateAccessTokenResponseDto {
|
||||
return { authStatus: true };
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { PartnerController } from 'src/controllers/partner.controller';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(PartnerController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(PartnerService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(PartnerController, [
|
||||
{ provide: PartnerService, useValue: service },
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /partners', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/partners');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require a direction`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).get(`/partners`).set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([
|
||||
'direction should not be empty',
|
||||
expect.stringContaining('direction must be one of the following values:'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it(`should require direction to be an enum`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.get(`/partners`)
|
||||
.query({ direction: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(
|
||||
errorDto.badRequest([expect.stringContaining('direction must be one of the following values:')]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /partners', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).post('/partners');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require sharedWithId to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.post(`/partners`)
|
||||
.send({ sharedWithId: 'invalid' })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /partners/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).put(`/partners/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/partners/invalid`)
|
||||
.send({ inTimeline: true })
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /partners/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).delete(`/partners/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it(`should require id to be a uuid`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.delete(`/partners/invalid`)
|
||||
.set('Authorization', `Bearer token`);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest([expect.stringContaining('must be a UUID')]));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { EndpointLifecycle } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
|
||||
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
@@ -18,10 +19,17 @@ export class PartnerController {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
@Post(':id')
|
||||
@Post()
|
||||
@Authenticated({ permission: Permission.PartnerCreate })
|
||||
createPartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
|
||||
return this.service.create(auth, id);
|
||||
createPartner(@Auth() auth: AuthDto, @Body() dto: PartnerCreateDto): Promise<PartnerResponseDto> {
|
||||
return this.service.create(auth, dto);
|
||||
}
|
||||
|
||||
@Post(':id')
|
||||
@EndpointLifecycle({ deprecatedAt: 'v1.141.0' })
|
||||
@Authenticated({ permission: Permission.PartnerCreate })
|
||||
createPartnerDeprecated(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<PartnerResponseDto> {
|
||||
return this.service.create(auth, { sharedWithId: id });
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@@ -29,7 +37,7 @@ export class PartnerController {
|
||||
updatePartner(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Body() dto: UpdatePartnerDto,
|
||||
@Body() dto: PartnerUpdateDto,
|
||||
): Promise<PartnerResponseDto> {
|
||||
return this.service.update(auth, id, dto);
|
||||
}
|
||||
|
||||
@@ -128,12 +128,6 @@ describe(SearchController.name, () => {
|
||||
await request(ctx.getHttpServer()).post('/search/smart');
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should require a query', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({});
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /search/explore', () => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { UserAdminController } from 'src/controllers/user-admin.controller';
|
||||
import { UserAdminCreateDto } from 'src/dtos/user.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { UserAdminService } from 'src/services/user-admin.service';
|
||||
import request from 'supertest';
|
||||
import { errorDto } from 'test/medium/responses';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { automock, ControllerContext, controllerSetup, mockBaseService } from 'test/utils';
|
||||
|
||||
describe(UserAdminController.name, () => {
|
||||
let ctx: ControllerContext;
|
||||
const service = mockBaseService(UserAdminService);
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await controllerSetup(UserAdminController, [
|
||||
{ provide: LoggingRepository, useValue: automock(LoggingRepository, { strict: false }) },
|
||||
{ provide: UserAdminService, useValue: service },
|
||||
]);
|
||||
return () => ctx.close();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
service.resetAllMocks();
|
||||
ctx.reset();
|
||||
});
|
||||
|
||||
describe('GET /admin/users', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get('/admin/users');
|
||||
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 not allow decimal quota`, 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'])));
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /admin/users/:id', () => {
|
||||
it('should be an authenticated route', async () => {
|
||||
await request(ctx.getHttpServer()).get(`/admin/users/${factory.uuid()}`);
|
||||
expect(ctx.authenticate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it(`should not allow decimal quota`, async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer())
|
||||
.put(`/admin/users/${factory.uuid()}`)
|
||||
.set('Authorization', `Bearer token`)
|
||||
.send({ quotaSizeInBytes: 1.2 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(expect.arrayContaining(['quotaSizeInBytes must be an integer number'])));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,7 +43,9 @@ export class StorageCore {
|
||||
private storageRepository: StorageRepository,
|
||||
private systemMetadataRepository: SystemMetadataRepository,
|
||||
private logger: LoggingRepository,
|
||||
) {}
|
||||
) {
|
||||
this.logger.setContext(StorageCore.name);
|
||||
}
|
||||
|
||||
static create(
|
||||
assetRepository: AssetRepository,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SetMetadata, applyDecorators } from '@nestjs/common';
|
||||
import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiExtension, ApiOperation, ApiOperationOptions, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||
import _ from 'lodash';
|
||||
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
|
||||
import { ImmichWorker, JobName, MetadataKey, QueueName } from 'src/enum';
|
||||
@@ -159,12 +159,21 @@ type LifecycleMetadata = {
|
||||
deprecatedAt?: LifecycleRelease;
|
||||
};
|
||||
|
||||
export const EndpointLifecycle = ({ addedAt, deprecatedAt }: LifecycleMetadata) => {
|
||||
export const EndpointLifecycle = ({
|
||||
addedAt,
|
||||
deprecatedAt,
|
||||
description,
|
||||
...options
|
||||
}: LifecycleMetadata & ApiOperationOptions) => {
|
||||
const decorators: MethodDecorator[] = [ApiExtension(LIFECYCLE_EXTENSION, { addedAt, deprecatedAt })];
|
||||
if (deprecatedAt) {
|
||||
decorators.push(
|
||||
ApiTags('Deprecated'),
|
||||
ApiOperation({ deprecated: true, description: DEPRECATED_IN_PREFIX + deprecatedAt }),
|
||||
ApiOperation({
|
||||
deprecated: true,
|
||||
description: DEPRECATED_IN_PREFIX + deprecatedAt + (description ? `. ${description}` : ''),
|
||||
...options,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { plainToInstance, Transform, Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { AssetMetadataUpsertItemDto } from 'src/dtos/asset.dto';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
@@ -64,6 +66,20 @@ export class AssetMediaCreateDto extends AssetMediaBase {
|
||||
@ValidateUUID({ optional: true })
|
||||
livePhotoVideoId?: string;
|
||||
|
||||
@Transform(({ value }) => {
|
||||
try {
|
||||
const json = JSON.parse(value);
|
||||
const items = Array.isArray(json) ? json : [json];
|
||||
return items.map((item) => plainToInstance(AssetMetadataUpsertItemDto, item));
|
||||
} catch {
|
||||
throw new BadRequestException(['metadata must be valid JSON']);
|
||||
}
|
||||
})
|
||||
@Optional()
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
metadata!: AssetMetadataUpsertItemDto[];
|
||||
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsTimeZone,
|
||||
Max,
|
||||
Min,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetMetadataKey, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetStats } from 'src/repositories/asset.repository';
|
||||
import { AssetMetadata, AssetMetadataItem } from 'src/types';
|
||||
import { IsNotSiblingOf, Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DeviceIdDto {
|
||||
@@ -135,6 +139,53 @@ export class AssetStatsResponseDto {
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export class AssetMetadataRouteParams {
|
||||
@ValidateUUID()
|
||||
id!: string;
|
||||
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AssetMetadataUpsertItemDto)
|
||||
items!: AssetMetadataUpsertItemDto[];
|
||||
}
|
||||
|
||||
export class AssetMetadataUpsertItemDto implements AssetMetadataItem {
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
|
||||
@IsObject()
|
||||
@ValidateNested()
|
||||
@Type((options) => {
|
||||
switch (options?.object.key) {
|
||||
case AssetMetadataKey.MobileApp: {
|
||||
return AssetMetadataMobileAppDto;
|
||||
}
|
||||
default: {
|
||||
return Object;
|
||||
}
|
||||
}
|
||||
})
|
||||
value!: AssetMetadata[AssetMetadataKey];
|
||||
}
|
||||
|
||||
export class AssetMetadataMobileAppDto {
|
||||
@IsString()
|
||||
@Optional()
|
||||
iCloudId?: string;
|
||||
}
|
||||
|
||||
export class AssetMetadataResponseDto {
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
value!: object;
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
||||
return {
|
||||
images: stats[AssetType.Image],
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { IsNotEmpty } from 'class-validator';
|
||||
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||
import { ValidateEnum } from 'src/validation';
|
||||
import { ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class UpdatePartnerDto {
|
||||
export class PartnerCreateDto {
|
||||
@ValidateUUID()
|
||||
sharedWithId!: string;
|
||||
}
|
||||
|
||||
export class PartnerUpdateDto {
|
||||
@IsNotEmpty()
|
||||
inTimeline!: boolean;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PropertyLifecycle } from 'src/decorators';
|
||||
import { AlbumResponseDto } from 'src/dtos/album.dto';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
|
||||
|
||||
class BaseSearchDto {
|
||||
@ValidateUUID({ optional: true, nullable: true })
|
||||
@@ -144,9 +144,7 @@ export class MetadataSearchDto extends RandomSearchDto {
|
||||
@Optional()
|
||||
deviceAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@@ -154,9 +152,7 @@ export class MetadataSearchDto extends RandomSearchDto {
|
||||
@Optional()
|
||||
checksum?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
originalFileName?: string;
|
||||
|
||||
@IsString()
|
||||
@@ -190,16 +186,17 @@ export class MetadataSearchDto extends RandomSearchDto {
|
||||
}
|
||||
|
||||
export class StatisticsSearchDto extends BaseSearchDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Optional()
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class SmartSearchDto extends BaseSearchWithResultsDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
query!: string;
|
||||
@ValidateString({ optional: true, trim: true })
|
||||
query?: string;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
@Optional()
|
||||
queryAssetId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrayMaxSize, IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetMetadataKey,
|
||||
AssetOrder,
|
||||
AssetType,
|
||||
AssetVisibility,
|
||||
@@ -162,6 +163,21 @@ export class SyncAssetExifV1 {
|
||||
fps!: number | null;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetMetadataV1 {
|
||||
assetId!: string;
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
value!: object;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAssetMetadataDeleteV1 {
|
||||
assetId!: string;
|
||||
@ValidateEnum({ enum: AssetMetadataKey, name: 'AssetMetadataKey' })
|
||||
key!: AssetMetadataKey;
|
||||
}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncAlbumDeleteV1 {
|
||||
albumId!: string;
|
||||
@@ -320,6 +336,9 @@ export class SyncAckV1 {}
|
||||
@ExtraModel()
|
||||
export class SyncResetV1 {}
|
||||
|
||||
@ExtraModel()
|
||||
export class SyncCompleteV1 {}
|
||||
|
||||
export type SyncItem = {
|
||||
[SyncEntityType.AuthUserV1]: SyncAuthUserV1;
|
||||
[SyncEntityType.UserV1]: SyncUserV1;
|
||||
@@ -328,6 +347,8 @@ export type SyncItem = {
|
||||
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
||||
[SyncEntityType.AssetV1]: SyncAssetV1;
|
||||
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
|
||||
[SyncEntityType.AssetMetadataV1]: SyncAssetMetadataV1;
|
||||
[SyncEntityType.AssetMetadataDeleteV1]: SyncAssetMetadataDeleteV1;
|
||||
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||
[SyncEntityType.PartnerAssetBackfillV1]: SyncAssetV1;
|
||||
@@ -364,6 +385,7 @@ export type SyncItem = {
|
||||
[SyncEntityType.UserMetadataV1]: SyncUserMetadataV1;
|
||||
[SyncEntityType.UserMetadataDeleteV1]: SyncUserMetadataDeleteV1;
|
||||
[SyncEntityType.SyncAckV1]: SyncAckV1;
|
||||
[SyncEntityType.SyncCompleteV1]: SyncCompleteV1;
|
||||
[SyncEntityType.SyncResetV1]: SyncResetV1;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Exclude, Transform, Type } from 'class-transformer';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsInt,
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { PropertyLifecycle } from 'src/decorators';
|
||||
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
|
||||
import {
|
||||
AudioCodec,
|
||||
@@ -257,21 +256,32 @@ class SystemConfigLoggingDto {
|
||||
level!: LogLevel;
|
||||
}
|
||||
|
||||
class MachineLearningAvailabilityChecksDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsInt()
|
||||
timeout!: number;
|
||||
|
||||
@IsInt()
|
||||
interval!: number;
|
||||
}
|
||||
|
||||
class SystemConfigMachineLearningDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@PropertyLifecycle({ deprecatedAt: 'v1.122.0' })
|
||||
@Exclude()
|
||||
url?: string;
|
||||
|
||||
@IsUrl({ require_tld: false, allow_underscores: true }, { each: true })
|
||||
@ArrayMinSize(1)
|
||||
@Transform(({ obj, value }) => (obj.url ? [obj.url] : value))
|
||||
@ValidateIf((dto) => dto.enabled)
|
||||
@ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 })
|
||||
urls!: string[];
|
||||
|
||||
@Type(() => MachineLearningAvailabilityChecksDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
availabilityChecks!: MachineLearningAvailabilityChecksDto;
|
||||
|
||||
@Type(() => CLIPConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
||||
@@ -53,6 +53,12 @@ export class TimeBucketDto {
|
||||
description: 'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
|
||||
})
|
||||
visibility?: AssetVisibility;
|
||||
|
||||
@ValidateBoolean({
|
||||
optional: true,
|
||||
description: 'Include location data in the response',
|
||||
})
|
||||
withCoordinates?: boolean;
|
||||
}
|
||||
|
||||
export class TimeBucketAssetDto extends TimeBucketDto {
|
||||
@@ -185,6 +191,22 @@ export class TimeBucketAssetResponseDto {
|
||||
description: 'Array of country names extracted from EXIF GPS data',
|
||||
})
|
||||
country!: (string | null)[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
required: false,
|
||||
items: { type: 'number', nullable: true },
|
||||
description: 'Array of latitude coordinates extracted from EXIF GPS data',
|
||||
})
|
||||
latitude!: number[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'array',
|
||||
required: false,
|
||||
items: { type: 'number', nullable: true },
|
||||
description: 'Array of longitude coordinates extracted from EXIF GPS data',
|
||||
})
|
||||
longitude!: number[];
|
||||
}
|
||||
|
||||
export class TimeBucketsResponseDto {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEmail, IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||
import { IsEmail, IsInt, IsNotEmpty, IsString, Min } from 'class-validator';
|
||||
import { User, UserAdmin } from 'src/database';
|
||||
import { UserAvatarColor, UserMetadataKey, UserStatus } from 'src/enum';
|
||||
import { UserMetadataItem } from 'src/types';
|
||||
@@ -91,7 +91,7 @@ export class UserAdminCreateDto {
|
||||
storageLabel?: string | null;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
@IsNumber()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
quotaSizeInBytes?: number | null;
|
||||
@@ -137,7 +137,7 @@ export class UserAdminUpdateDto {
|
||||
shouldChangePassword?: boolean;
|
||||
|
||||
@Optional({ nullable: true })
|
||||
@IsNumber()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@ApiProperty({ type: 'integer', format: 'int64' })
|
||||
quotaSizeInBytes?: number | null;
|
||||
|
||||
@@ -29,8 +29,8 @@ export const AlbumUpdateEmail = ({
|
||||
</Text>
|
||||
|
||||
<Text>
|
||||
New media has been added to <strong>{albumName}</strong>,
|
||||
<br /> check it out!
|
||||
New media has been added to <strong>{albumName}</strong>.
|
||||
<br /> Check it out!
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button, ButtonProps } from '@react-email/components';
|
||||
import { Button, ButtonProps, Text } from '@react-email/components';
|
||||
|
||||
export const ImmichButton = ({ children, ...props }: ButtonProps) => (
|
||||
<Button
|
||||
{...props}
|
||||
className="py-3 px-8 border bg-immich-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 font-bold uppercase"
|
||||
className="border bg-immich-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 font-bold uppercase"
|
||||
>
|
||||
{children}
|
||||
<Text className="my-3 mx-8">{children}</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -277,6 +277,10 @@ export enum UserMetadataKey {
|
||||
Onboarding = 'onboarding',
|
||||
}
|
||||
|
||||
export enum AssetMetadataKey {
|
||||
MobileApp = 'mobile-app',
|
||||
}
|
||||
|
||||
export enum UserAvatarColor {
|
||||
Primary = 'primary',
|
||||
Pink = 'pink',
|
||||
@@ -527,6 +531,7 @@ export enum JobName {
|
||||
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
|
||||
|
||||
AuditLogCleanup = 'AuditLogCleanup',
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
DatabaseBackup = 'DatabaseBackup',
|
||||
|
||||
@@ -627,6 +632,7 @@ export enum SyncRequestType {
|
||||
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
|
||||
AssetsV1 = 'AssetsV1',
|
||||
AssetExifsV1 = 'AssetExifsV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AuthUsersV1 = 'AuthUsersV1',
|
||||
MemoriesV1 = 'MemoriesV1',
|
||||
MemoryToAssetsV1 = 'MemoryToAssetsV1',
|
||||
@@ -650,6 +656,8 @@ export enum SyncEntityType {
|
||||
AssetV1 = 'AssetV1',
|
||||
AssetDeleteV1 = 'AssetDeleteV1',
|
||||
AssetExifV1 = 'AssetExifV1',
|
||||
AssetMetadataV1 = 'AssetMetadataV1',
|
||||
AssetMetadataDeleteV1 = 'AssetMetadataDeleteV1',
|
||||
|
||||
PartnerV1 = 'PartnerV1',
|
||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||
@@ -701,6 +709,7 @@ export enum SyncEntityType {
|
||||
|
||||
SyncAckV1 = 'SyncAckV1',
|
||||
SyncResetV1 = 'SyncResetV1',
|
||||
SyncCompleteV1 = 'SyncCompleteV1',
|
||||
}
|
||||
|
||||
export enum NotificationLevel {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
||||
import { asRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
||||
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
|
||||
const file = files[property]?.[0];
|
||||
@@ -99,18 +99,21 @@ export class FileUploadInterceptor implements NestInterceptor {
|
||||
}
|
||||
|
||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||
return callbackify(() => this.assetService.canUploadFile(asRequest(request, file)), callback);
|
||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
||||
}
|
||||
|
||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFilename(asRequest(request, file)),
|
||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
||||
return callbackify(() => this.assetService.getUploadFolder(asRequest(request, file)), callback as Callback<string>);
|
||||
return callbackify(
|
||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
||||
callback as Callback<string>,
|
||||
);
|
||||
}
|
||||
|
||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
||||
|
||||
@@ -547,9 +547,8 @@ where
|
||||
"asset"."visibility" != $1
|
||||
and "asset"."deletedAt" is null
|
||||
and "job_status"."previewAt" is not null
|
||||
and "job_status"."facesRecognizedAt" is null
|
||||
order by
|
||||
"asset"."createdAt" desc
|
||||
"asset"."fileCreatedAt" desc
|
||||
|
||||
-- AssetJobRepository.streamForMigrationJob
|
||||
select
|
||||
|
||||
@@ -19,6 +19,33 @@ returning
|
||||
"dateTimeOriginal",
|
||||
"timeZone"
|
||||
|
||||
-- AssetRepository.getMetadata
|
||||
select
|
||||
"key",
|
||||
"value",
|
||||
"updatedAt"
|
||||
from
|
||||
"asset_metadata"
|
||||
where
|
||||
"assetId" = $1
|
||||
|
||||
-- AssetRepository.getMetadataByKey
|
||||
select
|
||||
"key",
|
||||
"value",
|
||||
"updatedAt"
|
||||
from
|
||||
"asset_metadata"
|
||||
where
|
||||
"assetId" = $1
|
||||
and "key" = $2
|
||||
|
||||
-- AssetRepository.deleteMetadataByKey
|
||||
delete from "asset_metadata"
|
||||
where
|
||||
"assetId" = $1
|
||||
and "key" = $2
|
||||
|
||||
-- AssetRepository.getByDayOfYear
|
||||
with
|
||||
"res" as (
|
||||
|
||||
@@ -12,7 +12,7 @@ with
|
||||
) as "assets"
|
||||
from
|
||||
"asset"
|
||||
left join lateral (
|
||||
inner join lateral (
|
||||
select
|
||||
"asset".*,
|
||||
"asset_exif" as "exifInfo"
|
||||
|
||||
@@ -123,6 +123,14 @@ offset
|
||||
$8
|
||||
commit
|
||||
|
||||
-- SearchRepository.getEmbedding
|
||||
select
|
||||
*
|
||||
from
|
||||
"smart_search"
|
||||
where
|
||||
"assetId" = $1
|
||||
|
||||
-- SearchRepository.searchFaces
|
||||
begin
|
||||
set
|
||||
|
||||
@@ -539,6 +539,37 @@ where
|
||||
order by
|
||||
"asset_face"."updateId" asc
|
||||
|
||||
-- SyncRepository.assetMetadata.getDeletes
|
||||
select
|
||||
"asset_metadata_audit"."id",
|
||||
"assetId",
|
||||
"key"
|
||||
from
|
||||
"asset_metadata_audit" as "asset_metadata_audit"
|
||||
left join "asset" on "asset"."id" = "asset_metadata_audit"."assetId"
|
||||
where
|
||||
"asset_metadata_audit"."id" < $1
|
||||
and "asset_metadata_audit"."id" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
order by
|
||||
"asset_metadata_audit"."id" asc
|
||||
|
||||
-- SyncRepository.assetMetadata.getUpserts
|
||||
select
|
||||
"assetId",
|
||||
"key",
|
||||
"value",
|
||||
"asset_metadata"."updateId"
|
||||
from
|
||||
"asset_metadata" as "asset_metadata"
|
||||
inner join "asset" on "asset"."id" = "asset_metadata"."assetId"
|
||||
where
|
||||
"asset_metadata"."updateId" < $1
|
||||
and "asset_metadata"."updateId" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
order by
|
||||
"asset_metadata"."updateId" asc
|
||||
|
||||
-- SyncRepository.authUser.getUpserts
|
||||
select
|
||||
"id",
|
||||
@@ -560,6 +591,7 @@ from
|
||||
where
|
||||
"user"."updateId" < $1
|
||||
and "user"."updateId" > $2
|
||||
and "id" = $3
|
||||
order by
|
||||
"user"."updateId" asc
|
||||
|
||||
@@ -926,7 +958,7 @@ where
|
||||
order by
|
||||
"stack"."updateId" asc
|
||||
|
||||
-- SyncRepository.people.getDeletes
|
||||
-- SyncRepository.person.getDeletes
|
||||
select
|
||||
"id",
|
||||
"personId"
|
||||
@@ -939,7 +971,7 @@ where
|
||||
order by
|
||||
"person_audit"."id" asc
|
||||
|
||||
-- SyncRepository.people.getUpserts
|
||||
-- SyncRepository.person.getUpserts
|
||||
select
|
||||
"id",
|
||||
"createdAt",
|
||||
|
||||
@@ -12,6 +12,8 @@ where
|
||||
and "fileCreatedAt" is not null
|
||||
and "fileModifiedAt" is not null
|
||||
and "localDateTime" is not null
|
||||
order by
|
||||
"directoryPath" asc
|
||||
|
||||
-- ViewRepository.getAssetsByOriginalPath
|
||||
select
|
||||
|
||||
@@ -60,7 +60,7 @@ export class AssetJobRepository {
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', asUuid(id))
|
||||
.select(['id', 'originalPath'])
|
||||
.select(withFiles)
|
||||
.select((eb) => withFiles(eb, AssetFileType.Sidecar))
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
@@ -360,9 +360,9 @@ export class AssetJobRepository {
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamForDetectFacesJob(force?: boolean) {
|
||||
return this.assetsWithPreviews()
|
||||
.$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
|
||||
.$if(force === false, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
|
||||
.select(['asset.id'])
|
||||
.orderBy('asset.createdAt', 'desc')
|
||||
.orderBy('asset.fileCreatedAt', 'desc')
|
||||
.stream();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, NotNull, Selectable, UpdateResult, Updateable, sql } from 'kysely';
|
||||
import { Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
import { isEmpty, isUndefined, omitBy } from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Stack } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { AssetFileType, AssetMetadataKey, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { AssetMetadataItem } from 'src/types';
|
||||
import {
|
||||
anyUuid,
|
||||
asUuid,
|
||||
@@ -59,6 +60,7 @@ interface AssetBuilderOptions {
|
||||
status?: AssetStatus;
|
||||
assetType?: AssetType;
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
}
|
||||
|
||||
export interface TimeBucketOptions extends AssetBuilderOptions {
|
||||
@@ -210,6 +212,43 @@ export class AssetRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getMetadata(assetId: string) {
|
||||
return this.db
|
||||
.selectFrom('asset_metadata')
|
||||
.select(['key', 'value', 'updatedAt'])
|
||||
.where('assetId', '=', assetId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
upsertMetadata(id: string, items: AssetMetadataItem[]) {
|
||||
return this.db
|
||||
.insertInto('asset_metadata')
|
||||
.values(items.map((item) => ({ assetId: id, ...item })))
|
||||
.onConflict((oc) =>
|
||||
oc
|
||||
.columns(['assetId', 'key'])
|
||||
.doUpdateSet((eb) => ({ key: eb.ref('excluded.key'), value: eb.ref('excluded.value') })),
|
||||
)
|
||||
.returning(['key', 'value', 'updatedAt'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
getMetadataByKey(assetId: string, key: AssetMetadataKey) {
|
||||
return this.db
|
||||
.selectFrom('asset_metadata')
|
||||
.select(['key', 'value', 'updatedAt'])
|
||||
.where('assetId', '=', assetId)
|
||||
.where('key', '=', key)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||
async deleteMetadataByKey(id: string, key: AssetMetadataKey) {
|
||||
await this.db.deleteFrom('asset_metadata').where('assetId', '=', id).where('key', '=', key).execute();
|
||||
}
|
||||
|
||||
create(asset: Insertable<AssetTable>) {
|
||||
return this.db.insertInto('asset').values(asset).returningAll().executeTakeFirstOrThrow();
|
||||
}
|
||||
@@ -590,6 +629,7 @@ export class AssetRepository {
|
||||
)
|
||||
.as('ratio'),
|
||||
])
|
||||
.$if(!!options.withCoordinates, (qb) => qb.select(['asset_exif.latitude', 'asset_exif.longitude']))
|
||||
.where('asset.deletedAt', options.isTrashed ? 'is not' : 'is', null)
|
||||
.$if(options.visibility == undefined, withDefaultVisibility)
|
||||
.$if(!!options.visibility, (qb) => qb.where('asset.visibility', '=', options.visibility!))
|
||||
@@ -663,6 +703,12 @@ export class AssetRepository {
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['status']), sql.lit('{}')).as('status'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['thumbhash']), sql.lit('{}')).as('thumbhash'),
|
||||
])
|
||||
.$if(!!options.withCoordinates, (qb) =>
|
||||
qb.select((eb) => [
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['latitude']), sql.lit('{}')).as('latitude'),
|
||||
eb.fn.coalesce(eb.fn('array_agg', ['longitude']), sql.lit('{}')).as('longitude'),
|
||||
]),
|
||||
)
|
||||
.$if(!!options.withStacked, (qb) =>
|
||||
qb.select((eb) => eb.fn.coalesce(eb.fn('json_agg', ['stack']), sql.lit('[]')).as('stack')),
|
||||
),
|
||||
|
||||
@@ -34,7 +34,7 @@ export class DuplicateRepository {
|
||||
qb
|
||||
.selectFrom('asset')
|
||||
.$call(withDefaultVisibility)
|
||||
.leftJoinLateral(
|
||||
.innerJoinLateral(
|
||||
(qb) =>
|
||||
qb
|
||||
.selectFrom('asset_exif')
|
||||
|
||||
@@ -81,7 +81,7 @@ type EventMap = {
|
||||
StackDeleteAll: [{ stackIds: string[]; userId: string }];
|
||||
|
||||
// user events
|
||||
UserSignup: [{ notify: boolean; id: string; tempPassword?: string }];
|
||||
UserSignup: [{ notify: boolean; id: string; password?: string }];
|
||||
|
||||
// websocket events
|
||||
WebsocketConnect: [{ userId: string }];
|
||||
|
||||
@@ -142,6 +142,10 @@ export class LoggingRepository {
|
||||
this.handleMessage(LogLevel.Fatal, message, details);
|
||||
}
|
||||
|
||||
deprecate(message: string) {
|
||||
this.warn(`[Deprecated] ${message}`);
|
||||
}
|
||||
|
||||
private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) {
|
||||
if (this.logger.isLevelEnabled(level)) {
|
||||
this.handleMessage(level, message(), details);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Duration } from 'luxon';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants';
|
||||
import { MachineLearningConfig } from 'src/config';
|
||||
import { CLIPConfig } from 'src/dtos/model-config.dto';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
@@ -57,82 +58,100 @@ export type TextEncodingOptions = ModelOptions & { language?: string };
|
||||
|
||||
@Injectable()
|
||||
export class MachineLearningRepository {
|
||||
// Note that deleted URL's are not removed from this map (ie: they're leaked)
|
||||
// Cleaning them up is low priority since there should be very few over a
|
||||
// typical server uptime cycle
|
||||
private urlAvailability: {
|
||||
[url: string]:
|
||||
| {
|
||||
active: boolean;
|
||||
lastChecked: number;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
private healthyMap: Record<string, boolean> = {};
|
||||
private interval?: ReturnType<typeof setInterval>;
|
||||
private _config?: MachineLearningConfig;
|
||||
|
||||
private get config(): MachineLearningConfig {
|
||||
if (!this._config) {
|
||||
throw new Error('Machine learning repository not been setup');
|
||||
}
|
||||
|
||||
return this._config;
|
||||
}
|
||||
|
||||
constructor(private logger: LoggingRepository) {
|
||||
this.logger.setContext(MachineLearningRepository.name);
|
||||
this.urlAvailability = {};
|
||||
}
|
||||
|
||||
private setUrlAvailability(url: string, active: boolean) {
|
||||
const current = this.urlAvailability[url];
|
||||
if (current?.active !== active) {
|
||||
this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`);
|
||||
setup(config: MachineLearningConfig) {
|
||||
this._config = config;
|
||||
this.teardown();
|
||||
|
||||
// delete old servers
|
||||
for (const url of Object.keys(this.healthyMap)) {
|
||||
if (!config.urls.includes(url)) {
|
||||
delete this.healthyMap[url];
|
||||
}
|
||||
}
|
||||
this.urlAvailability[url] = {
|
||||
active,
|
||||
lastChecked: Date.now(),
|
||||
};
|
||||
|
||||
if (!config.availabilityChecks.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tick();
|
||||
this.interval = setInterval(
|
||||
() => this.tick(),
|
||||
Duration.fromObject({ milliseconds: config.availabilityChecks.interval }).as('milliseconds'),
|
||||
);
|
||||
}
|
||||
|
||||
private async checkAvailability(url: string) {
|
||||
let active = false;
|
||||
teardown() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
}
|
||||
|
||||
private tick() {
|
||||
for (const url of this.config.urls) {
|
||||
void this.check(url);
|
||||
}
|
||||
}
|
||||
|
||||
private async check(url: string) {
|
||||
let healthy = false;
|
||||
try {
|
||||
const response = await fetch(new URL('/ping', url), {
|
||||
signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT),
|
||||
signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
|
||||
});
|
||||
active = response.ok;
|
||||
if (response.ok) {
|
||||
healthy = true;
|
||||
}
|
||||
} catch {
|
||||
// nothing to do here
|
||||
}
|
||||
this.setUrlAvailability(url, active);
|
||||
return active;
|
||||
|
||||
this.setHealthy(url, healthy);
|
||||
}
|
||||
|
||||
private async shouldSkipUrl(url: string) {
|
||||
const availability = this.urlAvailability[url];
|
||||
if (availability === undefined) {
|
||||
// If this is a new endpoint, then check inline and skip if it fails
|
||||
if (!(await this.checkAvailability(url))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
private setHealthy(url: string, healthy: boolean) {
|
||||
if (this.healthyMap[url] !== healthy) {
|
||||
this.logger.log(`Machine learning server became ${healthy ? 'healthy' : 'unhealthy'} (${url}).`);
|
||||
}
|
||||
if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) {
|
||||
// If this is an old inactive endpoint that hasn't been checked in a
|
||||
// while then check but don't wait for the result, just skip it
|
||||
// This avoids delays on every search whilst allowing higher priority
|
||||
// ML servers to recover over time.
|
||||
void this.checkAvailability(url);
|
||||
|
||||
this.healthyMap[url] = healthy;
|
||||
}
|
||||
|
||||
private isHealthy(url: string) {
|
||||
if (!this.config.availabilityChecks.enabled) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
return this.healthyMap[url];
|
||||
}
|
||||
|
||||
private async predict<T>(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
|
||||
private async predict<T>(payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
|
||||
const formData = await this.getFormData(payload, config);
|
||||
let urlCounter = 0;
|
||||
for (const url of urls) {
|
||||
urlCounter++;
|
||||
const isLast = urlCounter >= urls.length;
|
||||
if (!isLast && (await this.shouldSkipUrl(url))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const url of [
|
||||
// try healthy servers first
|
||||
...this.config.urls.filter((url) => this.isHealthy(url)),
|
||||
...this.config.urls.filter((url) => !this.isHealthy(url)),
|
||||
]) {
|
||||
try {
|
||||
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
this.setUrlAvailability(url, true);
|
||||
this.setHealthy(url, true);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -144,20 +163,21 @@ export class MachineLearningRepository {
|
||||
`Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`,
|
||||
);
|
||||
}
|
||||
this.setUrlAvailability(url, false);
|
||||
|
||||
this.setHealthy(url, false);
|
||||
}
|
||||
|
||||
throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`);
|
||||
}
|
||||
|
||||
async detectFaces(urls: string[], imagePath: string, { modelName, minScore }: FaceDetectionOptions) {
|
||||
async detectFaces(imagePath: string, { modelName, minScore }: FaceDetectionOptions) {
|
||||
const request = {
|
||||
[ModelTask.FACIAL_RECOGNITION]: {
|
||||
[ModelType.DETECTION]: { modelName, options: { minScore } },
|
||||
[ModelType.RECOGNITION]: { modelName },
|
||||
},
|
||||
};
|
||||
const response = await this.predict<FacialRecognitionResponse>(urls, { imagePath }, request);
|
||||
const response = await this.predict<FacialRecognitionResponse>({ imagePath }, request);
|
||||
return {
|
||||
imageHeight: response.imageHeight,
|
||||
imageWidth: response.imageWidth,
|
||||
@@ -165,15 +185,15 @@ export class MachineLearningRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async encodeImage(urls: string[], imagePath: string, { modelName }: CLIPConfig) {
|
||||
async encodeImage(imagePath: string, { modelName }: CLIPConfig) {
|
||||
const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
|
||||
const response = await this.predict<ClipVisualResponse>(urls, { imagePath }, request);
|
||||
const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
|
||||
return response[ModelTask.SEARCH];
|
||||
}
|
||||
|
||||
async encodeText(urls: string[], text: string, { language, modelName }: TextEncodingOptions) {
|
||||
async encodeText(text: string, { language, modelName }: TextEncodingOptions) {
|
||||
const request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName, options: { language } } } };
|
||||
const response = await this.predict<ClipTextualResponse>(urls, { text }, request);
|
||||
const response = await this.predict<ClipTextualResponse>({ text }, request);
|
||||
return response[ModelTask.SEARCH];
|
||||
}
|
||||
|
||||
|
||||
@@ -57,28 +57,28 @@ export class MediaRepository {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw2', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug('Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next', error.message);
|
||||
this.logger.debug(`Could not extract JpgFromRaw2 buffer from image, trying JPEG from RAW next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('JpgFromRaw', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug('Could not extract JPEG buffer from image, trying PreviewJXL next', error.message);
|
||||
this.logger.debug(`Could not extract JPEG buffer from image, trying PreviewJXL next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewJXL', input);
|
||||
return { buffer, format: RawExtractedFormat.Jxl };
|
||||
} catch (error: any) {
|
||||
this.logger.debug('Could not extract PreviewJXL buffer from image, trying PreviewImage next', error.message);
|
||||
this.logger.debug(`Could not extract PreviewJXL buffer from image, trying PreviewImage next: ${error}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const buffer = await exiftool.extractBinaryTagToBuffer('PreviewImage', input);
|
||||
return { buffer, format: RawExtractedFormat.Jpeg };
|
||||
} catch (error: any) {
|
||||
this.logger.debug('Could not extract preview buffer from image', error.message);
|
||||
this.logger.debug(`Could not extract preview buffer from image: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,7 @@ export class MediaRepository {
|
||||
failOn: options.processInvalidImages ? 'none' : 'error',
|
||||
limitInputPixels: false,
|
||||
raw: options.raw,
|
||||
unlimited: true,
|
||||
})
|
||||
.pipelineColorspace(options.colorspace === Colorspace.Srgb ? 'srgb' : 'rgb16')
|
||||
.withIccProfile(options.colorspace);
|
||||
|
||||
@@ -103,7 +103,7 @@ export class MetadataRepository {
|
||||
|
||||
readTags(path: string): Promise<ImmichTags> {
|
||||
return this.exiftool.read(path).catch((error) => {
|
||||
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
||||
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
|
||||
return {};
|
||||
}) as Promise<ImmichTags>;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export class OAuthRepository {
|
||||
);
|
||||
const client = await this.getClient(config);
|
||||
state ??= randomState();
|
||||
|
||||
let codeVerifier: string | null;
|
||||
if (codeChallenge) {
|
||||
codeVerifier = null;
|
||||
@@ -36,13 +37,20 @@ export class OAuthRepository {
|
||||
codeVerifier = randomPKCECodeVerifier();
|
||||
codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||
}
|
||||
const url = buildAuthorizationUrl(client, {
|
||||
|
||||
const params: Record<string, string> = {
|
||||
redirect_uri: redirectUrl,
|
||||
scope: config.scope,
|
||||
state,
|
||||
code_challenge: client.serverMetadata().supportsPKCE() ? codeChallenge : '',
|
||||
code_challenge_method: client.serverMetadata().supportsPKCE() ? 'S256' : '',
|
||||
}).toString();
|
||||
};
|
||||
|
||||
if (client.serverMetadata().supportsPKCE()) {
|
||||
params.code_challenge = codeChallenge;
|
||||
params.code_challenge_method = 'S256';
|
||||
}
|
||||
|
||||
const url = buildAuthorizationUrl(client, params).toString();
|
||||
|
||||
return { url, state, codeVerifier };
|
||||
}
|
||||
|
||||
|
||||
@@ -293,6 +293,13 @@ export class SearchRepository {
|
||||
});
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [DummyValue.UUID],
|
||||
})
|
||||
async getEmbedding(assetId: string) {
|
||||
return this.db.selectFrom('smart_search').selectAll().where('assetId', '=', assetId).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
params: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
@@ -54,6 +54,7 @@ export class SyncRepository {
|
||||
asset: AssetSync;
|
||||
assetExif: AssetExifSync;
|
||||
assetFace: AssetFaceSync;
|
||||
assetMetadata: AssetMetadataSync;
|
||||
authUser: AuthUserSync;
|
||||
memory: MemorySync;
|
||||
memoryToAsset: MemoryToAssetSync;
|
||||
@@ -61,7 +62,7 @@ export class SyncRepository {
|
||||
partnerAsset: PartnerAssetsSync;
|
||||
partnerAssetExif: PartnerAssetExifsSync;
|
||||
partnerStack: PartnerStackSync;
|
||||
people: PersonSync;
|
||||
person: PersonSync;
|
||||
stack: StackSync;
|
||||
user: UserSync;
|
||||
userMetadata: UserMetadataSync;
|
||||
@@ -75,6 +76,7 @@ export class SyncRepository {
|
||||
this.asset = new AssetSync(this.db);
|
||||
this.assetExif = new AssetExifSync(this.db);
|
||||
this.assetFace = new AssetFaceSync(this.db);
|
||||
this.assetMetadata = new AssetMetadataSync(this.db);
|
||||
this.authUser = new AuthUserSync(this.db);
|
||||
this.memory = new MemorySync(this.db);
|
||||
this.memoryToAsset = new MemoryToAssetSync(this.db);
|
||||
@@ -82,7 +84,7 @@ export class SyncRepository {
|
||||
this.partnerAsset = new PartnerAssetsSync(this.db);
|
||||
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
|
||||
this.partnerStack = new PartnerStackSync(this.db);
|
||||
this.people = new PersonSync(this.db);
|
||||
this.person = new PersonSync(this.db);
|
||||
this.stack = new StackSync(this.db);
|
||||
this.user = new UserSync(this.db);
|
||||
this.userMetadata = new UserMetadataSync(this.db);
|
||||
@@ -115,6 +117,15 @@ class BaseSync {
|
||||
.orderBy(idRef, 'asc');
|
||||
}
|
||||
|
||||
protected auditCleanup<T extends keyof DB>(t: T, days: number) {
|
||||
const { table, ref } = this.db.dynamic;
|
||||
|
||||
return this.db
|
||||
.deleteFrom(table(t).as(t))
|
||||
.where(ref(`${t}.deletedAt`), '<', sql.raw(`now() - interval '${days} days'`))
|
||||
.execute();
|
||||
}
|
||||
|
||||
protected upsertQuery<T extends keyof DB>(t: T, { nowId, ack }: SyncQueryOptions) {
|
||||
const { table, ref } = this.db.dynamic;
|
||||
const updateIdRef = ref(`${t}.updateId`);
|
||||
@@ -148,6 +159,10 @@ class AlbumSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -284,6 +299,10 @@ class AlbumToAssetSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_asset_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -332,6 +351,10 @@ class AlbumUserSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('album_user_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -369,6 +392,10 @@ class AssetSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('asset', options)
|
||||
@@ -385,6 +412,7 @@ class AuthUserSync extends BaseSync {
|
||||
return this.upsertQuery('user', options)
|
||||
.select(columns.syncUser)
|
||||
.select(['isAdmin', 'pinCode', 'oauthId', 'storageLabel', 'quotaSizeInBytes', 'quotaUsageInBytes'])
|
||||
.where('id', '=', options.userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
@@ -398,6 +426,10 @@ class PersonSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('person_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('person', options)
|
||||
@@ -429,6 +461,10 @@ class AssetFaceSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_face_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('asset_face', options)
|
||||
@@ -471,6 +507,10 @@ class MemorySync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('memory_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('memory', options)
|
||||
@@ -503,6 +543,10 @@ class MemoryToAssetSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('memory_asset_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('memory_asset', options)
|
||||
@@ -535,6 +579,10 @@ class PartnerSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('partner_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
const userId = options.userId;
|
||||
@@ -614,6 +662,10 @@ class StackSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('stack_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('stack', options)
|
||||
@@ -662,6 +714,10 @@ class UserSync extends BaseSync {
|
||||
return this.auditQuery('user_audit', options).select(['id', 'userId']).stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('user_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('user', options).select(columns.syncUser).stream();
|
||||
@@ -677,6 +733,10 @@ class UserMetadataSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('user_metadata_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions], stream: true })
|
||||
getUpserts(options: SyncQueryOptions) {
|
||||
return this.upsertQuery('user_metadata', options)
|
||||
@@ -685,3 +745,27 @@ class UserMetadataSync extends BaseSync {
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
class AssetMetadataSync extends BaseSync {
|
||||
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||
getDeletes(options: SyncQueryOptions, userId: string) {
|
||||
return this.auditQuery('asset_metadata_audit', options)
|
||||
.select(['asset_metadata_audit.id', 'assetId', 'key'])
|
||||
.leftJoin('asset', 'asset.id', 'asset_metadata_audit.assetId')
|
||||
.where('asset.ownerId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
|
||||
cleanupAuditTable(daysAgo: number) {
|
||||
return this.auditCleanup('asset_metadata_audit', daysAgo);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [dummyQueryOptions, DummyValue.UUID], stream: true })
|
||||
getUpserts(options: SyncQueryOptions, userId: string) {
|
||||
return this.upsertQuery('asset_metadata', options)
|
||||
.select(['assetId', 'key', 'value', 'asset_metadata.updateId'])
|
||||
.innerJoin('asset', 'asset.id', 'asset_metadata.assetId')
|
||||
.where('asset.ownerId', '=', userId)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,13 +7,10 @@ import { columns } from 'src/database';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetType, AssetVisibility, UserStatus } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { UserMetadata, UserMetadataItem } from 'src/types';
|
||||
import { asUuid } from 'src/utils/database';
|
||||
|
||||
type Upsert = Insertable<UserMetadataTable>;
|
||||
|
||||
export interface UserListFilter {
|
||||
id?: string;
|
||||
withDeleted?: boolean;
|
||||
@@ -211,12 +208,12 @@ export class UserRepository {
|
||||
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
|
||||
await this.db
|
||||
.insertInto('user_metadata')
|
||||
.values({ userId: id, key, value } as Upsert)
|
||||
.values({ userId: id, key, value })
|
||||
.onConflict((oc) =>
|
||||
oc.columns(['userId', 'key']).doUpdateSet({
|
||||
key,
|
||||
value,
|
||||
} as Upsert),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export class ViewRepository {
|
||||
.where('fileCreatedAt', 'is not', null)
|
||||
.where('fileModifiedAt', 'is not', null)
|
||||
.where('localDateTime', 'is not', null)
|
||||
.orderBy('directoryPath', 'asc')
|
||||
.execute();
|
||||
|
||||
return results.map((row) => row.directoryPath.replaceAll(/\/$/g, ''));
|
||||
|
||||
@@ -230,6 +230,19 @@ export const user_metadata_audit = registerFunction({
|
||||
END`,
|
||||
});
|
||||
|
||||
export const asset_metadata_audit = registerFunction({
|
||||
name: 'asset_metadata_audit',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO asset_metadata_audit ("assetId", "key")
|
||||
SELECT "assetId", "key"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END`,
|
||||
});
|
||||
|
||||
export const asset_face_audit = registerFunction({
|
||||
name: 'asset_face_audit',
|
||||
returnType: 'TRIGGER',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
album_user_delete_audit,
|
||||
asset_delete_audit,
|
||||
asset_face_audit,
|
||||
asset_metadata_audit,
|
||||
f_concat_ws,
|
||||
f_unaccent,
|
||||
immich_uuid_v7,
|
||||
@@ -32,6 +33,8 @@ import { AssetFaceAuditTable } from 'src/schema/tables/asset-face-audit.table';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
|
||||
import { AssetJobStatusTable } from 'src/schema/tables/asset-job-status.table';
|
||||
import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.table';
|
||||
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { AuditTable } from 'src/schema/tables/audit.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
@@ -81,6 +84,8 @@ export class ImmichDatabase {
|
||||
AssetAuditTable,
|
||||
AssetFaceTable,
|
||||
AssetFaceAuditTable,
|
||||
AssetMetadataTable,
|
||||
AssetMetadataAuditTable,
|
||||
AssetJobStatusTable,
|
||||
AssetTable,
|
||||
AssetFileTable,
|
||||
@@ -135,6 +140,7 @@ export class ImmichDatabase {
|
||||
stack_delete_audit,
|
||||
person_delete_audit,
|
||||
user_metadata_audit,
|
||||
asset_metadata_audit,
|
||||
asset_face_audit,
|
||||
];
|
||||
|
||||
@@ -160,12 +166,14 @@ export interface DB {
|
||||
api_key: ApiKeyTable;
|
||||
|
||||
asset: AssetTable;
|
||||
asset_audit: AssetAuditTable;
|
||||
asset_exif: AssetExifTable;
|
||||
asset_face: AssetFaceTable;
|
||||
asset_face_audit: AssetFaceAuditTable;
|
||||
asset_file: AssetFileTable;
|
||||
asset_metadata: AssetMetadataTable;
|
||||
asset_metadata_audit: AssetMetadataAuditTable;
|
||||
asset_job_status: AssetJobStatusTable;
|
||||
asset_audit: AssetAuditTable;
|
||||
|
||||
audit: AuditTable;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE OR REPLACE FUNCTION asset_metadata_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO asset_metadata_audit ("assetId", "key")
|
||||
SELECT "assetId", "key"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE TABLE "asset_metadata_audit" (
|
||||
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"assetId" uuid NOT NULL,
|
||||
"key" character varying NOT NULL,
|
||||
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT "asset_metadata_audit_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "asset_metadata_audit_assetId_idx" ON "asset_metadata_audit" ("assetId");`.execute(db);
|
||||
await sql`CREATE INDEX "asset_metadata_audit_key_idx" ON "asset_metadata_audit" ("key");`.execute(db);
|
||||
await sql`CREATE INDEX "asset_metadata_audit_deletedAt_idx" ON "asset_metadata_audit" ("deletedAt");`.execute(db);
|
||||
await sql`CREATE TABLE "asset_metadata" (
|
||||
"assetId" uuid NOT NULL,
|
||||
"key" character varying NOT NULL,
|
||||
"value" jsonb NOT NULL,
|
||||
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
|
||||
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "asset_metadata_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE CASCADE ON DELETE CASCADE,
|
||||
CONSTRAINT "asset_metadata_pkey" PRIMARY KEY ("assetId", "key")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "asset_metadata_updateId_idx" ON "asset_metadata" ("updateId");`.execute(db);
|
||||
await sql`CREATE INDEX "asset_metadata_updatedAt_idx" ON "asset_metadata" ("updatedAt");`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "asset_metadata_audit"
|
||||
AFTER DELETE ON "asset_metadata"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION asset_metadata_audit();`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "asset_metadata_updated_at"
|
||||
BEFORE UPDATE ON "asset_metadata"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_asset_metadata_audit', '{"type":"function","name":"asset_metadata_audit","sql":"CREATE OR REPLACE FUNCTION asset_metadata_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO asset_metadata_audit (\\"assetId\\", \\"key\\")\\n SELECT \\"assetId\\", \\"key\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_audit', '{"type":"trigger","name":"asset_metadata_audit","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_audit\\"\\n AFTER DELETE ON \\"asset_metadata\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION asset_metadata_audit();"}'::jsonb);`.execute(db);
|
||||
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_asset_metadata_updated_at', '{"type":"trigger","name":"asset_metadata_updated_at","sql":"CREATE OR REPLACE TRIGGER \\"asset_metadata_updated_at\\"\\n BEFORE UPDATE ON \\"asset_metadata\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE "asset_metadata_audit";`.execute(db);
|
||||
await sql`DROP TABLE "asset_metadata";`.execute(db);
|
||||
await sql`DROP FUNCTION asset_metadata_audit;`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_asset_metadata_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_audit';`.execute(db);
|
||||
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_asset_metadata_updated_at';`.execute(db);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('asset_metadata_audit')
|
||||
export class AssetMetadataAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: Generated<string>;
|
||||
|
||||
@Column({ type: 'uuid', index: true })
|
||||
assetId!: string;
|
||||
|
||||
@Column({ index: true })
|
||||
key!: AssetMetadataKey;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Generated<Timestamp>;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetMetadataKey } from 'src/enum';
|
||||
import { asset_metadata_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
PrimaryColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
import { AssetMetadata, AssetMetadataItem } from 'src/types';
|
||||
|
||||
@UpdatedAtTrigger('asset_metadata_updated_at')
|
||||
@Table('asset_metadata')
|
||||
@AfterDeleteTrigger({
|
||||
scope: 'statement',
|
||||
function: asset_metadata_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class AssetMetadataTable<T extends keyof AssetMetadata = AssetMetadataKey> implements AssetMetadataItem<T> {
|
||||
@ForeignKeyColumn(() => AssetTable, {
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE',
|
||||
primary: true,
|
||||
// [assetId, key] is the PK constraint
|
||||
index: false,
|
||||
})
|
||||
assetId!: string;
|
||||
|
||||
@PrimaryColumn({ type: 'character varying' })
|
||||
key!: T;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
value!: AssetMetadata[T];
|
||||
|
||||
@UpdateIdColumn({ index: true })
|
||||
updateId!: Generated<string>;
|
||||
|
||||
@UpdateDateColumn({ index: true })
|
||||
updatedAt!: Generated<Timestamp>;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { MemoryTable } from 'src/schema/tables/memory.table';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
|
||||
import { Column, CreateDateColumn, ForeignKeyColumn, Generated, Table, Timestamp } from 'src/sql-tools';
|
||||
|
||||
@Table('memory_asset_audit')
|
||||
export class MemoryAssetAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: string;
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => MemoryTable, { type: 'uuid', onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||
memoryId!: string;
|
||||
@@ -14,5 +14,5 @@ export class MemoryAssetAuditTable {
|
||||
assetId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', index: true })
|
||||
deletedAt!: Date;
|
||||
deletedAt!: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
body: {},
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
@@ -37,6 +38,7 @@ const uploadFile = {
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
body: {},
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
@@ -916,7 +918,10 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('onUploadError', () => {
|
||||
it('should queue a job to delete the uploaded file', async () => {
|
||||
const request = { user: authStub.user1 } as AuthRequest;
|
||||
const request = {
|
||||
body: {},
|
||||
user: authStub.user1,
|
||||
} as AuthRequest;
|
||||
|
||||
const file = {
|
||||
fieldname: UploadFieldName.ASSET_DATA,
|
||||
|
||||
@@ -33,20 +33,14 @@ import {
|
||||
} from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile } from 'src/types';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
||||
import { asUploadRequest, getAssetFiles, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
interface UploadRequest {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
}
|
||||
|
||||
export interface AssetMediaRedirectResponse {
|
||||
targetSize: AssetMediaSize | 'original';
|
||||
}
|
||||
@@ -98,15 +92,15 @@ export class AssetMediaService extends BaseService {
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
|
||||
requireUploadAccess(auth);
|
||||
|
||||
const originalExtension = extname(file.originalName);
|
||||
const extension = extname(body.filename || file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.ASSET_DATA]: extension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
[UploadFieldName.PROFILE_DATA]: extension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
@@ -126,8 +120,8 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
|
||||
const uploadFilename = this.getUploadFilename(asRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asRequest(request, file));
|
||||
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
|
||||
const uploadPath = `${uploadFolder}/${uploadFilename}`;
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
|
||||
@@ -327,7 +321,7 @@ export class AssetMediaService extends BaseService {
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (error.constraint_name === ASSET_CHECKSUM_CONSTRAINT) {
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
@@ -433,6 +427,10 @@ export class AssetMediaService extends BaseService {
|
||||
originalFileName: dto.filename || file.originalName,
|
||||
});
|
||||
|
||||
if (dto.metadata) {
|
||||
await this.assetRepository.upsertMetadata(asset.id, dto.metadata);
|
||||
}
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
|
||||
@@ -420,7 +420,7 @@ describe(AssetService.name, () => {
|
||||
ids: ['asset-1'],
|
||||
latitude: 0,
|
||||
longitude: 0,
|
||||
visibility: undefined,
|
||||
visibility: AssetVisibility.Archive,
|
||||
isFavorite: false,
|
||||
duplicateId: undefined,
|
||||
rating: undefined,
|
||||
|
||||
@@ -9,12 +9,14 @@ import {
|
||||
AssetBulkUpdateDto,
|
||||
AssetJobName,
|
||||
AssetJobsDto,
|
||||
AssetMetadataResponseDto,
|
||||
AssetMetadataUpsertDto,
|
||||
AssetStatsDto,
|
||||
UpdateAssetDto,
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { AssetMetadataKey, AssetStatus, AssetVisibility, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
@@ -93,7 +95,7 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating });
|
||||
await this.updateExif({ id, description, dateTimeOriginal, latitude, longitude, rating });
|
||||
|
||||
const asset = await this.assetRepository.update({ id, ...rest });
|
||||
|
||||
@@ -113,59 +115,68 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
|
||||
const { ids, description, dateTimeOriginal, dateTimeRelative, timeZone, latitude, longitude, ...options } = dto;
|
||||
const {
|
||||
ids,
|
||||
isFavorite,
|
||||
visibility,
|
||||
dateTimeOriginal,
|
||||
latitude,
|
||||
longitude,
|
||||
rating,
|
||||
description,
|
||||
duplicateId,
|
||||
dateTimeRelative,
|
||||
timeZone,
|
||||
} = dto;
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids });
|
||||
|
||||
const staticValuesChanged =
|
||||
description !== undefined || dateTimeOriginal !== undefined || latitude !== undefined || longitude !== undefined;
|
||||
const assetDto = { isFavorite, visibility, duplicateId };
|
||||
const exifDto = { latitude, longitude, rating, description, dateTimeOriginal };
|
||||
|
||||
if (staticValuesChanged) {
|
||||
await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude });
|
||||
const isExifChanged = Object.values(exifDto).some((v) => v !== undefined);
|
||||
if (isExifChanged) {
|
||||
await this.assetRepository.updateAllExif(ids, exifDto);
|
||||
}
|
||||
|
||||
const assets =
|
||||
(dateTimeRelative !== undefined && dateTimeRelative !== 0) || timeZone !== undefined
|
||||
? await this.assetRepository.updateDateTimeOriginal(ids, dateTimeRelative, timeZone)
|
||||
: null;
|
||||
: undefined;
|
||||
|
||||
const dateTimesWithTimezone =
|
||||
assets?.map((asset) => {
|
||||
const isoString = asset.dateTimeOriginal?.toISOString();
|
||||
let dateTime = isoString ? DateTime.fromISO(isoString) : null;
|
||||
const dateTimesWithTimezone = assets
|
||||
? assets.map((asset) => {
|
||||
const isoString = asset.dateTimeOriginal?.toISOString();
|
||||
let dateTime = isoString ? DateTime.fromISO(isoString) : null;
|
||||
|
||||
if (dateTime && asset.timeZone) {
|
||||
dateTime = dateTime.setZone(asset.timeZone);
|
||||
}
|
||||
if (dateTime && asset.timeZone) {
|
||||
dateTime = dateTime.setZone(asset.timeZone);
|
||||
}
|
||||
|
||||
return {
|
||||
assetId: asset.assetId,
|
||||
dateTimeOriginal: dateTime?.toISO() ?? null,
|
||||
};
|
||||
}) ?? null;
|
||||
return {
|
||||
assetId: asset.assetId,
|
||||
dateTimeOriginal: dateTime?.toISO() ?? null,
|
||||
};
|
||||
})
|
||||
: ids.map((id) => ({ assetId: id, dateTimeOriginal }));
|
||||
|
||||
if (staticValuesChanged || dateTimesWithTimezone) {
|
||||
const entries: JobItem[] = (dateTimesWithTimezone ?? ids).map((entry: any) => ({
|
||||
name: JobName.SidecarWrite,
|
||||
data: {
|
||||
id: entry.assetId ?? entry,
|
||||
description,
|
||||
dateTimeOriginal: entry.dateTimeOriginal ?? dateTimeOriginal,
|
||||
latitude,
|
||||
longitude,
|
||||
},
|
||||
}));
|
||||
await this.jobRepository.queueAll(entries);
|
||||
if (dateTimesWithTimezone.length > 0) {
|
||||
await this.jobRepository.queueAll(
|
||||
dateTimesWithTimezone.map(({ assetId: id, dateTimeOriginal }) => ({
|
||||
name: JobName.SidecarWrite,
|
||||
data: {
|
||||
...exifDto,
|
||||
id,
|
||||
dateTimeOriginal: dateTimeOriginal ?? undefined,
|
||||
},
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
options.visibility !== undefined ||
|
||||
options.isFavorite !== undefined ||
|
||||
options.duplicateId !== undefined ||
|
||||
options.rating !== undefined
|
||||
) {
|
||||
await this.assetRepository.updateAll(ids, options);
|
||||
const isAssetChanged = Object.values(assetDto).some((v) => v !== undefined);
|
||||
if (isAssetChanged) {
|
||||
await this.assetRepository.updateAll(ids, assetDto);
|
||||
|
||||
if (options.visibility === AssetVisibility.Locked) {
|
||||
if (visibility === AssetVisibility.Locked) {
|
||||
await this.albumRepository.removeAssetsFromAll(ids);
|
||||
}
|
||||
}
|
||||
@@ -273,6 +284,31 @@ export class AssetService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
async getMetadata(auth: AuthDto, id: string): Promise<AssetMetadataResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
return this.assetRepository.getMetadata(id);
|
||||
}
|
||||
|
||||
async upsertMetadata(auth: AuthDto, id: string, dto: AssetMetadataUpsertDto): Promise<AssetMetadataResponseDto[]> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
||||
return this.assetRepository.upsertMetadata(id, dto.items);
|
||||
}
|
||||
|
||||
async getMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<AssetMetadataResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
|
||||
|
||||
const item = await this.assetRepository.getMetadataByKey(id, key);
|
||||
if (!item) {
|
||||
throw new BadRequestException(`Metadata with key "${key}" not found for asset with id "${id}"`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
async deleteMetadataByKey(auth: AuthDto, id: string, key: AssetMetadataKey): Promise<void> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
||||
return this.assetRepository.deleteMetadataByKey(id, key);
|
||||
}
|
||||
|
||||
async run(auth: AuthDto, dto: AssetJobsDto) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: dto.assetIds });
|
||||
|
||||
@@ -313,7 +349,7 @@ export class AssetService extends BaseService {
|
||||
return asset;
|
||||
}
|
||||
|
||||
private async updateMetadata(dto: ISidecarWriteJob) {
|
||||
private async updateExif(dto: ISidecarWriteJob) {
|
||||
const { id, description, dateTimeOriginal, latitude, longitude, rating } = dto;
|
||||
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
|
||||
if (Object.keys(writes).length > 0) {
|
||||
|
||||
@@ -344,7 +344,7 @@ export class AuthService extends BaseService {
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [oldPath] } });
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.warn(`Unable to sync oauth profile picture: ${error}`, error?.stack);
|
||||
this.logger.warn(`Unable to sync oauth profile picture: ${error}\n${error?.stack}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export class BackupService extends BaseService {
|
||||
{
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
PGPASSWORD: isUrlConnection ? undefined : config.password,
|
||||
PGPASSWORD: isUrlConnection ? new URL(config.url).password : config.password,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -132,12 +132,12 @@ export class BackupService extends BaseService {
|
||||
gzip.stdout.pipe(fileStream);
|
||||
|
||||
pgdump.on('error', (err) => {
|
||||
this.logger.error('Backup failed with error', err);
|
||||
this.logger.error(`Backup failed with error: ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
gzip.on('error', (err) => {
|
||||
this.logger.error('Gzip failed with error', err);
|
||||
this.logger.error(`Gzip failed with error: ${err}`);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -175,10 +175,10 @@ export class BackupService extends BaseService {
|
||||
});
|
||||
await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', ''));
|
||||
} catch (error) {
|
||||
this.logger.error('Database Backup Failure', error);
|
||||
this.logger.error(`Database Backup Failure: ${error}`);
|
||||
await this.storageRepository
|
||||
.unlink(backupFilePath)
|
||||
.catch((error) => this.logger.error('Failed to delete failed backup file', error));
|
||||
.catch((error) => this.logger.error(`Failed to delete failed backup file: ${error}`));
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const messages = {
|
||||
The ${name} extension version is ${version}, which means it is a nightly release.
|
||||
|
||||
Please run 'DROP EXTENSION IF EXISTS ${extension}' and switch to a release version.
|
||||
See https://immich.app/docs/guides/database-queries for how to query the database.`,
|
||||
See https://docs.immich.app/guides/database-queries for how to query the database.`,
|
||||
outOfRange: ({ name, version, range }: OutOfRangeArgs) =>
|
||||
`The ${name} extension version is ${version}, but Immich only supports ${range}.
|
||||
Please change ${name} to a compatible version in the Postgres instance.`,
|
||||
@@ -32,20 +32,20 @@ const messages = {
|
||||
|
||||
If the Postgres instance already has ${name} installed, Immich may not have the necessary permissions to activate it.
|
||||
In this case, please run 'CREATE EXTENSION IF NOT EXISTS ${extension} CASCADE' manually as a superuser.
|
||||
See https://immich.app/docs/guides/database-queries for how to query the database.`,
|
||||
See https://docs.immich.app/guides/database-queries for how to query the database.`,
|
||||
updateFailed: ({ name, extension, availableVersion }: UpdateFailedArgs) =>
|
||||
`The ${name} extension can be updated to ${availableVersion}.
|
||||
Immich attempted to update the extension, but failed to do so.
|
||||
This may be because Immich does not have the necessary permissions to update the extension.
|
||||
|
||||
Please run 'ALTER EXTENSION ${extension} UPDATE' manually as a superuser.
|
||||
See https://immich.app/docs/guides/database-queries for how to query the database.`,
|
||||
See https://docs.immich.app/guides/database-queries for how to query the database.`,
|
||||
dropFailed: ({ name, extension }: DropFailedArgs) =>
|
||||
`The ${name} extension is no longer needed, but could not be dropped.
|
||||
This may be because Immich does not have the necessary permissions to drop the extension.
|
||||
|
||||
Please run 'DROP EXTENSION ${extension};' manually as a superuser.
|
||||
See https://immich.app/docs/guides/database-queries for how to query the database.`,
|
||||
See https://docs.immich.app/guides/database-queries for how to query the database.`,
|
||||
restartRequired: ({ name, availableVersion }: RestartRequiredArgs) =>
|
||||
`The ${name} extension has been updated to ${availableVersion}.
|
||||
Please restart the Postgres instance to complete the update.`,
|
||||
@@ -55,7 +55,7 @@ const messages = {
|
||||
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
|
||||
deprecatedExtension: (name: string) =>
|
||||
`DEPRECATION WARNING: The ${name} extension is deprecated and support for it will be removed very soon.
|
||||
See https://immich.app/docs/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
|
||||
See https://docs.immich.app/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -42,6 +42,7 @@ describe(JobService.name, () => {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.AuditLogCleanup },
|
||||
{ name: JobName.MemoryGenerate },
|
||||
{ name: JobName.UserSyncUsage },
|
||||
|
||||
@@ -281,6 +281,7 @@ export class JobService extends BaseService {
|
||||
{ name: JobName.PersonCleanup },
|
||||
{ name: JobName.MemoryCleanup },
|
||||
{ name: JobName.SessionCleanup },
|
||||
{ name: JobName.AuditTableCleanup },
|
||||
{ name: JobName.AuditLogCleanup },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export class LibraryService extends BaseService {
|
||||
job.paths.map((path) =>
|
||||
this.processEntity(path, library.ownerId, job.libraryId)
|
||||
.then((asset) => assetImports.push(asset))
|
||||
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}`, error)),
|
||||
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export class MemoryService extends BaseService {
|
||||
try {
|
||||
await Promise.all(users.map((owner, i) => this.createOnThisDayMemories(owner.id, usersIds[i], target)));
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to create memories for ${target.toISO()}`, error);
|
||||
this.logger.error(`Failed to create memories for ${target.toISO()}: ${error}`);
|
||||
}
|
||||
// update system metadata even when there is an error to minimize the chance of duplicates
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MemoriesState, {
|
||||
|
||||
@@ -23,16 +23,24 @@ import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
function removeNonSidecarFiles(asset: any) {
|
||||
return {
|
||||
...asset,
|
||||
files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar),
|
||||
};
|
||||
}
|
||||
|
||||
const forSidecarJob = (
|
||||
asset: {
|
||||
id?: string;
|
||||
originalPath?: string;
|
||||
files: { id: string; type: AssetFileType; path: string }[];
|
||||
files?: { id: string; type: AssetFileType; path: string }[];
|
||||
} = {},
|
||||
) => {
|
||||
return {
|
||||
id: factory.uuid(),
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [],
|
||||
...asset,
|
||||
};
|
||||
};
|
||||
@@ -1501,7 +1509,7 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
describe('handleSidecarCheck', () => {
|
||||
it("should do nothing if asset isn't found", async () => {
|
||||
it('should do nothing if asset could not be found', async () => {
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(void 0);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: assetStub.image.id })).resolves.toBeUndefined();
|
||||
@@ -1510,18 +1518,25 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
it('should detect a new sidecar at .jpg.xmp', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', files: [] });
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: `/path/to/IMG_123.jpg.xmp` });
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/path/to/IMG_123.jpg.xmp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should detect a new sidecar at .xmp', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg' });
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [],
|
||||
});
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
|
||||
@@ -1529,29 +1544,39 @@ describe(MetadataService.name, () => {
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: '/path/to/IMG_123.xmp' });
|
||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Sidecar,
|
||||
path: '/path/to/IMG_123.xmp',
|
||||
});
|
||||
});
|
||||
|
||||
it('should unset sidecar path if file does not exist anymore', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg.xmp' });
|
||||
it('should unset sidecar path if file no longer exist', async () => {
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
});
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(false);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, sidecarPath: null });
|
||||
expect(mocks.asset.deleteFile).toHaveBeenCalledWith({ assetId: asset.id, type: AssetFileType.Sidecar });
|
||||
});
|
||||
|
||||
it('should do nothing if the sidecar file still exists', async () => {
|
||||
const asset = forSidecarJob({ originalPath: '/path/to/IMG_123.jpg', sidecarPath: '/path/to/IMG_123.jpg' });
|
||||
const asset = forSidecarJob({
|
||||
originalPath: '/path/to/IMG_123.jpg',
|
||||
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
|
||||
});
|
||||
|
||||
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
|
||||
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
|
||||
|
||||
await expect(sut.handleSidecarCheck({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1669,5 +1694,16 @@ describe(MetadataService.name, () => {
|
||||
expect(result?.tag).toBe('GPSDateTime');
|
||||
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2023-10-10T10:00:00.000Z');
|
||||
});
|
||||
|
||||
it('should prefer CreationDate over CreateDate', () => {
|
||||
const tags = {
|
||||
CreationDate: '2025:05:24 18:26:20+02:00',
|
||||
CreateDate: '2025:08:27 08:45:40',
|
||||
};
|
||||
|
||||
const result = firstDateTime(tags);
|
||||
expect(result?.tag).toBe('CreationDate');
|
||||
expect(result?.dateTime?.toDate()?.toISOString()).toBe('2025-05-24T16:26:20.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { PersonTable } from 'src/schema/tables/person.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobItem, JobOf } from 'src/types';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { isFaceImportEnabled } from 'src/utils/misc';
|
||||
import { upsertTags } from 'src/utils/tag';
|
||||
|
||||
@@ -39,9 +40,9 @@ const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
|
||||
'SubSecCreateDate',
|
||||
'SubSecMediaCreateDate',
|
||||
'DateTimeOriginal',
|
||||
'CreationDate',
|
||||
'CreateDate',
|
||||
'MediaCreateDate',
|
||||
'CreationDate',
|
||||
'DateTimeCreated',
|
||||
'GPSDateTime',
|
||||
'DateTimeUTC',
|
||||
@@ -372,11 +373,9 @@ export class MetadataService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
if (sidecarPath === null) {
|
||||
await this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar });
|
||||
} else {
|
||||
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath });
|
||||
}
|
||||
await (sidecarPath === null
|
||||
? this.assetRepository.deleteFile({ assetId: asset.id, type: AssetFileType.Sidecar })
|
||||
: this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.Sidecar, path: sidecarPath }));
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -431,6 +430,12 @@ export class MetadataService extends BaseService {
|
||||
private getSidecarCandidates({ files, originalPath }: { files: AssetFile[] | null; originalPath: string }) {
|
||||
const candidates: string[] = [];
|
||||
|
||||
const existingSidecar = files?.find((file) => file.type === AssetFileType.Sidecar);
|
||||
|
||||
if (existingSidecar) {
|
||||
candidates.push(existingSidecar.path);
|
||||
}
|
||||
|
||||
const assetPath = parse(originalPath);
|
||||
|
||||
candidates.push(
|
||||
@@ -598,47 +603,62 @@ export class MetadataService extends BaseService {
|
||||
});
|
||||
}
|
||||
const checksum = this.cryptoRepository.hashSha1(video);
|
||||
const checksumQuery = { ownerId: asset.ownerId, libraryId: asset.libraryId ?? undefined, checksum };
|
||||
|
||||
let motionAsset = await this.assetRepository.getByChecksum({
|
||||
ownerId: asset.ownerId,
|
||||
libraryId: asset.libraryId ?? undefined,
|
||||
checksum,
|
||||
});
|
||||
if (motionAsset) {
|
||||
let motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
|
||||
let isNewMotionAsset = false;
|
||||
|
||||
if (!motionAsset) {
|
||||
try {
|
||||
const motionAssetId = this.cryptoRepository.randomUUID();
|
||||
motionAsset = await this.assetRepository.create({
|
||||
id: motionAssetId,
|
||||
libraryId: asset.libraryId,
|
||||
type: AssetType.Video,
|
||||
fileCreatedAt: dates.dateTimeOriginal,
|
||||
fileModifiedAt: stats.mtime,
|
||||
localDateTime: dates.localDateTime,
|
||||
checksum,
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
||||
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
});
|
||||
|
||||
isNewMotionAsset = true;
|
||||
|
||||
if (!asset.isExternal) {
|
||||
await this.userRepository.updateUsage(asset.ownerId, video.byteLength);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAssetChecksumConstraint(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
motionAsset = await this.assetRepository.getByChecksum(checksumQuery);
|
||||
if (!motionAsset) {
|
||||
this.logger.warn(`Unable to find existing motion video asset for ${asset.id}: ${asset.originalPath}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isNewMotionAsset) {
|
||||
this.logger.debugFn(() => {
|
||||
const base64Checksum = checksum.toString('base64');
|
||||
return `Motion asset with checksum ${base64Checksum} already exists for asset ${asset.id}: ${asset.originalPath}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the motion photo video asset if it's not already hidden to prepare for linking
|
||||
if (motionAsset.visibility === AssetVisibility.Timeline) {
|
||||
await this.assetRepository.update({
|
||||
id: motionAsset.id,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
});
|
||||
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
|
||||
}
|
||||
} else {
|
||||
const motionAssetId = this.cryptoRepository.randomUUID();
|
||||
motionAsset = await this.assetRepository.create({
|
||||
id: motionAssetId,
|
||||
libraryId: asset.libraryId,
|
||||
type: AssetType.Video,
|
||||
fileCreatedAt: dates.dateTimeOriginal,
|
||||
fileModifiedAt: stats.mtime,
|
||||
localDateTime: dates.localDateTime,
|
||||
checksum,
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
|
||||
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
|
||||
// Hide the motion photo video asset if it's not already hidden to prepare for linking
|
||||
if (motionAsset.visibility === AssetVisibility.Timeline) {
|
||||
await this.assetRepository.update({
|
||||
id: motionAsset.id,
|
||||
visibility: AssetVisibility.Hidden,
|
||||
deviceAssetId: 'NONE',
|
||||
deviceId: 'NONE',
|
||||
});
|
||||
|
||||
if (!asset.isExternal) {
|
||||
await this.userRepository.updateUsage(asset.ownerId, video.byteLength);
|
||||
}
|
||||
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
|
||||
}
|
||||
|
||||
if (asset.livePhotoVideoId !== motionAsset.id) {
|
||||
@@ -830,7 +850,11 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private getDates(asset: { id: string; originalPath: string }, exifTags: ImmichTags, stats: Stats) {
|
||||
private getDates(
|
||||
asset: { id: string; originalPath: string; fileCreatedAt: Date },
|
||||
exifTags: ImmichTags,
|
||||
stats: Stats,
|
||||
) {
|
||||
const result = firstDateTime(exifTags);
|
||||
const tag = result?.tag;
|
||||
const dateTime = result?.dateTime;
|
||||
@@ -859,7 +883,12 @@ export class MetadataService extends BaseService {
|
||||
if (!localDateTime || !dateTimeOriginal) {
|
||||
// FileCreateDate is not available on linux, likely because exiftool hasn't integrated the statx syscall yet
|
||||
// birthtime is not available in Docker on macOS, so it appears as 0
|
||||
const earliestDate = stats.birthtimeMs ? new Date(Math.min(stats.mtimeMs, stats.birthtimeMs)) : stats.mtime;
|
||||
const earliestDate = new Date(
|
||||
Math.min(
|
||||
asset.fileCreatedAt.getTime(),
|
||||
stats.birthtimeMs ? Math.min(stats.mtimeMs, stats.birthtimeMs) : stats.mtime.getTime(),
|
||||
),
|
||||
);
|
||||
this.logger.debug(
|
||||
`No exif date time found, falling back on ${earliestDate.toISOString()}, earliest of file creation and modification for asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
|
||||
@@ -147,7 +147,7 @@ describe(NotificationService.name, () => {
|
||||
await sut.onUserSignup({ id: '', notify: true });
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NotifyUserSignup,
|
||||
data: { id: '', tempPassword: undefined },
|
||||
data: { id: '', password: undefined },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -191,9 +191,9 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'UserSignup' })
|
||||
async onUserSignup({ notify, id, tempPassword }: ArgOf<'UserSignup'>) {
|
||||
async onUserSignup({ notify, id, password: password }: ArgOf<'UserSignup'>) {
|
||||
if (notify) {
|
||||
await this.jobRepository.queue({ name: JobName.NotifyUserSignup, data: { id, tempPassword } });
|
||||
await this.jobRepository.queue({ name: JobName.NotifyUserSignup, data: { id, password } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,70 +251,8 @@ export class NotificationService extends BaseService {
|
||||
return { messageId };
|
||||
}
|
||||
|
||||
async getTemplate(name: EmailTemplate, customTemplate: string) {
|
||||
const { server, templates } = await this.getConfig({ withCache: false });
|
||||
|
||||
let templateResponse = '';
|
||||
|
||||
switch (name) {
|
||||
case EmailTemplate.WELCOME: {
|
||||
const { html: _welcomeHtml } = await this.emailRepository.renderEmail({
|
||||
template: EmailTemplate.WELCOME,
|
||||
data: {
|
||||
baseUrl: getExternalDomain(server),
|
||||
displayName: 'John Doe',
|
||||
username: 'john@doe.com',
|
||||
password: 'thisIsAPassword123',
|
||||
},
|
||||
customTemplate: customTemplate || templates.email.welcomeTemplate,
|
||||
});
|
||||
|
||||
templateResponse = _welcomeHtml;
|
||||
break;
|
||||
}
|
||||
case EmailTemplate.ALBUM_UPDATE: {
|
||||
const { html: _updateAlbumHtml } = await this.emailRepository.renderEmail({
|
||||
template: EmailTemplate.ALBUM_UPDATE,
|
||||
data: {
|
||||
baseUrl: getExternalDomain(server),
|
||||
albumId: '1',
|
||||
albumName: 'Favorite Photos',
|
||||
recipientName: 'Jane Doe',
|
||||
cid: undefined,
|
||||
},
|
||||
customTemplate: customTemplate || templates.email.albumInviteTemplate,
|
||||
});
|
||||
templateResponse = _updateAlbumHtml;
|
||||
break;
|
||||
}
|
||||
|
||||
case EmailTemplate.ALBUM_INVITE: {
|
||||
const { html } = await this.emailRepository.renderEmail({
|
||||
template: EmailTemplate.ALBUM_INVITE,
|
||||
data: {
|
||||
baseUrl: getExternalDomain(server),
|
||||
albumId: '1',
|
||||
albumName: "John Doe's Favorites",
|
||||
senderName: 'John Doe',
|
||||
recipientName: 'Jane Doe',
|
||||
cid: undefined,
|
||||
},
|
||||
customTemplate: customTemplate || templates.email.albumInviteTemplate,
|
||||
});
|
||||
templateResponse = html;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
templateResponse = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { name, html: templateResponse };
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.NotifyUserSignup, queue: QueueName.Notification })
|
||||
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NotifyUserSignup>) {
|
||||
async handleUserSignup({ id, password }: JobOf<JobName.NotifyUserSignup>) {
|
||||
const user = await this.userRepository.get(id, { withDeleted: false });
|
||||
if (!user) {
|
||||
return JobStatus.Skipped;
|
||||
@@ -327,7 +265,7 @@ export class NotificationService extends BaseService {
|
||||
baseUrl: getExternalDomain(server),
|
||||
displayName: user.name,
|
||||
username: user.email,
|
||||
password: tempPassword,
|
||||
password,
|
||||
},
|
||||
customTemplate: templates.email.welcomeTemplate,
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ describe(PartnerService.name, () => {
|
||||
mocks.partner.get.mockResolvedValue(void 0);
|
||||
mocks.partner.create.mockResolvedValue(partner);
|
||||
|
||||
await expect(sut.create(auth, user2.id)).resolves.toBeDefined();
|
||||
await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined();
|
||||
|
||||
expect(mocks.partner.create).toHaveBeenCalledWith({
|
||||
sharedById: partner.sharedById,
|
||||
@@ -69,7 +69,7 @@ describe(PartnerService.name, () => {
|
||||
|
||||
mocks.partner.get.mockResolvedValue(partner);
|
||||
|
||||
await expect(sut.create(auth, user2.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.partner.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Partner } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
|
||||
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
|
||||
import { mapUser } from 'src/dtos/user.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository';
|
||||
@@ -9,7 +9,7 @@ import { BaseService } from 'src/services/base.service';
|
||||
|
||||
@Injectable()
|
||||
export class PartnerService extends BaseService {
|
||||
async create(auth: AuthDto, sharedWithId: string): Promise<PartnerResponseDto> {
|
||||
async create(auth: AuthDto, { sharedWithId }: PartnerCreateDto): Promise<PartnerResponseDto> {
|
||||
const partnerId: PartnerIds = { sharedById: auth.user.id, sharedWithId };
|
||||
const exists = await this.partnerRepository.get(partnerId);
|
||||
if (exists) {
|
||||
@@ -39,7 +39,7 @@ export class PartnerService extends BaseService {
|
||||
.map((partner) => this.mapPartner(partner, direction));
|
||||
}
|
||||
|
||||
async update(auth: AuthDto, sharedById: string, dto: UpdatePartnerDto): Promise<PartnerResponseDto> {
|
||||
async update(auth: AuthDto, sharedById: string, dto: PartnerUpdateDto): Promise<PartnerResponseDto> {
|
||||
await this.requireAccess({ auth, permission: Permission.PartnerUpdate, ids: [sharedById] });
|
||||
const partnerId: PartnerIds = { sharedById, sharedWithId: auth.user.id };
|
||||
|
||||
|
||||
@@ -729,7 +729,6 @@ describe(PersonService.name, () => {
|
||||
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
|
||||
await sut.handleDetectFaces({ id: assetStub.image.id });
|
||||
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
|
||||
['http://immich-machine-learning:3003'],
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
|
||||
);
|
||||
|
||||
@@ -316,7 +316,6 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
|
||||
const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces(
|
||||
machineLearning.urls,
|
||||
previewFile.path,
|
||||
machineLearning.facialRecognition,
|
||||
);
|
||||
|
||||
@@ -211,7 +211,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ modelName: expect.any(String) }),
|
||||
);
|
||||
@@ -225,7 +224,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ modelName: expect.any(String) }),
|
||||
);
|
||||
@@ -243,7 +241,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }),
|
||||
);
|
||||
@@ -253,7 +250,6 @@ describe(SearchService.name, () => {
|
||||
await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' });
|
||||
|
||||
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
|
||||
[expect.any(String)],
|
||||
'test',
|
||||
expect.objectContaining({ language: 'de' }),
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
SmartSearchDto,
|
||||
StatisticsSearchDto,
|
||||
} from 'src/dtos/search.dto';
|
||||
import { AssetOrder, AssetVisibility } from 'src/enum';
|
||||
import { AssetOrder, AssetVisibility, Permission } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { requireElevatedPermission } from 'src/utils/access';
|
||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||
@@ -113,14 +113,27 @@ export class SearchService extends BaseService {
|
||||
}
|
||||
|
||||
const userIds = this.getUserIdsToSearch(auth);
|
||||
const key = machineLearning.clip.modelName + dto.query + dto.language;
|
||||
let embedding = this.embeddingCache.get(key);
|
||||
if (!embedding) {
|
||||
embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, {
|
||||
modelName: machineLearning.clip.modelName,
|
||||
language: dto.language,
|
||||
});
|
||||
this.embeddingCache.set(key, embedding);
|
||||
let embedding;
|
||||
if (dto.query) {
|
||||
const key = machineLearning.clip.modelName + dto.query + dto.language;
|
||||
embedding = this.embeddingCache.get(key);
|
||||
if (!embedding) {
|
||||
embedding = await this.machineLearningRepository.encodeText(dto.query, {
|
||||
modelName: machineLearning.clip.modelName,
|
||||
language: dto.language,
|
||||
});
|
||||
this.embeddingCache.set(key, embedding);
|
||||
}
|
||||
} else if (dto.queryAssetId) {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.queryAssetId] });
|
||||
const getEmbeddingResponse = await this.searchRepository.getEmbedding(dto.queryAssetId);
|
||||
const assetEmbedding = getEmbeddingResponse?.embedding;
|
||||
if (!assetEmbedding) {
|
||||
throw new BadRequestException(`Asset ${dto.queryAssetId} has no embedding`);
|
||||
}
|
||||
embedding = assetEmbedding;
|
||||
} else {
|
||||
throw new BadRequestException('Either `query` or `queryAssetId` must be set');
|
||||
}
|
||||
const page = dto.page ?? 1;
|
||||
const size = dto.size || 100;
|
||||
|
||||
@@ -205,7 +205,6 @@ describe(SmartInfoService.name, () => {
|
||||
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success);
|
||||
|
||||
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
|
||||
['http://immich-machine-learning:3003'],
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
|
||||
);
|
||||
@@ -242,7 +241,6 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
expect(mocks.database.wait).toHaveBeenCalledWith(512);
|
||||
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
|
||||
['http://immich-machine-learning:3003'],
|
||||
'/uploads/user-id/thumbs/path.jpg',
|
||||
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
|
||||
);
|
||||
|
||||
@@ -108,11 +108,7 @@ export class SmartInfoService extends BaseService {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const embedding = await this.machineLearningRepository.encodeImage(
|
||||
machineLearning.urls,
|
||||
asset.files[0].path,
|
||||
machineLearning.clip,
|
||||
);
|
||||
const embedding = await this.machineLearningRepository.encodeImage(asset.files[0].path, machineLearning.clip);
|
||||
|
||||
if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) {
|
||||
this.logger.verbose(`Waiting for CLIP dimension size to be updated`);
|
||||
|
||||
@@ -338,7 +338,7 @@ export class StorageTemplateService extends BaseService {
|
||||
|
||||
return destination;
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Unable to get template path for ${filename}`, error);
|
||||
this.logger.error(`Unable to get template path for ${filename}: ${error}`);
|
||||
return asset.originalPath;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf, SystemFlags } from 'src/types';
|
||||
import { ImmichStartupError } from 'src/utils/misc';
|
||||
|
||||
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
|
||||
const docsMessage = `Please see https://docs.immich.app/administration/system-integrity#folder-checks for more information.`;
|
||||
|
||||
@Injectable()
|
||||
export class StorageService extends BaseService {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { Insertable } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { Writable } from 'node:stream';
|
||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
@@ -15,7 +16,16 @@ import {
|
||||
SyncItem,
|
||||
SyncStreamDto,
|
||||
} from 'src/dtos/sync.dto';
|
||||
import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import {
|
||||
AssetVisibility,
|
||||
DatabaseAction,
|
||||
EntityType,
|
||||
JobName,
|
||||
Permission,
|
||||
QueueName,
|
||||
SyncEntityType,
|
||||
SyncRequestType,
|
||||
} from 'src/enum';
|
||||
import { SyncQueryOptions } from 'src/repositories/sync.repository';
|
||||
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -32,6 +42,8 @@ type AssetLike = Omit<SyncAssetV1, 'checksum' | 'thumbhash'> & {
|
||||
};
|
||||
|
||||
const COMPLETE_ID = 'complete';
|
||||
const MAX_DAYS = 30;
|
||||
const MAX_DURATION = Duration.fromObject({ days: MAX_DAYS });
|
||||
|
||||
const mapSyncAssetV1 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV1 => ({
|
||||
...data,
|
||||
@@ -74,6 +86,7 @@ export const SYNC_TYPES_ORDER = [
|
||||
SyncRequestType.PeopleV1,
|
||||
SyncRequestType.AssetFacesV1,
|
||||
SyncRequestType.UserMetadataV1,
|
||||
SyncRequestType.AssetMetadataV1,
|
||||
];
|
||||
|
||||
const throwSessionRequired = () => {
|
||||
@@ -136,19 +149,24 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
|
||||
const isPendingSyncReset = await this.sessionRepository.isPendingSyncReset(session.id);
|
||||
|
||||
if (isPendingSyncReset) {
|
||||
send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const checkpoints = await this.syncCheckpointRepository.getAll(session.id);
|
||||
const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)]));
|
||||
|
||||
if (this.needsFullSync(checkpointMap)) {
|
||||
send(response, { type: SyncEntityType.SyncResetV1, ids: ['reset'], data: {} });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const { nowId } = await this.syncCheckpointRepository.getNow();
|
||||
const options: SyncQueryOptions = { nowId, userId: auth.user.id };
|
||||
|
||||
const checkpoints = await this.syncCheckpointRepository.getAll(session.id);
|
||||
const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)]));
|
||||
|
||||
const handlers: Record<SyncRequestType, () => Promise<void>> = {
|
||||
[SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap),
|
||||
[SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap),
|
||||
@@ -156,6 +174,7 @@ export class SyncService extends BaseService {
|
||||
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
|
||||
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
|
||||
[SyncRequestType.PartnerAssetExifsV1]: () =>
|
||||
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
|
||||
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
|
||||
@@ -178,9 +197,41 @@ export class SyncService extends BaseService {
|
||||
await handler();
|
||||
}
|
||||
|
||||
send(response, { type: SyncEntityType.SyncCompleteV1, ids: [nowId], data: {} });
|
||||
|
||||
response.end();
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AuditTableCleanup, queue: QueueName.BackgroundTask })
|
||||
async onAuditTableCleanup() {
|
||||
const pruneThreshold = MAX_DAYS + 1;
|
||||
|
||||
await this.syncRepository.album.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumUser.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.albumToAsset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.asset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.assetFace.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.assetMetadata.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.memory.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.memoryToAsset.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.partner.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.person.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.stack.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.user.cleanupAuditTable(pruneThreshold);
|
||||
await this.syncRepository.userMetadata.cleanupAuditTable(pruneThreshold);
|
||||
}
|
||||
|
||||
private needsFullSync(checkpointMap: CheckpointMap) {
|
||||
const completeAck = checkpointMap[SyncEntityType.SyncCompleteV1];
|
||||
if (!completeAck) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const milliseconds = Number.parseInt(completeAck.updateId.replaceAll('-', '').slice(0, 12), 16);
|
||||
|
||||
return DateTime.fromMillis(milliseconds) < DateTime.now().minus(MAX_DURATION);
|
||||
}
|
||||
|
||||
private async syncAuthUsersV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
const upsertType = SyncEntityType.AuthUserV1;
|
||||
const upserts = this.syncRepository.authUser.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
@@ -717,13 +768,13 @@ export class SyncService extends BaseService {
|
||||
|
||||
private async syncPeopleV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
|
||||
const deleteType = SyncEntityType.PersonDeleteV1;
|
||||
const deletes = this.syncRepository.people.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
||||
const deletes = this.syncRepository.person.getDeletes({ ...options, ack: checkpointMap[deleteType] });
|
||||
for await (const { id, ...data } of deletes) {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.PersonV1;
|
||||
const upserts = this.syncRepository.people.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
const upserts = this.syncRepository.person.getUpserts({ ...options, ack: checkpointMap[upsertType] });
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data });
|
||||
}
|
||||
@@ -759,6 +810,33 @@ export class SyncService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async syncAssetMetadataV1(
|
||||
options: SyncQueryOptions,
|
||||
response: Writable,
|
||||
checkpointMap: CheckpointMap,
|
||||
auth: AuthDto,
|
||||
) {
|
||||
const deleteType = SyncEntityType.AssetMetadataDeleteV1;
|
||||
const deletes = this.syncRepository.assetMetadata.getDeletes(
|
||||
{ ...options, ack: checkpointMap[deleteType] },
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
for await (const { id, ...data } of deletes) {
|
||||
send(response, { type: deleteType, ids: [id], data });
|
||||
}
|
||||
|
||||
const upsertType = SyncEntityType.AssetMetadataV1;
|
||||
const upserts = this.syncRepository.assetMetadata.getUpserts(
|
||||
{ ...options, ack: checkpointMap[upsertType] },
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
send(response, { type: upsertType, ids: [updateId], data });
|
||||
}
|
||||
}
|
||||
|
||||
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
|
||||
const { type, sessionId, createId } = item;
|
||||
await this.syncCheckpointRepository.upsertAll([
|
||||
|
||||
@@ -52,7 +52,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
threads: 0,
|
||||
preset: 'ultrafast',
|
||||
targetAudioCodec: AudioCodec.Aac,
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus, AudioCodec.PcmS16le],
|
||||
acceptedAudioCodecs: [AudioCodec.Aac, AudioCodec.Mp3, AudioCodec.LibOpus],
|
||||
targetResolution: '720',
|
||||
targetVideoCodec: VideoCodec.H264,
|
||||
acceptedVideoCodecs: [VideoCodec.H264],
|
||||
@@ -82,6 +82,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
machineLearning: {
|
||||
enabled: true,
|
||||
urls: ['http://immich-machine-learning:3003'],
|
||||
availabilityChecks: {
|
||||
enabled: true,
|
||||
interval: 30_000,
|
||||
timeout: 2000,
|
||||
},
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
|
||||
@@ -16,6 +16,20 @@ export class SystemConfigService extends BaseService {
|
||||
async onBootstrap() {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
await this.eventRepository.emit('ConfigInit', { newConfig: config });
|
||||
|
||||
if (
|
||||
process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT ||
|
||||
process.env.IMMICH_MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME
|
||||
) {
|
||||
this.logger.deprecate(
|
||||
'IMMICH_MACHINE_LEARNING_PING_TIMEOUT and MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME have been moved to system config(`machineLearning.availabilityChecks`) and will be removed in a future release.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'AppShutdown' })
|
||||
onShutdown() {
|
||||
this.machineLearningRepository.teardown();
|
||||
}
|
||||
|
||||
async getSystemConfig(): Promise<SystemConfigDto> {
|
||||
@@ -28,12 +42,14 @@ export class SystemConfigService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigInit', priority: -100 })
|
||||
onConfigInit({ newConfig: { logging } }: ArgOf<'ConfigInit'>) {
|
||||
onConfigInit({ newConfig: { logging, machineLearning } }: ArgOf<'ConfigInit'>) {
|
||||
const { logLevel: envLevel } = this.configRepository.getEnv();
|
||||
const configLevel = logging.enabled ? logging.level : false;
|
||||
const level = envLevel ?? configLevel;
|
||||
this.logger.setLogLevel(level);
|
||||
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`);
|
||||
|
||||
this.machineLearningRepository.setup(machineLearning);
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'ConfigUpdate', server: true })
|
||||
|
||||
@@ -38,7 +38,7 @@ export class UserAdminService extends BaseService {
|
||||
await this.eventRepository.emit('UserSignup', {
|
||||
notify: !!notify,
|
||||
id: user.id,
|
||||
tempPassword: user.shouldChangePassword ? userDto.password : undefined,
|
||||
password: userDto.password,
|
||||
});
|
||||
|
||||
return mapUserAdmin(user);
|
||||
|
||||
@@ -95,7 +95,7 @@ export class VersionService extends BaseService {
|
||||
this.eventRepository.clientBroadcast('on_new_release', asNotification(metadata));
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
|
||||
this.logger.warn(`Unable to run version check: ${error}\n${error?.stack}`);
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
|
||||
+31
-6
@@ -1,6 +1,9 @@
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
AssetMetadataKey,
|
||||
AssetOrder,
|
||||
AssetType,
|
||||
DatabaseSslMode,
|
||||
@@ -246,7 +249,7 @@ export interface IEmailJob {
|
||||
}
|
||||
|
||||
export interface INotifySignupJob extends IEntityJob {
|
||||
tempPassword?: string;
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface INotifyAlbumInviteJob extends IEntityJob {
|
||||
@@ -272,6 +275,9 @@ export interface QueueStatus {
|
||||
}
|
||||
|
||||
export type JobItem =
|
||||
// Audit
|
||||
| { name: JobName.AuditTableCleanup; data?: IBaseJob }
|
||||
|
||||
// Backups
|
||||
| { name: JobName.DatabaseBackup; data?: IBaseJob }
|
||||
|
||||
@@ -406,6 +412,16 @@ export interface UploadFile {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export type UploadRequest = {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
body: {
|
||||
filename?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
@@ -464,11 +480,6 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
|
||||
[SystemMetadataKey.MemoriesState]: MemoriesState;
|
||||
}
|
||||
|
||||
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
||||
key: T;
|
||||
value: UserMetadata[T];
|
||||
};
|
||||
|
||||
export interface UserPreferences {
|
||||
albums: {
|
||||
defaultAssetOrder: AssetOrder;
|
||||
@@ -513,8 +524,22 @@ export interface UserPreferences {
|
||||
};
|
||||
}
|
||||
|
||||
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
|
||||
key: T;
|
||||
value: UserMetadata[T];
|
||||
};
|
||||
|
||||
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
|
||||
[UserMetadataKey.Preferences]: DeepPartial<UserPreferences>;
|
||||
[UserMetadataKey.License]: { licenseKey: string; activationKey: string; activatedAt: string };
|
||||
[UserMetadataKey.Onboarding]: { isOnboarded: boolean };
|
||||
}
|
||||
|
||||
export type AssetMetadataItem<T extends keyof AssetMetadata = AssetMetadataKey> = {
|
||||
key: T;
|
||||
value: AssetMetadata[T];
|
||||
};
|
||||
|
||||
export interface AssetMetadata extends Record<AssetMetadataKey, Record<string, any>> {
|
||||
[AssetMetadataKey.MobileApp]: { iCloudId: string };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
|
||||
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
|
||||
import { checkAccess } from 'src/utils/access';
|
||||
|
||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
|
||||
@@ -191,9 +191,10 @@ export function mapToUploadFile(file: ImmichFile): UploadFile {
|
||||
};
|
||||
}
|
||||
|
||||
export const asRequest = (request: AuthRequest, file: Express.Multer.File) => {
|
||||
export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => {
|
||||
return {
|
||||
auth: request.user || null,
|
||||
body: request.body,
|
||||
fieldName: file.fieldname as UploadFieldName,
|
||||
file: mapToUploadFile(file as ImmichFile),
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import postgres, { Notice } from 'postgres';
|
||||
import postgres, { Notice, PostgresError } from 'postgres';
|
||||
import { columns, Exif, Person } from 'src/database';
|
||||
import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
@@ -153,6 +153,10 @@ export function toJson<DB, TB extends keyof DB & string, T extends TB | Expressi
|
||||
|
||||
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
|
||||
|
||||
export const isAssetChecksumConstraint = (error: unknown) => {
|
||||
return (error as PostgresError)?.constraint_name === 'UQ_assets_owner_checksum';
|
||||
};
|
||||
|
||||
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
|
||||
return qb.where('asset.visibility', 'in', [sql.lit(AssetVisibility.Archive), sql.lit(AssetVisibility.Timeline)]);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export const sendFile = async (
|
||||
|
||||
// log non-http errors
|
||||
if (error instanceof HttpException === false) {
|
||||
logger.error(`Unable to send file: ${error.name}`, error.stack);
|
||||
logger.error(`Unable to send file: ${error}`, error.stack);
|
||||
}
|
||||
|
||||
res.header('Cache-Control', 'none');
|
||||
|
||||
@@ -211,6 +211,18 @@ export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => {
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type StringOptions = { optional?: boolean; nullable?: boolean; trim?: boolean };
|
||||
export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, trim, ...apiPropertyOptions } = options || {};
|
||||
const decorators = [ApiProperty(apiPropertyOptions), IsString(), optional ? Optional({ nullable }) : IsNotEmpty()];
|
||||
|
||||
if (trim) {
|
||||
decorators.push(Transform(({ value }: { value: string }) => value?.trim()));
|
||||
}
|
||||
|
||||
return applyDecorators(...decorators);
|
||||
};
|
||||
|
||||
type BooleanOptions = { optional?: boolean; nullable?: boolean };
|
||||
export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => {
|
||||
const { optional, nullable, ...apiPropertyOptions } = options || {};
|
||||
|
||||
Vendored
+1
-1
@@ -47,7 +47,7 @@ export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif
|
||||
primaryAssetId: assets[0].id,
|
||||
createdAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
|
||||
updateId: 'uuid-v7',
|
||||
updateId: expect.any(String),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Vendored
+5
-1
@@ -1,5 +1,6 @@
|
||||
import { Tag } from 'src/database';
|
||||
import { TagResponseDto } from 'src/dtos/tag.dto';
|
||||
import { newUuidV7 } from 'test/small.factory';
|
||||
|
||||
const parent = Object.freeze<Tag>({
|
||||
id: 'tag-parent',
|
||||
@@ -37,7 +38,10 @@ const color = {
|
||||
parentId: null,
|
||||
};
|
||||
|
||||
const upsert = { userId: 'tag-user', updateId: 'uuid-v7' };
|
||||
const upsert = {
|
||||
userId: 'tag-user',
|
||||
updateId: newUuidV7(),
|
||||
};
|
||||
|
||||
export const tagStub = {
|
||||
tag,
|
||||
|
||||
@@ -258,6 +258,12 @@ export class SyncTestContext extends MediumTestContext<SyncService> {
|
||||
return stream.getResponse();
|
||||
}
|
||||
|
||||
async assertSyncIsComplete(auth: AuthDto, types: SyncRequestType[]) {
|
||||
await expect(this.syncStream(auth, types)).resolves.toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
}
|
||||
|
||||
async syncAckAll(auth: AuthDto, response: Array<{ type: string; ack: string }>) {
|
||||
const acks: Record<string, string> = {};
|
||||
const syncAcks: string[] = [];
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AssetMetadataKey, UserMetadataKey } from 'src/enum';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(SyncService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [DatabaseRepository, SyncRepository],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
const deletedLongAgo = DateTime.now().minus({ days: 35 }).toISO();
|
||||
|
||||
const assertTableCount = async <T extends keyof DB>(db: Kysely<DB>, t: T, count: number) => {
|
||||
const { table } = db.dynamic;
|
||||
const results = await db.selectFrom(table(t).as(t)).selectAll().execute();
|
||||
expect(results).toHaveLength(count);
|
||||
};
|
||||
|
||||
describe(SyncService.name, () => {
|
||||
describe('onAuditTableCleanup', () => {
|
||||
it('should work', async () => {
|
||||
const { sut } = setup();
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cleanup the album_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'album_audit';
|
||||
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ albumId: v4(), userId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the album_asset_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'album_asset_audit';
|
||||
const { user } = await ctx.newUser();
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ albumId: album.id, assetId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the album_user_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'album_user_audit';
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ albumId: v4(), userId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the asset_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
await ctx.database
|
||||
.insertInto('asset_audit')
|
||||
.values({ assetId: v4(), ownerId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, 'asset_audit', 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, 'asset_audit', 0);
|
||||
});
|
||||
|
||||
it('should cleanup the asset_face_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'asset_face_audit';
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ assetFaceId: v4(), assetId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the asset_metadata_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'asset_metadata_audit';
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ assetId: v4(), key: AssetMetadataKey.MobileApp, deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the memory_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'memory_audit';
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ memoryId: v4(), userId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the memory_asset_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'memory_asset_audit';
|
||||
const { user } = await ctx.newUser();
|
||||
const { memory } = await ctx.newMemory({ ownerId: user.id });
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ memoryId: memory.id, assetId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the partner_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'partner_audit';
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ sharedById: v4(), sharedWithId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the stack_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'stack_audit';
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ stackId: v4(), userId: v4(), deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the user_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'user_audit';
|
||||
await ctx.database.insertInto(tableName).values({ userId: v4(), deletedAt: deletedLongAgo }).execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should cleanup the user_metadata_audit table', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const tableName = 'user_metadata_audit';
|
||||
await ctx.database
|
||||
.insertInto(tableName)
|
||||
.values({ userId: v4(), key: UserMetadataKey.Onboarding, deletedAt: deletedLongAgo })
|
||||
.execute();
|
||||
|
||||
await assertTableCount(ctx.database, tableName, 1);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
await assertTableCount(ctx.database, tableName, 0);
|
||||
});
|
||||
|
||||
it('should skip recent records', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
|
||||
const keep = {
|
||||
id: v4(),
|
||||
assetId: v4(),
|
||||
ownerId: v4(),
|
||||
deletedAt: DateTime.now().minus({ days: 25 }).toISO(),
|
||||
};
|
||||
|
||||
const remove = {
|
||||
id: v4(),
|
||||
assetId: v4(),
|
||||
ownerId: v4(),
|
||||
deletedAt: DateTime.now().minus({ days: 35 }).toISO(),
|
||||
};
|
||||
|
||||
await ctx.database.insertInto('asset_audit').values([keep, remove]).execute();
|
||||
await assertTableCount(ctx.database, 'asset_audit', 2);
|
||||
await expect(sut.onAuditTableCleanup()).resolves.toBeUndefined();
|
||||
|
||||
const after = await ctx.database.selectFrom('asset_audit').select(['id']).execute();
|
||||
expect(after).toHaveLength(1);
|
||||
expect(after[0].id).toBe(keep.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -74,11 +74,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumAssetExifCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(response).toHaveLength(2);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
|
||||
});
|
||||
|
||||
it('should sync album asset exif for own user', async () => {
|
||||
@@ -88,8 +88,15 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(2);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AssetExifsV1])).resolves.toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumAssetExifCreateV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not sync album asset exif for unrelated user', async () => {
|
||||
@@ -104,8 +111,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
const { session } = await ctx.newSession({ userId: user3.id });
|
||||
const authUser3 = factory.auth({ session, user: user3 });
|
||||
|
||||
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toHaveLength(0);
|
||||
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AssetExifV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
|
||||
});
|
||||
|
||||
it('should backfill album assets exif when a user shares an album with you', async () => {
|
||||
@@ -139,8 +149,8 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(response).toHaveLength(2);
|
||||
|
||||
// ack initial album asset exif sync
|
||||
await ctx.syncAckAll(auth, response);
|
||||
@@ -174,11 +184,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(newResponse).toHaveLength(5);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
|
||||
});
|
||||
|
||||
it('should sync old asset exif when a user adds them to an album they share you', async () => {
|
||||
@@ -207,8 +217,8 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(firstAlbumResponse).toHaveLength(2);
|
||||
|
||||
await ctx.syncAckAll(auth, firstAlbumResponse);
|
||||
|
||||
@@ -224,8 +234,8 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
type: SyncEntityType.AlbumAssetExifBackfillV1,
|
||||
},
|
||||
backfillSyncAck,
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(response).toHaveLength(2);
|
||||
|
||||
// ack initial album asset sync
|
||||
await ctx.syncAckAll(auth, response);
|
||||
@@ -244,11 +254,11 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(newResponse).toHaveLength(2);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetExifsV1]);
|
||||
});
|
||||
|
||||
it('should sync asset exif updates for an album shared with you', async () => {
|
||||
@@ -262,7 +272,6 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
|
||||
expect(response).toHaveLength(2);
|
||||
expect(response).toEqual([
|
||||
updateSyncAck,
|
||||
{
|
||||
@@ -272,6 +281,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
@@ -283,9 +293,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
city: 'New City',
|
||||
});
|
||||
|
||||
const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
|
||||
expect(updateResponse).toHaveLength(1);
|
||||
expect(updateResponse).toEqual([
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({
|
||||
@@ -294,6 +302,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifUpdateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -330,8 +339,8 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(response).toHaveLength(3);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
|
||||
@@ -342,8 +351,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
city: 'Delayed Exif',
|
||||
});
|
||||
|
||||
const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1]);
|
||||
expect(updateResponse).toEqual([
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetExifsV1])).resolves.toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({
|
||||
@@ -352,7 +360,7 @@ describe(SyncRequestType.AlbumAssetExifsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetExifUpdateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
expect(updateResponse).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,6 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
expect(response).toHaveLength(2);
|
||||
expect(response).toEqual([
|
||||
updateSyncAck,
|
||||
{
|
||||
@@ -83,10 +82,11 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
});
|
||||
|
||||
it('should sync album asset for own user', async () => {
|
||||
@@ -95,8 +95,15 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(2);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumAssetCreateV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('should not sync album asset for unrelated user', async () => {
|
||||
@@ -110,8 +117,11 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
const { session } = await ctx.newSession({ userId: user3.id });
|
||||
const authUser3 = factory.auth({ session, user: user3 });
|
||||
|
||||
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(0);
|
||||
await expect(ctx.syncStream(authUser3, [SyncRequestType.AssetsV1])).resolves.toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AssetV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
});
|
||||
|
||||
it('should backfill album assets when a user shares an album with you', async () => {
|
||||
@@ -133,7 +143,6 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
expect(response).toHaveLength(2);
|
||||
expect(response).toEqual([
|
||||
updateSyncAck,
|
||||
{
|
||||
@@ -143,6 +152,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
// ack initial album asset sync
|
||||
@@ -176,10 +186,11 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
});
|
||||
|
||||
it('should sync old assets when a user adds them to an album they share you', async () => {
|
||||
@@ -196,7 +207,6 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
expect(firstAlbumResponse).toHaveLength(2);
|
||||
expect(firstAlbumResponse).toEqual([
|
||||
updateSyncAck,
|
||||
{
|
||||
@@ -206,6 +216,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, firstAlbumResponse);
|
||||
@@ -213,7 +224,6 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
// expect(response).toHaveLength(2);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -223,6 +233,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
type: SyncEntityType.AlbumAssetBackfillV1,
|
||||
},
|
||||
backfillSyncAck,
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
// ack initial album asset sync
|
||||
@@ -242,10 +253,11 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
});
|
||||
|
||||
it('should sync asset updates for an album shared with you', async () => {
|
||||
@@ -258,7 +270,6 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
expect(response).toHaveLength(2);
|
||||
expect(response).toEqual([
|
||||
updateSyncAck,
|
||||
{
|
||||
@@ -268,6 +279,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetCreateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
@@ -280,7 +292,6 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
});
|
||||
|
||||
const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
|
||||
expect(updateResponse).toHaveLength(1);
|
||||
expect(updateResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -290,6 +301,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumAssetUpdateV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -38,10 +37,11 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should sync album to asset for owned albums', async () => {
|
||||
@@ -51,7 +51,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -61,10 +60,11 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync the album to asset for shared albums', async () => {
|
||||
@@ -76,7 +76,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -86,10 +85,11 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should not sync album to asset for an album owned by another user', async () => {
|
||||
@@ -98,7 +98,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user2.id });
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should backfill album to assets when a user shares an album with you', async () => {
|
||||
@@ -114,7 +114,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -124,6 +123,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
// ack initial album to asset sync
|
||||
@@ -148,10 +148,11 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
data: {},
|
||||
type: SyncEntityType.SyncAckV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync a deleted album to asset relation', async () => {
|
||||
@@ -162,7 +163,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -172,6 +172,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
@@ -179,7 +180,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await wait(2);
|
||||
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(newResponse).toHaveLength(1);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -189,10 +189,11 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync a deleted album to asset relation when an asset is deleted', async () => {
|
||||
@@ -203,7 +204,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -213,6 +213,7 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
@@ -220,7 +221,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await wait(2);
|
||||
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(newResponse).toHaveLength(1);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -230,10 +230,11 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
|
||||
it('should not sync a deleted album to asset relation when the album is deleted', async () => {
|
||||
@@ -244,7 +245,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -254,11 +254,12 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumToAssetV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await albumRepo.delete(album.id);
|
||||
await wait(2);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -45,7 +46,6 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -56,10 +56,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync an updated shared user', async () => {
|
||||
@@ -71,11 +72,10 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
|
||||
await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.Viewer });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(newResponse).toHaveLength(1);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -86,10 +86,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync a deleted shared user', async () => {
|
||||
@@ -100,9 +101,8 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
const { albumUser } = await ctx.newAlbumUser({ albumId: album.id, userId: user1.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
|
||||
await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
@@ -115,10 +115,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,7 +135,6 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
});
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -145,10 +145,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync an updated shared user', async () => {
|
||||
@@ -161,10 +162,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(response).toHaveLength(2);
|
||||
expect(response).toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
|
||||
await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.Viewer });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
@@ -178,10 +183,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync a deleted shared user', async () => {
|
||||
@@ -194,10 +200,14 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(response).toHaveLength(2);
|
||||
expect(response).toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumUserV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
await ctx.syncAckAll(auth, response);
|
||||
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
await albumUserRepo.delete({ albumsId: album.id, usersId: user.id });
|
||||
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
@@ -210,10 +220,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
});
|
||||
|
||||
it('should backfill album users when a user shares an album with you', async () => {
|
||||
@@ -232,7 +243,6 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album1.id, userId: user2.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -243,6 +253,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
// ack initial user
|
||||
@@ -285,10 +296,11 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumUserV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -35,10 +34,11 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync a new album', async () => {
|
||||
@@ -46,7 +46,6 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -55,10 +54,11 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync an album delete', async () => {
|
||||
@@ -67,7 +67,6 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
const { album } = await ctx.newAlbum({ ownerId: auth.user.id });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -76,12 +75,12 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
}),
|
||||
type: SyncEntityType.AlbumV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await albumRepo.delete(album.id);
|
||||
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(newResponse).toHaveLength(1);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
@@ -90,10 +89,11 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
},
|
||||
type: SyncEntityType.AlbumDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
|
||||
describe('shared albums', () => {
|
||||
@@ -104,17 +104,17 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({ id: album.id }),
|
||||
type: SyncEntityType.AlbumV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync an album share (share before sync)', async () => {
|
||||
@@ -124,17 +124,17 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({ id: album.id }),
|
||||
type: SyncEntityType.AlbumV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync an album share (share after sync)', async () => {
|
||||
@@ -150,23 +150,24 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
data: expect.objectContaining({ id: userAlbum.id }),
|
||||
type: SyncEntityType.AlbumV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await ctx.newAlbumUser({ userId: auth.user.id, albumId: user2Album.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(newResponse).toHaveLength(1);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: expect.objectContaining({ id: user2Album.id }),
|
||||
type: SyncEntityType.AlbumV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync an album delete`', async () => {
|
||||
@@ -177,24 +178,27 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
|
||||
await albumRepo.delete(album.id);
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(newResponse).toHaveLength(1);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.any(String),
|
||||
data: { albumId: album.id },
|
||||
type: SyncEntityType.AlbumDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
|
||||
it('should detect and sync an album unshare as an album delete', async () => {
|
||||
@@ -205,10 +209,13 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
|
||||
|
||||
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(response).toHaveLength(1);
|
||||
expect(response).toEqual([
|
||||
expect.objectContaining({ type: SyncEntityType.AlbumV1 }),
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
|
||||
await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
@@ -218,10 +225,11 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
data: { albumId: album.id },
|
||||
type: SyncEntityType.AlbumDeleteV1,
|
||||
},
|
||||
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
|
||||
]);
|
||||
|
||||
await ctx.syncAckAll(auth, newResponse);
|
||||
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([]);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user