1
0
forked from Cutlery/immich

Compare commits

..

21 Commits

Author SHA1 Message Date
Alex The Bot 4027cba4eb Version v1.98.2 2024-03-11 14:17:12 +00:00
Mert 5bd597f14b fix(server): external library sync not working for large libraries (#7759) 2024-03-10 22:30:57 -04:00
Ghazi Tounsi 49d9051879 fix(mobile): archive desc sorting (#7822)
desc sort
2024-03-10 21:15:34 -05:00
DeclanE e5978981f3 Fix: Disable 'As profile picture' option for videos in context menu a… (#7830)
* Fix: Disable 'As profile picture' option for videos in context menu asset-viewer-nav-bar.svelte

This commit modifies the context menu behavior to disable the "As profile picture" option when interacting with video assets. Previously, the option was available for all asset types, including videos, which could lead to confusion when this displayed an error.

With this change, the "As profile picture" option is conditionally rendered based on the asset type. If the asset is a video, the option is not displayed in the context menu.

This adjustment enhances the web experience by preventing users from attempting to set a video as their profile picture, which is not supported by the system.

Fixes: #7724

* Switched to check if photo instead of video
2024-03-10 18:32:27 -04:00
Sam Holton d257cdcbbf feat(web): add sticky date headers for asset-date-group (#7824)
* feat(web): add sticky date headers for asset-date-group

* use existing classes
2024-03-10 15:32:05 -04:00
Daniel Dietzler 60c521101a chore(server): type checks for e2e (#7800)
type checks for e2e
2024-03-09 23:18:25 +00:00
Daniel Dietzler 11e7533a4d chore(server): user e2e: wait for user delete event (#7799)
* wait for user delete event

* fix update event names

* add test for hard deletion of user
2024-03-10 00:10:24 +01:00
Daniel Dietzler ec8fb0be83 chore(server): remove unused storage repository variable from microservices app service (#7797)
remove unused storage repository from microservices app service
2024-03-09 16:06:31 -05:00
Alex a6cd4b8427 chore(server): openapi (#7794)
* chore(server): openapi

* openapi
2024-03-09 14:01:52 -06:00
renovate[bot] 3bdd2612ce chore(deps): update typescript-eslint monorepo to v7.1.1 (#7790)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-03-09 13:33:11 -05:00
Jason Rasmussen 30b0b2474e refactor: asset e2e (#7769) 2024-03-09 12:51:58 -05:00
martin 8eb9dad989 fix: update e2e (#7710)
* fix: update e2e

* update package.json

* fix: version
2024-03-08 23:16:36 -05:00
Fynn Petersen-Frey 3f1d37e556 feat(server): hardware HDR tonemapping for RKMPP (#7655)
* feat(server): hardware HDR tonemapping for RKMPP

* review feedback
2024-03-08 21:17:26 -05:00
Ben McCann ba55e867e0 perf: precompress and cache assets (#7757)
* perf: precompress and cache assets

* fix cache header

* use startswith

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2024-03-08 21:15:38 -05:00
Alex 4fdb0835c9 chore: post release tasks 2024-03-08 19:08:33 -06:00
Alex The Bot 430561d692 Version v1.98.1 2024-03-08 23:44:13 +00:00
Alex e8fb529026 fix(server): getAllAssets doesn't return all assets (#7752)
* fix(server): getAllAssets doesn't return all assets

* try reverting

* fix: archive and remove unused method

* update sql

* remove unused code

* linting
2024-03-08 17:16:32 -06:00
Sam Holton 7a4ae7d142 feat(server,web): add force delete to immediately remove user (#7681)
* feat(server,web): add force delete to immediately remove user

* update wording on force delete confirmation

* fix force delete css

* PR feedback

* cleanup user service delete for force

* adding user status column

* some cleanup and tests

* more test fixes

* run npm run sql:generate

* chore: cleanup and websocket

* chore: linting

* userRepository.restore

* removed bad color class from delete-confirm-dialoge

* additional confirmation for user force delete

* shorten confirmation message

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2024-03-08 17:49:39 -05:00
Andrew Roberts 9cb0a1ffbf fix(web): modal password reset modal in dark mode (#7748)
* Fixed dark mode password reset success

* Fixed prettier issue
2024-03-08 14:05:15 -05:00
Michel Heusschen fa32c6660c fix(web): album state after removing assets (#7745)
* fix(web): album state after removing assets

* refresh album on remove + simplify AlbumSummary
2024-03-08 14:03:37 -05:00
DeclanE fe8c6b17a6 chore: rename "Library" to "External Library" in system settings (#7744)
* Change "Library" > "External Library" under system settings

This is intended to assist with any confusion regarding standard libraries

* Changed key from "library" to "external-library"

* Updated "Encode Clip" to "Smart Search"
2024-03-08 16:49:44 +00:00
122 changed files with 2071 additions and 2242 deletions
+4 -17
View File
@@ -10,23 +10,6 @@ concurrency:
cancel-in-progress: true
jobs:
server-e2e-api:
name: Server (e2e-api)
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./server
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run npm install
run: npm ci
- name: Run e2e tests
run: npm run e2e:api
server-e2e-jobs:
name: Server (e2e-jobs)
runs-on: ubuntu-latest
@@ -213,6 +196,10 @@ jobs:
run: npm run format
if: ${{ !cancelled() }}
- name: Run tsc
run: npm run check
if: ${{ !cancelled() }}
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
if: ${{ !cancelled() }}
-3
View File
@@ -19,9 +19,6 @@ pull-stage:
server-e2e-jobs:
docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build
server-e2e-api:
npm run e2e:api --prefix server
.PHONY: e2e
e2e:
docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans
+1 -1
View File
@@ -46,7 +46,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"devDependencies": {
+6 -2
View File
@@ -1,9 +1,9 @@
version: "3.8"
# Configurations for hardware-accelerated transcoding
# Configurations for hardware-accelerated transcoding
# If using Unraid or another platform that doesn't allow multiple Compose files,
# you can inline the config for a backend by copying its contents
# you can inline the config for a backend by copying its contents
# into the immich-microservices service in the docker-compose.yml file.
# See https://immich.app/docs/features/hardware-transcoding for more info on using hardware transcoding.
@@ -38,6 +38,10 @@ services:
- /dev/dri:/dev/dri
- /dev/dma_heap:/dev/dma_heap
- /dev/mpp_service:/dev/mpp_service
#- /dev/mali0:/dev/mali0 # only required to enable OpenCL-accelerated HDR -> SDR tonemapping
volumes:
#- /etc/OpenCL:/etc/OpenCL:ro # only required to enable OpenCL-accelerated HDR -> SDR tonemapping
#- /usr/lib/aarch64-linux-gnu/libmali.so.1:/usr/lib/aarch64-linux-gnu/libmali.so.1:ro # only required to enable OpenCL-accelerated HDR -> SDR tonemapping
vaapi:
devices:
@@ -42,6 +42,18 @@ You do not need to redo any transcoding jobs after enabling hardware acceleratio
- If you have an 11th gen CPU or older, then you may need to follow [these][jellyfin-lp] instructions as Low-Power mode is required
- Additionally, if the server specifically has an 11th gen CPU and is running kernel 5.15 (shipped with Ubuntu 22.04 LTS), then you will need to upgrade this kernel (from [Jellyfin docs][jellyfin-kernel-bug])
#### RKMPP
For RKMPP to work:
- You must have a supported Rockchip ARM SoC.
- Only RK3588 supports hardware tonemapping, other SoCs use slower software tonemapping while still using hardware encoding.
- Tonemapping requires `/usr/lib/aarch64-linux-gnu/libmali.so.1` to be present on your host system. Install [`libmali-valhall-g610-g6p0-gbm`][libmali-rockchip] and modify the [`hwaccel.transcoding.yml`][hw-file] file:
- under `rkmpp` uncomment the 3 lines required for OpenCL tonemapping by removing the `#` symbol at the beginning of each line
- `- /dev/mali0:/dev/mali0`
- `- /etc/OpenCL:/etc/OpenCL:ro`
- `- /usr/lib/aarch64-linux-gnu/libmali.so.1:/usr/lib/aarch64-linux-gnu/libmali.so.1:ro`
## Setup
#### Basic Setup
@@ -106,3 +118,4 @@ Once this is done, you can continue to step 3 of "Basic Setup".
[nvcr]: https://github.com/NVIDIA/nvidia-container-runtime/
[jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux
[jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations
[libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases
+45 -45
View File
@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.0.0",
"version": "1.98.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.0.0",
"version": "1.98.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@immich/cli": "file:../cli",
@@ -79,7 +79,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.2",
"dev": true,
"license": "GNU Affero General Public License version 3",
"devDependencies": {
@@ -1274,16 +1274,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.0.tgz",
"integrity": "sha512-j6vT/kCulhG5wBmGtstKeiVr1rdXE4nk+DT1k6trYkwlrvW9eOF5ZbgKnd/YR6PcM4uTEXa0h6Fcvf6X7Dxl0w==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz",
"integrity": "sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.1.0",
"@typescript-eslint/type-utils": "7.1.0",
"@typescript-eslint/utils": "7.1.0",
"@typescript-eslint/visitor-keys": "7.1.0",
"@typescript-eslint/scope-manager": "7.1.1",
"@typescript-eslint/type-utils": "7.1.1",
"@typescript-eslint/utils": "7.1.1",
"@typescript-eslint/visitor-keys": "7.1.1",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -1309,15 +1309,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.0.tgz",
"integrity": "sha512-V1EknKUubZ1gWFjiOZhDSNToOjs63/9O0puCgGS8aDOgpZY326fzFu15QAUjwaXzRZjf/qdsdBrckYdv9YxB8w==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.1.tgz",
"integrity": "sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.1.0",
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/typescript-estree": "7.1.0",
"@typescript-eslint/visitor-keys": "7.1.0",
"@typescript-eslint/scope-manager": "7.1.1",
"@typescript-eslint/types": "7.1.1",
"@typescript-eslint/typescript-estree": "7.1.1",
"@typescript-eslint/visitor-keys": "7.1.1",
"debug": "^4.3.4"
},
"engines": {
@@ -1337,13 +1337,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.0.tgz",
"integrity": "sha512-6TmN4OJiohHfoOdGZ3huuLhpiUgOGTpgXNUPJgeZOZR3DnIpdSgtt83RS35OYNNXxM4TScVlpVKC9jyQSETR1A==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz",
"integrity": "sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/visitor-keys": "7.1.0"
"@typescript-eslint/types": "7.1.1",
"@typescript-eslint/visitor-keys": "7.1.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -1354,13 +1354,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.0.tgz",
"integrity": "sha512-UZIhv8G+5b5skkcuhgvxYWHjk7FW7/JP5lPASMEUoliAPwIH/rxoUSQPia2cuOj9AmDZmwUl1usKm85t5VUMew==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.1.tgz",
"integrity": "sha512-5r4RKze6XHEEhlZnJtR3GYeCh1IueUHdbrukV2KSlLXaTjuSfeVF8mZUVPLovidCuZfbVjfhi4c0DNSa/Rdg5g==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.1.0",
"@typescript-eslint/utils": "7.1.0",
"@typescript-eslint/typescript-estree": "7.1.1",
"@typescript-eslint/utils": "7.1.1",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@@ -1381,9 +1381,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.0.tgz",
"integrity": "sha512-qTWjWieJ1tRJkxgZYXx6WUYtWlBc48YRxgY2JN1aGeVpkhmnopq+SUC8UEVGNXIvWH7XyuTjwALfG6bFEgCkQA==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz",
"integrity": "sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -1394,13 +1394,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.0.tgz",
"integrity": "sha512-k7MyrbD6E463CBbSpcOnwa8oXRdHzH1WiVzOipK3L5KSML92ZKgUBrTlehdi7PEIMT8k0bQixHUGXggPAlKnOQ==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.1.tgz",
"integrity": "sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/visitor-keys": "7.1.0",
"@typescript-eslint/types": "7.1.1",
"@typescript-eslint/visitor-keys": "7.1.1",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -1446,17 +1446,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.0.tgz",
"integrity": "sha512-WUFba6PZC5OCGEmbweGpnNJytJiLG7ZvDBJJoUcX4qZYf1mGZ97mO2Mps6O2efxJcJdRNpqweCistDbZMwIVHw==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz",
"integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.1.0",
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/typescript-estree": "7.1.0",
"@typescript-eslint/scope-manager": "7.1.1",
"@typescript-eslint/types": "7.1.1",
"@typescript-eslint/typescript-estree": "7.1.1",
"semver": "^7.5.4"
},
"engines": {
@@ -1471,12 +1471,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.0.tgz",
"integrity": "sha512-FhUqNWluiGNzlvnDZiXad4mZRhtghdoKW6e98GoEOYSu5cND+E39rG5KwJMUzeENwm1ztYBRqof8wMLP+wNPIA==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.1.tgz",
"integrity": "sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/types": "7.1.1",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "1.0.0",
"version": "1.98.2",
"description": "",
"main": "index.js",
"type": "module",
@@ -12,7 +12,8 @@
"format": "prettier --check .",
"format:fix": "prettier --write .",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"lint:fix": "npm run lint -- --fix"
"lint:fix": "npm run lint -- --fix",
"check": "tsc --noEmit"
},
"keywords": [],
"author": "",
+1 -1
View File
@@ -92,7 +92,7 @@ describe('/album', () => {
}),
]);
await deleteUser({ id: user3.userId }, { headers: asBearerAuth(admin.accessToken) });
await deleteUser({ id: user3.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /album', () => {
+538 -31
View File
@@ -2,20 +2,45 @@ import {
AssetFileUploadResponseDto,
AssetResponseDto,
AssetTypeEnum,
LibraryResponseDto,
LoginResponseDto,
SharedLinkType,
TimeBucketSize,
getAllLibraries,
getAssetInfo,
updateAssets,
} from '@immich/sdk';
import { exiftool } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
import { createUserDto, uuidDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import { errorDto } from 'src/responses';
import { app, tempDir, testAssetDir, utils } from 'src/utils';
import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
const dto: Record<string, any> = {
deviceAssetId: 'example-image',
deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
isFavorite: 'testing',
duration: '0:00:00.000000',
};
const omit = options?.omit;
if (omit) {
delete dto[omit];
}
return dto;
};
const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
@@ -35,34 +60,43 @@ const yesterday = today.minus({ days: 1 });
describe('/asset', () => {
let admin: LoginResponseDto;
let websocket: Socket;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let userStats: LoginResponseDto;
let timeBucketUser: LoginResponseDto;
let quotaUser: LoginResponseDto;
let statsUser: LoginResponseDto;
let stackUser: LoginResponseDto;
let user1Assets: AssetFileUploadResponseDto[];
let user2Assets: AssetFileUploadResponseDto[];
let assetLocation: AssetFileUploadResponseDto;
let ws: Socket;
let stackAssets: AssetFileUploadResponseDto[];
let locationAsset: AssetFileUploadResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[ws, user1, user2, userStats] = await Promise.all([
[websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([
utils.connectWebsocket(admin.accessToken),
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.create('1')),
utils.userSetup(admin.accessToken, createUserDto.create('2')),
utils.userSetup(admin.accessToken, createUserDto.create('stats')),
utils.userSetup(admin.accessToken, createUserDto.userQuota),
utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')),
utils.userSetup(admin.accessToken, createUserDto.create('stack')),
]);
// asset location
assetLocation = await utils.createAsset(admin.accessToken, {
locationAsset = await utils.createAsset(admin.accessToken, {
assetData: {
filename: 'thompson-springs.jpg',
bytes: await readFile(locationAssetFilepath),
},
});
await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id });
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id });
user1Assets = await Promise.all([
utils.createAsset(user1.accessToken),
@@ -80,22 +114,43 @@ describe('/asset', () => {
user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]);
await Promise.all([
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }),
]);
for (const asset of [...user1Assets, ...user2Assets]) {
expect(asset.duplicate).toBe(false);
}
await Promise.all([
// stats
utils.createAsset(userStats.accessToken),
utils.createAsset(userStats.accessToken, { isFavorite: true }),
utils.createAsset(userStats.accessToken, { isArchived: true }),
utils.createAsset(userStats.accessToken, {
utils.createAsset(statsUser.accessToken),
utils.createAsset(statsUser.accessToken, { isFavorite: true }),
utils.createAsset(statsUser.accessToken, { isArchived: true }),
utils.createAsset(statsUser.accessToken, {
isArchived: true,
isFavorite: true,
assetData: { filename: 'example.mp4' },
}),
]);
// stacks
stackAssets = await Promise.all([
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
utils.createAsset(stackUser.accessToken),
]);
await updateAssets(
{ assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } },
{ headers: asBearerAuth(stackUser.accessToken) },
);
const person1 = await utils.createPerson(user1.accessToken, {
name: 'Test Person',
});
@@ -106,7 +161,7 @@ describe('/asset', () => {
}, 30_000);
afterAll(() => {
utils.disconnectWebsocket(ws);
utils.disconnectWebsocket(websocket);
});
describe('GET /asset/:id', () => {
@@ -193,7 +248,7 @@ describe('/asset', () => {
it('should return stats of all assets', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`);
.set('Authorization', `Bearer ${statsUser.accessToken}`);
expect(body).toEqual({ images: 3, videos: 1, total: 4 });
expect(status).toBe(200);
@@ -202,7 +257,7 @@ describe('/asset', () => {
it('should return stats of all favored assets', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`)
.set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: true });
expect(status).toBe(200);
@@ -212,7 +267,7 @@ describe('/asset', () => {
it('should return stats of all archived assets', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`)
.set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isArchived: true });
expect(status).toBe(200);
@@ -222,7 +277,7 @@ describe('/asset', () => {
it('should return stats of all favored and archived assets', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`)
.set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: true, isArchived: true });
expect(status).toBe(200);
@@ -232,7 +287,7 @@ describe('/asset', () => {
it('should return stats of all assets neither favored nor archived', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`)
.set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: false, isArchived: false });
expect(status).toBe(200);
@@ -488,6 +543,35 @@ describe('/asset', () => {
});
describe('POST /asset/upload', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(`/asset/upload`);
expect(body).toEqual(errorDto.unauthorized);
expect(status).toBe(401);
});
const invalid = [
{ should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } },
{ should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } },
{ should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } },
{ should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } },
{ should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } },
{ should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } },
{ should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } },
{ should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } },
];
for (const { should, dto } of invalid) {
it(`should ${should}`, async () => {
const { status, body } = await request(app)
.post('/asset/upload')
.set('Authorization', `Bearer ${user1.accessToken}`)
.attach('assetData', makeRandomImage(), 'example.png')
.field(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
}
const tests = [
{
input: 'formats/jpg/el_torcal_rocks.jpg',
@@ -601,7 +685,7 @@ describe('/asset', () => {
];
for (const { input, expected } of tests) {
it(`should generate a thumbnail for ${input}`, async () => {
it(`should upload and generate a thumbnail for ${input}`, async () => {
const filepath = join(testAssetDir, input);
const { id, duplicate } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(filepath), filename: basename(filepath) },
@@ -609,7 +693,7 @@ describe('/asset', () => {
expect(duplicate).toBe(false);
await utils.waitForWebsocketEvent({ event: 'upload', assetId: id });
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id });
const asset = await utils.getAssetInfo(admin.accessToken, id);
@@ -631,6 +715,57 @@ describe('/asset', () => {
expect(duplicate).toBe(true);
});
it("should not upload to another user's library", async () => {
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
const library = libraries.find((library) => library.ownerId === user1.userId) as LibraryResponseDto;
const { body, status } = await request(app)
.post('/asset/upload')
.set('Authorization', `Bearer ${admin.accessToken}`)
.field('libraryId', library.id)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('duration', '0:00:00.000000')
.attach('assetData', makeRandomImage(), 'example.png');
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not found or no asset.upload access'));
});
it('should update the used quota', async () => {
const { body, status } = await request(app)
.post('/asset/upload')
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.attach('assetData', makeRandomImage(), 'example.jpg');
expect(body).toEqual({ id: expect.any(String), duplicate: false });
expect(status).toBe(201);
const { body: user } = await request(app).get('/user/me').set('Authorization', `Bearer ${quotaUser.accessToken}`);
expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 }));
});
it('should not upload an asset if it would exceed the quota', async () => {
const { body, status } = await request(app)
.post('/asset/upload')
.set('Authorization', `Bearer ${quotaUser.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'e2e')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.attach('assetData', randomBytes(2014), 'example.jpg');
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!'));
});
// These hashes were created by copying the image files to a Samsung phone,
// exporting the video from Samsung's stock Gallery app, and hashing them locally.
// This ensures that immich+exiftool are extracting the videos the same way Samsung does.
@@ -660,7 +795,7 @@ describe('/asset', () => {
},
});
await utils.waitForWebsocketEvent({ event: 'upload', assetId: response.id });
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id });
expect(response.duplicate).toBe(false);
@@ -675,7 +810,7 @@ describe('/asset', () => {
describe('GET /asset/thumbnail/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -683,12 +818,12 @@ describe('/asset', () => {
it('should not include gps data for webp thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
.get(`/asset/thumbnail/${locationAsset.id}?format=WEBP`)
.set('Authorization', `Bearer ${admin.accessToken}`);
await utils.waitForWebsocketEvent({
event: 'upload',
assetId: assetLocation.id,
event: 'assetUpload',
id: locationAsset.id,
});
expect(status).toBe(200);
@@ -702,7 +837,7 @@ describe('/asset', () => {
it('should not include gps data for jpeg thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`)
.get(`/asset/thumbnail/${locationAsset.id}?format=JPEG`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
@@ -717,7 +852,7 @@ describe('/asset', () => {
describe('GET /asset/file/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`);
const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -725,14 +860,14 @@ describe('/asset', () => {
it('should download the original', async () => {
const { status, body, type } = await request(app)
.get(`/asset/file/${assetLocation.id}`)
.get(`/asset/file/${locationAsset.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const asset = await utils.getAssetInfo(admin.accessToken, assetLocation.id);
const asset = await utils.getAssetInfo(admin.accessToken, locationAsset.id);
const original = await readFile(locationAssetFilepath);
const originalChecksum = utils.sha1(original);
@@ -742,4 +877,376 @@ describe('/asset', () => {
expect(downloadChecksum).toBe(asset.checksum);
});
});
describe('GET /asset/map-marker', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/map-marker');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
// TODO archive one of these assets
it('should get map markers for all non-archived assets', async () => {
const { status, body } = await request(app)
.get('/asset/map-marker')
.query({ isArchived: false })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body).toEqual([
{
city: 'Palisade',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400_968),
state: 'Mesa County, Colorado',
},
{
city: 'Ralston',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071_625),
state: 'Douglas County, Nebraska',
},
]);
});
// TODO archive one of these assets
it('should get all map markers', async () => {
const { status, body } = await request(app)
.get('/asset/map-marker')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
{
city: 'Palisade',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400_968),
state: 'Mesa County, Colorado',
},
{
city: 'Ralston',
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071_625),
state: 'Douglas County, Nebraska',
},
]);
});
});
describe('GET /asset/time-buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should get time buckets by month', async () => {
const { status, body } = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month });
expect(status).toBe(200);
expect(body).toEqual(
expect.arrayContaining([
{ count: 3, timeBucket: '1970-02-01T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]),
);
});
it('should not allow access for unrelated shared links', async () => {
const sharedLink = await utils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: user1Assets.map(({ id }) => id),
});
const { status, body } = await request(app)
.get('/asset/time-buckets')
.query({ key: sharedLink.key, size: TimeBucketSize.Month });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get time buckets by day', async () => {
const { status, body } = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Day });
expect(status).toBe(200);
expect(body).toEqual([
{ count: 2, timeBucket: '1970-02-11T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-02-10T00:00:00.000Z' },
{ count: 1, timeBucket: '1970-01-01T00:00:00.000Z' },
]);
});
});
describe('GET /asset/time-bucket', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/time-bucket').query({
size: TimeBucketSize.Month,
timeBucket: '1900-01-01T00:00:00.000Z',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should handle 5 digit years', async () => {
const { status, body } = await request(app)
.get('/asset/time-bucket')
.query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' })
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
// TODO enable date string validation while still accepting 5 digit years
// it('should fail if time bucket is invalid', async () => {
// const { status, body } = await request(app)
// .get('/asset/time-bucket')
// .set('Authorization', `Bearer ${user1.accessToken}`)
// .query({ size: TimeBucketSize.Month, timeBucket: 'foo' });
// expect(status).toBe(400);
// expect(body).toEqual(errorDto.badRequest);
// });
it('should return time bucket', async () => {
const { status, body } = await request(app)
.get('/asset/time-bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' });
expect(status).toBe(200);
expect(body).toEqual([]);
});
it('should return error if time bucket is requested with partners asset and archived', async () => {
const req1 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and favorite', async () => {
const req1 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
const req2 = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());
});
it('should return error if time bucket is requested with partners asset and trash', async () => {
const req = await request(app)
.get('/asset/time-buckets')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true });
expect(req.status).toBe(400);
expect(req.body).toEqual(errorDto.badRequest());
});
});
describe('GET /asset', () => {
it('should return stack data', async () => {
const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`);
const stack = body.find((asset: AssetResponseDto) => asset.id === stackAssets[0].id);
expect(status).toBe(200);
expect(stack).toEqual(
expect.objectContaining({
stackCount: 3,
stack:
// Response includes children at the root level
expect.arrayContaining([
expect.objectContaining({ id: stackAssets[1].id }),
expect.objectContaining({ id: stackAssets[2].id }),
]),
}),
);
});
});
describe('PUT /asset', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put('/asset');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid parent id', async () => {
const { status, body } = await request(app)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID']));
});
it('should require access to the parent', async () => {
const { status, body } = await request(app)
.put('/asset')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should add stack children', async () => {
const { status } = await request(app)
.put('/asset')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })]));
});
it('should remove stack children', async () => {
const { status } = await request(app)
.put('/asset')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ removeParent: true, ids: [stackAssets[1].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: stackAssets[2].id }),
expect.objectContaining({ id: stackAssets[3].id }),
]),
);
});
it('should remove all stack children', async () => {
const { status } = await request(app)
.put('/asset')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).toBeUndefined();
});
it('should merge stack children', async () => {
// create stack after previous test removed stack children
await updateAssets(
{ assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } },
{ headers: asBearerAuth(stackUser.accessToken) },
);
const { status } = await request(app)
.put('/asset')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] });
expect(status).toBe(204);
const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) });
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: stackAssets[0].id }),
expect.objectContaining({ id: stackAssets[1].id }),
expect.objectContaining({ id: stackAssets[2].id }),
]),
);
});
});
describe('PUT /asset/stack/parent', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put('/asset/stack/parent');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
});
it('should require access', async () => {
const { status, body } = await request(app)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should make old parent child of new parent', async () => {
const { status } = await request(app)
.put('/asset/stack/parent')
.set('Authorization', `Bearer ${stackUser.accessToken}`)
.send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id });
expect(status).toBe(200);
const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) });
// new parent
expect(asset.stack).not.toBeUndefined();
expect(asset.stack).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: stackAssets[1].id }),
expect.objectContaining({ id: stackAssets[2].id }),
expect.objectContaining({ id: stackAssets[3].id }),
]),
);
});
});
});
+289 -62
View File
@@ -1,61 +1,108 @@
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets } from '@immich/sdk';
import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const albums = { total: 0, count: 0, items: [], facets: [] };
const today = DateTime.now();
describe('/search', () => {
let admin: LoginResponseDto;
let websocket: Socket;
let assetFalcon: AssetFileUploadResponseDto;
let assetDenali: AssetFileUploadResponseDto;
let websocket: Socket;
let assetCyclamen: AssetFileUploadResponseDto;
let assetNotocactus: AssetFileUploadResponseDto;
let assetSilver: AssetFileUploadResponseDto;
// let assetDensity: AssetFileUploadResponseDto;
// let assetPhiladelphia: AssetFileUploadResponseDto;
// let assetOrychophragmus: AssetFileUploadResponseDto;
// let assetRidge: AssetFileUploadResponseDto;
// let assetPolemonium: AssetFileUploadResponseDto;
// let assetWood: AssetFileUploadResponseDto;
let assetHeic: AssetFileUploadResponseDto;
let assetRocks: AssetFileUploadResponseDto;
let assetOneJpg6: AssetFileUploadResponseDto;
let assetOneHeic6: AssetFileUploadResponseDto;
let assetOneJpg5: AssetFileUploadResponseDto;
let assetGlarus: AssetFileUploadResponseDto;
let assetSprings: AssetFileUploadResponseDto;
let assetLast: AssetFileUploadResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
websocket = await utils.connectWebsocket(admin.accessToken);
const files: string[] = [
'/albums/nature/prairie_falcon.jpg',
'/formats/webp/denali.webp',
'/formats/raw/Nikon/D700/philadelphia.nef',
'/albums/nature/orychophragmus_violaceus.jpg',
'/albums/nature/notocactus_minimus.jpg',
'/albums/nature/silver_fir.jpg',
'/albums/nature/tanners_ridge.jpg',
'/albums/nature/cyclamen_persicum.jpg',
'/albums/nature/polemonium_reptans.jpg',
'/albums/nature/wood_anemones.jpg',
'/formats/heic/IMG_2682.heic',
'/formats/jpg/el_torcal_rocks.jpg',
'/formats/png/density_plot.png',
'/formats/motionphoto/Samsung One UI 6.jpg',
'/formats/motionphoto/Samsung One UI 6.heic',
'/formats/motionphoto/Samsung One UI 5.jpg',
'/formats/raw/Nikon/D80/glarus.nef',
'/metadata/gps-position/thompson-springs.jpg',
const files = [
{ filename: '/albums/nature/prairie_falcon.jpg' },
{ filename: '/formats/webp/denali.webp' },
{ filename: '/albums/nature/cyclamen_persicum.jpg', dto: { isFavorite: true } },
{ filename: '/albums/nature/notocactus_minimus.jpg' },
{ filename: '/albums/nature/silver_fir.jpg' },
{ filename: '/formats/heic/IMG_2682.heic' },
{ filename: '/formats/jpg/el_torcal_rocks.jpg' },
{ filename: '/formats/motionphoto/Samsung One UI 6.jpg' },
{ filename: '/formats/motionphoto/Samsung One UI 6.heic' },
{ filename: '/formats/motionphoto/Samsung One UI 5.jpg' },
{ filename: '/formats/raw/Nikon/D80/glarus.nef', dto: { isReadOnly: true } },
{ filename: '/metadata/gps-position/thompson-springs.jpg', dto: { isArchived: true } },
// used for search suggestions
{ filename: '/formats/png/density_plot.png' },
{ filename: '/formats/raw/Nikon/D700/philadelphia.nef' },
{ filename: '/albums/nature/orychophragmus_violaceus.jpg' },
{ filename: '/albums/nature/tanners_ridge.jpg' },
{ filename: '/albums/nature/polemonium_reptans.jpg' },
// last asset
{ filename: '/albums/nature/wood_anemones.jpg' },
];
const assets: AssetFileUploadResponseDto[] = [];
for (const filename of files) {
for (const { filename, dto } of files) {
const bytes = await readFile(join(testAssetDir, filename));
assets.push(
await utils.createAsset(admin.accessToken, {
deviceAssetId: `test-${filename}`,
assetData: { bytes, filename },
...dto,
}),
);
}
for (const asset of assets) {
await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id });
await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id });
}
[assetFalcon, assetDenali] = assets;
[
assetFalcon,
assetDenali,
assetCyclamen,
assetNotocactus,
assetSilver,
assetHeic,
assetRocks,
assetOneJpg6,
assetOneHeic6,
assetOneJpg5,
assetGlarus,
assetSprings,
// assetDensity,
// assetPhiladelphia,
// assetOrychophragmus,
// assetRidge,
// assetPolemonium,
// assetWood,
] = assets;
assetLast = assets.at(-1) as AssetFileUploadResponseDto;
await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) });
});
afterAll(async () => {
@@ -69,44 +116,224 @@ describe('/search', () => {
expect(body).toEqual(errorDto.unauthorized);
});
it('should search by camera make', async () => {
const { status, body } = await request(app)
.post('/search/metadata')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ make: 'Canon' });
expect(status).toBe(200);
expect(body).toEqual({
albums,
assets: {
count: 2,
items: expect.arrayContaining([
expect.objectContaining({ id: assetDenali.id }),
expect.objectContaining({ id: assetFalcon.id }),
]),
facets: [],
nextPage: null,
total: 2,
},
});
});
const badTests = [
{
should: 'should reject page as a string',
dto: { page: 'abc' },
expected: ['page must not be less than 1', 'page must be an integer number'],
},
{
should: 'should reject page as a decimal',
dto: { page: 1.5 },
expected: ['page must be an integer number'],
},
{
should: 'should reject page as a negative number',
dto: { page: -10 },
expected: ['page must not be less than 1'],
},
{
should: 'should reject page as 0',
dto: { page: 0 },
expected: ['page must not be less than 1'],
},
{
should: 'should reject size as a string',
dto: { size: 'abc' },
expected: [
'size must not be greater than 1000',
'size must not be less than 1',
'size must be an integer number',
],
},
{
should: 'should reject an invalid size',
dto: { size: -1.5 },
expected: ['size must not be less than 1', 'size must be an integer number'],
},
...[
'isArchived',
'isFavorite',
'isReadOnly',
'isExternal',
'isEncoded',
'isMotion',
'isOffline',
'isVisible',
].map((value) => ({
should: `should reject ${value} not a boolean`,
dto: { [value]: 'immich' },
expected: [`${value} must be a boolean value`],
})),
];
it('should search by camera model', async () => {
const { status, body } = await request(app)
.post('/search/metadata')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ model: 'Canon EOS 7D' });
expect(status).toBe(200);
expect(body).toEqual({
albums,
assets: {
count: 1,
items: [expect.objectContaining({ id: assetDenali.id })],
facets: [],
nextPage: null,
total: 1,
},
for (const { should, dto, expected } of badTests) {
it(should, async () => {
const { status, body } = await request(app)
.post('/search/metadata')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send(dto);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(expected));
});
});
}
const searchTests = [
{
should: 'should get my assets',
deferred: () => ({ dto: { size: 1 }, assets: [assetLast] }),
},
{
should: 'should sort my assets in reverse',
deferred: () => ({ dto: { order: 'asc', size: 2 }, assets: [assetCyclamen, assetNotocactus] }),
},
{
should: 'should support pagination',
deferred: () => ({ dto: { order: 'asc', size: 1, page: 2 }, assets: [assetNotocactus] }),
},
{
should: 'should search by checksum (base64)',
deferred: () => ({ dto: { checksum: '9IXBDMjj9OrQb+1YMHprZJgZ/UQ=' }, assets: [assetCyclamen] }),
},
{
should: 'should search by checksum (hex)',
deferred: () => ({ dto: { checksum: 'f485c10cc8e3f4ead06fed58307a6b649819fd44' }, assets: [assetCyclamen] }),
},
{ should: 'should search by id', deferred: () => ({ dto: { id: assetCyclamen.id }, assets: [assetCyclamen] }) },
{
should: 'should search by isFavorite (true)',
deferred: () => ({ dto: { isFavorite: true }, assets: [assetCyclamen] }),
},
{
should: 'should search by isFavorite (false)',
deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }),
},
{
should: 'should search by isArchived (true)',
deferred: () => ({ dto: { isArchived: true }, assets: [assetSprings] }),
},
{
should: 'should search by isArchived (false)',
deferred: () => ({ dto: { size: 1, isArchived: false }, assets: [assetLast] }),
},
{
should: 'should search by isReadOnly (true)',
deferred: () => ({ dto: { isReadOnly: true }, assets: [assetGlarus] }),
},
{
should: 'should search by isReadOnly (false)',
deferred: () => ({ dto: { size: 1, isReadOnly: false }, assets: [assetLast] }),
},
{
should: 'should search by type (image)',
deferred: () => ({ dto: { size: 1, type: 'IMAGE' }, assets: [assetLast] }),
},
{
should: 'should search by type (video)',
deferred: () => ({
dto: { type: 'VIDEO' },
assets: [
// the three live motion photos
{ id: expect.any(String) },
{ id: expect.any(String) },
{ id: expect.any(String) },
],
}),
},
{
should: 'should search by trashedBefore',
deferred: () => ({ dto: { trashedBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }),
},
{
should: 'should search by trashedBefore (no results)',
deferred: () => ({ dto: { trashedBefore: today.minus({ days: 1 }).toJSDate() }, assets: [] }),
},
{
should: 'should search by trashedAfter',
deferred: () => ({ dto: { trashedAfter: today.minus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }),
},
{
should: 'should search by trashedAfter (no results)',
deferred: () => ({ dto: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }),
},
{
should: 'should search by takenBefore',
deferred: () => ({ dto: { size: 1, takenBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetLast] }),
},
{
should: 'should search by takenBefore (no results)',
deferred: () => ({ dto: { takenBefore: DateTime.fromObject({ year: 1234 }).toJSDate() }, assets: [] }),
},
{
should: 'should search by takenAfter',
deferred: () => ({
dto: { size: 1, takenAfter: DateTime.fromObject({ year: 1234 }).toJSDate() },
assets: [assetLast],
}),
},
{
should: 'should search by takenAfter (no results)',
deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }),
},
// {
// should: 'should search by originalPath',
// deferred: () => ({
// dto: { originalPath: asset1.originalPath },
// assets: [asset1],
// }),
// },
{
should: 'should search by originalFilename',
deferred: () => ({
dto: { originalFileName: 'rocks' },
assets: [assetRocks],
}),
},
{
should: 'should search by originalFilename with spaces',
deferred: () => ({
dto: { originalFileName: 'Samsung One', type: 'IMAGE' },
assets: [assetOneJpg5, assetOneJpg6, assetOneHeic6],
}),
},
{
should: 'should search by city',
deferred: () => ({ dto: { city: 'Ralston' }, assets: [assetHeic] }),
},
{
should: 'should search by state',
deferred: () => ({ dto: { state: 'Douglas County, Nebraska' }, assets: [assetHeic] }),
},
{
should: 'should search by country',
deferred: () => ({ dto: { country: 'United States of America' }, assets: [assetHeic] }),
},
{
should: 'should search by make',
deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }),
},
{
should: 'should search by model',
deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }),
},
];
for (const { should, deferred } of searchTests) {
it(should, async () => {
const { assets, dto } = deferred();
const { status, body } = await request(app)
.post('/search/metadata')
.send(dto)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body.assets).toBeDefined();
expect(Array.isArray(body.assets.items)).toBe(true);
for (const [i, asset] of assets.entries()) {
expect(body.assets.items[i]).toEqual(expect.objectContaining({ id: asset.id }));
}
expect(body.assets.items).toHaveLength(assets.length);
});
}
});
describe('POST /search/smart', () => {
+1 -1
View File
@@ -86,7 +86,7 @@ describe('/shared-link', () => {
}),
]);
await deleteUser({ id: user2.userId }, { headers: asBearerAuth(admin.accessToken) });
await deleteUser({ id: user2.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /shared-link', () => {
+1 -1
View File
@@ -38,7 +38,7 @@ describe('/trash', () => {
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await utils.waitForWebsocketEvent({ event: 'delete', assetId });
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) });
expect(after.length).toBe(0);
+38 -9
View File
@@ -1,27 +1,37 @@
import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk';
import { Socket } from 'socket.io-client';
import { createUserDto, userDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/user', () => {
let websocket: Socket;
describe('/server-info', () => {
let admin: LoginResponseDto;
let deletedUser: LoginResponseDto;
let userToDelete: LoginResponseDto;
let userToHardDelete: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
[deletedUser, nonAdmin, userToDelete] = await Promise.all([
[websocket, deletedUser, nonAdmin, userToDelete, userToHardDelete] = await Promise.all([
utils.connectWebsocket(admin.accessToken),
utils.userSetup(admin.accessToken, createUserDto.user1),
utils.userSetup(admin.accessToken, createUserDto.user2),
utils.userSetup(admin.accessToken, createUserDto.user3),
utils.userSetup(admin.accessToken, createUserDto.user4),
]);
await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) });
await deleteUser({ id: deletedUser.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) });
});
afterAll(() => {
utils.disconnectWebsocket(websocket);
});
describe('GET /user', () => {
@@ -34,13 +44,14 @@ describe('/server-info', () => {
it('should get users', async () => {
const { status, body } = await request(app).get('/user').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(4);
expect(body).toHaveLength(5);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
expect.objectContaining({ email: 'user4@immich.cloud' }),
]),
);
});
@@ -51,12 +62,13 @@ describe('/server-info', () => {
.query({ isAll: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toHaveLength(4);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
expect.objectContaining({ email: 'user4@immich.cloud' }),
]),
);
});
@@ -68,13 +80,14 @@ describe('/server-info', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
expect(body).toHaveLength(5);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ email: 'admin@immich.cloud' }),
expect.objectContaining({ email: 'user1@immich.cloud' }),
expect.objectContaining({ email: 'user2@immich.cloud' }),
expect.objectContaining({ email: 'user3@immich.cloud' }),
expect.objectContaining({ email: 'user4@immich.cloud' }),
]),
);
});
@@ -138,13 +151,13 @@ describe('/server-info', () => {
.post(`/user`)
.send({
isAdmin: true,
email: 'user4@immich.cloud',
email: 'user5@immich.cloud',
password: 'password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toMatchObject({
email: 'user4@immich.cloud',
email: 'user5@immich.cloud',
isAdmin: false,
shouldChangePassword: true,
});
@@ -188,6 +201,22 @@ describe('/server-info', () => {
deletedAt: expect.any(String),
});
});
it('should hard delete user', async () => {
const { status, body } = await request(app)
.delete(`/user/${userToHardDelete.userId}`)
.send({ force: true })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: userToHardDelete.userId,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
await utils.waitForWebsocketEvent({ event: 'userDelete', id: userToHardDelete.userId, timeout: 5000 });
});
});
describe('PUT /user', () => {
+18
View File
@@ -21,6 +21,13 @@ export const signupDto = {
};
export const createUserDto = {
create(key: string) {
return {
email: `${key}@immich.cloud`,
name: `Generated User ${key}`,
password: `password-${key}`,
};
},
user1: {
email: 'user1@immich.cloud',
name: 'User 1',
@@ -36,6 +43,17 @@ export const createUserDto = {
name: 'User 3',
password: 'password123',
},
user4: {
email: 'user4@immich.cloud',
name: 'User 4',
password: 'password123',
},
userQuota: {
email: 'user-quota@immich.cloud',
name: 'User Quota',
password: 'password-quota',
quotaSizeInBytes: 512,
},
};
export const userDto = {
+1
View File
@@ -76,6 +76,7 @@ export const signupResponseDto = {
memoriesEnabled: true,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
},
};
+28 -16
View File
@@ -36,8 +36,8 @@ import { makeRandomImage } from 'src/generators';
import request from 'supertest';
type CliResponse = { stdout: string; stderr: string; exitCode: number | null };
type EventType = 'upload' | 'delete';
type WaitOptions = { event: EventType; assetId: string; timeout?: number };
type EventType = 'assetUpload' | 'assetDelete' | 'userDelete';
type WaitOptions = { event: EventType; id: string; timeout?: number };
type AdminSetupOptions = { onboarding?: boolean };
type AssetData = { bytes?: Buffer; filename: string };
@@ -78,20 +78,21 @@ export const immichCli = async (args: string[]) => {
let client: pg.Client | null = null;
const events: Record<EventType, Set<string>> = {
upload: new Set<string>(),
delete: new Set<string>(),
assetUpload: new Set<string>(),
assetDelete: new Set<string>(),
userDelete: new Set<string>(),
};
const callbacks: Record<string, () => void> = {};
const execPromise = promisify(exec);
const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
events[event].add(assetId);
const callback = callbacks[assetId];
const onEvent = ({ event, id }: { event: EventType; id: string }) => {
events[event].add(id);
const callback = callbacks[id];
if (callback) {
callback();
delete callbacks[assetId];
delete callbacks[id];
}
};
@@ -104,6 +105,8 @@ export const utils = {
}
tables = tables || [
// TODO e2e test for deleting a stack, since it is quite complex
'asset_stack',
'libraries',
'shared_links',
'person',
@@ -117,9 +120,17 @@ export const utils = {
'system_metadata',
];
for (const table of tables) {
await client.query(`DELETE FROM ${table} CASCADE;`);
const sql: string[] = [];
if (tables.includes('asset_stack')) {
sql.push('UPDATE "assets" SET "stackId" = NULL;');
}
for (const table of tables) {
sql.push(`DELETE FROM ${table} CASCADE;`);
}
await client.query(sql.join('\n'));
} catch (error) {
console.error('Failed to reset database', error);
throw error;
@@ -156,8 +167,9 @@ export const utils = {
return new Promise<Socket>((resolve) => {
websocket
.on('connect', () => resolve(websocket))
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'upload', assetId: data.id }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'delete', assetId }))
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))
.connect();
});
},
@@ -172,17 +184,17 @@ export const utils = {
}
},
waitForWebsocketEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise<void> => {
console.log(`Waiting for ${event} [${assetId}]`);
waitForWebsocketEvent: async ({ event, id, timeout: ms }: WaitOptions): Promise<void> => {
console.log(`Waiting for ${event} [${id}]`);
const set = events[event];
if (set.has(assetId)) {
if (set.has(id)) {
return;
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
callbacks[assetId] = () => {
callbacks[id] = () => {
clearTimeout(timeout);
resolve();
};
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.98.0"
version = "1.98.2"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
+1
View File
@@ -65,6 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
make open-api
npm --prefix open-api/typescript-sdk version "$SERVER_PUMP"
npm --prefix web version "$SERVER_PUMP"
npm --prefix e2e version "$SERVER_PUMP"
npm --prefix web i --package-lock-only
npm --prefix cli i --package-lock-only
npm --prefix e2e i --package-lock-only
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 126,
"android.injected.version.name" => "1.98.0",
"android.injected.version.code" => 128,
"android.injected.version.name" => "1.98.2",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+3 -3
View File
@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000266">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000215">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.342186">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="77.732834">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="48.746195">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="37.34283">
</testcase>
+1 -1
View File
@@ -180,4 +180,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1
+3 -3
View File
@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 143;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 143;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 141;
CURRENT_PROJECT_VERSION = 143;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
+2 -2
View File
@@ -55,11 +55,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.97.0</string>
<string>1.98.1</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>141</string>
<string>143</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>
+1 -1
View File
@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.98.0"
version_number: "1.98.2"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,
+6 -6
View File
@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000304">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000228">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.272646">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.19012">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="3.560896">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.018704">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.235745">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.163831">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="114.820395">
<testcase classname="fastlane.lanes" name="4: build_app" time="126.642495">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.950812">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="97.082963">
</testcase>
@@ -17,6 +17,6 @@ final archiveProvider = StreamProvider<RenderList>((ref) {
.filter()
.isArchivedEqualTo(true)
.isTrashedEqualTo(false)
.sortByFileCreatedAt();
.sortByFileCreatedAtDesc();
return renderListGenerator(query, ref);
});
+7 -15
View File
@@ -49,23 +49,16 @@ class AssetService {
return changes;
}
/// Returns `(null, null, time)` if changes are invalid -> requires full sync
Future<(List<Asset>? toUpsert, List<String>? toDelete, DateTime? time)>
/// Returns `(null, null)` if changes are invalid -> requires full sync
Future<(List<Asset>? toUpsert, List<String>? toDelete)>
_getRemoteAssetChanges(User user, DateTime since) async {
final deleted = await _apiService.auditApi
.getAuditDeletes(since, EntityType.ASSET, userId: user.id);
if (deleted == null || deleted.needsFullSync) {
return (null, null, deleted?.requestedAt);
}
if (deleted == null || deleted.needsFullSync) return (null, null);
final assetDto = await _apiService.assetApi
.getAllAssets(userId: user.id, updatedAfter: since);
if (assetDto == null) return (null, null, deleted.requestedAt);
return (
assetDto.map(Asset.remote).toList(),
deleted.ids,
deleted.requestedAt
);
if (assetDto == null) return (null, null);
return (assetDto.map(Asset.remote).toList(), deleted.ids);
}
/// Returns the list of people of the given asset id.
@@ -90,11 +83,10 @@ class AssetService {
}
/// Returns `null` if the server state did not change, else list of assets
Future<List<Asset>?> _getRemoteAssets(User user, DateTime now) async {
Future<List<Asset>?> _getRemoteAssets(User user) async {
const int chunkSize = 10000;
try {
final DateTime now = DateTime.now().toUtc();
final List<Asset> allAssets = [];
for (int i = 0;; i += chunkSize) {
final List<AssetResponseDto>? assets =
+22 -28
View File
@@ -41,20 +41,17 @@ class SyncService {
/// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(
User user,
Future<(List<Asset>? toUpsert, List<String>? toDelete, DateTime? time)>
Function(
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
User user,
DateTime since,
) getChangedAssets,
FutureOr<List<Asset>?> Function(User user, DateTime now) loadAssets,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) =>
_lock.run(() async {
final (changes, serverTime) =
await _syncRemoteAssetChanges(user, getChangedAssets);
if (changes != null) return changes;
final time = serverTime ?? DateTime.now().toUtc();
return await _syncRemoteAssetsFull(user, time, loadAssets);
});
_lock.run(
() async =>
await _syncRemoteAssetChanges(user, getChangedAssets) ??
await _syncRemoteAssetsFull(user, loadAssets),
);
/// Syncs remote albums to the database
/// returns `true` if there were any changes
@@ -149,22 +146,19 @@ class SyncService {
return true;
}
/// Efficiently syncs assets via changes. Returns `(null, serverTime)` when a full sync is required.
Future<(bool?, DateTime? serverTime)> _syncRemoteAssetChanges(
/// Efficiently syncs assets via changes. Returns `null` when a full sync is required.
Future<bool?> _syncRemoteAssetChanges(
User user,
Future<(List<Asset>? toUpsert, List<String>? toDelete, DateTime? time)>
Function(
Future<(List<Asset>? toUpsert, List<String>? toDelete)> Function(
User user,
DateTime since,
) getChangedAssets,
) async {
final DateTime? since = _db.eTags.getByIdSync(user.id)?.time?.toUtc();
final DateTime now = DateTime.now().toUtc();
final (toUpsert, toDelete, serverTime) =
await getChangedAssets(user, since ?? now);
if (since == null || toUpsert == null || toDelete == null) {
return (null, serverTime);
}
if (since == null) return null;
final DateTime now = DateTime.now();
final (toUpsert, toDelete) = await getChangedAssets(user, since);
if (toUpsert == null || toDelete == null) return null;
try {
if (toDelete.isNotEmpty) {
await handleRemoteAssetRemoval(toDelete);
@@ -174,14 +168,14 @@ class SyncService {
await upsertAssetsWithExif(updated);
}
if (toUpsert.isNotEmpty || toDelete.isNotEmpty) {
await _updateUserAssetsETag(user, serverTime ?? now);
return (true, serverTime);
await _updateUserAssetsETag(user, now);
return true;
}
return (false, serverTime);
return false;
} on IsarError catch (e) {
_log.severe("Failed to sync remote assets to db", e);
}
return (null, serverTime);
return null;
}
/// Deletes remote-only assets, updates merged assets to be local-only
@@ -208,11 +202,11 @@ class SyncService {
/// Syncs assets by loading and comparing all assets from the server.
Future<bool> _syncRemoteAssetsFull(
final User user,
final DateTime now,
final FutureOr<List<Asset>?> Function(User user, DateTime now) loadAssets,
User user,
FutureOr<List<Asset>?> Function(User user) loadAssets,
) async {
final List<Asset>? remote = await loadAssets(user, now);
final DateTime now = DateTime.now().toUtc();
final List<Asset>? remote = await loadAssets(user);
if (remote == null) {
return false;
}
+6
View File
@@ -58,6 +58,7 @@ doc/CreateTagDto.md
doc/CreateUserDto.md
doc/CuratedLocationsResponseDto.md
doc/CuratedObjectsResponseDto.md
doc/DeleteUserDto.md
doc/DownloadApi.md
doc/DownloadArchiveInfo.md
doc/DownloadInfoDto.md
@@ -184,6 +185,7 @@ doc/UserApi.md
doc/UserAvatarColor.md
doc/UserDto.md
doc/UserResponseDto.md
doc/UserStatus.md
doc/ValidateAccessTokenResponseDto.md
doc/ValidateLibraryDto.md
doc/ValidateLibraryImportPathResponseDto.md
@@ -268,6 +270,7 @@ lib/model/create_tag_dto.dart
lib/model/create_user_dto.dart
lib/model/curated_locations_response_dto.dart
lib/model/curated_objects_response_dto.dart
lib/model/delete_user_dto.dart
lib/model/download_archive_info.dart
lib/model/download_info_dto.dart
lib/model/download_response_dto.dart
@@ -380,6 +383,7 @@ lib/model/usage_by_user_dto.dart
lib/model/user_avatar_color.dart
lib/model/user_dto.dart
lib/model/user_response_dto.dart
lib/model/user_status.dart
lib/model/validate_access_token_response_dto.dart
lib/model/validate_library_dto.dart
lib/model/validate_library_import_path_response_dto.dart
@@ -441,6 +445,7 @@ test/create_tag_dto_test.dart
test/create_user_dto_test.dart
test/curated_locations_response_dto_test.dart
test/curated_objects_response_dto_test.dart
test/delete_user_dto_test.dart
test/download_api_test.dart
test/download_archive_info_test.dart
test/download_info_dto_test.dart
@@ -567,6 +572,7 @@ test/user_api_test.dart
test/user_avatar_color_test.dart
test/user_dto_test.dart
test/user_response_dto_test.dart
test/user_status_test.dart
test/validate_access_token_response_dto_test.dart
test/validate_library_dto_test.dart
test/validate_library_import_path_response_dto_test.dart
+3 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.98.0
- API version: 1.98.2
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -264,6 +264,7 @@ Class | Method | HTTP request | Description
- [CreateUserDto](doc//CreateUserDto.md)
- [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md)
- [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md)
- [DeleteUserDto](doc//DeleteUserDto.md)
- [DownloadArchiveInfo](doc//DownloadArchiveInfo.md)
- [DownloadInfoDto](doc//DownloadInfoDto.md)
- [DownloadResponseDto](doc//DownloadResponseDto.md)
@@ -376,6 +377,7 @@ Class | Method | HTTP request | Description
- [UserAvatarColor](doc//UserAvatarColor.md)
- [UserDto](doc//UserDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [UserStatus](doc//UserStatus.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
- [ValidateLibraryDto](doc//ValidateLibraryDto.md)
- [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md)
-1
View File
@@ -10,7 +10,6 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ids** | **List<String>** | | [default to const []]
**needsFullSync** | **bool** | |
**requestedAt** | [**DateTime**](DateTime.md) | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+15
View File
@@ -0,0 +1,15 @@
# openapi.model.DeleteUserDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**force** | **bool** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+1
View File
@@ -22,6 +22,7 @@ Name | Type | Description | Notes
**quotaSizeInBytes** | **int** | |
**quotaUsageInBytes** | **int** | |
**shouldChangePassword** | **bool** | |
**status** | [**UserStatus**](UserStatus.md) | |
**storageLabel** | **String** | |
**updatedAt** | [**DateTime**](DateTime.md) | |
+5 -3
View File
@@ -182,7 +182,7 @@ void (empty response body)
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **deleteUser**
> UserResponseDto deleteUser(id)
> UserResponseDto deleteUser(id, deleteUserDto)
@@ -206,9 +206,10 @@ import 'package:openapi/api.dart';
final api_instance = UserApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final deleteUserDto = DeleteUserDto(); // DeleteUserDto |
try {
final result = api_instance.deleteUser(id);
final result = api_instance.deleteUser(id, deleteUserDto);
print(result);
} catch (e) {
print('Exception when calling UserApi->deleteUser: $e\n');
@@ -220,6 +221,7 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
**deleteUserDto** | [**DeleteUserDto**](DeleteUserDto.md)| |
### Return type
@@ -231,7 +233,7 @@ Name | Type | Description | Notes
### HTTP request headers
- **Content-Type**: Not defined
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+1
View File
@@ -21,6 +21,7 @@ Name | Type | Description | Notes
**quotaSizeInBytes** | **int** | |
**quotaUsageInBytes** | **int** | |
**shouldChangePassword** | **bool** | |
**status** | [**UserStatus**](UserStatus.md) | |
**storageLabel** | **String** | |
**updatedAt** | [**DateTime**](DateTime.md) | |
+14
View File
@@ -0,0 +1,14 @@
# openapi.model.UserStatus
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+2
View File
@@ -99,6 +99,7 @@ part 'model/create_tag_dto.dart';
part 'model/create_user_dto.dart';
part 'model/curated_locations_response_dto.dart';
part 'model/curated_objects_response_dto.dart';
part 'model/delete_user_dto.dart';
part 'model/download_archive_info.dart';
part 'model/download_info_dto.dart';
part 'model/download_response_dto.dart';
@@ -211,6 +212,7 @@ part 'model/usage_by_user_dto.dart';
part 'model/user_avatar_color.dart';
part 'model/user_dto.dart';
part 'model/user_response_dto.dart';
part 'model/user_status.dart';
part 'model/validate_access_token_response_dto.dart';
part 'model/validate_library_dto.dart';
part 'model/validate_library_import_path_response_dto.dart';
+9 -5
View File
@@ -157,19 +157,21 @@ class UserApi {
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteUserWithHttpInfo(String id,) async {
///
/// * [DeleteUserDto] deleteUserDto (required):
Future<Response> deleteUserWithHttpInfo(String id, DeleteUserDto deleteUserDto,) async {
// ignore: prefer_const_declarations
final path = r'/user/{id}'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
Object? postBody = deleteUserDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
@@ -186,8 +188,10 @@ class UserApi {
/// Parameters:
///
/// * [String] id (required):
Future<UserResponseDto?> deleteUser(String id,) async {
final response = await deleteUserWithHttpInfo(id,);
///
/// * [DeleteUserDto] deleteUserDto (required):
Future<UserResponseDto?> deleteUser(String id, DeleteUserDto deleteUserDto,) async {
final response = await deleteUserWithHttpInfo(id, deleteUserDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+4
View File
@@ -280,6 +280,8 @@ class ApiClient {
return CuratedLocationsResponseDto.fromJson(value);
case 'CuratedObjectsResponseDto':
return CuratedObjectsResponseDto.fromJson(value);
case 'DeleteUserDto':
return DeleteUserDto.fromJson(value);
case 'DownloadArchiveInfo':
return DownloadArchiveInfo.fromJson(value);
case 'DownloadInfoDto':
@@ -504,6 +506,8 @@ class ApiClient {
return UserDto.fromJson(value);
case 'UserResponseDto':
return UserResponseDto.fromJson(value);
case 'UserStatus':
return UserStatusTypeTransformer().decode(value);
case 'ValidateAccessTokenResponseDto':
return ValidateAccessTokenResponseDto.fromJson(value);
case 'ValidateLibraryDto':
+3
View File
@@ -136,6 +136,9 @@ String parameterToString(dynamic value) {
if (value is UserAvatarColor) {
return UserAvatarColorTypeTransformer().encode(value).toString();
}
if (value is UserStatus) {
return UserStatusTypeTransformer().encode(value).toString();
}
if (value is VideoCodec) {
return VideoCodecTypeTransformer().encode(value).toString();
}
+13 -41
View File
@@ -15,49 +15,30 @@ class AuditDeletesResponseDto {
AuditDeletesResponseDto({
this.ids = const [],
required this.needsFullSync,
this.requestedAt,
});
List<String> ids;
bool needsFullSync;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? requestedAt;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AuditDeletesResponseDto &&
other.ids == ids &&
other.needsFullSync == needsFullSync &&
other.requestedAt == requestedAt;
bool operator ==(Object other) => identical(this, other) || other is AuditDeletesResponseDto &&
_deepEquality.equals(other.ids, ids) &&
other.needsFullSync == needsFullSync;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(needsFullSync.hashCode) +
(requestedAt == null ? 0 : requestedAt!.hashCode);
// ignore: unnecessary_parenthesis
(ids.hashCode) +
(needsFullSync.hashCode);
@override
String toString() =>
'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync, requestedAt=$requestedAt]';
String toString() => 'AuditDeletesResponseDto[ids=$ids, needsFullSync=$needsFullSync]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'ids'] = this.ids;
json[r'needsFullSync'] = this.needsFullSync;
if (this.requestedAt != null) {
json[r'requestedAt'] = this.requestedAt!.toUtc().toIso8601String();
} else {
// json[r'requestedAt'] = null;
}
json[r'ids'] = this.ids;
json[r'needsFullSync'] = this.needsFullSync;
return json;
}
@@ -73,16 +54,12 @@ class AuditDeletesResponseDto {
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
needsFullSync: mapValueOfType<bool>(json, r'needsFullSync')!,
requestedAt: mapDateTime(json, r'requestedAt', ''),
);
}
return null;
}
static List<AuditDeletesResponseDto> listFromJson(
dynamic json, {
bool growable = false,
}) {
static List<AuditDeletesResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AuditDeletesResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
@@ -110,19 +87,13 @@ class AuditDeletesResponseDto {
}
// maps a json object with a list of AuditDeletesResponseDto-objects as value to a dart map
static Map<String, List<AuditDeletesResponseDto>> mapListFromJson(
dynamic json, {
bool growable = false,
}) {
static Map<String, List<AuditDeletesResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AuditDeletesResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = AuditDeletesResponseDto.listFromJson(
entry.value,
growable: growable,
);
map[entry.key] = AuditDeletesResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
@@ -134,3 +105,4 @@ class AuditDeletesResponseDto {
'needsFullSync',
};
}
+107
View File
@@ -0,0 +1,107 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class DeleteUserDto {
/// Returns a new [DeleteUserDto] instance.
DeleteUserDto({
this.force,
});
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? force;
@override
bool operator ==(Object other) => identical(this, other) || other is DeleteUserDto &&
other.force == force;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(force == null ? 0 : force!.hashCode);
@override
String toString() => 'DeleteUserDto[force=$force]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.force != null) {
json[r'force'] = this.force;
} else {
// json[r'force'] = null;
}
return json;
}
/// Returns a new [DeleteUserDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static DeleteUserDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return DeleteUserDto(
force: mapValueOfType<bool>(json, r'force'),
);
}
return null;
}
static List<DeleteUserDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <DeleteUserDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = DeleteUserDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, DeleteUserDto> mapFromJson(dynamic json) {
final map = <String, DeleteUserDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = DeleteUserDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of DeleteUserDto-objects as value to a dart map
static Map<String, List<DeleteUserDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<DeleteUserDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = DeleteUserDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}
+9 -1
View File
@@ -27,6 +27,7 @@ class PartnerResponseDto {
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
required this.shouldChangePassword,
required this.status,
required this.storageLabel,
required this.updatedAt,
});
@@ -71,6 +72,8 @@ class PartnerResponseDto {
bool shouldChangePassword;
UserStatus status;
String? storageLabel;
DateTime updatedAt;
@@ -91,6 +94,7 @@ class PartnerResponseDto {
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
other.shouldChangePassword == shouldChangePassword &&
other.status == status &&
other.storageLabel == storageLabel &&
other.updatedAt == updatedAt;
@@ -111,11 +115,12 @@ class PartnerResponseDto {
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) +
(shouldChangePassword.hashCode) +
(status.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -153,6 +158,7 @@ class PartnerResponseDto {
// json[r'quotaUsageInBytes'] = null;
}
json[r'shouldChangePassword'] = this.shouldChangePassword;
json[r'status'] = this.status;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
@@ -184,6 +190,7 @@ class PartnerResponseDto {
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
status: UserStatus.fromJson(json[r'status'])!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
@@ -245,6 +252,7 @@ class PartnerResponseDto {
'quotaSizeInBytes',
'quotaUsageInBytes',
'shouldChangePassword',
'status',
'storageLabel',
'updatedAt',
};
+9 -1
View File
@@ -26,6 +26,7 @@ class UserResponseDto {
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
required this.shouldChangePassword,
required this.status,
required this.storageLabel,
required this.updatedAt,
});
@@ -62,6 +63,8 @@ class UserResponseDto {
bool shouldChangePassword;
UserStatus status;
String? storageLabel;
DateTime updatedAt;
@@ -81,6 +84,7 @@ class UserResponseDto {
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
other.shouldChangePassword == shouldChangePassword &&
other.status == status &&
other.storageLabel == storageLabel &&
other.updatedAt == updatedAt;
@@ -100,11 +104,12 @@ class UserResponseDto {
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) +
(shouldChangePassword.hashCode) +
(status.hashCode) +
(storageLabel == null ? 0 : storageLabel!.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]';
String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -137,6 +142,7 @@ class UserResponseDto {
// json[r'quotaUsageInBytes'] = null;
}
json[r'shouldChangePassword'] = this.shouldChangePassword;
json[r'status'] = this.status;
if (this.storageLabel != null) {
json[r'storageLabel'] = this.storageLabel;
} else {
@@ -167,6 +173,7 @@ class UserResponseDto {
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes'),
shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!,
status: UserStatus.fromJson(json[r'status'])!,
storageLabel: mapValueOfType<String>(json, r'storageLabel'),
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);
@@ -228,6 +235,7 @@ class UserResponseDto {
'quotaSizeInBytes',
'quotaUsageInBytes',
'shouldChangePassword',
'status',
'storageLabel',
'updatedAt',
};
+88
View File
@@ -0,0 +1,88 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class UserStatus {
/// Instantiate a new enum with the provided [value].
const UserStatus._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const active = UserStatus._(r'active');
static const removing = UserStatus._(r'removing');
static const deleted = UserStatus._(r'deleted');
/// List of all possible values in this [enum][UserStatus].
static const values = <UserStatus>[
active,
removing,
deleted,
];
static UserStatus? fromJson(dynamic value) => UserStatusTypeTransformer().decode(value);
static List<UserStatus> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UserStatus>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UserStatus.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [UserStatus] to String,
/// and [decode] dynamic data back to [UserStatus].
class UserStatusTypeTransformer {
factory UserStatusTypeTransformer() => _instance ??= const UserStatusTypeTransformer._();
const UserStatusTypeTransformer._();
String encode(UserStatus data) => data.value;
/// Decodes a [dynamic value][data] to a UserStatus.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
UserStatus? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'active': return UserStatus.active;
case r'removing': return UserStatus.removing;
case r'deleted': return UserStatus.deleted;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [UserStatusTypeTransformer] instance.
static UserStatusTypeTransformer? _instance;
}
@@ -26,11 +26,6 @@ void main() {
// TODO
});
// DateTime requestedAt
test('to test the property `requestedAt`', () async {
// TODO
});
});
+27
View File
@@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for DeleteUserDto
void main() {
// final instance = DeleteUserDto();
group('test DeleteUserDto', () {
// bool force
test('to test the property `force`', () async {
// TODO
});
});
}
+5
View File
@@ -86,6 +86,11 @@ void main() {
// TODO
});
// UserStatus status
test('to test the property `status`', () async {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
+1 -1
View File
@@ -32,7 +32,7 @@ void main() {
// TODO
});
//Future<UserResponseDto> deleteUser(String id) async
//Future<UserResponseDto> deleteUser(String id, DeleteUserDto deleteUserDto) async
test('test deleteUser', () async {
// TODO
});
+5
View File
@@ -81,6 +81,11 @@ void main() {
// TODO
});
// UserStatus status
test('to test the property `status`', () async {
// TODO
});
// String storageLabel
test('to test the property `storageLabel`', () async {
// TODO
+21
View File
@@ -0,0 +1,21 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for UserStatus
void main() {
group('test UserStatus', () {
});
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.98.0+126
version: 1.98.2+128
isar_version: &isar_version 3.1.0+1
environment:
+40 -7
View File
@@ -2358,6 +2358,7 @@
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "array",
"items": {
"type": "string"
@@ -4630,7 +4631,7 @@
"required": true
},
"responses": {
"201": {
"200": {
"content": {
"application/json": {
"schema": {
@@ -4768,7 +4769,7 @@
"required": true
},
"responses": {
"201": {
"200": {
"content": {
"application/json": {
"schema": {
@@ -6402,6 +6403,16 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteUserDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
@@ -6476,7 +6487,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.98.0",
"version": "1.98.2",
"contact": {}
},
"tags": [],
@@ -7326,10 +7337,6 @@
},
"needsFullSync": {
"type": "boolean"
},
"requestedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
@@ -7754,6 +7761,14 @@
],
"type": "object"
},
"DeleteUserDto": {
"properties": {
"force": {
"type": "boolean"
}
},
"type": "object"
},
"DownloadArchiveInfo": {
"properties": {
"assetIds": {
@@ -8467,6 +8482,7 @@
},
"personIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
@@ -8620,6 +8636,9 @@
"shouldChangePassword": {
"type": "boolean"
},
"status": {
"$ref": "#/components/schemas/UserStatus"
},
"storageLabel": {
"nullable": true,
"type": "string"
@@ -8642,6 +8661,7 @@
"quotaSizeInBytes",
"quotaUsageInBytes",
"shouldChangePassword",
"status",
"storageLabel",
"updatedAt"
],
@@ -9593,6 +9613,7 @@
},
"personIds": {
"items": {
"format": "uuid",
"type": "string"
},
"type": "array"
@@ -10565,6 +10586,9 @@
"shouldChangePassword": {
"type": "boolean"
},
"status": {
"$ref": "#/components/schemas/UserStatus"
},
"storageLabel": {
"nullable": true,
"type": "string"
@@ -10587,11 +10611,20 @@
"quotaSizeInBytes",
"quotaUsageInBytes",
"shouldChangePassword",
"status",
"storageLabel",
"updatedAt"
],
"type": "object"
},
"UserStatus": {
"enum": [
"active",
"removing",
"deleted"
],
"type": "string"
},
"ValidateAccessTokenResponseDto": {
"properties": {
"authStatus": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.2",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@oazapfts/runtime": "^1.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "1.98.0",
"version": "1.98.2",
"description": "",
"type": "module",
"main": "./build/index.js",
+19 -7
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 1.98.0
* 1.98.2
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -75,6 +75,7 @@ export type UserResponseDto = {
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
shouldChangePassword: boolean;
status: UserStatus;
storageLabel: string | null;
updatedAt: string;
};
@@ -518,6 +519,7 @@ export type PartnerResponseDto = {
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
shouldChangePassword: boolean;
status: UserStatus;
storageLabel: string | null;
updatedAt: string;
};
@@ -994,6 +996,9 @@ export type CreateProfileImageResponseDto = {
profileImagePath: string;
userId: string;
};
export type DeleteUserDto = {
force?: boolean;
};
export function getActivities({ albumId, assetId, level, $type, userId }: {
albumId: string;
assetId?: string;
@@ -2205,7 +2210,7 @@ export function searchMetadata({ metadataSearchDto }: {
metadataSearchDto: MetadataSearchDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
status: 200;
data: SearchResponseDto;
}>("/search/metadata", oazapfts.json({
...opts,
@@ -2243,7 +2248,7 @@ export function searchSmart({ smartSearchDto }: {
smartSearchDto: SmartSearchDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
status: 200;
data: SearchResponseDto;
}>("/search/smart", oazapfts.json({
...opts,
@@ -2678,16 +2683,18 @@ export function getProfileImage({ id }: {
...opts
}));
}
export function deleteUser({ id }: {
export function deleteUser({ id, deleteUserDto }: {
id: string;
deleteUserDto: DeleteUserDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: UserResponseDto;
}>(`/user/${encodeURIComponent(id)}`, {
}>(`/user/${encodeURIComponent(id)}`, oazapfts.json({
...opts,
method: "DELETE"
}));
method: "DELETE",
body: deleteUserDto
})));
}
export function restoreUser({ id }: {
id: string;
@@ -2724,6 +2731,11 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum UserStatus {
Active = "active",
Removing = "removing",
Deleted = "deleted"
}
export enum TagTypeEnum {
Object = "OBJECT",
Face = "FACE",
-23
View File
@@ -1,23 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"modulePaths": ["<rootDir>"],
"rootDir": "../..",
"globalSetup": "<rootDir>/e2e/api/setup.ts",
"testEnvironment": "node",
"testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"],
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s",
"!<rootDir>/src/**/*.spec.(t|s)s",
"!<rootDir>/src/infra/migrations/**"
],
"coverageDirectory": "./coverage",
"moduleNameMapper": {
"^@test(|/.*)$": "<rootDir>/test/$1",
"^@app/immich(|/.*)$": "<rootDir>/src/immich/$1",
"^@app/infra(|/.*)$": "<rootDir>/src/infra/$1",
"^@app/domain(|/.*)$": "<rootDir>/src/domain/$1"
}
}
-29
View File
@@ -1,29 +0,0 @@
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import path from 'node:path';
export default async () => {
let IMMICH_TEST_ASSET_PATH: string = '';
if (process.env.IMMICH_TEST_ASSET_PATH === undefined) {
IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`);
process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH;
} else {
IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH;
}
const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0')
.withDatabase('immich')
.withUsername('postgres')
.withPassword('postgres')
.withReuse()
.withCommand(['-c', 'fsync=off', '-c', 'shared_preload_libraries=vectors.so'])
.start();
process.env.DB_URL = pg.getConnectionUri();
process.env.NODE_ENV = 'development';
process.env.TZ = 'Z';
if (process.env.LOG_LEVEL === undefined) {
process.env.LOG_LEVEL = 'fatal';
}
};
File diff suppressed because it is too large Load Diff
-118
View File
@@ -1,118 +0,0 @@
import { AssetCreate, IJobRepository, IMetadataRepository, LibraryResponseDto } from '@app/domain';
import { AppModule } from '@app/immich';
import { InfraModule, InfraTestModule, dataSource } from '@app/infra';
import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { EntityTarget, ObjectLiteral } from 'typeorm';
import { AppService } from '../../src/microservices/app.service';
import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test';
export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 });
export const yesterday = today.minus({ days: 1 });
export interface ResetOptions {
entities?: EntityTarget<ObjectLiteral>[];
}
export const db = {
reset: async (options?: ResetOptions) => {
if (!dataSource.isInitialized) {
await dataSource.initialize();
}
await dataSource.transaction(async (em) => {
const entities = options?.entities || [];
const tableNames =
entities.length > 0
? entities.map((entity) => em.getRepository(entity).metadata.tableName)
: dataSource.entityMetadatas
.map((entity) => entity.tableName)
.filter((tableName) => !tableName.startsWith('geodata'));
if (tableNames.includes('asset_stack')) {
await em.query(`DELETE FROM "asset_stack" CASCADE;`);
}
let deleteUsers = false;
for (const tableName of tableNames) {
if (tableName === 'users') {
deleteUsers = true;
continue;
}
await em.query(`DELETE FROM ${tableName} CASCADE;`);
}
if (deleteUsers) {
await em.query(`DELETE FROM "users" CASCADE;`);
}
});
},
disconnect: async () => {
if (dataSource.isInitialized) {
await dataSource.destroy();
}
},
};
let app: INestApplication;
export const testApp = {
create: async (): Promise<INestApplication> => {
const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] })
.overrideModule(InfraModule)
.useModule(InfraTestModule)
.overrideProvider(IJobRepository)
.useValue(newJobRepositoryMock())
.overrideProvider(IMetadataRepository)
.useValue(newMetadataRepositoryMock())
.compile();
app = await moduleFixture.createNestApplication().init();
await app.get(AppService).init();
return app;
},
reset: async (options?: ResetOptions) => {
await db.reset(options);
},
teardown: async () => {
if (app) {
await app.get(AppService).teardown();
await app.close();
}
await db.disconnect();
},
};
function randomDate(start: Date, end: Date): Date {
return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime()));
}
let assetCount = 0;
export function generateAsset(
userId: string,
libraries: LibraryResponseDto[],
other: Partial<AssetEntity> = {},
): AssetCreate {
const id = assetCount++;
const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other;
return {
createdAt: today.toJSDate(),
updatedAt: today.toJSDate(),
ownerId: userId,
checksum: randomBytes(20),
originalPath: `/tests/test_${id}`,
deviceAssetId: `test_${id}`,
deviceId: 'e2e-test',
libraryId: (
libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto
).id,
isVisible: true,
fileCreatedAt,
fileModifiedAt: new Date(),
localDateTime: fileCreatedAt,
type: AssetType.IMAGE,
originalFileName: `test_${id}`,
...other,
};
}
-67
View File
@@ -1,77 +1,10 @@
import { AssetResponseDto } from '@app/domain';
import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { randomBytes } from 'node:crypto';
import request from 'supertest';
type UploadDto = Partial<CreateAssetDto> & { content?: Buffer; filename?: string };
const asset = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date(),
fileModifiedAt: new Date(),
};
export const assetApi = {
create: async (
server: any,
accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'>,
): Promise<AssetResponseDto> => {
dto = dto || asset;
const { status, body } = await request(server)
.post(`/asset/upload`)
.field('deviceAssetId', dto.deviceAssetId)
.field('deviceId', dto.deviceId)
.field('fileCreatedAt', dto.fileCreatedAt.toISOString())
.field('fileModifiedAt', dto.fileModifiedAt.toISOString())
.attach('assetData', randomBytes(32), 'example.jpg')
.set('Authorization', `Bearer ${accessToken}`);
expect([200, 201].includes(status)).toBe(true);
return body as AssetResponseDto;
},
get: async (server: any, accessToken: string, id: string): Promise<AssetResponseDto> => {
const { body, status } = await request(server).get(`/asset/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body as AssetResponseDto;
},
getAllAssets: async (server: any, accessToken: string) => {
const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body as AssetResponseDto[];
},
upload: async (server: any, accessToken: string, deviceAssetId: string, dto: UploadDto = {}) => {
const { content, filename, isFavorite = false, isArchived = false } = dto;
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${accessToken}`)
.field('deviceAssetId', deviceAssetId)
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', isFavorite)
.field('isArchived', isArchived)
.field('duration', '0:00:00.000000')
.attach('assetData', content || randomBytes(32), filename || 'example.jpg');
expect(status).toBe(201);
return body as AssetFileUploadResponseDto;
},
getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => {
const { body, status } = await request(server)
.get(`/asset/thumbnail/${assetId}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => {
const { body, status } = await request(server)
.get(`/asset/thumbnail/${assetId}?format=JPEG`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
};
+1 -9
View File
@@ -1,4 +1,4 @@
import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain';
import { LoginResponseDto, UserResponseDto } from '@app/domain';
import { adminSignupStub, loginResponseStub, loginStub } from '@test';
import request from 'supertest';
@@ -17,14 +17,6 @@ export const authApi = {
expect(body).toMatchObject({ accessToken: expect.any(String) });
expect(status).toBe(201);
return body as LoginResponseDto;
},
login: async (server: any, dto: LoginCredentialDto) => {
const { status, body } = await request(server).post('/auth/login').send(dto);
expect(status).toEqual(201);
expect(body).toMatchObject({ accessToken: expect.any(String) });
return body as LoginResponseDto;
},
};
-6
View File
@@ -1,15 +1,9 @@
import { assetApi } from './asset-api';
import { authApi } from './auth-api';
import { libraryApi } from './library-api';
import { sharedLinkApi } from './shared-link-api';
import { trashApi } from './trash-api';
import { userApi } from './user-api';
export const api = {
authApi,
assetApi,
libraryApi,
sharedLinkApi,
trashApi,
userApi,
};
+1 -39
View File
@@ -1,12 +1,4 @@
import {
CreateLibraryDto,
LibraryResponseDto,
LibraryStatsResponseDto,
ScanLibraryDto,
UpdateLibraryDto,
ValidateLibraryDto,
ValidateLibraryResponseDto,
} from '@app/domain';
import { CreateLibraryDto, LibraryResponseDto, ScanLibraryDto } from '@app/domain';
import request from 'supertest';
export const libraryApi = {
@@ -38,34 +30,4 @@ export const libraryApi = {
.send(dto);
expect(status).toBe(204);
},
removeOfflineFiles: async (server: any, accessToken: string, id: string) => {
const { status } = await request(server)
.post(`/library/${id}/removeOffline`)
.set('Authorization', `Bearer ${accessToken}`)
.send();
expect(status).toBe(204);
},
getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise<LibraryStatsResponseDto> => {
const { body, status } = await request(server)
.get(`/library/${id}/statistics`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
return body;
},
update: async (server: any, accessToken: string, id: string, data: UpdateLibraryDto) => {
const { body, status } = await request(server)
.put(`/library/${id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send(data);
expect(status).toBe(200);
return body as LibraryResponseDto;
},
validate: async (server: any, accessToken: string, id: string, data: ValidateLibraryDto) => {
const { body, status } = await request(server)
.post(`/library/${id}/validate`)
.set('Authorization', `Bearer ${accessToken}`)
.send(data);
expect(status).toBe(200);
return body as ValidateLibraryResponseDto;
},
};
-13
View File
@@ -1,13 +0,0 @@
import { SharedLinkCreateDto, SharedLinkResponseDto } from '@app/domain';
import request from 'supertest';
export const sharedLinkApi = {
create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
return body as SharedLinkResponseDto;
},
};
-13
View File
@@ -1,13 +0,0 @@
import request from 'supertest';
import type { App } from 'supertest/types';
export const trashApi = {
async empty(server: App, accessToken: string) {
const { status } = await request(server).post('/trash/empty').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
},
async restore(server: App, accessToken: string) {
const { status } = await request(server).post('/trash/restore').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
},
};
-37
View File
@@ -1,37 +0,0 @@
import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain';
import request from 'supertest';
export const userApi = {
create: async (server: any, accessToken: string, dto: CreateUserDto) => {
const { status, body } = await request(server)
.post('/user')
.set('Authorization', `Bearer ${accessToken}`)
.send(dto);
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
email: dto.email,
});
return body as UserResponseDto;
},
update: async (server: any, accessToken: string, dto: UpdateUserDto) => {
const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(status).toBe(200);
expect(body).toMatchObject({ id: dto.id });
return body as UserResponseDto;
},
delete: async (server: any, accessToken: string, id: string) => {
const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id, deletedAt: expect.any(String) });
return body as UserResponseDto;
},
};
+68 -29
View File
@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.98.0",
"version": "1.98.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.98.0",
"version": "1.98.2",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@babel/runtime": "^7.22.11",
@@ -33,6 +33,7 @@
"cookie-parser": "^1.4.6",
"exiftool-vendored": "~24.5.0",
"exiftool-vendored.pl": "12.76",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"glob": "^10.3.3",
@@ -51,6 +52,7 @@
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"sharp": "^0.33.0",
"sirv": "^2.0.4",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.17",
"ua-parser-js": "^1.0.35"
@@ -2656,7 +2658,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@@ -2669,7 +2670,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"engines": {
"node": ">= 8"
}
@@ -2678,7 +2678,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@@ -2730,6 +2729,11 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.25",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ=="
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -6339,7 +6343,6 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -6372,7 +6375,6 @@
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
"integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
"dev": true,
"dependencies": {
"reusify": "^1.0.4"
}
@@ -8659,7 +8661,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"engines": {
"node": ">= 8"
}
@@ -8676,7 +8677,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"dev": true,
"dependencies": {
"braces": "^3.0.2",
"picomatch": "^2.3.1"
@@ -8689,7 +8689,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@@ -8831,6 +8830,14 @@
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"dev": true
},
"node_modules/mrmime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==",
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -9951,7 +9958,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -10391,7 +10397,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
@@ -10427,7 +10432,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -10821,6 +10825,19 @@
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ=="
},
"node_modules/sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -11748,6 +11765,14 @@
"node": ">=0.6"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -14419,7 +14444,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"requires": {
"@nodelib/fs.stat": "2.0.5",
"run-parallel": "^1.1.9"
@@ -14428,14 +14452,12 @@
"@nodelib/fs.stat": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="
},
"@nodelib/fs.walk": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"requires": {
"@nodelib/fs.scandir": "2.1.5",
"fastq": "^1.6.0"
@@ -14468,6 +14490,11 @@
"integrity": "sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ==",
"dev": true
},
"@polka/url": {
"version": "1.0.0-next.25",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz",
"integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ=="
},
"@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
@@ -17281,7 +17308,6 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"requires": {
"@nodelib/fs.stat": "^2.0.2",
"@nodelib/fs.walk": "^1.2.3",
@@ -17311,7 +17337,6 @@
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz",
"integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==",
"dev": true,
"requires": {
"reusify": "^1.0.4"
}
@@ -19033,8 +19058,7 @@
"merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="
},
"methods": {
"version": "1.1.2",
@@ -19045,7 +19069,6 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
"dev": true,
"requires": {
"braces": "^3.0.2",
"picomatch": "^2.3.1"
@@ -19054,8 +19077,7 @@
"picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
}
}
},
@@ -19156,6 +19178,11 @@
"integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==",
"dev": true
},
"mrmime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz",
"integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw=="
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -19990,8 +20017,7 @@
"queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="
},
"queue-tick": {
"version": "1.0.1",
@@ -20322,8 +20348,7 @@
"reusify": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw=="
},
"rimraf": {
"version": "5.0.5",
@@ -20343,7 +20368,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"requires": {
"queue-microtask": "^1.2.2"
}
@@ -20648,6 +20672,16 @@
}
}
},
"sirv": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz",
"integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==",
"requires": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
}
},
"sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -21386,6 +21420,11 @@
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="
},
"totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="
},
"tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.98.0",
"version": "1.98.2",
"description": "",
"author": "",
"private": true,
@@ -23,7 +23,6 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"e2e:jobs": "jest --config e2e/jobs/jest-e2e.json --runInBand",
"e2e:api": "jest --config e2e/api/jest-e2e.json --runInBand",
"typeorm": "typeorm",
"typeorm:migrations:create": "typeorm migration:create",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js",
@@ -58,6 +57,7 @@
"cookie-parser": "^1.4.6",
"exiftool-vendored": "~24.5.0",
"exiftool-vendored.pl": "12.76",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"glob": "^10.3.3",
@@ -76,6 +76,7 @@
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"sharp": "^0.33.0",
"sirv": "^2.0.4",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.17",
"ua-parser-js": "^1.0.35"
-1
View File
@@ -29,7 +29,6 @@ export enum PathEntityType {
export class AuditDeletesResponseDto {
needsFullSync!: boolean;
ids!: string[];
requestedAt?: Date;
}
export class FileReportDto {
@@ -61,7 +61,6 @@ describe(AuditService.name, () => {
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
needsFullSync: true,
ids: [],
requestedAt: expect.any(Date),
});
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
@@ -78,7 +77,6 @@ describe(AuditService.name, () => {
await expect(sut.getDeletes(authStub.admin, { after: date, entityType: EntityType.ASSET })).resolves.toEqual({
needsFullSync: false,
ids: ['asset-deleted'],
requestedAt: expect.any(Date),
});
expect(auditMock.getAfter).toHaveBeenCalledWith(date, {
+4 -5
View File
@@ -5,7 +5,7 @@ import { DateTime } from 'luxon';
import { resolve } from 'node:path';
import { AccessCore, Permission } from '../access';
import { AuthDto } from '../auth';
import { AUDIT_LOG_CLEANUP_DURATION, AUDIT_LOG_MAX_DURATION } from '../domain.constant';
import { AUDIT_LOG_MAX_DURATION } from '../domain.constant';
import { usePagination } from '../domain.util';
import { JOBS_ASSET_PAGINATION_SIZE } from '../job';
import {
@@ -45,7 +45,7 @@ export class AuditService {
}
async handleCleanup(): Promise<boolean> {
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_CLEANUP_DURATION).toJSDate());
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return true;
}
@@ -53,16 +53,15 @@ export class AuditService {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const now = DateTime.utc();
const duration = now.diff(DateTime.fromJSDate(dto.after));
const audits = await this.repository.getAfter(dto.after, {
ownerId: userId,
entityType: dto.entityType,
action: DatabaseAction.DELETE,
});
const duration = DateTime.now().diff(DateTime.fromJSDate(dto.after));
return {
requestedAt: now.toJSDate(),
needsFullSync: duration > AUDIT_LOG_MAX_DURATION,
ids: audits.map(({ entityId }) => entityId),
};
-1
View File
@@ -4,7 +4,6 @@ import { readFileSync } from 'node:fs';
import { extname, join } from 'node:path';
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const AUDIT_LOG_CLEANUP_DURATION = Duration.fromObject({ days: 101 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export interface IVersion {
+5
View File
@@ -280,6 +280,11 @@ export class JobService {
}
break;
}
case JobName.USER_DELETION: {
this.communicationRepository.broadcast(ClientEvent.USER_DELETE, item.data.id);
break;
}
}
}
}
@@ -156,8 +156,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getPathsNotInLibrary.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
await sut.handleQueueAssetRefresh(mockLibraryJob);
@@ -183,7 +182,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']);
assetMock.getByLibraryId.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
await sut.handleQueueAssetRefresh(mockLibraryJob);
@@ -233,7 +232,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1);
storageMock.crawl.mockResolvedValue([]);
assetMock.getByLibraryId.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false });
await sut.handleQueueAssetRefresh(mockLibraryJob);
@@ -242,6 +241,48 @@ describe(LibraryService.name, () => {
exclusionPatterns: [],
});
});
it('should set missing assets offline', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue([]);
assetMock.getLibraryAssetPaths.mockResolvedValue({
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueAssetRefresh(mockLibraryJob);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true });
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false });
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
it('should set crawled assets that were previously offline back online', async () => {
const mockLibraryJob: ILibraryRefreshJob = {
id: libraryStub.externalLibrary1.id,
refreshModifiedFiles: false,
refreshAllFiles: false,
};
libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1);
storageMock.crawl.mockResolvedValue([assetStub.offline.originalPath]);
assetMock.getLibraryAssetPaths.mockResolvedValue({
items: [assetStub.offline],
hasNextPage: false,
});
await sut.handleQueueAssetRefresh(mockLibraryJob);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.offline.id], { isOffline: false });
expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true });
expect(jobMock.queueAll).not.toHaveBeenCalled();
});
});
describe('handleAssetRefresh', () => {
+41 -12
View File
@@ -640,27 +640,56 @@ export class LibraryService extends EventEmitter {
.filter((validation) => validation.isValid)
.map((validation) => validation.importPath);
const rawPaths = await this.storageRepository.crawl({
let rawPaths = await this.storageRepository.crawl({
pathsToCrawl: validImportPaths,
exclusionPatterns: library.exclusionPatterns,
});
const crawledAssetPaths = rawPaths.map((filePath) => path.normalize(filePath));
const crawledAssetPaths = new Set<string>(rawPaths);
this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`);
const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles;
let pathsToScan: string[] = shouldScanAll ? rawPaths : [];
rawPaths = [];
await this.assetRepository.updateOfflineLibraryAssets(library.id, crawledAssetPaths);
this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`);
if (crawledAssetPaths.length > 0) {
let filteredPaths: string[] = [];
if (job.refreshAllFiles || job.refreshModifiedFiles) {
filteredPaths = crawledAssetPaths;
} else {
filteredPaths = await this.assetRepository.getPathsNotInLibrary(library.id, crawledAssetPaths);
const assetIdsToMarkOffline = [];
const assetIdsToMarkOnline = [];
const pagination = usePagination(5000, (pagination) =>
this.assetRepository.getLibraryAssetPaths(pagination, library.id),
);
this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`);
for await (const page of pagination) {
for (const asset of page) {
const isOffline = !crawledAssetPaths.has(asset.originalPath);
if (isOffline && !asset.isOffline) {
assetIdsToMarkOffline.push(asset.id);
}
if (!isOffline && asset.isOffline) {
assetIdsToMarkOnline.push(asset.id);
}
crawledAssetPaths.delete(asset.originalPath);
}
}
await this.scanAssets(job.id, filteredPaths, library.ownerId, job.refreshAllFiles ?? false);
if (assetIdsToMarkOffline.length > 0) {
this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`);
await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true });
}
if (assetIdsToMarkOnline.length > 0) {
this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`);
await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false });
}
if (!shouldScanAll) {
pathsToScan = [...crawledAssetPaths];
this.logger.debug(`Will import ${pathsToScan.length} new asset(s)`);
}
if (pathsToScan.length > 0) {
await this.scanAssets(job.id, pathsToScan, library.ownerId, job.refreshAllFiles ?? false);
}
await this.repository.update({ id: job.id, refreshedAt: new Date() });
@@ -23,6 +23,7 @@ import {
personStub,
probeStub,
} from '@test';
import { Stats } from 'node:fs';
import { JobName } from '../job';
import {
IAssetRepository,
@@ -1853,6 +1854,41 @@ describe(MediaService.name, () => {
},
);
});
it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true });
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP },
{ key: SystemConfigKey.FFMPEG_CRF, value: 30 },
{ key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' },
]);
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'],
outputOptions: [
`-c:v h264_rkmpp`,
'-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
'-map 0:0',
'-map 0:1',
'-g 256',
'-v verbose',
'-vf scale_rkrga=-2:720:format=p010:afbc=1,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime',
'-level 51',
'-rc_mode CQP',
'-qp_init 30',
],
twoPass: false,
},
);
});
});
it('should tonemap when policy is required and video is hdr', async () => {
+13 -1
View File
@@ -47,6 +47,7 @@ export class MediaService {
private logger = new ImmichLogger(MediaService.name);
private configCore: SystemConfigCore;
private storageCore: StorageCore;
private hasOpenCL?: boolean = undefined;
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@@ -456,8 +457,19 @@ export class MediaService {
break;
}
case TranscodeHWAccel.RKMPP: {
if (this.hasOpenCL === undefined) {
try {
const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd');
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.hasOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.warn('OpenCL not available for transcoding, using CPU instead.');
this.hasOpenCL = false;
}
}
devices = await this.storageRepository.readdir('/dev/dri');
handler = new RKMPPConfig(config, devices);
handler = new RKMPPConfig(config, devices, this.hasOpenCL);
break;
}
default: {
+26 -9
View File
@@ -608,6 +608,17 @@ export class VAAPIConfig extends BaseHWConfig {
}
export class RKMPPConfig extends BaseHWConfig {
private hasOpenCL: boolean;
constructor(
protected config: SystemConfigFFmpegDto,
devices: string[] = [],
hasOpenCL: boolean = false,
) {
super(config, devices);
this.hasOpenCL = hasOpenCL;
}
eligibleForTwoPass(): boolean {
return false;
}
@@ -616,19 +627,25 @@ export class RKMPPConfig extends BaseHWConfig {
if (this.devices.length === 0) {
throw new Error('No RKMPP device found');
}
if (this.shouldToneMap(videoStream)) {
// disable hardware decoding
return [];
}
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'];
return this.shouldToneMap(videoStream) && !this.hasOpenCL
? [] // disable hardware decoding & filters
: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
if (this.shouldToneMap(videoStream)) {
// use software filter options
return super.getFilterOptions(videoStream);
}
if (this.shouldScale(videoStream)) {
if (!this.hasOpenCL) {
return super.getFilterOptions(videoStream);
}
const colors = this.getColors();
return [
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`,
'hwmap=derive_device=opencl:mode=read',
`tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`,
'hwmap=derive_device=rkmpp:mode=write:reverse=1',
'format=drm_prime',
];
} else if (this.shouldScale(videoStream)) {
return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`];
}
return [];
@@ -1,9 +1,4 @@
import {
AssetSearchOneToOneRelationOptions,
AssetSearchOptions,
ReverseGeocodeResult,
SearchExploreItem,
} from '@app/domain';
import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain';
import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { FindOptionsRelations, FindOptionsSelect } from 'typeorm';
import { Paginated, PaginationOptions } from '../domain.util';
@@ -114,6 +109,8 @@ export interface MetadataSearchOptions {
numResults: number;
}
export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffline'>;
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
@@ -134,16 +131,10 @@ export interface IAssetRepository {
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getByLibraryId(libraryIds: string[]): Promise<AssetEntity[]>;
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise<string[]>;
updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise<void>;
deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
getAllByFileCreationDate(
pagination: PaginationOptions,
options?: AssetSearchOneToOneRelationOptions,
): Paginated<AssetEntity>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
@@ -4,6 +4,7 @@ export const ICommunicationRepository = 'ICommunicationRepository';
export enum ClientEvent {
UPLOAD_SUCCESS = 'on_upload_success',
USER_DELETE = 'on_user_delete',
ASSET_DELETE = 'on_asset_delete',
ASSET_TRASH = 'on_asset_trash',
ASSET_UPDATE = 'on_asset_update',
@@ -22,6 +23,7 @@ export enum ServerEvent {
export interface ClientEventMap {
[ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto;
[ClientEvent.USER_DELETE]: string;
[ClientEvent.ASSET_DELETE]: string;
[ClientEvent.ASSET_TRASH]: string[];
[ClientEvent.ASSET_UPDATE]: AssetResponseDto;
@@ -32,7 +32,6 @@ export interface IUserRepository {
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>;
updateUsage(id: string, delta: number): Promise<void>;
syncUsage(id?: string): Promise<void>;
}
@@ -0,0 +1,6 @@
import { ValidateBoolean } from '../../domain.util';
export class DeleteUserDto {
@ValidateBoolean({ optional: true })
force?: boolean;
}
+1
View File
@@ -1,3 +1,4 @@
export * from './create-profile-image.dto';
export * from './create-user.dto';
export * from './delete-user.dto';
export * from './update-user.dto';
@@ -1,4 +1,4 @@
import { UserAvatarColor, UserEntity } from '@app/infra/entities';
import { UserAvatarColor, UserEntity, UserStatus } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum } from 'class-validator';
@@ -33,6 +33,8 @@ export class UserResponseDto extends UserDto {
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaUsageInBytes!: number | null;
@ApiProperty({ enumName: 'UserStatus', enum: UserStatus })
status!: string;
}
export const mapSimpleUser = (entity: UserEntity): UserDto => {
@@ -58,5 +60,6 @@ export function mapUser(entity: UserEntity): UserResponseDto {
memoriesEnabled: entity.memoriesEnabled,
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status,
};
}
+31 -13
View File
@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra/entities';
import { UserEntity, UserStatus } from '@app/infra/entities';
import {
BadRequestException,
ForbiddenException,
@@ -243,16 +243,14 @@ describe(UserService.name, () => {
it('should throw error if user could not be found', async () => {
when(userMock.get).calledWith(userStub.admin.id, { withDeleted: true }).mockResolvedValue(null);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.restore).not.toHaveBeenCalled();
expect(userMock.update).not.toHaveBeenCalled();
});
it('should restore an user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.restore.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: true });
expect(userMock.restore).toHaveBeenCalledWith(userStub.user1);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null });
});
});
@@ -260,27 +258,47 @@ describe(UserService.name, () => {
it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null);
await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('cannot delete admin user', async () => {
await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toBeInstanceOf(ForbiddenException);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException);
});
it('should require the auth user be an admin', async () => {
await expect(sut.delete(authStub.user1, authStub.admin.user.id)).rejects.toBeInstanceOf(ForbiddenException);
await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(userMock.delete).not.toHaveBeenCalled();
});
it('should delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.delete.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, {});
expect(userMock.delete).toHaveBeenCalledWith(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUser(userStub.user1));
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.DELETED,
deletedAt: expect.any(Date),
});
});
it('should force delete user', async () => {
userMock.get.mockResolvedValue(userStub.user1);
userMock.update.mockResolvedValue(userStub.user1);
await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual(
mapUser(userStub.user1),
);
expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, {
status: UserStatus.REMOVING,
deletedAt: expect.any(Date),
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.USER_DELETION,
data: { id: userStub.user1.id, force: true },
});
});
});
+18 -11
View File
@@ -1,4 +1,4 @@
import { UserEntity } from '@app/infra/entities';
import { UserEntity, UserStatus } from '@app/infra/entities';
import { ImmichLogger } from '@app/infra/logger';
import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DateTime } from 'luxon';
@@ -18,7 +18,7 @@ import {
} from '../repositories';
import { StorageCore, StorageFolder } from '../storage';
import { SystemConfigCore } from '../system-config/system-config.core';
import { CreateUserDto, UpdateUserDto } from './dto';
import { CreateUserDto, DeleteUserDto, UpdateUserDto } from './dto';
import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto';
import { UserCore } from './user.core';
@@ -73,22 +73,29 @@ export class UserService {
return this.userCore.updateUser(auth.user, dto.id, dto).then(mapUser);
}
async delete(auth: AuthDto, id: string): Promise<UserResponseDto> {
const user = await this.findOrFail(id, {});
if (user.isAdmin) {
async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise<UserResponseDto> {
const { force } = dto;
const { isAdmin } = await this.findOrFail(id, {});
if (isAdmin) {
throw new ForbiddenException('Cannot delete admin user');
}
await this.albumRepository.softDeleteAll(id);
return this.userRepository.delete(user).then(mapUser);
const status = force ? UserStatus.REMOVING : UserStatus.DELETED;
const user = await this.userRepository.update(id, { status, deletedAt: new Date() });
if (force) {
await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } });
}
return mapUser(user);
}
async restore(auth: AuthDto, id: string): Promise<UserResponseDto> {
let user = await this.findOrFail(id, { withDeleted: true });
user = await this.userRepository.restore(user);
await this.findOrFail(id, { withDeleted: true });
await this.albumRepository.restoreAll(id);
return mapUser(user);
return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser);
}
async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
@@ -154,7 +161,7 @@ export class UserService {
return true;
}
async handleUserDelete({ id }: IEntityJob) {
async handleUserDelete({ id, force }: IEntityJob) {
const config = await this.configCore.getConfig();
const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) {
@@ -162,7 +169,7 @@ export class UserService {
}
// just for extra protection here
if (!this.isReadyForDeletion(user, config.user.deleteDelay)) {
if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) {
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
return false;
}
@@ -1,13 +1,14 @@
import { AssetEntity } from '@app/infra/entities';
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { OptionalBetween } from '@app/infra/infra.utils';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In } from 'typeorm/find-options/operator/In.js';
import { Repository } from 'typeorm/repository/Repository.js';
import { AssetSearchDto } from './dto/asset-search.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { SearchPropertiesDto } from './dto/search-properties.dto';
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
export interface AssetCheck {
id: string;
checksum: Buffer;
@@ -21,6 +22,7 @@ export interface IAssetRepositoryV1 {
get(id: string): Promise<AssetEntity | null>;
getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
@@ -31,7 +33,40 @@ export const IAssetRepositoryV1 = 'IAssetRepositoryV1';
@Injectable()
export class AssetRepositoryV1 implements IAssetRepositoryV1 {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
/**
* Retrieves all assets by user ID.
*
* @param ownerId - The ID of the owner.
* @param dto - The AssetSearchDto object containing search criteria.
* @returns A Promise that resolves to an array of AssetEntity objects.
*/
getAllByUserId(ownerId: string, dto: AssetSearchDto): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
ownerId,
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore),
},
relations: {
exifInfo: true,
tags: true,
stack: { assets: true },
},
skip: dto.skip || 0,
take: dto.take,
order: {
fileCreatedAt: 'DESC',
},
withDeleted: true,
});
}
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
return this.assetRepository
@@ -77,6 +77,7 @@ describe('AssetService', () => {
beforeEach(() => {
assetRepositoryMockV1 = {
get: jest.fn(),
getAllByUserId: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
@@ -113,19 +113,8 @@ export class AssetService {
public async getAllAssets(auth: AuthDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const assets = await this.assetRepository.getAllByFileCreationDate(
{ take: dto.take ?? 1000, skip: dto.skip },
{
...dto,
userIds: [userId],
withDeleted: true,
orderDirection: 'DESC',
withExif: true,
isVisible: true,
withStacked: true,
},
);
return assets.items.map((asset) => mapAsset(asset, { withStack: true }));
const assets = await this.assetRepositoryV1.getAllByUserId(userId, dto);
return assets.map((asset) => mapAsset(asset, { withStack: true, auth }));
}
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
@@ -3,6 +3,7 @@ import {
CreateUserDto as CreateDto,
CreateProfileImageDto,
CreateProfileImageResponseDto,
DeleteUserDto,
UpdateUserDto as UpdateDto,
UserResponseDto,
UserService,
@@ -66,8 +67,12 @@ export class UserController {
@AdminRoute()
@Delete(':id')
deleteUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<UserResponseDto> {
return this.service.delete(auth, id);
deleteUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: DeleteUserDto,
): Promise<UserResponseDto> {
return this.service.delete(auth, id, dto);
}
@AdminRoute()
+15 -1
View File
@@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import cookieParser from 'cookie-parser';
import sirv from 'sirv';
import { AppModule } from './app.module';
import { AppService } from './app.service';
import { useSwagger } from './app.utils';
@@ -28,7 +29,20 @@ export async function bootstrap() {
const excludePaths = ['/.well-known/immich', '/custom.css'];
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useStaticAssets('www');
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv('www', {
etag: true,
gzip: true,
brotli: true,
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
app.use(app.get(AppService).ssr(excludePaths));
const server = await app.listen(port);
+9
View File
@@ -23,6 +23,12 @@ export enum UserAvatarColor {
AMBER = 'amber',
}
export enum UserStatus {
ACTIVE = 'active',
REMOVING = 'removing',
DELETED = 'deleted',
}
@Entity('users')
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
@@ -61,6 +67,9 @@ export class UserEntity {
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt!: Date | null;
@Column({ type: 'varchar', default: UserStatus.ACTIVE })
status!: UserStatus;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddUserStatus1709870213078 implements MigrationInterface {
name = 'AddUserStatus1709870213078'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "status" character varying NOT NULL DEFAULT 'active'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "status"`);
}
}
@@ -2,7 +2,7 @@ import {
AssetBuilderOptions,
AssetCreate,
AssetExploreFieldOptions,
AssetSearchOneToOneRelationOptions,
AssetPathEntity,
AssetSearchOptions,
AssetStats,
AssetStatsOptions,
@@ -185,10 +185,10 @@ export class AssetRepository implements IAssetRepository {
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
getByLibraryId(libraryIds: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { library: { id: In(libraryIds) } },
getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity> {
return paginate(this.repository, pagination, {
select: { id: true, originalPath: true, isOffline: true },
where: { library: { id: libraryId } },
});
}
@@ -233,29 +233,6 @@ export class AssetRepository implements IAssetRepository {
});
}
@GenerateSql({
params: [
{ skip: 20_000, take: 10_000 },
{
takenBefore: DummyValue.DATE,
userIds: [DummyValue.UUID],
},
],
})
getAllByFileCreationDate(
pagination: PaginationOptions,
options: AssetSearchOneToOneRelationOptions = {},
): Paginated<AssetEntity> {
let builder = this.repository.createQueryBuilder('asset');
builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.LIMIT_OFFSET,
skip: pagination.skip,
take: pagination.take,
});
}
/**
* Get assets by device's Id on the database
* @param ownerId
@@ -11,7 +11,7 @@ import {
import { ImmichLogger } from '@app/infra/logger';
import archiver from 'archiver';
import chokidar, { WatchOptions } from 'chokidar';
import { glob } from 'glob';
import { glob } from 'fast-glob';
import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs';
import fs, { copyFile, readdir, rename, stat, utimes, writeFile } from 'node:fs/promises';
import path from 'node:path';
@@ -123,7 +123,7 @@ export class FilesystemProvider implements IStorageRepository {
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
if (!pathsToCrawl) {
if (pathsToCrawl.length === 0) {
return Promise.resolve([]);
}
@@ -132,8 +132,8 @@ export class FilesystemProvider implements IStorageRepository {
return glob(`${base}/**/${extensions}`, {
absolute: true,
nocase: true,
nodir: true,
caseSensitiveMatch: false,
onlyFiles: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
@@ -77,10 +77,6 @@ export class UserRepository implements IUserRepository {
return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user);
}
async restore(user: UserEntity): Promise<UserEntity> {
return this.userRepository.recover(user);
}
@GenerateSql()
async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository
@@ -135,6 +131,6 @@ export class UserRepository implements IUserRepository {
private async save(user: Partial<UserEntity>) {
const { id } = await this.userRepository.save(user);
return this.userRepository.findOneByOrFail({ id });
return this.userRepository.findOneOrFail({ where: { id }, withDeleted: true });
}
}
+13
View File
@@ -26,6 +26,7 @@ FROM
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -41,6 +42,7 @@ FROM
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -100,6 +102,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -115,6 +118,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -156,6 +160,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -171,6 +176,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -284,6 +290,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -311,6 +318,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -355,6 +363,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -382,6 +391,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -463,6 +473,7 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword",
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
@@ -490,6 +501,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -552,6 +564,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
@@ -20,6 +20,7 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."shouldChangePassword" AS "APIKeyEntity__APIKeyEntity_user_shouldChangePassword",
"APIKeyEntity__APIKeyEntity_user"."createdAt" AS "APIKeyEntity__APIKeyEntity_user_createdAt",
"APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt",
"APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status",
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled",
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
-96
View File
@@ -293,53 +293,6 @@ DELETE FROM "assets"
WHERE
"ownerId" = $1
-- AssetRepository.getByLibraryId
SELECT
"AssetEntity"."id" AS "AssetEntity_id",
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
"AssetEntity"."ownerId" AS "AssetEntity_ownerId",
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."resizePath" AS "AssetEntity_resizePath",
"AssetEntity"."webpPath" AS "AssetEntity_webpPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
"AssetEntity"."createdAt" AS "AssetEntity_createdAt",
"AssetEntity"."updatedAt" AS "AssetEntity_updatedAt",
"AssetEntity"."deletedAt" AS "AssetEntity_deletedAt",
"AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt",
"AssetEntity"."localDateTime" AS "AssetEntity_localDateTime",
"AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt",
"AssetEntity"."isFavorite" AS "AssetEntity_isFavorite",
"AssetEntity"."isArchived" AS "AssetEntity_isArchived",
"AssetEntity"."isExternal" AS "AssetEntity_isExternal",
"AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly",
"AssetEntity"."isOffline" AS "AssetEntity_isOffline",
"AssetEntity"."checksum" AS "AssetEntity_checksum",
"AssetEntity"."duration" AS "AssetEntity_duration",
"AssetEntity"."isVisible" AS "AssetEntity_isVisible",
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
"AssetEntity"."stackId" AS "AssetEntity_stackId"
FROM
"assets" "AssetEntity"
LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId"
AND (
"AssetEntity__AssetEntity_library"."deletedAt" IS NULL
)
WHERE
(
(
(
(("AssetEntity__AssetEntity_library"."id" IN ($1)))
)
)
)
AND ("AssetEntity"."deletedAt" IS NULL)
-- AssetRepository.getByLibraryIdAndOriginalPath
SELECT DISTINCT
"distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id"
@@ -428,55 +381,6 @@ WHERE
AND "isOffline" = $4
)
-- AssetRepository.getAllByFileCreationDate
SELECT
"asset"."id" AS "asset_id",
"asset"."deviceAssetId" AS "asset_deviceAssetId",
"asset"."ownerId" AS "asset_ownerId",
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."originalPath" AS "asset_originalPath",
"asset"."resizePath" AS "asset_resizePath",
"asset"."webpPath" AS "asset_webpPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
"asset"."createdAt" AS "asset_createdAt",
"asset"."updatedAt" AS "asset_updatedAt",
"asset"."deletedAt" AS "asset_deletedAt",
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
"asset"."localDateTime" AS "asset_localDateTime",
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
"asset"."isFavorite" AS "asset_isFavorite",
"asset"."isArchived" AS "asset_isArchived",
"asset"."isExternal" AS "asset_isExternal",
"asset"."isReadOnly" AS "asset_isReadOnly",
"asset"."isOffline" AS "asset_isOffline",
"asset"."checksum" AS "asset_checksum",
"asset"."duration" AS "asset_duration",
"asset"."isVisible" AS "asset_isVisible",
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
"asset"."originalFileName" AS "asset_originalFileName",
"asset"."sidecarPath" AS "asset_sidecarPath",
"asset"."stackId" AS "asset_stackId"
FROM
"assets" "asset"
WHERE
(
"asset"."fileCreatedAt" <= $1
AND 1 = 1
AND "asset"."ownerId" IN ($2)
AND 1 = 1
AND "asset"."isArchived" = $3
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
"asset"."fileCreatedAt" DESC
LIMIT
10001
OFFSET
20000
-- AssetRepository.getAllByDeviceId
SELECT
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",

Some files were not shown because too many files have changed in this diff Show More