Compare commits

...

42 Commits

Author SHA1 Message Date
Alex The Bot
6f53e83d49 Version v1.97.0 2024-02-28 22:34:00 +00:00
martyfuhry
b1a896ba61 feat(mobile): Adds better precaching for assets in gallery view and memory lane (#7486)
Adds better precaching for assets in gallery view and memory lane
2024-02-28 16:13:15 -06:00
martyfuhry
d28abaad7b fix(mobile): Fixes prefer remote assets in thumbnail provider (#7485)
Fixes prefer remote assets in thumbnail provider

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-02-28 21:55:29 +00:00
martyfuhry
79442fc8a1 fix(mobile): Fixes thumbnail size with blur and alignment in video player (#7483)
* Clip the edges of the image filter

* Fixes thumbnail blur effect expanding outside of the image

* Fixes thumbs with video player and delayed loader
2024-02-28 15:48:59 -06:00
Michel Heusschen
93f0a866a3 fix(web): settings accordion open state (#7504) 2024-02-28 15:40:08 -06:00
martin
84fe41df31 fix(web): re-render albums (#7403)
* fix: re-render albums

* fix: album description

* fix: reactivity

* fix album reactivity + components for title and description

* only update AssetGrid when albumId changes

* remove title and description bindings

* remove console.log

* chore: fix merge

* pr feedback

* pr feedback

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2024-02-28 15:39:53 -06:00
Jonathan Jogenfors
e4f32a045d chore: remove watcher polling option (#7480)
* remove watcher polling

* fix lint

* add db migration
2024-02-28 21:20:10 +01:00
Simon Séhier
784d92dbb3 chore(deployment): add explicit registry to docker image names (#7496) 2024-02-28 09:15:32 -06:00
Michel Heusschen
c88184673a fix(web): keep notifications in view when scrolling (#7493) 2024-02-28 07:25:08 -05:00
Jason Rasmussen
74d431f881 refactor(server): format and metadata e2e (#7477)
* refactor(server): format and metadata e2e

* refactor: on upload success waiting
2024-02-28 10:21:31 +01:00
Alex
e2c0945bc1 chore: post release tasks 2024-02-27 23:09:48 -06:00
Alex
a02a24f349 chore: post release tasks 2024-02-27 23:09:40 -06:00
Fynn Petersen-Frey
87a7825cbc feat(server): rkmpp hardware decoding scaling (#7472)
* feat(server): RKMPP hardware decode & scaling

* disable hardware decoding for HDR
2024-02-27 20:32:07 -05:00
renovate[bot]
f0ea99cea9 chore(deps): update dependency @types/node to v20.11.20 (#7474)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 22:45:29 +01:00
Jonathan Jogenfors
0d2a656aa1 chore: add agpl milestone (#7479)
add agpl milestone
2024-02-27 21:43:12 +00:00
Alex The Bot
6d91c23f65 Version v1.96.0 2024-02-27 20:14:58 +00:00
renovate[bot]
df02a9f5ed chore(deps): update dependency @types/node to v20.11.20 (#7476)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 19:16:37 +00:00
renovate[bot]
2702bcc407 chore(deps): update dependency @types/node to v20.11.20 (#7475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 19:09:26 +00:00
Jason Rasmussen
807cd245f4 refactor(server): e2e (#7462)
* refactor: trash e2e

* refactor: asset e2e
2024-02-27 14:04:38 -05:00
renovate[bot]
dc0f8756f5 chore(deps): update dependency @types/node to v20.11.20 (#7473)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 14:03:34 -05:00
renovate[bot]
df9ab8943d chore(deps): update base-image to v20240227 (major) (#7470)
chore(deps): update base-image to v20240227

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 14:03:02 -05:00
Ben McCann
79409438a7 chore(web): upgrade dependencies (#7471) 2024-02-27 14:01:11 -05:00
martyfuhry
b15eec7ca7 refactor(mobile): Uses blurhash for memory card instead of blurred thumbnail (#7469)
* Uses blurhash for memory card instead of blurred thumbnail

New blurred backdrop widget

Comments

* Fixes video placeholder image placement

* unused import
2024-02-27 12:38:14 -06:00
Alex
908104299d chore(web): remove album's action notification (#7467) 2024-02-27 12:16:52 -06:00
renovate[bot]
c94874296c chore(deps): update web (#7448)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 12:19:21 -05:00
renovate[bot]
8361130351 chore(deps): update @immich/cli (#7445)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 12:19:04 -05:00
Ben McCann
907a95a746 chore(web): cleanup promise handling (#7382)
* no-misused-promises

* no-floating-promises

* format

* revert for now

* remove load function

* require-await

* revert a few no-floating-promises changes that would cause no-misused-promises failures

* format

* fix a few more

* fix most remaining errors

* executor-queue

* executor-queue.spec

* remove duplicate comments by grouping rules

* upgrade sveltekit and enforce rules

* oops. move await

* try this

* just ignore for now since it's only a test

* run in parallel

* Update web/src/routes/admin/jobs-status/+page.svelte

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

* remove Promise.resolve call

* rename function

* remove unnecessary warning silencing

* make handleError sync

* fix new errors from recently merged PR to main

* extract method

* use handlePromiseError

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-27 10:37:37 -06:00
Alex
57f25855d3 fix(web/server): revert renovate hash update (#7464)
* Revert "chore(deps): update node.js to f3299f1 (#7444)"

This reverts commit cfb49c8be0.

* Revert "chore(deps): update node.js to f3299f1 (#7443)"

This reverts commit 2f121af9ec.
2024-02-27 10:28:00 -06:00
renovate[bot]
e02964ca0d chore(deps): update server (#7447)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 10:26:37 -06:00
renovate[bot]
fd301a3261 fix(deps): update dependency orjson to v3.9.15 [security] (#7438) 2024-02-27 10:59:28 -05:00
martyfuhry
d76baee50d refactor(mobile): Use ImmichThumbnail and local thumbnail image provider (#7279)
* Refactor to use ImmichThumbnail and local thumbnail image provider

format

* dart format

linter errors

linter

* Adds blurhash

format

* Fixes image blur

* uses hook instead of stateful widget to be more consistent

* Uses blurhash hook state

* Uses blurhash ref instead of state

* Fixes fade in duration for fade in placeholder

* Fixes an issue where thumbnails fail to load if too many thumbnail requests are made simultaenously

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2024-02-27 09:51:19 -06:00
Fynn Petersen-Frey
5e485e35e9 feat(server): easy RKMPP video encoding (#7460)
* feat(server): easy RKMPP video encoding

* make linter happy
2024-02-27 09:47:04 -06:00
renovate[bot]
cfb49c8be0 chore(deps): update node.js to f3299f1 (#7444)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 09:29:25 -06:00
renovate[bot]
2f121af9ec chore(deps): update node.js to f3299f1 (#7443)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 09:29:08 -06:00
Michel Heusschen
21feb69083 fix(web): don't ask password for invalid shared link (#7456)
* fix(web): don't ask password for invalid shared link

* use apiUtils for e2e test
2024-02-27 09:25:57 -06:00
Mert
fb18129843 fix(server): truncate embedding tables (#7449)
truncate
2024-02-27 09:24:23 -06:00
Michel Heusschen
9fa2424652 fix(web): shared links page broken by enhanced:img (#7453) 2024-02-27 07:44:32 -05:00
dependabot[bot]
7d1edddd51 chore(deps): bump docker/setup-buildx-action from 3.0.0 to 3.1.0 (#7459)
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.0.0 to 3.1.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3.0.0...v3.1.0)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-27 07:43:06 -05:00
renovate[bot]
3f18a936b2 chore(deps): update dependency @oazapfts/runtime to v1.0.1 (#7446)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 07:37:12 -05:00
Alex
e20d3048cd chore(ci): move e2e test to macos runner (#7452)
* chore(ci): move e2e test to macos runner

* setup docker

* ubuntu test
2024-02-27 07:35:14 -05:00
renovate[bot]
8965c25f54 chore(deps): update machine-learning (#7451)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-27 00:53:37 -05:00
Mert
7e18e69c1c fix(ml): only use openvino if a gpu is available (#7450)
use `device_type`
2024-02-27 00:45:14 -05:00
174 changed files with 3638 additions and 2676 deletions

View File

@@ -58,7 +58,7 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@v3

View File

@@ -66,7 +66,7 @@ jobs:
uses: docker/setup-qemu-action@v3.0.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.0.0
uses: docker/setup-buildx-action@v3.1.0
# Workaround to fix error:
# failed to push: failed to copy: io: read/write on closed pipe
# See https://github.com/docker/build-push-action/issues/761

View File

@@ -12,7 +12,7 @@ concurrency:
jobs:
server-e2e-api:
name: Server (e2e-api)
runs-on: mich
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./server
@@ -29,7 +29,7 @@ jobs:
server-e2e-jobs:
name: Server (e2e-jobs)
runs-on: mich
runs-on: ubuntu-latest
steps:
- name: Checkout code

818
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -60,12 +60,12 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
image: registry.hub.docker.com/library/redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5
restart: always
database:
container_name: immich_postgres
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
image: registry.hub.docker.com/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}

View File

@@ -38,12 +38,6 @@ services:
- /dev/dri:/dev/dri
- /dev/dma_heap:/dev/dma_heap
- /dev/mpp_service:/dev/mpp_service
volumes:
- /usr/bin/ffmpeg:/usr/bin/ffmpeg_mpp:ro
- /lib/aarch64-linux-gnu:/lib/ffmpeg-mpp:ro
- /lib/aarch64-linux-gnu/libblas.so.3:/lib/ffmpeg-mpp/libblas.so.3:ro # symlink is resolved by mounting
- /lib/aarch64-linux-gnu/liblapack.so.3:/lib/ffmpeg-mpp/liblapack.so.3:ro # symlink is resolved by mounting
- /lib/aarch64-linux-gnu/pulseaudio/libpulsecommon-15.99.so:/lib/ffmpeg-mpp/libpulsecommon-15.99.so:ro
vaapi:
devices:

View File

@@ -88,10 +88,7 @@ Some basic examples:
This feature - currently hidden in the config file - is considered experimental and for advanced users only. If enabled, it will allow automatic watching of the filesystem which means new assets are automatically imported to Immich without needing to rescan. Deleted assets are, as always, marked as offline and can be removed with the "Remove offline files" button.
If your photos are on a network drive you will likely have to enable filesystem polling. The performance hit for polling large libraries is currently unknown, feel free to test this feature and report back. In addition to the boolean feature flag, the configuration file allows customization of the following parameters, please see the [chokidar documentation](https://github.com/paulmillr/chokidar?tab=readme-ov-file#performance) for reference.
- `usePolling` (default: `false`).
- `interval`. (default: 10000). When using polling, this is how often (in milliseconds) the filesystem is polled.
If your photos are on a network drive, automatic file watching likely won't work. In that case, you will have to rely on a periodic library refresh to pull in your changes.
### Nightly job

View File

@@ -50,12 +50,22 @@ import {
mdiVectorCombine,
mdiVideo,
mdiWeb,
mdiScaleBalance,
} from '@mdi/js';
import Layout from '@theme/Layout';
import React from 'react';
import Timeline, { DateType, Item } from '../components/timeline';
const items: Item[] = [
{
icon: mdiScaleBalance,
description: 'Immich switches to AGPLv3 license',
title: 'AGPL License',
release: 'v1.95.0',
tag: 'v1.95.0',
date: new Date(2024, 1, 20),
dateType: DateType.RELEASE,
},
{
icon: mdiEyeRefreshOutline,
description: 'Automatically import files in external libraries when the operating system detects changes.',

View File

@@ -4,7 +4,6 @@ name: immich-e2e
x-server-build: &server-common
image: immich-server:latest
container_name: immich-e2e-server
build:
context: ../
dockerfile: server/Dockerfile
@@ -23,14 +22,16 @@ x-server-build: &server-common
services:
immich-server:
container_name: immich-e2e-server
command: [ "./start.sh", "immich" ]
<<: *server-common
ports:
- 2283:3001
# immich-microservices:
# command: [ "./start.sh", "microservices" ]
# <<: *server-common
immich-microservices:
container_name: immich-e2e-microservices
command: [ "./start.sh", "microservices" ]
<<: *server-common
redis:
image: redis:6.2-alpine@sha256:51d6c56749a4243096327e3fb964a48ed92254357108449cb6e23999c37773c5

172
e2e/package-lock.json generated
View File

@@ -12,11 +12,15 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.41.2",
"@types/luxon": "^3.4.2",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^1.3.0",
"exiftool-vendored": "^24.5.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
"socket.io-client": "^4.7.4",
"supertest": "^6.3.4",
"typescript": "^5.3.3",
"vitest": "^1.3.0"
@@ -591,6 +595,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@photostructure/tz-lookup": {
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz",
"integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw==",
"dev": true
},
"node_modules/@playwright/test": {
"version": "1.41.2",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.41.2.tgz",
@@ -781,6 +791,12 @@
"integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==",
"dev": true
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==",
"dev": true
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -799,6 +815,12 @@
"integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==",
"dev": true
},
"node_modules/@types/luxon": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz",
"integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==",
"dev": true
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -806,9 +828,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
@@ -1059,6 +1081,15 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
"node_modules/batch-cluster": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-13.0.0.tgz",
"integrity": "sha512-EreW0Vi8TwovhYUHBXXRA5tthuU2ynGsZFlboyMJHCCUXYa2AjgwnE3ubBOJs2xJLcuXFJbi6c/8pH5+FVj8Og==",
"dev": true,
"engines": {
"node": ">=14"
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@@ -1263,6 +1294,28 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/engine.io-client": {
"version": "6.5.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz",
"integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==",
"dev": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.11.0",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz",
"integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==",
"dev": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz",
@@ -1354,6 +1407,43 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/exiftool-vendored": {
"version": "24.5.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz",
"integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==",
"dev": true,
"dependencies": {
"@photostructure/tz-lookup": "^9.0.1",
"@types/luxon": "^3.4.2",
"batch-cluster": "^13.0.0",
"he": "^1.2.0",
"luxon": "^3.4.4"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.76.0",
"exiftool-vendored.pl": "12.76.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.76.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz",
"integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==",
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/exiftool-vendored.pl": {
"version": "12.76.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz",
"integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==",
"dev": true,
"optional": true,
"os": [
"!win32"
]
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -1547,6 +1637,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"dev": true,
"bin": {
"he": "bin/he"
}
},
"node_modules/hexoid": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz",
@@ -1704,6 +1803,15 @@
"node": ">=10"
}
},
"node_modules/luxon": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.7",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",
@@ -2346,6 +2454,34 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io-client": {
"version": "4.7.4",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.4.tgz",
"integrity": "sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==",
"dev": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"dev": true,
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -2743,6 +2879,36 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/ws": {
"version": "8.11.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
"integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
"dev": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": "^5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -16,11 +16,15 @@
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.41.2",
"@types/luxon": "^3.4.2",
"@types/node": "^20.11.17",
"@types/pg": "^8.11.0",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^1.3.0",
"exiftool-vendored": "^24.5.0",
"luxon": "^3.4.4",
"pg": "^8.11.3",
"socket.io-client": "^4.7.4",
"supertest": "^6.3.4",
"typescript": "^5.3.3",
"vitest": "^1.3.0"

View File

@@ -1,7 +1,7 @@
import {
ActivityCreateDto,
AlbumResponseDto,
AssetResponseDto,
AssetFileUploadResponseDto,
LoginResponseDto,
ReactionType,
createActivity as create,
@@ -16,13 +16,13 @@ import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
describe('/activity', () => {
let admin: LoginResponseDto;
let nonOwner: LoginResponseDto;
let asset: AssetResponseDto;
let asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
const createActivity = (dto: ActivityCreateDto, accessToken?: string) =>
create(
{ activityCreateDto: dto },
{ headers: asBearerAuth(accessToken || admin.accessToken) }
{ headers: asBearerAuth(accessToken || admin.accessToken) },
);
beforeAll(async () => {
@@ -40,7 +40,7 @@ describe('/activity', () => {
sharedWithUserIds: [nonOwner.userId],
},
},
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
);
});
@@ -61,7 +61,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
});
@@ -72,7 +72,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
});
@@ -83,7 +83,7 @@ describe('/activity', () => {
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID']))
errorDto.badRequest(expect.arrayContaining(['assetId must be a UUID'])),
);
});
@@ -104,7 +104,7 @@ describe('/activity', () => {
assetIds: [asset.id],
},
},
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
);
const [reaction] = await Promise.all([
@@ -216,7 +216,7 @@ describe('/activity', () => {
.send({ albumId: uuidDto.invalid });
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID']))
errorDto.badRequest(expect.arrayContaining(['albumId must be a UUID'])),
);
});
@@ -230,7 +230,7 @@ describe('/activity', () => {
errorDto.badRequest([
'comment must be a string',
'comment should not be empty',
])
]),
);
});
@@ -357,7 +357,7 @@ describe('/activity', () => {
describe('DELETE /activity/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/activity/${uuidDto.notFound}`
`/activity/${uuidDto.notFound}`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -421,7 +421,7 @@ describe('/activity', () => {
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no activity.delete access')
errorDto.badRequest('Not found or no activity.delete access'),
);
});
@@ -432,7 +432,7 @@ describe('/activity', () => {
type: ReactionType.Comment,
comment: 'This is a test comment',
},
nonOwner.accessToken
nonOwner.accessToken,
);
const { status } = await request(app)

View File

@@ -1,6 +1,6 @@
import {
AlbumResponseDto,
AssetResponseDto,
AssetFileUploadResponseDto,
LoginResponseDto,
SharedLinkType,
deleteUser,
@@ -21,8 +21,8 @@ const user2NotShared = 'user2NotShared';
describe('/album', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset1: AssetResponseDto;
let user1Asset2: AssetResponseDto;
let user1Asset1: AssetFileUploadResponseDto;
let user1Asset2: AssetFileUploadResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
@@ -95,7 +95,7 @@ describe('/album', () => {
await deleteUser(
{ id: user3.userId },
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
);
});
@@ -112,7 +112,7 @@ describe('/album', () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(
errorDto.badRequest(['shared must be a boolean value'])
errorDto.badRequest(['shared must be a boolean value']),
);
});
@@ -148,7 +148,7 @@ describe('/album', () => {
albumName: user2SharedUser,
shared: true,
}),
])
]),
);
});
@@ -175,7 +175,7 @@ describe('/album', () => {
albumName: user1NotShared,
shared: false,
}),
])
]),
);
});
@@ -202,7 +202,7 @@ describe('/album', () => {
albumName: user2SharedUser,
shared: true,
}),
])
]),
);
});
@@ -219,7 +219,7 @@ describe('/album', () => {
albumName: user1NotShared,
shared: false,
}),
])
]),
);
});
@@ -251,7 +251,7 @@ describe('/album', () => {
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/album/${user1Albums[0].id}`
`/album/${user1Albums[0].id}`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -361,7 +361,7 @@ describe('/album', () => {
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/album/${user1Albums[0].id}/assets`
`/album/${user1Albums[0].id}/assets`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
@@ -519,7 +519,7 @@ describe('/album', () => {
expect(body).toEqual(
expect.objectContaining({
sharedUsers: [expect.objectContaining({ id: user2.userId })],
})
}),
);
});

View File

@@ -0,0 +1,808 @@
import {
AssetFileUploadResponseDto,
AssetResponseDto,
AssetTypeEnum,
LoginResponseDto,
SharedLinkType,
} from '@immich/sdk';
import { exiftool } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { createHash } 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 { errorDto } from 'src/responses';
import {
apiUtils,
app,
dbUtils,
tempDir,
testAssetDir,
wsUtils,
} from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const sha1 = (bytes: Buffer) =>
createHash('sha1').update(bytes).digest('base64');
const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename);
await writeFile(filepath, bytes);
return exiftool.read(filepath);
};
const today = DateTime.fromObject({
year: 2023,
month: 11,
day: 3,
}) as DateTime<true>;
const yesterday = today.minus({ days: 1 });
describe('/asset', () => {
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let userStats: LoginResponseDto;
let user1Assets: AssetFileUploadResponseDto[];
let user2Assets: AssetFileUploadResponseDto[];
let assetLocation: AssetFileUploadResponseDto;
let ws: Socket;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
[ws, user1, user2, userStats] = await Promise.all([
wsUtils.connect(admin.accessToken),
apiUtils.userSetup(admin.accessToken, createUserDto.user1),
apiUtils.userSetup(admin.accessToken, createUserDto.user2),
apiUtils.userSetup(admin.accessToken, createUserDto.user3),
]);
// asset location
assetLocation = await apiUtils.createAsset(
admin.accessToken,
{},
{
filename: 'thompson-springs.jpg',
bytes: await readFile(locationAssetFilepath),
},
);
await wsUtils.waitForEvent({ event: 'upload', assetId: assetLocation.id });
user1Assets = await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(
user1.accessToken,
{
isFavorite: true,
isExternal: true,
isReadOnly: true,
fileCreatedAt: yesterday.toISO(),
fileModifiedAt: yesterday.toISO(),
},
{ filename: 'example.mp4' },
),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
]);
user2Assets = await Promise.all([apiUtils.createAsset(user2.accessToken)]);
await Promise.all([
// stats
apiUtils.createAsset(userStats.accessToken),
apiUtils.createAsset(userStats.accessToken, { isFavorite: true }),
apiUtils.createAsset(userStats.accessToken, { isArchived: true }),
apiUtils.createAsset(
userStats.accessToken,
{
isArchived: true,
isFavorite: true,
},
{ filename: 'example.mp4' },
),
]);
const person1 = await apiUtils.createPerson(user1.accessToken, {
name: 'Test Person',
});
await dbUtils.createFace({
assetId: user1Assets[0].id,
personId: person1.id,
});
}, 30_000);
afterAll(() => {
wsUtils.disconnect(ws);
});
describe('GET /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/${uuidDto.notFound}`,
);
expect(body).toEqual(errorDto.unauthorized);
expect(status).toBe(401);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.get(`/asset/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.get(`/asset/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should get the asset info', async () => {
const { status, body } = await request(app)
.get(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id });
});
it('should work with a shared link', async () => {
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [user1Assets[0].id],
});
const { status, body } = await request(app).get(
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
expect(status).toBe(200);
expect(body).toMatchObject({ id: user1Assets[0].id });
});
it('should not send people data for shared links for un-authenticated users', async () => {
const { status, body } = await request(app)
.get(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(body).toMatchObject({
id: user1Assets[0].id,
isFavorite: false,
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '/my/awesome/thumbnail.jpg',
},
],
});
const sharedLink = await apiUtils.createSharedLink(user1.accessToken, {
type: SharedLinkType.Individual,
assetIds: [user1Assets[0].id],
});
const data = await request(app).get(
`/asset/${user1Assets[0].id}?key=${sharedLink.key}`,
);
expect(data.status).toBe(200);
expect(data.body).toMatchObject({ people: [] });
});
});
describe('GET /asset/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return stats of all assets', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`);
expect(body).toEqual({ images: 3, videos: 1, total: 4 });
expect(status).toBe(200);
});
it('should return stats of all favored assets', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`)
.query({ isFavorite: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 1, videos: 1, total: 2 });
});
it('should return stats of all archived assets', async () => {
const { status, body } = await request(app)
.get('/asset/statistics')
.set('Authorization', `Bearer ${userStats.accessToken}`)
.query({ isArchived: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 1, videos: 1, total: 2 });
});
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}`)
.query({ isFavorite: true, isArchived: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 0, videos: 1, total: 1 });
});
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}`)
.query({ isFavorite: false, isArchived: false });
expect(status).toBe(200);
expect(body).toEqual({ images: 1, videos: 0, total: 1 });
});
});
describe('GET /asset/random', () => {
beforeAll(async () => {
await Promise.all([
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
apiUtils.createAsset(user1.accessToken),
]);
});
it('should require authentication', async () => {
const { status, body } = await request(app).get('/asset/random');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it.each(Array(10))('should return 1 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
// assets owned by user1
expect([user1Assets.map(({ id }) => id)]).toContain(assets[0].id);
// assets owned by user2
expect([user1Assets.map(({ id }) => id)]).not.toContain(assets[0].id);
});
it.each(Array(10))('should return 2 random assets', async () => {
const { status, body } = await request(app)
.get('/asset/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(2);
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([user1Assets.map(({ id }) => id)]).toContain(asset.id);
// assets owned by user2
expect([user2Assets.map(({ id }) => id)]).not.toContain(asset.id);
}
});
it.each(Array(10))(
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
async () => {
const { status, body } = await request(app)
.get('/[]asset/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([
expect.objectContaining({ id: user2Assets[0].id }),
]);
},
);
it('should return error', async () => {
const { status } = await request(app)
.get('/asset/random?count=ABC')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
});
});
describe('PUT /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put(
`/asset/:${uuidDto.notFound}`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(app)
.put(`/asset/${uuidDto.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(app)
.put(`/asset/${user2Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.noPermission);
});
it('should favorite an asset', async () => {
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
expect(before.isFavorite).toBe(false);
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isFavorite: true });
expect(status).toEqual(200);
});
it('should archive an asset', async () => {
const before = await apiUtils.getAssetInfo(
user1.accessToken,
user1Assets[0].id,
);
expect(before.isArchived).toBe(false);
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true });
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
expect(status).toEqual(200);
});
it('should update date time original', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
dateTimeOriginal: '2023-11-20T01:11:00.000Z',
}),
});
expect(status).toEqual(200);
});
it('should reject invalid gps coordinates', async () => {
for (const test of [
{ latitude: 12 },
{ longitude: 12 },
{ latitude: 12, longitude: 'abc' },
{ latitude: 'abc', longitude: 12 },
{ latitude: null, longitude: 12 },
{ latitude: 12, longitude: null },
{ latitude: 91, longitude: 12 },
{ latitude: -91, longitude: 12 },
{ latitude: 12, longitude: -181 },
{ latitude: 12, longitude: 181 },
]) {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.send(test)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest());
}
});
it('should update gps data', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ latitude: 12, longitude: 12 });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
});
expect(status).toEqual(200);
});
it('should set the description', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'Test asset description' });
expect(body).toMatchObject({
id: user1Assets[0].id,
exifInfo: expect.objectContaining({
description: 'Test asset description',
}),
});
expect(status).toEqual(200);
});
it('should return tagged people', async () => {
const { status, body } = await request(app)
.put(`/asset/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(status).toEqual(200);
expect(body).toMatchObject({
id: user1Assets[0].id,
isFavorite: true,
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '/my/awesome/thumbnail.jpg',
},
],
});
});
});
describe('DELETE /asset', () => {
it('should require authentication', async () => {
const { status, body } = await request(app)
.delete(`/asset`)
.send({ ids: [uuidDto.notFound] });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should require a valid uuid', async () => {
const { status, body } = await request(app)
.delete(`/asset`)
.send({ ids: [uuidDto.invalid] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest(['each value in ids must be a UUID']),
);
});
it('should throw an error when the id is not found', async () => {
const { status, body } = await request(app)
.delete(`/asset`)
.send({ ids: [uuidDto.notFound] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest('Not found or no asset.delete access'),
);
});
it('should move an asset to the trash', async () => {
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(false);
const { status } = await request(app)
.delete('/asset')
.send({ ids: [assetId] })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(true);
});
});
describe('POST /asset/upload', () => {
const tests = [
{
input: 'formats/jpg/el_torcal_rocks.jpg',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'el_torcal_rocks',
resized: true,
exifInfo: {
dateTimeOriginal: '2012-08-05T11:39:59.000Z',
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
focalLength: 75,
iso: 200,
fNumber: 11,
exposureTime: '1/160',
fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
description: 'SONY DSC',
},
},
},
{
input: 'formats/heic/IMG_2682.heic',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'IMG_2682',
resized: true,
fileCreatedAt: '2019-03-21T16:04:22.348Z',
exifInfo: {
dateTimeOriginal: '2019-03-21T16:04:22.348Z',
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
fileSizeInByte: 880_703,
exposureTime: '1/887',
iso: 20,
focalLength: 3.99,
fNumber: 1.8,
timeZone: 'America/Chicago',
},
},
},
{
input: 'formats/png/density_plot.png',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'density_plot',
resized: true,
exifInfo: {
exifImageWidth: 800,
exifImageHeight: 800,
latitude: null,
longitude: null,
fileSizeInByte: 25_408,
},
},
},
{
input: 'formats/raw/Nikon/D80/glarus.nef',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'glarus',
resized: true,
fileCreatedAt: '2010-07-20T17:27:12.000Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D80',
exposureTime: '1/200',
fNumber: 10,
focalLength: 18,
iso: 100,
fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Nikon/D700/philadelphia.nef',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'philadelphia',
resized: true,
fileCreatedAt: '2016-09-22T22:10:29.060Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D700',
exposureTime: '1/400',
fNumber: 11,
focalLength: 85,
iso: 200,
fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
orientation: '1',
timeZone: 'UTC-5',
},
},
},
];
for (const { input, expected } of tests) {
it(`should generate a thumbnail for ${input}`, async () => {
const filepath = join(testAssetDir, input);
const { id, duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{ bytes: await readFile(filepath), filename: basename(filepath) },
);
expect(duplicate).toBe(false);
await wsUtils.waitForEvent({ event: 'upload', assetId: id });
const asset = await apiUtils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo).toMatchObject(expected.exifInfo);
expect(asset).toMatchObject(expected);
});
}
it('should handle a duplicate', async () => {
const filepath = 'formats/jpeg/el_torcal_rocks.jpeg';
const { duplicate } = await apiUtils.createAsset(
admin.accessToken,
{},
{
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
expect(duplicate).toBe(true);
});
// 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.
// DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
// into the test here.
const motionTests = [
{
filepath: 'formats/motionphoto/Samsung One UI 5.jpg',
checksum: 'fr14niqCq6N20HB8rJYEvpsUVtI=',
},
{
filepath: 'formats/motionphoto/Samsung One UI 6.jpg',
checksum: 'lT9Uviw/FFJYCjfIxAGPTjzAmmw=',
},
{
filepath: 'formats/motionphoto/Samsung One UI 6.heic',
checksum: '/ejgzywvgvzvVhUYVfvkLzFBAF0=',
},
];
for (const { filepath, checksum } of motionTests) {
it(`should extract motionphoto video from ${filepath}`, async () => {
const response = await apiUtils.createAsset(
admin.accessToken,
{},
{
bytes: await readFile(join(testAssetDir, filepath)),
filename: basename(filepath),
},
);
await wsUtils.waitForEvent({ event: 'upload', assetId: response.id });
expect(response.duplicate).toBe(false);
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
response.id,
);
expect(asset.livePhotoVideoId).toBeDefined();
const video = await apiUtils.getAssetInfo(
admin.accessToken,
asset.livePhotoVideoId as string,
);
expect(video.checksum).toStrictEqual(checksum);
});
}
});
describe('GET /asset/thumbnail/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should not include gps data for webp thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`)
.set('Authorization', `Bearer ${admin.accessToken}`);
await wsUtils.waitForEvent({
event: 'upload',
assetId: assetLocation.id,
});
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/webp');
const exifData = await readTags(body, 'thumbnail.webp');
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
it('should not include gps data for jpeg thumbnails', async () => {
const { status, body, type } = await request(app)
.get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const exifData = await readTags(body, 'thumbnail.jpg');
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
});
describe('GET /asset/file/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/asset/thumbnail/${assetLocation.id}`,
);
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should download the original', async () => {
const { status, body, type } = await request(app)
.get(`/asset/file/${assetLocation.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toBeDefined();
expect(type).toBe('image/jpeg');
const asset = await apiUtils.getAssetInfo(
admin.accessToken,
assetLocation.id,
);
const original = await readFile(locationAssetFilepath);
const originalChecksum = sha1(original);
const downloadChecksum = sha1(body);
expect(originalChecksum).toBe(downloadChecksum);
expect(downloadChecksum).toBe(asset.checksum);
});
});
});

View File

@@ -29,14 +29,14 @@ describe('/audit', () => {
await Promise.all([
deleteAssets(
{ assetBulkDeleteDto: { ids: [trashedAsset.id] } },
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
),
updateAsset(
{
id: archivedAsset.id,
updateAssetDto: { isArchived: true },
},
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
),
]);

View File

@@ -1,4 +1,4 @@
import { AssetResponseDto, LoginResponseDto } from '@immich/sdk';
import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk';
import { errorDto } from 'src/responses';
import { apiUtils, app, dbUtils } from 'src/utils';
import request from 'supertest';
@@ -6,7 +6,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/download', () => {
let admin: LoginResponseDto;
let asset1: AssetResponseDto;
let asset1: AssetFileUploadResponseDto;
beforeAll(async () => {
apiUtils.setup();
@@ -35,7 +35,7 @@ describe('/download', () => {
expect(body).toEqual(
expect.objectContaining({
archives: [expect.objectContaining({ assetIds: [asset1.id] })],
})
}),
);
});
});
@@ -43,7 +43,7 @@ describe('/download', () => {
describe('POST /download/asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post(
`/download/asset/${asset1.id}`
`/download/asset/${asset1.id}`,
);
expect(status).toBe(401);

View File

@@ -1,11 +1,9 @@
import {
AlbumResponseDto,
AssetResponseDto,
AssetFileUploadResponseDto,
LoginResponseDto,
SharedLinkCreateDto,
SharedLinkResponseDto,
SharedLinkType,
createSharedLink as create,
createAlbum,
deleteUser,
} from '@immich/sdk';
@@ -17,8 +15,8 @@ import { beforeAll, describe, expect, it } from 'vitest';
describe('/shared-link', () => {
let admin: LoginResponseDto;
let asset1: AssetResponseDto;
let asset2: AssetResponseDto;
let asset1: AssetFileUploadResponseDto;
let asset2: AssetFileUploadResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let album: AlbumResponseDto;
@@ -50,11 +48,11 @@ describe('/shared-link', () => {
[album, deletedAlbum, metadataAlbum] = await Promise.all([
createAlbum(
{ createAlbumDto: { albumName: 'album' } },
{ headers: asBearerAuth(user1.accessToken) }
{ headers: asBearerAuth(user1.accessToken) },
),
createAlbum(
{ createAlbumDto: { albumName: 'deleted album' } },
{ headers: asBearerAuth(user2.accessToken) }
{ headers: asBearerAuth(user2.accessToken) },
),
createAlbum(
{
@@ -63,7 +61,7 @@ describe('/shared-link', () => {
assetIds: [asset1.id],
},
},
{ headers: asBearerAuth(user1.accessToken) }
{ headers: asBearerAuth(user1.accessToken) },
),
]);
@@ -106,7 +104,7 @@ describe('/shared-link', () => {
await deleteUser(
{ id: user2.userId },
{ headers: asBearerAuth(admin.accessToken) }
{ headers: asBearerAuth(admin.accessToken) },
);
});
@@ -132,7 +130,7 @@ describe('/shared-link', () => {
expect.objectContaining({ id: linkWithPassword.id }),
expect.objectContaining({ id: linkWithMetadata.id }),
expect.objectContaining({ id: linkWithoutMetadata.id }),
])
]),
);
});
@@ -166,7 +164,7 @@ describe('/shared-link', () => {
album,
userId: user1.userId,
type: SharedLinkType.Album,
})
}),
);
});
@@ -208,7 +206,7 @@ describe('/shared-link', () => {
album,
userId: user1.userId,
type: SharedLinkType.Album,
})
}),
);
});
@@ -225,7 +223,7 @@ describe('/shared-link', () => {
localDateTime: expect.any(String),
fileCreatedAt: expect.any(String),
exifInfo: expect.any(Object),
})
}),
);
expect(body.album).toBeDefined();
});
@@ -250,7 +248,7 @@ describe('/shared-link', () => {
describe('GET /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get(
`/shared-link/${linkWithAlbum.id}`
`/shared-link/${linkWithAlbum.id}`,
);
expect(status).toBe(401);
@@ -268,7 +266,7 @@ describe('/shared-link', () => {
album,
userId: user1.userId,
type: SharedLinkType.Album,
})
}),
);
});
@@ -279,7 +277,7 @@ describe('/shared-link', () => {
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Shared link not found' })
expect.objectContaining({ message: 'Shared link not found' }),
);
});
});
@@ -311,7 +309,7 @@ describe('/shared-link', () => {
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Invalid albumId' })
expect.objectContaining({ message: 'Invalid albumId' }),
);
});
@@ -323,7 +321,7 @@ describe('/shared-link', () => {
expect(status).toBe(400);
expect(body).toEqual(
expect.objectContaining({ message: 'Invalid assetIds' })
expect.objectContaining({ message: 'Invalid assetIds' }),
);
});
@@ -338,7 +336,7 @@ describe('/shared-link', () => {
expect.objectContaining({
type: SharedLinkType.Album,
userId: user1.userId,
})
}),
);
});
});
@@ -375,7 +373,7 @@ describe('/shared-link', () => {
type: SharedLinkType.Album,
userId: user1.userId,
description: 'foo',
})
}),
);
});
});
@@ -427,7 +425,7 @@ describe('/shared-link', () => {
describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete(
`/shared-link/${linkWithAlbum.id}`
`/shared-link/${linkWithAlbum.id}`,
);
expect(status).toBe(401);

View File

@@ -0,0 +1,107 @@
import { LoginResponseDto, getAllAssets } from '@immich/sdk';
import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses';
import { apiUtils, app, asBearerAuth, dbUtils, wsUtils } from 'src/utils';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
describe('/trash', () => {
let admin: LoginResponseDto;
let ws: Socket;
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
admin = await apiUtils.adminSetup({ onboarding: false });
ws = await wsUtils.connect(admin.accessToken);
});
afterAll(() => {
wsUtils.disconnect(ws);
});
describe('POST /trash/empty', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/trash/empty');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should empty the trash', async () => {
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
const before = await getAllAssets(
{},
{ headers: asBearerAuth(admin.accessToken) },
);
expect(before.length).toBeGreaterThanOrEqual(1);
const { status } = await request(app)
.post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
await wsUtils.waitForEvent({ event: 'delete', assetId });
const after = await getAllAssets(
{},
{ headers: asBearerAuth(admin.accessToken) },
);
expect(after.length).toBe(0);
});
});
describe('POST /trash/restore', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/trash/restore');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should restore all trashed assets', async () => {
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(true);
const { status } = await request(app)
.post('/trash/restore')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(false);
});
});
describe('POST /trash/restore/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/trash/restore/assets');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should restore a trashed asset by id', async () => {
const { id: assetId } = await apiUtils.createAsset(admin.accessToken);
await apiUtils.deleteAssets(admin.accessToken, [assetId]);
const before = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(true);
const { status } = await request(app)
.post('/trash/restore/assets')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [assetId] });
expect(status).toBe(204);
const after = await apiUtils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(false);
});
});
});

View File

@@ -1,12 +1,9 @@
import { apiUtils, cliUtils, dbUtils, immichCli } from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { beforeAll, describe, expect, it } from 'vitest';
describe(`immich server-info`, () => {
beforeAll(() => {
beforeAll(async () => {
apiUtils.setup();
});
beforeEach(async () => {
await dbUtils.reset();
await cliUtils.login();
});

View File

@@ -1,4 +1,5 @@
import { getAllAlbums, getAllAssets } from '@immich/sdk';
import { mkdir, readdir, rm, symlink } from 'fs/promises';
import {
apiUtils,
asKeyAuth,
@@ -8,18 +9,18 @@ import {
testAssetDir,
} from 'src/utils';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { mkdir, readdir, rm, symlink } from 'fs/promises';
describe(`immich upload`, () => {
let key: string;
beforeAll(() => {
beforeAll(async () => {
apiUtils.setup();
await dbUtils.reset();
key = await cliUtils.login();
});
beforeEach(async () => {
await dbUtils.reset();
key = await cliUtils.login();
await dbUtils.reset(['assets', 'albums']);
});
describe('immich upload --recursive', () => {
@@ -33,7 +34,7 @@ describe(`immich upload`, () => {
expect(stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
])
]),
);
expect(exitCode).toBe(0);
@@ -55,7 +56,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Successfully created 1 new album'),
expect.stringContaining('Successfully updated 9 assets'),
])
]),
);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
@@ -77,7 +78,7 @@ describe(`immich upload`, () => {
expect(response1.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
])
]),
);
expect(response1.stderr).toBe('');
expect(response1.exitCode).toBe(0);
@@ -97,10 +98,10 @@ describe(`immich upload`, () => {
expect(response2.stdout.split('\n')).toEqual(
expect.arrayContaining([
expect.stringContaining(
'All assets were already uploaded, nothing to do.'
'All assets were already uploaded, nothing to do.',
),
expect.stringContaining('Successfully updated 9 assets'),
])
]),
);
expect(response2.stderr).toBe('');
expect(response2.exitCode).toBe(0);
@@ -127,7 +128,7 @@ describe(`immich upload`, () => {
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Successfully created 1 new album'),
expect.stringContaining('Successfully updated 9 assets'),
])
]),
);
expect(stderr).toBe('');
expect(exitCode).toBe(0);
@@ -148,7 +149,7 @@ describe(`immich upload`, () => {
for (const file of filesToLink) {
await symlink(
`${testAssetDir}/albums/nature/${file}`,
`/tmp/albums/nature/${file}`
`/tmp/albums/nature/${file}`,
);
}
@@ -166,7 +167,7 @@ describe(`immich upload`, () => {
expect.arrayContaining([
expect.stringContaining('Successfully uploaded 9 assets'),
expect.stringContaining('Deleting assets that have been uploaded'),
])
]),
);
expect(stderr).toBe('');
expect(exitCode).toBe(0);

View File

@@ -2,25 +2,25 @@ import { spawn, exec } from 'child_process';
export default async () => {
let _resolve: () => unknown;
const promise = new Promise<void>((resolve) => (_resolve = resolve));
const ready = new Promise<void>((resolve) => (_resolve = resolve));
const child = spawn('docker', ['compose', 'up'], { stdio: 'pipe' });
child.stdout.on('data', (data) => {
const input = data.toString();
console.log(input);
if (input.includes('Immich Server is listening')) {
if (input.includes('Immich Microservices is listening')) {
_resolve();
}
});
child.stderr.on('data', (data) => console.log(data.toString()));
await promise;
await ready;
return async () => {
await new Promise<void>((resolve) =>
exec('docker compose down', () => resolve())
exec('docker compose down', () => resolve()),
);
};
};

View File

@@ -1,4 +1,5 @@
import {
AssetFileUploadResponseDto,
AssetResponseDto,
CreateAlbumDto,
CreateAssetDto,
@@ -11,18 +12,23 @@ import {
createSharedLink,
createUser,
defaults,
deleteAssets,
getAssetInfo,
login,
setAdminOnboarding,
signUpAdmin,
updatePerson,
} from '@immich/sdk';
import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'child_process';
import { exec, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { access } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { EventEmitter } from 'node:stream';
import { promisify } from 'node:util';
import pg from 'pg';
import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import request from 'supertest';
@@ -37,17 +43,23 @@ const directoryExists = (directory: string) =>
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve(`./../server/test/assets/`);
export const tempDir = tmpdir();
const serverContainerName = 'immich-e2e-server';
const uploadMediaDir = '/usr/src/app/upload/upload';
const mediaDir = '/usr/src/app/upload';
const dirs = [
`"${mediaDir}/thumbs"`,
`"${mediaDir}/upload"`,
`"${mediaDir}/library"`,
`"${mediaDir}/encoded-video"`,
].join(' ');
if (!(await directoryExists(`${testAssetDir}/albums`))) {
throw new Error(
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`
`Test assets not found. Please checkout https://github.com/immich-app/test-assets into ${testAssetDir} before testing`,
);
}
const setBaseUrl = () => (defaults.baseUrl = app);
export const asBearerAuth = (accessToken: string) => ({
Authorization: `Bearer ${accessToken}`,
});
@@ -59,7 +71,7 @@ let client: pg.Client | null = null;
export const fileUtils = {
reset: async () => {
await execPromise(
`docker exec -i "${serverContainerName}" rm -R "${uploadMediaDir}"`
`docker exec -i "${serverContainerName}" /bin/bash -c "rm -rf ${dirs} && mkdir ${dirs}"`,
);
},
};
@@ -81,7 +93,7 @@ export const dbUtils = {
await client.query(
'INSERT INTO asset_faces ("assetId", "personId", "embedding") VALUES ($1, $2, $3)',
[assetId, personId, embedding]
[assetId, personId, embedding],
);
},
setPersonThumbnail: async (personId: string) => {
@@ -91,14 +103,14 @@ export const dbUtils = {
await client.query(
`UPDATE "person" set "thumbnailPath" = '/my/awesome/thumbnail.jpg' where "id" = $1`,
[personId]
[personId],
);
},
reset: async (tables?: string[]) => {
try {
if (!client) {
client = new pg.Client(
'postgres://postgres:postgres@127.0.0.1:5433/immich'
'postgres://postgres:postgres@127.0.0.1:5433/immich',
);
await client.connect();
}
@@ -170,10 +182,94 @@ export interface AdminSetupOptions {
onboarding?: boolean;
}
export enum SocketEvent {
UPLOAD = 'upload',
DELETE = 'delete',
}
export type EventType = 'upload' | 'delete';
export interface WaitOptions {
event: EventType;
assetId: string;
timeout?: number;
}
const events: Record<EventType, Set<string>> = {
upload: new Set<string>(),
delete: new Set<string>(),
};
const callbacks: Record<string, () => void> = {};
const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => {
events[event].add(assetId);
const callback = callbacks[assetId];
if (callback) {
callback();
delete callbacks[assetId];
}
};
export const wsUtils = {
connect: async (accessToken: string) => {
const websocket = io('http://127.0.0.1:2283', {
path: '/api/socket.io',
transports: ['websocket'],
extraHeaders: { Authorization: `Bearer ${accessToken}` },
autoConnect: true,
forceNew: true,
});
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 }),
)
.connect();
});
},
disconnect: (ws: Socket) => {
if (ws?.connected) {
ws.disconnect();
}
for (const set of Object.values(events)) {
set.clear();
}
},
waitForEvent: async ({
event,
assetId,
timeout: ms,
}: WaitOptions): Promise<void> => {
const set = events[event];
if (set.has(assetId)) {
return;
}
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error(`Timed out waiting for ${event} event`)),
ms || 5000,
);
callbacks[assetId] = () => {
clearTimeout(timeout);
resolve();
};
});
},
};
export const apiUtils = {
setup: () => {
setBaseUrl();
defaults.baseUrl = app;
},
adminSetup: async (options?: AdminSetupOptions) => {
options = options || { onboarding: true };
@@ -187,7 +283,7 @@ export const apiUtils = {
userSetup: async (accessToken: string, dto: CreateUserDto) => {
await createUser(
{ createUserDto: dto },
{ headers: asBearerAuth(accessToken) }
{ headers: asBearerAuth(accessToken) },
);
return login({
loginCredentialDto: { email: dto.email, password: dto.password },
@@ -196,48 +292,74 @@ export const apiUtils = {
createApiKey: (accessToken: string) => {
return createApiKey(
{ apiKeyCreateDto: { name: 'e2e' } },
{ headers: asBearerAuth(accessToken) }
{ headers: asBearerAuth(accessToken) },
);
},
createAlbum: (accessToken: string, dto: CreateAlbumDto) =>
createAlbum(
{ createAlbumDto: dto },
{ headers: asBearerAuth(accessToken) }
{ headers: asBearerAuth(accessToken) },
),
createAsset: async (
accessToken: string,
dto?: Omit<CreateAssetDto, 'assetData'>
dto?: Partial<Omit<CreateAssetDto, 'assetData'>>,
data?: {
bytes?: Buffer;
filename?: string;
},
) => {
dto = dto || {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...(dto || {}),
};
const { body } = await request(app)
const _assetData = {
bytes: randomBytes(32),
filename: 'example.jpg',
...(data || {}),
};
const builder = request(app)
.post(`/asset/upload`)
.field('deviceAssetId', dto.deviceAssetId)
.field('deviceId', dto.deviceId)
.field('fileCreatedAt', dto.fileCreatedAt)
.field('fileModifiedAt', dto.fileModifiedAt)
.attach('assetData', randomBytes(32), 'example.jpg')
.attach('assetData', _assetData.bytes, _assetData.filename)
.set('Authorization', `Bearer ${accessToken}`);
return body as AssetResponseDto;
for (const [key, value] of Object.entries(_dto)) {
builder.field(key, String(value));
}
const { body } = await builder;
return body as AssetFileUploadResponseDto;
},
createPerson: async (accessToken: string, dto: PersonUpdateDto) => {
getAssetInfo: (accessToken: string, id: string) =>
getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets(
{ assetBulkDeleteDto: { ids } },
{ headers: asBearerAuth(accessToken) },
),
createPerson: async (accessToken: string, dto?: PersonUpdateDto) => {
// TODO fix createPerson to accept a body
const { id } = await createPerson({ headers: asBearerAuth(accessToken) });
await dbUtils.setPersonThumbnail(id);
let person = await createPerson({ headers: asBearerAuth(accessToken) });
await dbUtils.setPersonThumbnail(person.id);
if (!dto) {
return person;
}
return updatePerson(
{ id, personUpdateDto: dto },
{ headers: asBearerAuth(accessToken) }
{ id: person.id, personUpdateDto: dto },
{ headers: asBearerAuth(accessToken) },
);
},
createSharedLink: (accessToken: string, dto: SharedLinkCreateDto) =>
createSharedLink(
{ sharedLinkCreateDto: dto },
{ headers: asBearerAuth(accessToken) }
{ headers: asBearerAuth(accessToken) },
),
};

View File

@@ -15,6 +15,7 @@ test.describe('Shared Links', () => {
let asset: AssetResponseDto;
let album: AlbumResponseDto;
let sharedLink: SharedLinkResponseDto;
let sharedLinkPassword: SharedLinkResponseDto;
test.beforeAll(async () => {
apiUtils.setup();
@@ -29,17 +30,16 @@ test.describe('Shared Links', () => {
},
},
{ headers: asBearerAuth(admin.accessToken) }
// { headers: asBearerAuth(admin.accessToken)},
);
sharedLink = await createSharedLink(
{
sharedLinkCreateDto: {
type: SharedLinkType.Album,
albumId: album.id,
},
},
{ headers: asBearerAuth(admin.accessToken) }
);
sharedLink = await apiUtils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
});
sharedLinkPassword = await apiUtils.createSharedLink(admin.accessToken, {
type: SharedLinkType.Album,
albumId: album.id,
password: 'test-password',
});
});
test.afterAll(async () => {
@@ -55,4 +55,16 @@ test.describe('Shared Links', () => {
await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING').waitFor();
});
test('enter password for a shared link', async ({ page }) => {
await page.goto(`/share/${sharedLinkPassword.key}`);
await page.getByPlaceholder('Password').fill('test-password');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
});
test('show error for invalid shared link', async ({ page }) => {
await page.goto('/share/invalid');
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor();
});
});

View File

@@ -165,6 +165,14 @@ class InferenceModel(ABC):
def providers_default(self) -> list[str]:
available_providers = set(ort.get_available_providers())
log.debug(f"Available ORT providers: {available_providers}")
if (openvino := "OpenVINOExecutionProvider") in available_providers:
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
log.debug(f"Available OpenVINO devices: {device_ids}")
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
if not gpu_devices:
log.warning("No GPU device found in OpenVINO. Falling back to CPU.")
available_providers.remove(openvino)
return [provider for provider in SUPPORTED_PROVIDERS if provider in available_providers]
@property
@@ -184,15 +192,7 @@ class InferenceModel(ABC):
case "CPUExecutionProvider" | "CUDAExecutionProvider":
option = {"arena_extend_strategy": "kSameAsRequested"}
case "OpenVINOExecutionProvider":
try:
device_ids: list[str] = ort.capi._pybind_state.get_available_openvino_device_ids()
log.debug(f"Available OpenVINO devices: {device_ids}")
gpu_devices = [device_id for device_id in device_ids if device_id.startswith("GPU")]
option = {"device_id": gpu_devices[0]} if gpu_devices else {}
except AttributeError as e:
log.warning("Failed to get OpenVINO device IDs. Using default options.")
log.error(e)
option = {}
option = {"device_type": "GPU_FP32"}
case _:
option = {}
options.append(option)

View File

@@ -45,11 +45,23 @@ class TestBase:
assert encoder.providers == self.CUDA_EP
@pytest.mark.providers(OV_EP)
def test_sets_openvino_provider_if_available(self, providers: list[str]) -> None:
def test_sets_openvino_provider_if_available(self, providers: list[str], mocker: MockerFixture) -> None:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai")
assert encoder.providers == self.OV_EP
@pytest.mark.providers(OV_EP)
def test_avoids_openvino_if_gpu_not_available(self, providers: list[str], mocker: MockerFixture) -> None:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai")
assert encoder.providers == self.CPU_EP
@pytest.mark.providers(CUDA_EP_OUT_OF_ORDER)
def test_sets_providers_in_correct_order(self, providers: list[str]) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai")
@@ -68,22 +80,14 @@ class TestBase:
assert encoder.providers == providers
def test_sets_default_provider_options(self) -> None:
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
assert encoder.provider_options == [
{},
{"arena_extend_strategy": "kSameAsRequested"},
]
def test_sets_openvino_device_id_if_possible(self, mocker: MockerFixture) -> None:
def test_sets_default_provider_options(self, mocker: MockerFixture) -> None:
mocked = mocker.patch("app.models.base.ort.capi._pybind_state")
mocked.get_available_openvino_device_ids.return_value = ["GPU.0", "CPU"]
encoder = OpenCLIPEncoder("ViT-B-32__openai", providers=["OpenVINOExecutionProvider", "CPUExecutionProvider"])
assert encoder.provider_options == [
{"device_id": "GPU.0"},
{"device_type": "GPU_FP32"},
{"arena_extend_strategy": "kSameAsRequested"},
]

View File

@@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:926cac38640709f90f3fef2a3f730733b5c350be612f0d14706be8833b79ad8c as builder
FROM mambaorg/micromamba:bookworm-slim@sha256:6038b89363c9181215f3d9e8ce2720c880e224537f4028a854482e43a9b4998a as builder
ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \

View File

@@ -1250,13 +1250,13 @@ test = ["Cython (>=0.29.24,<0.30.0)"]
[[package]]
name = "httpx"
version = "0.26.0"
version = "0.27.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
{file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
{file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
]
[package.dependencies]
@@ -2101,61 +2101,61 @@ numpy = [
[[package]]
name = "orjson"
version = "3.9.14"
version = "3.9.15"
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
optional = false
python-versions = ">=3.8"
files = [
{file = "orjson-3.9.14-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:793f6c9448ab6eb7d4974b4dde3f230345c08ca6c7995330fbceeb43a5c8aa5e"},
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a6bc7928d161840096adc956703494b5c0193ede887346f028216cac0af87500"},
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:58b36f54da759602d8e2f7dad958752d453dfe2c7122767bc7f765e17dc59959"},
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:abcda41ecdc950399c05eff761c3de91485d9a70d8227cb599ad3a66afe93bcc"},
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df76ecd17b1b3627bddfd689faaf206380a1a38cc9f6c4075bd884eaedcf46c2"},
{file = "orjson-3.9.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d450a8e0656efb5d0fcb062157b918ab02dcca73278975b4ee9ea49e2fcf5bd5"},
{file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:95c03137b0cf66517c8baa65770507a756d3a89489d8ecf864ea92348e1beabe"},
{file = "orjson-3.9.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20837e10835c98973673406d6798e10f821e7744520633811a5a3d809762d8cc"},
{file = "orjson-3.9.14-cp310-none-win32.whl", hash = "sha256:1f7b6f3ef10ae8e3558abb729873d033dbb5843507c66b1c0767e32502ba96bb"},
{file = "orjson-3.9.14-cp310-none-win_amd64.whl", hash = "sha256:ea890e6dc1711aeec0a33b8520e395c2f3d59ead5b4351a788e06bf95fc7ba81"},
{file = "orjson-3.9.14-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c19009ff37f033c70acd04b636380379499dac2cba27ae7dfc24f304deabbc81"},
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19cdea0664aec0b7f385be84986d4defd3334e9c3c799407686ee1c26f7b8251"},
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:135d518f73787ce323b1a5e21fb854fe22258d7a8ae562b81a49d6c7f826f2a3"},
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2cf1d0557c61c75e18cf7d69fb689b77896e95553e212c0cc64cf2087944b84"},
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7c11667421df2d8b18b021223505dcc3ee51be518d54e4dc49161ac88ac2b87"},
{file = "orjson-3.9.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2eefc41ba42e75ed88bc396d8fe997beb20477f3e7efa000cd7a47eda452fbb2"},
{file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:917311d6a64d1c327c0dfda1e41f3966a7fb72b11ca7aa2e7a68fcccc7db35d9"},
{file = "orjson-3.9.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4dc1c132259b38d12c6587d190cd09cd76e3b5273ce71fe1372437b4cbc65f6f"},
{file = "orjson-3.9.14-cp311-none-win32.whl", hash = "sha256:6f39a10408478f4c05736a74da63727a1ae0e83e3533d07b19443400fe8591ca"},
{file = "orjson-3.9.14-cp311-none-win_amd64.whl", hash = "sha256:26280a7fcb62d8257f634c16acebc3bec626454f9ab13558bbf7883b9140760e"},
{file = "orjson-3.9.14-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:08e722a8d06b13b67a51f247a24938d1a94b4b3862e40e0eef3b2e98c99cd04c"},
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2591faa0c031cf3f57e5bce1461cfbd6160f3f66b5a72609a130924917cb07d"},
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e2450d87dd7b4f277f4c5598faa8b49a0c197b91186c47a2c0b88e15531e4e3e"},
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90903d2908158a2c9077a06f11e27545de610af690fb178fd3ba6b32492d4d1c"},
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce6f095eef0026eae76fc212f20f786011ecf482fc7df2f4c272a8ae6dd7b1ef"},
{file = "orjson-3.9.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:751250a31fef2bac05a2da2449aae7142075ea26139271f169af60456d8ad27a"},
{file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a1af21160a38ee8be3f4fcf24ee4b99e6184cadc7f915d599f073f478a94d2c"},
{file = "orjson-3.9.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:449bf090b2aa4e019371d7511a6ea8a5a248139205c27d1834bb4b1e3c44d936"},
{file = "orjson-3.9.14-cp312-none-win_amd64.whl", hash = "sha256:a603161318ff699784943e71f53899983b7dee571b4dd07c336437c9c5a272b0"},
{file = "orjson-3.9.14-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:814f288c011efdf8f115c5ebcc1ab94b11da64b207722917e0ceb42f52ef30a3"},
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a88cafb100af68af3b9b29b5ccd09fdf7a48c63327916c8c923a94c336d38dd3"},
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ba3518b999f88882ade6686f1b71e207b52e23546e180499be5bbb63a2f9c6e6"},
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:978f416bbff9da8d2091e3cf011c92da68b13f2c453dcc2e8109099b2a19d234"},
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75fc593cf836f631153d0e21beaeb8d26e144445c73645889335c2247fcd71a0"},
{file = "orjson-3.9.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23d1528db3c7554f9d6eeb09df23cb80dd5177ec56eeb55cc5318826928de506"},
{file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:7183cc68ee2113b19b0b8714221e5e3b07b3ba10ca2bb108d78fd49cefaae101"},
{file = "orjson-3.9.14-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:df3266d54246cb56b8bb17fa908660d2a0f2e3f63fbc32451ffc1b1505051d07"},
{file = "orjson-3.9.14-cp38-none-win32.whl", hash = "sha256:7913079b029e1b3501854c9a78ad938ed40d61fe09bebab3c93e60ff1301b189"},
{file = "orjson-3.9.14-cp38-none-win_amd64.whl", hash = "sha256:29512eb925b620e5da2fd7585814485c67cc6ba4fe739a0a700c50467a8a8065"},
{file = "orjson-3.9.14-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:5bf597530544db27a8d76aced49cfc817ee9503e0a4ebf0109cd70331e7bbe0c"},
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac650d49366fa41fe702e054cb560171a8634e2865537e91f09a8d05ea5b1d37"},
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:236230433a9a4968ab895140514c308fdf9f607cb8bee178a04372b771123860"},
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3014ccbda9be0b1b5f8ea895121df7e6524496b3908f4397ff02e923bcd8f6dd"},
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac0c7eae7ad3a223bde690565442f8a3d620056bd01196f191af8be58a5248e1"},
{file = "orjson-3.9.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fca33fdd0b38839b01912c57546d4f412ba7bfa0faf9bf7453432219aec2df07"},
{file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f75823cc1674a840a151e999a7dfa0d86c911150dd6f951d0736ee9d383bf415"},
{file = "orjson-3.9.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6f52ac2eb49e99e7373f62e2a68428c6946cda52ce89aa8fe9f890c7278e2d3a"},
{file = "orjson-3.9.14-cp39-none-win32.whl", hash = "sha256:0572f174f50b673b7df78680fb52cd0087a8585a6d06d295a5f790568e1064c6"},
{file = "orjson-3.9.14-cp39-none-win_amd64.whl", hash = "sha256:ab90c02cb264250b8a58cedcc72ed78a4a257d956c8d3c8bebe9751b818dfad8"},
{file = "orjson-3.9.14.tar.gz", hash = "sha256:06fb40f8e49088ecaa02f1162581d39e2cf3fd9dbbfe411eb2284147c99bad79"},
{file = "orjson-3.9.15-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d61f7ce4727a9fa7680cd6f3986b0e2c732639f46a5e0156e550e35258aa313a"},
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4feeb41882e8aa17634b589533baafdceb387e01e117b1ec65534ec724023d04"},
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fbbeb3c9b2edb5fd044b2a070f127a0ac456ffd079cb82746fc84af01ef021a4"},
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b66bcc5670e8a6b78f0313bcb74774c8291f6f8aeef10fe70e910b8040f3ab75"},
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2973474811db7b35c30248d1129c64fd2bdf40d57d84beed2a9a379a6f57d0ab"},
{file = "orjson-3.9.15-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fe41b6f72f52d3da4db524c8653e46243c8c92df826ab5ffaece2dba9cccd58"},
{file = "orjson-3.9.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4228aace81781cc9d05a3ec3a6d2673a1ad0d8725b4e915f1089803e9efd2b99"},
{file = "orjson-3.9.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6f7b65bfaf69493c73423ce9db66cfe9138b2f9ef62897486417a8fcb0a92bfe"},
{file = "orjson-3.9.15-cp310-none-win32.whl", hash = "sha256:2d99e3c4c13a7b0fb3792cc04c2829c9db07838fb6973e578b85c1745e7d0ce7"},
{file = "orjson-3.9.15-cp310-none-win_amd64.whl", hash = "sha256:b725da33e6e58e4a5d27958568484aa766e825e93aa20c26c91168be58e08cbb"},
{file = "orjson-3.9.15-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c8e8fe01e435005d4421f183038fc70ca85d2c1e490f51fb972db92af6e047c2"},
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87f1097acb569dde17f246faa268759a71a2cb8c96dd392cd25c668b104cad2f"},
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ff0f9913d82e1d1fadbd976424c316fbc4d9c525c81d047bbdd16bd27dd98cfc"},
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8055ec598605b0077e29652ccfe9372247474375e0e3f5775c91d9434e12d6b1"},
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6768a327ea1ba44c9114dba5fdda4a214bdb70129065cd0807eb5f010bfcbb5"},
{file = "orjson-3.9.15-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:12365576039b1a5a47df01aadb353b68223da413e2e7f98c02403061aad34bde"},
{file = "orjson-3.9.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:71c6b009d431b3839d7c14c3af86788b3cfac41e969e3e1c22f8a6ea13139404"},
{file = "orjson-3.9.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e18668f1bd39e69b7fed19fa7cd1cd110a121ec25439328b5c89934e6d30d357"},
{file = "orjson-3.9.15-cp311-none-win32.whl", hash = "sha256:62482873e0289cf7313461009bf62ac8b2e54bc6f00c6fabcde785709231a5d7"},
{file = "orjson-3.9.15-cp311-none-win_amd64.whl", hash = "sha256:b3d336ed75d17c7b1af233a6561cf421dee41d9204aa3cfcc6c9c65cd5bb69a8"},
{file = "orjson-3.9.15-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:82425dd5c7bd3adfe4e94c78e27e2fa02971750c2b7ffba648b0f5d5cc016a73"},
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c51378d4a8255b2e7c1e5cc430644f0939539deddfa77f6fac7b56a9784160a"},
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae4e06be04dc00618247c4ae3f7c3e561d5bc19ab6941427f6d3722a0875ef7"},
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bcef128f970bb63ecf9a65f7beafd9b55e3aaf0efc271a4154050fc15cdb386e"},
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b72758f3ffc36ca566ba98a8e7f4f373b6c17c646ff8ad9b21ad10c29186f00d"},
{file = "orjson-3.9.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c57bc7b946cf2efa67ac55766e41764b66d40cbd9489041e637c1304400494"},
{file = "orjson-3.9.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:946c3a1ef25338e78107fba746f299f926db408d34553b4754e90a7de1d44068"},
{file = "orjson-3.9.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2f256d03957075fcb5923410058982aea85455d035607486ccb847f095442bda"},
{file = "orjson-3.9.15-cp312-none-win_amd64.whl", hash = "sha256:5bb399e1b49db120653a31463b4a7b27cf2fbfe60469546baf681d1b39f4edf2"},
{file = "orjson-3.9.15-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b17f0f14a9c0ba55ff6279a922d1932e24b13fc218a3e968ecdbf791b3682b25"},
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f6cbd8e6e446fb7e4ed5bac4661a29e43f38aeecbf60c4b900b825a353276a1"},
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:76bc6356d07c1d9f4b782813094d0caf1703b729d876ab6a676f3aaa9a47e37c"},
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdfa97090e2d6f73dced247a2f2d8004ac6449df6568f30e7fa1a045767c69a6"},
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7413070a3e927e4207d00bd65f42d1b780fb0d32d7b1d951f6dc6ade318e1b5a"},
{file = "orjson-3.9.15-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9cf1596680ac1f01839dba32d496136bdd5d8ffb858c280fa82bbfeb173bdd40"},
{file = "orjson-3.9.15-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:809d653c155e2cc4fd39ad69c08fdff7f4016c355ae4b88905219d3579e31eb7"},
{file = "orjson-3.9.15-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:920fa5a0c5175ab14b9c78f6f820b75804fb4984423ee4c4f1e6d748f8b22bc1"},
{file = "orjson-3.9.15-cp38-none-win32.whl", hash = "sha256:2b5c0f532905e60cf22a511120e3719b85d9c25d0e1c2a8abb20c4dede3b05a5"},
{file = "orjson-3.9.15-cp38-none-win_amd64.whl", hash = "sha256:67384f588f7f8daf040114337d34a5188346e3fae6c38b6a19a2fe8c663a2f9b"},
{file = "orjson-3.9.15-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:6fc2fe4647927070df3d93f561d7e588a38865ea0040027662e3e541d592811e"},
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34cbcd216e7af5270f2ffa63a963346845eb71e174ea530867b7443892d77180"},
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f541587f5c558abd93cb0de491ce99a9ef8d1ae29dd6ab4dbb5a13281ae04cbd"},
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92255879280ef9c3c0bcb327c5a1b8ed694c290d61a6a532458264f887f052cb"},
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:05a1f57fb601c426635fcae9ddbe90dfc1ed42245eb4c75e4960440cac667262"},
{file = "orjson-3.9.15-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ede0bde16cc6e9b96633df1631fbcd66491d1063667f260a4f2386a098393790"},
{file = "orjson-3.9.15-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e88b97ef13910e5f87bcbc4dd7979a7de9ba8702b54d3204ac587e83639c0c2b"},
{file = "orjson-3.9.15-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:57d5d8cf9c27f7ef6bc56a5925c7fbc76b61288ab674eb352c26ac780caa5b10"},
{file = "orjson-3.9.15-cp39-none-win32.whl", hash = "sha256:001f4eb0ecd8e9ebd295722d0cbedf0748680fb9998d3993abaed2f40587257a"},
{file = "orjson-3.9.15-cp39-none-win_amd64.whl", hash = "sha256:ea0b183a5fe6b2b45f3b854b0d19c4e932d6f5934ae1f723b07cf9560edd4ec7"},
{file = "orjson-3.9.15.tar.gz", hash = "sha256:95cae920959d772f30ab36d3b25f83bb0f3be671e986c72ce22f8fa700dae061"},
]
[[package]]
@@ -2465,13 +2465,13 @@ files = [
[[package]]
name = "pytest"
version = "8.0.0"
version = "8.0.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.0.0-py3-none-any.whl", hash = "sha256:50fb9cbe836c3f20f0dfa99c565201fb75dc54c8d76373cd1bde06b06657bdb6"},
{file = "pytest-8.0.0.tar.gz", hash = "sha256:249b1b0864530ba251b7438274c4d251c58d868edaaec8762893ad4a0d71c36c"},
{file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"},
{file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"},
]
[package.dependencies]
@@ -2836,28 +2836,28 @@ files = [
[[package]]
name = "ruff"
version = "0.2.1"
version = "0.2.2"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:dd81b911d28925e7e8b323e8d06951554655021df8dd4ac3045d7212ac4ba080"},
{file = "ruff-0.2.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:dc586724a95b7d980aa17f671e173df00f0a2eef23f8babbeee663229a938fec"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c92db7101ef5bfc18e96777ed7bc7c822d545fa5977e90a585accac43d22f18a"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13471684694d41ae0f1e8e3a7497e14cd57ccb7dd72ae08d56a159d6c9c3e30e"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a11567e20ea39d1f51aebd778685582d4c56ccb082c1161ffc10f79bebe6df35"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:00a818e2db63659570403e44383ab03c529c2b9678ba4ba6c105af7854008105"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be60592f9d218b52f03384d1325efa9d3b41e4c4d55ea022cd548547cc42cd2b"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbd2288890b88e8aab4499e55148805b58ec711053588cc2f0196a44f6e3d855"},
{file = "ruff-0.2.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3ef052283da7dec1987bba8d8733051c2325654641dfe5877a4022108098683"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7022d66366d6fded4ba3889f73cd791c2d5621b2ccf34befc752cb0df70f5fad"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0a725823cb2a3f08ee743a534cb6935727d9e47409e4ad72c10a3faf042ad5ba"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0034d5b6323e6e8fe91b2a1e55b02d92d0b582d2953a2b37a67a2d7dedbb7acc"},
{file = "ruff-0.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e5cb5526d69bb9143c2e4d2a115d08ffca3d8e0fddc84925a7b54931c96f5c02"},
{file = "ruff-0.2.1-py3-none-win32.whl", hash = "sha256:6b95ac9ce49b4fb390634d46d6ece32ace3acdd52814671ccaf20b7f60adb232"},
{file = "ruff-0.2.1-py3-none-win_amd64.whl", hash = "sha256:e3affdcbc2afb6f5bd0eb3130139ceedc5e3f28d206fe49f63073cb9e65988e0"},
{file = "ruff-0.2.1-py3-none-win_arm64.whl", hash = "sha256:efababa8e12330aa94a53e90a81eb6e2d55f348bc2e71adbf17d9cad23c03ee6"},
{file = "ruff-0.2.1.tar.gz", hash = "sha256:3b42b5d8677cd0c72b99fcaf068ffc62abb5a19e71b4a3b9cfa50658a0af02f1"},
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0a9efb032855ffb3c21f6405751d5e147b0c6b631e3ca3f6b20f917572b97eb6"},
{file = "ruff-0.2.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d450b7fbff85913f866a5384d8912710936e2b96da74541c82c1b458472ddb39"},
{file = "ruff-0.2.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ecd46e3106850a5c26aee114e562c329f9a1fbe9e4821b008c4404f64ff9ce73"},
{file = "ruff-0.2.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e22676a5b875bd72acd3d11d5fa9075d3a5f53b877fe7b4793e4673499318ba"},
{file = "ruff-0.2.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1695700d1e25a99d28f7a1636d85bafcc5030bba9d0578c0781ba1790dbcf51c"},
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b0c232af3d0bd8f521806223723456ffebf8e323bd1e4e82b0befb20ba18388e"},
{file = "ruff-0.2.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f63d96494eeec2fc70d909393bcd76c69f35334cdbd9e20d089fb3f0640216ca"},
{file = "ruff-0.2.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a61ea0ff048e06de273b2e45bd72629f470f5da8f71daf09fe481278b175001"},
{file = "ruff-0.2.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e1439c8f407e4f356470e54cdecdca1bd5439a0673792dbe34a2b0a551a2fe3"},
{file = "ruff-0.2.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:940de32dc8853eba0f67f7198b3e79bc6ba95c2edbfdfac2144c8235114d6726"},
{file = "ruff-0.2.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:0c126da55c38dd917621552ab430213bdb3273bb10ddb67bc4b761989210eb6e"},
{file = "ruff-0.2.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3b65494f7e4bed2e74110dac1f0d17dc8e1f42faaa784e7c58a98e335ec83d7e"},
{file = "ruff-0.2.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1ec49be4fe6ddac0503833f3ed8930528e26d1e60ad35c2446da372d16651ce9"},
{file = "ruff-0.2.2-py3-none-win32.whl", hash = "sha256:d920499b576f6c68295bc04e7b17b6544d9d05f196bb3aac4358792ef6f34325"},
{file = "ruff-0.2.2-py3-none-win_amd64.whl", hash = "sha256:cc9a91ae137d687f43a44c900e5d95e9617cb37d4c989e462980ba27039d239d"},
{file = "ruff-0.2.2-py3-none-win_arm64.whl", hash = "sha256:c9d15fc41e6054bfc7200478720570078f0b41c9ae4f010bcc16bd6f4d1aacdd"},
{file = "ruff-0.2.2.tar.gz", hash = "sha256:e62ed7f36b3068a30ba39193a14274cd706bc486fad521276458022f7bccb31d"},
]
[[package]]

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.95.1"
version = "1.97.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 123,
"android.injected.version.name" => "1.95.1",
"android.injected.version.code" => 125,
"android.injected.version.name" => "1.97.0",
}
)
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')

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000232">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000271">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="78.881681">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="74.334294">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="32.080999">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.507669">
</testcase>

View File

@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 139;
CURRENT_PROJECT_VERSION = 140;
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 = 139;
CURRENT_PROJECT_VERSION = 140;
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 = 139;
CURRENT_PROJECT_VERSION = 140;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -55,11 +55,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.95.0</string>
<string>1.96.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>139</string>
<string>140</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.95.1"
version_number: "1.97.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000255">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000316">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.157832">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.190055">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.825919">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.109364">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.18815">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.15926">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="110.912709">
<testcase classname="fastlane.lanes" name="4: build_app" time="80.90681">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="78.396901">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="71.634559">
</testcase>

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
class AlbumThumbnailCard extends StatelessWidget {
final Function()? onTap;
@@ -45,8 +45,8 @@ class AlbumThumbnailCard extends StatelessWidget {
);
}
buildAlbumThumbnail() => ImmichImage.thumbnail(
album.thumbnail.value,
buildAlbumThumbnail() => ImmichThumbnail(
asset: album.thumbnail.value,
width: cardSize,
height: cardSize,
);

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
class SharedAlbumThumbnailImage extends HookConsumerWidget {
final Asset asset;
@@ -16,8 +16,8 @@ class SharedAlbumThumbnailImage extends HookConsumerWidget {
},
child: Stack(
children: [
ImmichImage.thumbnail(
asset,
ImmichThumbnail(
asset: asset,
width: 500,
height: 500,
),

View File

@@ -12,7 +12,7 @@ import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
@RoutePage()
class SharingPage extends HookConsumerWidget {
@@ -72,8 +72,8 @@ class SharingPage extends HookConsumerWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: ImmichImage.thumbnail(
album.thumbnail.value,
child: ImmichThumbnail(
asset: album.thumbnail.value,
width: 60,
height: 60,
),

View File

@@ -11,7 +11,7 @@ import 'package:photo_manager/photo_manager.dart';
/// The local image provider for an asset
/// Only viable
class ImmichLocalImageProvider extends ImageProvider<Asset> {
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {
final Asset asset;
ImmichLocalImageProvider({
@@ -21,15 +21,18 @@ class ImmichLocalImageProvider extends ImageProvider<Asset> {
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<Asset> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(asset);
Future<ImmichLocalImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) {
ImageStreamCompleter loadImage(
ImmichLocalImageProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents),
codec: _codec(key.asset, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
@@ -82,11 +85,6 @@ class ImmichLocalImageProvider extends ImageProvider<Asset> {
yield codec;
} catch (error) {
throw StateError("Loading asset ${asset.fileName} failed");
} finally {
if (Platform.isIOS) {
// Clean up this file
await file.delete();
}
}
}
}

View File

@@ -0,0 +1,86 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:photo_manager/photo_manager.dart';
/// The local image provider for an asset
/// Only viable
class ImmichLocalThumbnailProvider extends ImageProvider<Asset> {
final Asset asset;
final int height;
final int width;
ImmichLocalThumbnailProvider({
required this.asset,
this.height = 256,
this.width = 256,
}) : assert(asset.local != null, 'Only usable when asset.local is set');
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<Asset> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(asset);
}
@override
ImageStreamCompleter loadImage(Asset key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
informationCollector: () sync* {
yield ErrorDescription(asset.fileName);
},
);
}
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
Asset key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a small thumbnail
final thumbBytes = await asset.local?.thumbnailDataWithSize(
const ThumbnailSize.square(32),
quality: 75,
);
if (thumbBytes != null) {
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
final codec = await decode(buffer);
yield codec;
} else {
debugPrint("Loading thumb for ${asset.fileName} failed");
}
final normalThumbBytes =
await asset.local?.thumbnailDataWithSize(ThumbnailSize(width, height));
if (normalThumbBytes == null) {
throw StateError(
"Loading thumb for local photo ${asset.fileName} failed",
);
}
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
final codec = await decode(buffer);
yield codec;
chunkEvents.close();
}
@override
bool operator ==(Object other) {
if (other is! ImmichLocalThumbnailProvider) return false;
if (identical(this, other)) return true;
return asset == other.asset;
}
@override
int get hashCode => asset.hashCode;
}

View File

@@ -13,10 +13,13 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
/// Our Image Provider HTTP client to make the request
final _httpClient = HttpClient()..autoUncompress = false;
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 10;
/// The remote image provider
class ImmichRemoteImageProvider extends ImageProvider<String> {
class ImmichRemoteImageProvider
extends ImageProvider<ImmichRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
@@ -32,16 +35,20 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<String> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture('$assetId,$isThumbnail');
Future<ImmichRemoteImageProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) {
final id = key.split(',').first;
ImageStreamCompleter loadImage(
ImmichRemoteImageProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(id, decode, chunkEvents),
codec: _codec(key, decode, chunkEvents),
scale: 1.0,
chunkEvents: chunkEvents.stream,
);
@@ -61,14 +68,14 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
String key,
ImmichRemoteImageProvider key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a preview to the chunk events
if (_loadPreview || isThumbnail) {
if (_loadPreview || key.isThumbnail) {
final preview = getThumbnailUrlForRemoteId(
assetId,
key.assetId,
type: api.ThumbnailFormat.WEBP,
);
@@ -80,14 +87,14 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
}
// Guard thumnbail rendering
if (isThumbnail) {
if (key.isThumbnail) {
await chunkEvents.close();
return;
}
// Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId(
assetId,
key.assetId,
type: api.ThumbnailFormat.JPEG,
);
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
@@ -96,7 +103,7 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
// Load the final remote image
if (_useOriginal) {
// Load the original image
final url = getImageUrlFromId(assetId);
final url = getImageUrlFromId(key.assetId);
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents);
yield codec;
}
@@ -137,7 +144,7 @@ class ImmichRemoteImageProvider extends ImageProvider<String> {
bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true;
return assetId == other.assetId;
return assetId == other.assetId && isThumbnail == other.isThumbnail;
}
@override

View File

@@ -12,14 +12,17 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
/// Our HTTP client to make the request
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 100;
/// The remote image provider
class ImmichRemoteThumbnailProvider extends ImageProvider<String> {
class ImmichRemoteThumbnailProvider
extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch
final String assetId;
/// Our HTTP client to make the request
final _httpClient = HttpClient()..autoUncompress = false;
ImmichRemoteThumbnailProvider({
required this.assetId,
});
@@ -27,12 +30,17 @@ class ImmichRemoteThumbnailProvider extends ImageProvider<String> {
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
/// that describes the precise image to load.
@override
Future<String> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture(assetId);
Future<ImmichRemoteThumbnailProvider> obtainKey(
ImageConfiguration configuration,
) {
return SynchronousFuture(this);
}
@override
ImageStreamCompleter loadImage(String key, ImageDecoderCallback decode) {
ImageStreamCompleter loadImage(
ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode,
) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents),
@@ -43,13 +51,13 @@ class ImmichRemoteThumbnailProvider extends ImageProvider<String> {
// Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec(
String key,
ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* {
// Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId(
assetId,
key.assetId,
type: api.ThumbnailFormat.WEBP,
);

View File

@@ -1,8 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:easy_localization/easy_localization.dart';
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
@@ -10,6 +11,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/album/providers/current_album.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/asset_stack.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/show_controls.provider.dart';
@@ -26,13 +28,13 @@ import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.da
import 'package:immich_mobile/modules/home/ui/upload_dialog.dart';
import 'package:immich_mobile/modules/partner/providers/partner.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/modules/home/ui/delete_dialog.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/shared/ui/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/shared/ui/photo_view/src/photo_view_computed_scale.dart';
@@ -131,7 +133,7 @@ class GalleryViewerPage extends HookConsumerWidget {
void toggleFavorite(Asset asset) =>
ref.read(assetProvider.notifier).toggleFavorite([asset]);
void precacheNextImage(int index) {
Future<void> precacheNextImage(int index) async {
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
debugPrint('Error precaching next image: $exception, $stackTrace');
@@ -139,7 +141,7 @@ class GalleryViewerPage extends HookConsumerWidget {
if (index < totalAssets && index >= 0) {
final asset = loadAsset(index);
precacheImage(
await precacheImage(
ImmichImage.imageProvider(asset: asset),
context,
onError: onError,
@@ -481,15 +483,9 @@ class GalleryViewerPage extends HookConsumerWidget {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage(
child: Image(
fit: BoxFit.cover,
imageUrl:
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/$assetId',
httpHeaders: {
"x-immich-user-token": Store.get(StoreKey.accessToken),
},
errorWidget: (context, url, error) =>
const Icon(Icons.image_not_supported_outlined),
image: ImmichRemoteImageProvider(assetId: assetId!),
),
),
),
@@ -716,6 +712,21 @@ class GalleryViewerPage extends HookConsumerWidget {
[],
);
useEffect(
() {
// No need to await this
unawaited(
// Delay this a bit so we can finish loading the page
Future.delayed(const Duration(milliseconds: 400)).then(
// Precache the next image
(_) => precacheNextImage(currentIndex.value + 1),
),
);
return null;
},
[],
);
ref.listen(showControlsProvider, (_, show) {
if (show) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
@@ -740,9 +751,22 @@ class GalleryViewerPage extends HookConsumerWidget {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
},
loadingBuilder: (context, event, index) => ImmichImage.thumbnail(
asset(),
fit: BoxFit.contain,
loadingBuilder: (context, event, index) => ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: 10,
sigmaY: 10,
),
),
ImmichThumbnail(
asset: asset(),
fit: BoxFit.contain,
),
],
),
),
pageController: controller,
scrollPhysics: isZoomed.value
@@ -753,12 +777,16 @@ class GalleryViewerPage extends HookConsumerWidget {
),
itemCount: totalAssets,
scrollDirection: Axis.horizontal,
onPageChanged: (value) {
onPageChanged: (value) async {
final next = currentIndex.value < value ? value + 1 : value - 1;
precacheNextImage(next);
HapticFeedback.selectionClick();
currentIndex.value = value;
stackIndex.value = -1;
HapticFeedback.selectionClick();
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// Then precache the next image
unawaited(precacheNextImage(next));
},
builder: (context, index) {
final a =
@@ -817,7 +845,7 @@ class GalleryViewerPage extends HookConsumerWidget {
isMotionVideo: isPlayingMotionVideo.value,
placeholder: Image(
image: provider,
fit: BoxFit.fitWidth,
fit: BoxFit.contain,
height: context.height,
width: context.width,
alignment: Alignment.center,

View File

@@ -59,8 +59,12 @@ class VideoViewerPage extends HookWidget {
return Stack(
children: [
if (placeholder != null) placeholder!,
const DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
const Positioned.fill(
child: Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 500),
),
),
),
],
);

View File

@@ -4,7 +4,7 @@ import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/immich_thumbnail.dart';
import 'package:immich_mobile/utils/storage_indicator.dart';
import 'package:isar/isar.dart';
@@ -134,10 +134,10 @@ class ThumbnailImage extends StatelessWidget {
tag: isFromDto
? '${asset.remoteId}-$heroOffset'
: asset.id + heroOffset,
child: ImmichImage.thumbnail(
asset,
height: 300,
width: 300,
child: ImmichThumbnail(
asset: asset,
height: 250,
width: 250,
),
),
);

View File

@@ -1,10 +1,11 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class MemoryCard extends StatelessWidget {
@@ -21,8 +22,6 @@ class MemoryCard extends StatelessWidget {
super.key,
});
String get accessToken => Store.get(StoreKey.accessToken);
@override
Widget build(BuildContext context) {
return Card(
@@ -37,20 +36,8 @@ class MemoryCard extends StatelessWidget {
clipBehavior: Clip.hardEdge,
child: Stack(
children: [
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: ImmichImage.imageProvider(
asset: asset,
isThumbnail: true,
),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withOpacity(0.2)),
),
SizedBox.expand(
child: _BlurredBackdrop(asset: asset),
),
LayoutBuilder(
builder: (context, constraints) {
@@ -113,3 +100,50 @@ class MemoryCard extends StatelessWidget {
);
}
}
class _BlurredBackdrop extends HookWidget {
final Asset asset;
const _BlurredBackdrop({required this.asset});
@override
Widget build(BuildContext context) {
final blurhash = useBlurHashRef(asset).value;
if (blurhash != null) {
// Use a nice cheap blur hash image decoration
return Container(
decoration: BoxDecoration(
image: DecorationImage(
image: MemoryImage(
blurhash,
),
fit: BoxFit.cover,
),
),
child: Container(
color: Colors.black.withOpacity(0.2),
),
);
} else {
// Fall back to using a more expensive image filtered
// Since the ImmichImage is already precached, we can
// safely use that as the image provider
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: ImmichImage.imageProvider(
asset: asset,
),
fit: BoxFit.cover,
),
),
child: Container(
color: Colors.black.withOpacity(0.2),
),
),
);
}
}
}

View File

@@ -109,25 +109,13 @@ class MemoryPage extends HookConsumerWidget {
asset = memories[nextMemoryIndex].assets.first;
}
// Gets the thumbnail url and precaches it
final precaches = <Future<dynamic>>[];
precaches.addAll([
precacheImage(
ImmichImage.imageProvider(
asset: asset,
),
context,
// Precache the asset
await precacheImage(
ImmichImage.imageProvider(
asset: asset,
),
precacheImage(
ImmichImage.imageProvider(
asset: asset,
isThumbnail: true,
),
context,
),
]);
await Future.wait(precaches);
context,
);
}
// Precache the next page right away if we are on the first page
@@ -136,11 +124,14 @@ class MemoryPage extends HookConsumerWidget {
.then((_) => precacheAsset(1));
}
onAssetChanged(int otherIndex) {
Future<void> onAssetChanged(int otherIndex) async {
HapticFeedback.selectionClick();
currentAssetPage.value = otherIndex;
precacheAsset(otherIndex + 1);
updateProgressText();
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// And then precache the next asset
await precacheAsset(otherIndex + 1);
}
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called

View File

@@ -38,7 +38,8 @@ class Asset {
// stack handling to properly handle it
stackParentId =
remote.stackParentId == remote.id ? null : remote.stackParentId,
stackCount = remote.stackCount;
stackCount = remote.stackCount,
thumbhash = remote.thumbhash;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
@@ -91,6 +92,7 @@ class Asset {
this.stackCount = 0,
this.isReadOnly = false,
this.isOffline = false,
this.thumbhash,
});
@ignore
@@ -119,6 +121,8 @@ class Asset {
/// because Isar cannot sort lists of byte arrays
String checksum;
String? thumbhash;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId;
@@ -279,6 +283,7 @@ class Asset {
a.exifInfo?.latitude != exifInfo?.latitude ||
a.exifInfo?.longitude != exifInfo?.longitude ||
// no local stack count or different count from remote
a.thumbhash != thumbhash ||
((stackCount == null && a.stackCount != null) ||
(stackCount != null &&
a.stackCount != null &&
@@ -343,6 +348,7 @@ class Asset {
isReadOnly: a.isReadOnly,
isOffline: a.isOffline,
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
thumbhash: a.thumbhash,
);
} else {
// add only missing values (and set isLocal to true)
@@ -379,6 +385,7 @@ class Asset {
ExifInfo? exifInfo,
String? stackParentId,
int? stackCount,
String? thumbhash,
}) =>
Asset(
id: id ?? this.id,
@@ -403,6 +410,7 @@ class Asset {
exifInfo: exifInfo ?? this.exifInfo,
stackParentId: stackParentId ?? this.stackParentId,
stackCount: stackCount ?? this.stackCount,
thumbhash: thumbhash ?? this.thumbhash,
);
Future<void> put(Isar db) async {

View File

@@ -102,19 +102,24 @@ const AssetSchema = CollectionSchema(
name: r'stackParentId',
type: IsarType.string,
),
r'type': PropertySchema(
r'thumbhash': PropertySchema(
id: 17,
name: r'thumbhash',
type: IsarType.string,
),
r'type': PropertySchema(
id: 18,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 18,
id: 19,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 19,
id: 20,
name: r'width',
type: IsarType.int,
)
@@ -210,6 +215,12 @@ int _assetEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.thumbhash;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
return bytesCount;
}
@@ -236,9 +247,10 @@ void _assetSerialize(
writer.writeString(offsets[14], object.remoteId);
writer.writeLong(offsets[15], object.stackCount);
writer.writeString(offsets[16], object.stackParentId);
writer.writeByte(offsets[17], object.type.index);
writer.writeDateTime(offsets[18], object.updatedAt);
writer.writeInt(offsets[19], object.width);
writer.writeString(offsets[17], object.thumbhash);
writer.writeByte(offsets[18], object.type.index);
writer.writeDateTime(offsets[19], object.updatedAt);
writer.writeInt(offsets[20], object.width);
}
Asset _assetDeserialize(
@@ -266,10 +278,11 @@ Asset _assetDeserialize(
remoteId: reader.readStringOrNull(offsets[14]),
stackCount: reader.readLongOrNull(offsets[15]),
stackParentId: reader.readStringOrNull(offsets[16]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[17])] ??
thumbhash: reader.readStringOrNull(offsets[17]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[18])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[18]),
width: reader.readIntOrNull(offsets[19]),
updatedAt: reader.readDateTime(offsets[19]),
width: reader.readIntOrNull(offsets[20]),
);
return object;
}
@@ -316,11 +329,13 @@ P _assetDeserializeProp<P>(
case 16:
return (reader.readStringOrNull(offset)) as P;
case 17:
return (reader.readStringOrNull(offset)) as P;
case 18:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 18:
return (reader.readDateTime(offset)) as P;
case 19:
return (reader.readDateTime(offset)) as P;
case 20:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@@ -2078,6 +2093,152 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'thumbhash',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'thumbhash',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'thumbhash',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'thumbhash',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'thumbhash',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'thumbhash',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> thumbhashIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'thumbhash',
value: '',
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> typeEqualTo(
AssetType value) {
return QueryBuilder.apply(this, (query) {
@@ -2462,6 +2623,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhash() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByThumbhashDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@@ -2716,6 +2889,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhash() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByThumbhashDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'thumbhash', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByType() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'type', Sort.asc);
@@ -2864,6 +3049,13 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByThumbhash(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'thumbhash', caseSensitive: caseSensitive);
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByType() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'type');
@@ -2992,6 +3184,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, String?, QQueryOperations> thumbhashProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'thumbhash');
});
}
QueryBuilder<Asset, AssetType, QQueryOperations> typeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'type');

View File

@@ -20,21 +20,24 @@ class DelayedLoadingIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: fadeInDuration ?? Duration.zero,
child: FutureBuilder(
future: Future.delayed(delay),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return child ??
const ImmichLoadingIndicator(
key: ValueKey('loading'),
);
}
return FutureBuilder(
future: Future.delayed(delay),
builder: (context, snapshot) {
late Widget c;
if (snapshot.connectionState == ConnectionState.done) {
c = child ??
const ImmichLoadingIndicator(
key: ValueKey('loading'),
);
} else {
c = Container(key: const ValueKey('hiding'));
}
return Container(key: const ValueKey('hiding'));
},
),
return AnimatedSwitcher(
duration: fadeInDuration ?? Duration.zero,
child: c,
);
},
);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/ui/transparent_image.dart';
class FadeInPlaceholderImage extends StatelessWidget {
final Widget placeholder;
final ImageProvider image;
final Duration duration;
final BoxFit fit;
const FadeInPlaceholderImage({
super.key,
required this.placeholder,
required this.image,
this.duration = const Duration(milliseconds: 100),
this.fit = BoxFit.cover,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
placeholder,
FadeInImage(
fadeInDuration: duration,
image: image,
fit: fit,
placeholder: MemoryImage(kTransparentImage),
),
],
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:thumbhash/thumbhash.dart' as thumbhash;
ObjectRef<Uint8List?> useBlurHashRef(Asset? asset) {
if (asset?.thumbhash == null) {
return useRef(null);
}
final rbga = thumbhash.thumbHashToRGBA(
base64Decode(asset!.thumbhash!),
);
return useRef(thumbhash.rgbaToBmp(rbga));
}

View File

@@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -9,8 +7,6 @@ import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.d
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:octo_image/octo_image.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
class ImmichImage extends StatelessWidget {
const ImmichImage(
@@ -19,8 +15,6 @@ class ImmichImage extends StatelessWidget {
this.height,
this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(),
this.isThumbnail = false,
this.thumbnailSize = 250,
super.key,
});
@@ -29,32 +23,6 @@ class ImmichImage extends StatelessWidget {
final double? width;
final double? height;
final BoxFit fit;
final bool isThumbnail;
final int thumbnailSize;
/// Factory constructor to use the thumbnail variant
factory ImmichImage.thumbnail(
Asset? asset, {
BoxFit fit = BoxFit.cover,
double? width,
double? height,
}) {
// Use the width and height to derive thumbnail size
final thumbnailSize = max(width ?? 250, height ?? 250).toInt();
return ImmichImage(
asset,
isThumbnail: true,
fit: fit,
width: width,
height: height,
placeholder: ThumbnailPlaceholder(
height: thumbnailSize.toDouble(),
width: thumbnailSize.toDouble(),
),
thumbnailSize: thumbnailSize,
);
}
// Helper function to return the image provider for the asset
// either by using the asset ID or the asset itself
@@ -66,8 +34,6 @@ class ImmichImage extends StatelessWidget {
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
bool isThumbnail = false,
int thumbnailSize = 250,
}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
@@ -76,44 +42,36 @@ class ImmichImage extends StatelessWidget {
if (asset == null) {
return ImmichRemoteImageProvider(
assetId: assetId!,
isThumbnail: isThumbnail,
isThumbnail: false,
);
}
if (useLocal(asset) && isThumbnail) {
return AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: ThumbnailSize.square(thumbnailSize),
);
} else if (useLocal(asset) && !isThumbnail) {
if (useLocal(asset)) {
return ImmichLocalImageProvider(
asset: asset,
);
} else {
return ImmichRemoteImageProvider(
assetId: asset.remoteId!,
isThumbnail: isThumbnail,
isThumbnail: false,
);
}
}
// Whether to use the local asset image provider or a remote one
static bool useLocal(Asset asset) =>
!asset.isRemote ||
asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
@override
Widget build(BuildContext context) {
if (asset == null) {
return Container(
decoration: const BoxDecoration(
color: Colors.grey,
),
child: SizedBox(
width: width,
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
color: Colors.grey,
width: width,
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
);
}
@@ -131,7 +89,6 @@ class ImmichImage extends StatelessWidget {
},
image: ImmichImage.imageProvider(
asset: asset,
isThumbnail: isThumbnail,
),
width: width,
height: height,

View File

@@ -0,0 +1,88 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/shared/ui/thumbhash_placeholder.dart';
import 'package:octo_image/octo_image.dart';
class ImmichThumbnail extends HookWidget {
const ImmichThumbnail({
this.asset,
this.width = 250,
this.height = 250,
this.fit = BoxFit.cover,
super.key,
});
final Asset? asset;
final double width;
final double height;
final BoxFit fit;
/// Helper function to return the image provider for the asset thumbnail
/// either by using the asset ID or the asset itself
/// [asset] is the Asset to request, or else use [assetId] to get a remote
/// image provider
static ImageProvider imageProvider({
Asset? asset,
String? assetId,
int thumbnailSize = 256,
}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return ImmichRemoteImageProvider(
assetId: assetId!,
isThumbnail: true,
);
}
if (ImmichImage.useLocal(asset)) {
return ImmichLocalThumbnailProvider(
asset: asset,
height: thumbnailSize,
width: thumbnailSize,
);
} else {
return ImmichRemoteImageProvider(
assetId: asset.remoteId!,
isThumbnail: true,
);
}
}
@override
Widget build(BuildContext context) {
Uint8List? blurhash = useBlurHashRef(asset).value;
if (asset == null) {
return Container(
color: Colors.grey,
width: width,
height: height,
child: const Center(
child: Icon(Icons.no_photography),
),
);
}
return OctoImage.fromSet(
placeholderFadeInDuration: Duration.zero,
fadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 100),
octoSet: blurHashOrPlaceholder(blurhash),
image: ImmichThumbnail.imageProvider(
asset: asset,
),
width: width,
height: height,
fit: fit,
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/shared/ui/fade_in_placeholder_image.dart';
import 'package:octo_image/octo_image.dart';
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
/// placeholder and [OctoError.icon] as error.
OctoSet blurHashOrPlaceholder(
Uint8List? blurhash, {
BoxFit? fit,
Text? errorMessage,
}) {
return OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit),
errorBuilder: blurHashErrorBuilder(blurhash, fit: fit),
);
}
OctoPlaceholderBuilder blurHashPlaceholderBuilder(
Uint8List? blurhash, {
BoxFit? fit,
}) {
return (context) => blurhash == null
? const ThumbnailPlaceholder()
: FadeInPlaceholderImage(
placeholder: const ThumbnailPlaceholder(),
image: MemoryImage(blurhash),
fit: fit ?? BoxFit.cover,
);
}
OctoErrorBuilder blurHashErrorBuilder(
Uint8List? blurhash, {
BoxFit? fit,
Text? message,
IconData? icon,
Color? iconColor,
double? iconSize,
}) {
return OctoError.placeholderWithErrorIcon(
blurHashPlaceholderBuilder(blurhash, fit: fit),
message: message,
icon: icon,
iconColor: iconColor,
iconSize: iconSize,
);
}

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.95.1
- API version: 1.97.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements

View File

@@ -9,8 +9,6 @@ import 'package:openapi/api.dart';
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**enabled** | **bool** | |
**interval** | **int** | |
**usePolling** | **bool** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -14,37 +14,25 @@ class SystemConfigLibraryWatchDto {
/// Returns a new [SystemConfigLibraryWatchDto] instance.
SystemConfigLibraryWatchDto({
required this.enabled,
required this.interval,
required this.usePolling,
});
bool enabled;
int interval;
bool usePolling;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigLibraryWatchDto &&
other.enabled == enabled &&
other.interval == interval &&
other.usePolling == usePolling;
other.enabled == enabled;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(interval.hashCode) +
(usePolling.hashCode);
(enabled.hashCode);
@override
String toString() => 'SystemConfigLibraryWatchDto[enabled=$enabled, interval=$interval, usePolling=$usePolling]';
String toString() => 'SystemConfigLibraryWatchDto[enabled=$enabled]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
json[r'interval'] = this.interval;
json[r'usePolling'] = this.usePolling;
return json;
}
@@ -57,8 +45,6 @@ class SystemConfigLibraryWatchDto {
return SystemConfigLibraryWatchDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: mapValueOfType<int>(json, r'interval')!,
usePolling: mapValueOfType<bool>(json, r'usePolling')!,
);
}
return null;
@@ -107,8 +93,6 @@ class SystemConfigLibraryWatchDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
'interval',
'usePolling',
};
}

View File

@@ -21,16 +21,6 @@ void main() {
// TODO
});
// int interval
test('to test the property `interval`', () async {
// TODO
});
// bool usePolling
test('to test the property `usePolling`', () async {
// TODO
});
});

View File

@@ -413,10 +413,10 @@ packages:
dependency: transitive
description:
name: file
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
sha256: "1b92bec4fc2a72f59a8e15af5f52cd441e4a7860b49499d69dfa817af20e925d"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
version: "6.1.4"
file_selector_linux:
dependency: transitive
description:
@@ -860,30 +860,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.8.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "78eb209deea09858f5269f5a5b02be4049535f568c07b275096836f01ea323fa"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: b46c5e37c19120a8a01918cfaf293547f47269f7cb4b0058f21531c2465d6ef0
url: "https://pub.dev"
source: hosted
version: "2.0.1"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: a597f72a664dbd293f3bfc51f9ba69816f84dcd403cdac7066cb3f6003f3ab47
url: "https://pub.dev"
source: hosted
version: "2.0.1"
lints:
dependency: transitive
description:
@@ -931,18 +907,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e"
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
version: "0.12.16"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a"
sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.5.0"
meta:
dependency: "direct overridden"
description:
@@ -1026,10 +1002,10 @@ packages:
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.8.3"
path_provider:
dependency: "direct main"
description:
@@ -1162,10 +1138,10 @@ packages:
dependency: transitive
description:
name: platform
sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec"
sha256: ae68c7bfcd7383af3629daafb32fb4e8681c7154428da4febcff06200585f102
url: "https://pub.dev"
source: hosted
version: "3.1.4"
version: "3.1.2"
plugin_platform_interface:
dependency: transitive
description:
@@ -1194,10 +1170,10 @@ packages:
dependency: transitive
description:
name: process
sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "4.2.4"
provider:
dependency: transitive
description:
@@ -1491,6 +1467,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.1"
thumbhash:
dependency: "direct main"
description:
name: thumbhash
sha256: "5f6d31c5279ca0b5caa81ec10aae8dcaab098d82cb699ea66ada4ed09c794a37"
url: "https://pub.dev"
source: hosted
version: "0.1.0+1"
time:
dependency: transitive
description:
@@ -1655,10 +1639,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: b3d56ff4341b8f182b96aceb2fa20e3dcb336b9f867bc0eafc0de10f1048e957
sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583
url: "https://pub.dev"
source: hosted
version: "13.0.0"
version: "11.10.0"
wakelock_plus:
dependency: "direct main"
description:
@@ -1703,10 +1687,10 @@ packages:
dependency: transitive
description:
name: webdriver
sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e"
sha256: "3c923e918918feeb90c4c9fdf1fe39220fa4c0e8e2c0fffaded174498ef86c49"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
version: "3.0.2"
win32:
dependency: transitive
description:

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.95.1+123
version: 1.97.0+125
isar_version: &isar_version 3.1.0+1
environment:
@@ -57,6 +57,7 @@ dependencies:
flutter_local_notifications: ^16.3.2
timezone: ^0.9.2
octo_image: ^2.0.0
thumbhash: 0.1.0+1
openapi:
path: openapi

View File

@@ -6458,7 +6458,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.95.1",
"version": "1.97.0",
"contact": {}
},
"tags": [],
@@ -9831,18 +9831,10 @@
"properties": {
"enabled": {
"type": "boolean"
},
"interval": {
"type": "integer"
},
"usePolling": {
"type": "boolean"
}
},
"required": [
"enabled",
"interval",
"usePolling"
"enabled"
],
"type": "object"
},

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.1
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -4401,18 +4401,6 @@ export interface SystemConfigLibraryWatchDto {
* @memberof SystemConfigLibraryWatchDto
*/
'enabled': boolean;
/**
*
* @type {number}
* @memberof SystemConfigLibraryWatchDto
*/
'interval': number;
/**
*
* @type {boolean}
* @memberof SystemConfigLibraryWatchDto
*/
'usePolling': boolean;
}
/**
*

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.1
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.1
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.1
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.95.1
* The version of the OpenAPI document: 1.97.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.95.1
* 1.97.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -835,8 +835,6 @@ export type SystemConfigLibraryScanDto = {
};
export type SystemConfigLibraryWatchDto = {
enabled: boolean;
interval: number;
usePolling: boolean;
};
export type SystemConfigLibraryDto = {
scan: SystemConfigLibraryScanDto;

View File

@@ -23,15 +23,15 @@
}
},
"node_modules/@oazapfts/runtime": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.0.tgz",
"integrity": "sha512-1ovqeaeEvShbYge5/7ctJokpvqB0anBdfDNfU5jWstjV2/Gbe+vvcBM274Z0abM3IM0b9MmSNWYBXnJXYO8KCw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@oazapfts/runtime/-/runtime-1.0.1.tgz",
"integrity": "sha512-CMl7f1gXYpjIyEtDhg4YfXwr2MXfbadbvqwKbMsaHkVtSglmuz5A8jSyefTqaJlmh0MOA2ZNS9jnbfIdtcoDiw==",
"dev": true
},
"node_modules/@types/node": {
"version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20240222@sha256:2ff467d6ae5c00a2317eb7b13cb40ba5be0fd33c160175dba621b1bf72bc1cd1 as dev
FROM ghcr.io/immich-app/base-server-dev:20240227@sha256:b1e212c106ce2318a587e0b2ef377215c958e877f61993ed9310534e4589cce4 as dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -40,7 +40,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20240222@sha256:9ae5eebf95cf7759eec9dcfbd9e48a722701075ac855209f2e0b01c631b76f5c
FROM ghcr.io/immich-app/base-server-prod:20240227@sha256:d47f5f7f2b6c53957c6353352b2fa24f2845da50e6491a7c74eb779ace10628c
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@@ -538,90 +538,6 @@ describe(`${AssetController.name} (e2e)`, () => {
}
});
describe('GET /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/asset/${uuidStub.notFound}`);
expect(body).toEqual(errorStub.unauthorized);
expect(status).toBe(401);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.get(`/asset/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(server)
.get(`/asset/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should get the asset info', async () => {
const { status, body } = await request(server)
.get(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: asset1.id });
});
it('should work with a shared link', async () => {
const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
});
const { status, body } = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: asset1.id });
});
it('should not send people data for shared links for un-authenticated users', async () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
await personRepository.createFaces([
{
assetId: asset1.id,
personId: person.id,
embedding: Array.from({ length: 512 }, Math.random),
},
]);
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(status).toEqual(200);
expect(body).toMatchObject({
id: asset1.id,
isFavorite: true,
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '',
},
],
});
const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
});
const data = await request(server).get(`/asset/${asset1.id}?key=${sharedLink.key}`);
expect(data.status).toBe(200);
expect(data.body).toMatchObject({ people: [] });
});
});
describe('POST /asset/upload', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
@@ -759,286 +675,6 @@ describe(`${AssetController.name} (e2e)`, () => {
});
});
describe('PUT /asset/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/asset/:${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid id', async () => {
const { status, body } = await request(server)
.put(`/asset/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should require access', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset4.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noPermission);
});
it('should favorite an asset', async () => {
expect(asset1).toMatchObject({ isFavorite: false });
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(body).toMatchObject({ id: asset1.id, isFavorite: true });
expect(status).toEqual(200);
});
it('should archive an asset', async () => {
expect(asset1).toMatchObject({ isArchived: false });
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true });
expect(body).toMatchObject({ id: asset1.id, isArchived: true });
expect(status).toEqual(200);
});
it('should update date time original', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ dateTimeOriginal: '2023-11-19T18:11:00.000-07:00' });
expect(body).toMatchObject({
id: asset1.id,
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-20T01:11:00.000Z' }),
});
expect(status).toEqual(200);
});
it('should reject invalid gps coordinates', async () => {
for (const test of [
{ latitude: 12 },
{ longitude: 12 },
{ latitude: 12, longitude: 'abc' },
{ latitude: 'abc', longitude: 12 },
{ latitude: null, longitude: 12 },
{ latitude: 12, longitude: null },
{ latitude: 91, longitude: 12 },
{ latitude: -91, longitude: 12 },
{ latitude: 12, longitude: -181 },
{ latitude: 12, longitude: 181 },
]) {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.send(test)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
}
});
it('should update gps data', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ latitude: 12, longitude: 12 });
expect(body).toMatchObject({
id: asset1.id,
exifInfo: expect.objectContaining({ latitude: 12, longitude: 12 }),
});
expect(status).toEqual(200);
});
it('should set the description', async () => {
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'Test asset description' });
expect(body).toMatchObject({
id: asset1.id,
exifInfo: expect.objectContaining({ description: 'Test asset description' }),
});
expect(status).toEqual(200);
});
it('should return tagged people', async () => {
const personRepository = app.get<IPersonRepository>(IPersonRepository);
const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' });
await personRepository.createFaces([
{
assetId: asset1.id,
personId: person.id,
embedding: Array.from({ length: 512 }, Math.random),
},
]);
const { status, body } = await request(server)
.put(`/asset/${asset1.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isFavorite: true });
expect(status).toEqual(200);
expect(body).toMatchObject({
id: asset1.id,
isFavorite: true,
people: [
{
birthDate: null,
id: expect.any(String),
isHidden: false,
name: 'Test Person',
thumbnailPath: '',
},
],
});
});
});
describe('GET /asset/statistics', () => {
beforeEach(async () => {
await api.assetApi.upload(server, user1.accessToken, 'favored_asset', { isFavorite: true });
await api.assetApi.upload(server, user1.accessToken, 'archived_asset', { isArchived: true });
await api.assetApi.upload(server, user1.accessToken, 'favored_archived_asset', {
isFavorite: true,
isArchived: true,
});
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return stats of all assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(body).toEqual({ images: 6, videos: 1, total: 7 });
expect(status).toBe(200);
});
it('should return stats of all favored assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isFavorite: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 2, videos: 1, total: 3 });
});
it('should return stats of all archived assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isArchived: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 3, videos: 0, total: 3 });
});
it('should return stats of all favored and archived assets', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isFavorite: true, isArchived: true });
expect(status).toBe(200);
expect(body).toEqual({ images: 1, videos: 0, total: 1 });
});
it('should return stats of all assets neither favored nor archived', async () => {
const { status, body } = await request(server)
.get('/asset/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ isFavorite: false, isArchived: false });
expect(status).toBe(200);
expect(body).toEqual({ images: 2, videos: 0, total: 2 });
});
});
describe('GET /asset/random', () => {
beforeAll(async () => {
await Promise.all([
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
createAsset(user1, new Date('1970-02-01')),
]);
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/random');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it.each(Array(10))('should return 1 random assets', async () => {
const { status, body } = await request(server)
.get('/asset/random')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(1);
expect(assets[0].ownerId).toBe(user1.userId);
//
// assets owned by user2
expect(assets[0].id).not.toBe(asset4.id);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(assets[0].id);
});
it.each(Array(10))('should return 2 random assets', async () => {
const { status, body } = await request(server)
.get('/asset/random?count=2')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
const assets: AssetResponseDto[] = body;
expect(assets.length).toBe(2);
for (const asset of assets) {
expect(asset.ownerId).toBe(user1.userId);
// assets owned by user1
expect([asset1.id, asset2.id, asset3.id]).toContain(asset.id);
// assets owned by user2
expect(asset.id).not.toBe(asset4.id);
}
});
it.each(Array(10))(
'should return 1 asset if there are 10 assets in the database but user 2 only has 1',
async () => {
const { status, body } = await request(server)
.get('/[]asset/random')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset4.id })]);
},
);
it('should return error', async () => {
const { status } = await request(server)
.get('/asset/random?count=ABC')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
});
});
describe('GET /asset/time-buckets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH });

View File

@@ -1,4 +1,4 @@
import { AssetBulkDeleteDto, AssetResponseDto } from '@app/domain';
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';
@@ -74,8 +74,4 @@ export const assetApi = {
expect(status).toBe(200);
return body;
},
delete: async (server: any, accessToken: string, dto: AssetBulkDeleteDto) => {
const { status } = await request(server).delete('/asset').set('Authorization', `Bearer ${accessToken}`).send(dto);
expect(status).toBe(204);
},
};

View File

@@ -1,158 +0,0 @@
import { LoginResponseDto } from '@app/domain';
import { AssetType } from '@app/infra/entities';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils';
import { api } from '../../client';
const JPEG = {
type: AssetType.IMAGE,
originalFileName: 'el_torcal_rocks',
resized: true,
exifInfo: {
dateTimeOriginal: '2012-08-05T11:39:59.000Z',
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
focalLength: 75,
iso: 200,
fNumber: 11,
exposureTime: '1/160',
fileSizeInByte: 53_493,
make: 'SONY',
model: 'DSLR-A550',
orientation: null,
description: 'SONY DSC',
},
};
const tests = [
{ input: 'formats/jpg/el_torcal_rocks.jpg', expected: JPEG },
{ input: 'formats/jpeg/el_torcal_rocks.jpeg', expected: JPEG },
{
input: 'formats/heic/IMG_2682.heic',
expected: {
type: AssetType.IMAGE,
originalFileName: 'IMG_2682',
resized: true,
fileCreatedAt: '2019-03-21T16:04:22.348Z',
exifInfo: {
dateTimeOriginal: '2019-03-21T16:04:22.348Z',
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
longitude: -96.071_625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
fileSizeInByte: 880_703,
exposureTime: '1/887',
iso: 20,
focalLength: 3.99,
fNumber: 1.8,
timeZone: 'America/Chicago',
},
},
},
{
input: 'formats/png/density_plot.png',
expected: {
type: AssetType.IMAGE,
originalFileName: 'density_plot',
resized: true,
exifInfo: {
exifImageWidth: 800,
exifImageHeight: 800,
latitude: null,
longitude: null,
fileSizeInByte: 25_408,
},
},
},
{
input: 'formats/raw/Nikon/D80/glarus.nef',
expected: {
type: AssetType.IMAGE,
originalFileName: 'glarus',
resized: true,
fileCreatedAt: '2010-07-20T17:27:12.000Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D80',
exposureTime: '1/200',
fNumber: 10,
focalLength: 18,
iso: 100,
fileSizeInByte: 9_057_784,
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Nikon/D700/philadelphia.nef',
expected: {
type: AssetType.IMAGE,
originalFileName: 'philadelphia',
resized: true,
fileCreatedAt: '2016-09-22T22:10:29.060Z',
exifInfo: {
make: 'NIKON CORPORATION',
model: 'NIKON D700',
exposureTime: '1/400',
fNumber: 11,
focalLength: 85,
iso: 200,
fileSizeInByte: 15_856_335,
dateTimeOriginal: '2016-09-22T22:10:29.060Z',
latitude: null,
longitude: null,
orientation: '1',
timeZone: 'UTC-5',
},
},
},
];
describe(`Format (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
beforeAll(async () => {
const app = await testApp.create();
server = app.getHttpServer();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
});
afterAll(async () => {
await testApp.teardown();
});
for (const { input, expected } of tests) {
it(`should generate a thumbnail for ${input}`, async () => {
const filepath = join(IMMICH_TEST_ASSET_PATH, input);
const content = await readFile(filepath);
await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
content,
filename: basename(filepath),
});
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toHaveLength(1);
const asset = assets[0];
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo).toMatchObject(expected.exifInfo);
expect(asset).toMatchObject(expected);
});
}
});

View File

@@ -1,102 +0,0 @@
import { AssetResponseDto, LoginResponseDto } from '@app/domain';
import { AssetController } from '@app/immich';
import { exiftool } from 'exiftool-vendored';
import { readFile, writeFile } from 'fs/promises';
import {
IMMICH_TEST_ASSET_PATH,
IMMICH_TEST_ASSET_TEMP_PATH,
db,
restoreTempFolder,
testApp,
} from '../../../src/test-utils/utils';
import { api } from '../../client';
describe(`${AssetController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
beforeEach(async () => {
await testApp.reset();
await restoreTempFolder();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
});
afterAll(async () => {
await testApp.teardown();
await restoreTempFolder();
});
describe('should strip metadata of', () => {
let assetWithLocation: AssetResponseDto;
beforeEach(async () => {
const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`);
await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent });
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toHaveLength(1);
assetWithLocation = assets[0];
expect(assetWithLocation).toEqual(
expect.objectContaining({
exifInfo: expect.objectContaining({ latitude: 39.115, longitude: -108.400968333333 }),
}),
);
});
it('small webp thumbnails', async () => {
const assetId = assetWithLocation.id;
const thumbnail = await api.assetApi.getWebpThumbnail(server, admin.accessToken, assetId);
await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`, thumbnail);
const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.webp`);
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
it('large jpeg thumbnails', async () => {
const assetId = assetWithLocation.id;
const thumbnail = await api.assetApi.getJpegThumbnail(server, admin.accessToken, assetId);
await writeFile(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`, thumbnail);
const exifData = await exiftool.read(`${IMMICH_TEST_ASSET_TEMP_PATH}/thumbnail.jpg`);
expect(exifData).not.toHaveProperty('GPSLongitude');
expect(exifData).not.toHaveProperty('GPSLatitude');
});
});
describe.each([
// 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.
// DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
// into the test here.
['Samsung One UI 5.jpg', 'fr14niqCq6N20HB8rJYEvpsUVtI='],
['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='],
['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='],
])('should extract motionphoto video', (file, checksum) => {
it(`with checksum ${checksum} from ${file}`, async () => {
const fileContent = await readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`);
const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent });
const asset = await api.assetApi.get(server, admin.accessToken, response.id);
expect(asset).toHaveProperty('livePhotoVideoId');
const video = await api.assetApi.get(server, admin.accessToken, asset.livePhotoVideoId as string);
expect(video.checksum).toStrictEqual(checksum);
});
});
});

View File

@@ -1,80 +0,0 @@
import { LoginResponseDto } from '@app/domain';
import { api } from 'e2e/client';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import type { App } from 'supertest/types';
import { IMMICH_TEST_ASSET_PATH, testApp } from '../../../src/test-utils/utils';
const assetFilePath = join(IMMICH_TEST_ASSET_PATH, 'formats/png/density_plot.png');
describe(`Trash (e2e)`, () => {
let server: App;
let admin: LoginResponseDto;
beforeAll(async () => {
const app = await testApp.create();
server = app.getHttpServer();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
});
afterAll(async () => {
await testApp.teardown();
});
it('should move an asset to trash', async () => {
const content = await readFile(assetFilePath);
const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
content,
filename: basename(assetFilePath),
});
const uploadedAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(uploadedAsset.isTrashed).toBe(false);
await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] });
const deletedAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(deletedAsset.isTrashed).toBe(true);
});
it('should delete all trashed assets', async () => {
const content = await readFile(assetFilePath);
const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
content,
filename: basename(assetFilePath),
});
await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] });
const assetsBeforeEmpty = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assetsBeforeEmpty.length).toBe(1);
await api.trashApi.empty(server, admin.accessToken);
const assetsAfterEmpty = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assetsAfterEmpty.length).toBe(0);
});
it('should restore all trashed assets', async () => {
const content = await readFile(assetFilePath);
const { id: assetId } = await api.assetApi.upload(server, admin.accessToken, 'test-device-id', {
content,
filename: basename(assetFilePath),
});
await api.assetApi.delete(server, admin.accessToken, { ids: [assetId] });
const deletedAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(deletedAsset.isTrashed).toBe(true);
await api.trashApi.restore(server, admin.accessToken);
const restoredAsset = await api.assetApi.get(server, admin.accessToken, assetId);
expect(restoredAsset.isTrashed).toBe(false);
});
});

220
server/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.95.1",
"version": "1.97.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.95.1",
"version": "1.97.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@babel/runtime": "^7.22.11",
@@ -3179,9 +3179,9 @@
}
},
"node_modules/@types/node": {
"version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"dependencies": {
"undici-types": "~5.26.4"
}
@@ -3281,9 +3281,9 @@
}
},
"node_modules/@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/send": {
@@ -3398,16 +3398,16 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.2.tgz",
"integrity": "sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/type-utils": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/type-utils": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -3433,15 +3433,15 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.2.tgz",
"integrity": "sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4"
},
"engines": {
@@ -3461,13 +3461,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.2.tgz",
"integrity": "sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1"
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -3478,13 +3478,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.2.tgz",
"integrity": "sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@@ -3505,9 +3505,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.2.tgz",
"integrity": "sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -3518,13 +3518,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz",
"integrity": "sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -3570,17 +3570,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.2.tgz",
"integrity": "sha512-PZPIONBIB/X684bhT1XlrkjNZJIEevwkKDsdwfiu1WeqBxYEEdIgVDgm8/bbKHVu+6YOpeRqcfImTdImx/4Bsw==",
"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.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"semver": "^7.5.4"
},
"engines": {
@@ -3595,12 +3595,12 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz",
"integrity": "sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@@ -5504,9 +5504,9 @@
}
},
"node_modules/dotenv": {
"version": "16.4.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.4.tgz",
"integrity": "sha512-XvPXc8XAQThSjAbY6cQ/9PcBXmFoWuw1sQ3b8HqUCR6ziGXjkTi//kB9SWa2UwqlgdAIuRqAa/9hVljzPehbYg==",
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==",
"engines": {
"node": ">=12"
},
@@ -8139,9 +8139,9 @@
}
},
"node_modules/joi": {
"version": "17.12.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.1.tgz",
"integrity": "sha512-vtxmq+Lsc5SlfqotnfVjlViWfOL9nt/avKNbKYizwf6gsCfq9NYY/ceYRMFD8XDdrjJ9abJyScWmhmIiy+XRtQ==",
"version": "17.12.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.2.tgz",
"integrity": "sha512-RonXAIzCiHLc8ss3Ibuz45u28GOsWE1UpfDXLbN/9NKbL4tCJf8TWYVKsoYuuh+sAUt7fsSNpA+r2+TBA6Wjmw==",
"dependencies": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",
@@ -14730,9 +14730,9 @@
}
},
"@types/node": {
"version": "20.11.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz",
"integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==",
"version": "20.11.20",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.20.tgz",
"integrity": "sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==",
"requires": {
"undici-types": "~5.26.4"
}
@@ -14819,9 +14819,9 @@
}
},
"@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"@types/send": {
@@ -14936,16 +14936,16 @@
"dev": true
},
"@typescript-eslint/eslint-plugin": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.2.tgz",
"integrity": "sha512-/XtVZJtbaphtdrWjr+CJclaCVGPtOdBpFEnvtNf/jRV0IiEemRrL0qABex/nEt8isYcnFacm3nPHYQwL+Wb7qg==",
"dev": true,
"requires": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/type-utils": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/type-utils": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -14955,54 +14955,54 @@
}
},
"@typescript-eslint/parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.2.tgz",
"integrity": "sha512-GdwfDglCxSmU+QTS9vhz2Sop46ebNCXpPPvsByK7hu0rFGRHL+AusKQJ7SoN+LbLh6APFpQwHKmDSwN35Z700Q==",
"dev": true,
"requires": {
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4"
}
},
"@typescript-eslint/scope-manager": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.2.tgz",
"integrity": "sha512-l6sa2jF3h+qgN2qUMjVR3uCNGjWw4ahGfzIYsCtFrQJCjhbrDPdiihYT8FnnqFwsWX+20hK592yX9I2rxKTP4g==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1"
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2"
}
},
"@typescript-eslint/type-utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.2.tgz",
"integrity": "sha512-IKKDcFsKAYlk8Rs4wiFfEwJTQlHcdn8CLwLaxwd6zb8HNiMcQIFX9sWax2k4Cjj7l7mGS5N1zl7RCHOVwHq2VQ==",
"dev": true,
"requires": {
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.2",
"@typescript-eslint/utils": "7.0.2",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
}
},
"@typescript-eslint/types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.2.tgz",
"integrity": "sha512-ZzcCQHj4JaXFjdOql6adYV4B/oFOFjPOC9XYwCaZFRvqN8Llfvv4gSxrkQkd2u4Ci62i2c6W6gkDwQJDaRc4nA==",
"dev": true
},
"@typescript-eslint/typescript-estree": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.2.tgz",
"integrity": "sha512-3AMc8khTcELFWcKcPc0xiLviEvvfzATpdPj/DXuOGIdQIIFybf4DMT1vKRbuAEOFMwhWt7NFLXRkbjsvKZQyvw==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/visitor-keys": "7.0.2",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -15032,27 +15032,27 @@
}
},
"@typescript-eslint/utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.2.tgz",
"integrity": "sha512-PZPIONBIB/X684bhT1XlrkjNZJIEevwkKDsdwfiu1WeqBxYEEdIgVDgm8/bbKHVu+6YOpeRqcfImTdImx/4Bsw==",
"dev": true,
"requires": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/scope-manager": "7.0.2",
"@typescript-eslint/types": "7.0.2",
"@typescript-eslint/typescript-estree": "7.0.2",
"semver": "^7.5.4"
}
},
"@typescript-eslint/visitor-keys": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.2.tgz",
"integrity": "sha512-8Y+YiBmqPighbm5xA2k4wKTxRzx9EkBu7Rlw+WHqMvRJ3RPz/BMBO9b2ru0LUNmXg120PHUXD5+SWFy2R8DqlQ==",
"dev": true,
"requires": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/types": "7.0.2",
"eslint-visitor-keys": "^3.4.1"
}
},
@@ -16494,9 +16494,9 @@
}
},
"dotenv": {
"version": "16.4.4",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.4.tgz",
"integrity": "sha512-XvPXc8XAQThSjAbY6cQ/9PcBXmFoWuw1sQ3b8HqUCR6ziGXjkTi//kB9SWa2UwqlgdAIuRqAa/9hVljzPehbYg=="
"version": "16.4.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz",
"integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg=="
},
"dotenv-expand": {
"version": "10.0.0",
@@ -18453,9 +18453,9 @@
}
},
"joi": {
"version": "17.12.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.1.tgz",
"integrity": "sha512-vtxmq+Lsc5SlfqotnfVjlViWfOL9nt/avKNbKYizwf6gsCfq9NYY/ceYRMFD8XDdrjJ9abJyScWmhmIiy+XRtQ==",
"version": "17.12.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.12.2.tgz",
"integrity": "sha512-RonXAIzCiHLc8ss3Ibuz45u28GOsWE1UpfDXLbN/9NKbL4tCJf8TWYVKsoYuuh+sAUt7fsSNpA+r2+TBA6Wjmw==",
"requires": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.95.1",
"version": "1.97.0",
"description": "",
"author": "",
"private": true,

View File

@@ -112,20 +112,13 @@ export class LibraryService extends EventEmitter {
ignore: library.exclusionPatterns,
});
const config = await this.configCore.getConfig();
const { usePolling, interval } = config.library.watch;
this.logger.debug(`Settings for watcher: usePolling: ${usePolling}, interval: ${interval}`);
let _resolve: () => void;
const ready$ = new Promise<void>((resolve) => (_resolve = resolve));
this.watchers[id] = this.storageRepository.watch(
library.importPaths,
{
usePolling,
interval,
binaryInterval: interval,
usePolling: false,
ignoreInitial: true,
},
{

View File

@@ -1799,9 +1799,9 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
inputOptions: [],
inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'],
outputOptions: [
`-c:v hevc_rkmpp_encoder`,
`-c:v hevc_rkmpp`,
'-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
@@ -1810,17 +1810,12 @@ describe(MediaService.name, () => {
'-g 256',
'-tag:v hvc1',
'-v verbose',
'-vf scale_rkrga=-2:720:format=nv12:afbc=1',
'-level 153',
'-rc_mode 3',
'-quality_min 0',
'-quality_max 100',
'-rc_mode AVBR',
'-b:v 10000k',
'-width 1280',
'-height 720',
],
twoPass: false,
ffmpegPath: 'ffmpeg_mpp',
ldLibraryPath: '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp',
},
);
});
@@ -1839,9 +1834,9 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
{
inputOptions: [],
inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'],
outputOptions: [
`-c:v h264_rkmpp_encoder`,
`-c:v h264_rkmpp`,
'-c:a copy',
'-movflags faststart',
'-fps_mode passthrough',
@@ -1849,16 +1844,12 @@ describe(MediaService.name, () => {
'-map 0:1',
'-g 256',
'-v verbose',
'-vf scale_rkrga=-2:720:format=nv12:afbc=1',
'-level 51',
'-rc_mode 2',
'-quality_min 51',
'-quality_max 51',
'-width 1280',
'-height 720',
'-rc_mode CQP',
'-qp_init 30',
],
twoPass: false,
ffmpegPath: 'ffmpeg_mpp',
ldLibraryPath: '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp',
},
);
});

View File

@@ -14,7 +14,7 @@ class BaseConfig implements VideoCodecSWConfig {
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = {
inputOptions: this.getBaseInputOptions(),
inputOptions: this.getBaseInputOptions(videoStream),
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
twoPass: this.eligibleForTwoPass(),
} as TranscodeOptions;
@@ -30,7 +30,8 @@ class BaseConfig implements VideoCodecSWConfig {
return options;
}
getBaseInputOptions(): string[] {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getBaseInputOptions(videoStream: VideoStreamInfo): string[] {
return [];
}
@@ -607,35 +608,28 @@ export class VAAPIConfig extends BaseHWConfig {
}
export class RKMPPConfig extends BaseHWConfig {
getOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo): TranscodeOptions {
const options = super.getOptions(target, videoStream, audioStream);
options.ffmpegPath = 'ffmpeg_mpp';
options.ldLibraryPath = '/lib/aarch64-linux-gnu:/lib/ffmpeg-mpp';
if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) {
options.outputOptions.push(...this.getSizeOptions(videoStream));
}
return options;
}
eligibleForTwoPass(): boolean {
return false;
}
getBaseInputOptions() {
getBaseInputOptions(videoStream: VideoStreamInfo) {
if (this.devices.length === 0) {
throw new Error('No RKMPP device found');
}
return [];
if (this.shouldToneMap(videoStream)) {
// disable hardware decoding
return [];
}
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
return this.shouldToneMap(videoStream) ? this.getToneMapping() : [];
}
getSizeOptions(videoStream: VideoStreamInfo) {
if (this.shouldToneMap(videoStream)) {
// use software filter options
return super.getFilterOptions(videoStream);
}
if (this.shouldScale(videoStream)) {
const { width, height } = this.getSize(videoStream);
return [`-width ${width}`, `-height ${height}`];
return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`];
}
return [];
}
@@ -659,12 +653,11 @@ export class RKMPPConfig extends BaseHWConfig {
getBitrateOptions() {
const bitrate = this.getMaxBitrateValue();
if (bitrate > 0) {
return ['-rc_mode 3', '-quality_min 0', '-quality_max 100', `-b:v ${bitrate}${this.getBitrateUnit()}`];
} else {
// convert CQP from 51-10 to 0-100, values below 10 are set to 10
const quality = Math.floor(125 - Math.max(this.config.crf, 10) * (125 / 51));
return ['-rc_mode 2', `-quality_min ${quality}`, `-quality_max ${quality}`];
// -b:v specifies max bitrate, average bitrate is derived automatically...
return ['-rc_mode AVBR', `-b:v ${bitrate}${this.getBitrateUnit()}`];
}
// use CRF value as QP value
return ['-rc_mode CQP', `-qp_init ${this.config.crf}`];
}
getSupportedCodecs() {
@@ -672,6 +665,6 @@ export class RKMPPConfig extends BaseHWConfig {
}
getVideoCodec(): string {
return `${this.config.targetVideoCodec}_rkmpp_encoder`;
return `${this.config.targetVideoCodec}_rkmpp`;
}
}

View File

@@ -51,8 +51,6 @@ export interface TranscodeOptions {
inputOptions: string[];
outputOptions: string[];
twoPass: boolean;
ffmpegPath?: string;
ldLibraryPath?: string;
}
export interface BitrateDistribution {

View File

@@ -187,4 +187,5 @@ export interface ISearchRepository {
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
deleteAllSearchEmbeddings(): Promise<void>;
}

View File

@@ -71,6 +71,7 @@ describe(SmartInfoService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]);
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.SMART_SEARCH);
expect(searchMock.deleteAllSearchEmbeddings).not.toHaveBeenCalled();
});
it('should queue all the assets', async () => {
@@ -83,6 +84,7 @@ describe(SmartInfoService.name, () => {
expect(jobMock.queueAll).toHaveBeenCalledWith([{ name: JobName.SMART_SEARCH, data: { id: assetStub.image.id } }]);
expect(assetMock.getAll).toHaveBeenCalled();
expect(searchMock.deleteAllSearchEmbeddings).toHaveBeenCalled();
});
});

View File

@@ -50,6 +50,10 @@ export class SmartInfoService {
return true;
}
if (force) {
await this.repository.deleteAllSearchEmbeddings();
}
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)

View File

@@ -1,12 +1,9 @@
import { validateCronExpression } from '@app/domain';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsInt,
IsNotEmpty,
IsObject,
IsPositive,
IsString,
Validate,
ValidateIf,
@@ -38,14 +35,6 @@ export class SystemConfigLibraryScanDto {
export class SystemConfigLibraryWatchDto {
@IsBoolean()
enabled!: boolean;
@IsBoolean()
usePolling!: boolean;
@IsInt()
@IsPositive()
@ApiProperty({ type: 'integer' })
interval!: number;
}
export class SystemConfigLibraryDto {

View File

@@ -132,8 +132,6 @@ export const defaults = Object.freeze<SystemConfig>({
},
watch: {
enabled: false,
usePolling: false,
interval: 10_000,
},
},
server: {

View File

@@ -136,8 +136,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
watch: {
enabled: false,
usePolling: false,
interval: 10_000,
},
},
});

View File

@@ -51,8 +51,6 @@ export enum SystemConfigKey {
LIBRARY_SCAN_CRON_EXPRESSION = 'library.scan.cronExpression',
LIBRARY_WATCH_ENABLED = 'library.watch.enabled',
LIBRARY_WATCH_USE_POLLING = 'library.watch.usePolling',
LIBRARY_WATCH_INTERVAL = 'library.watch.interval',
LOGGING_ENABLED = 'logging.enabled',
LOGGING_LEVEL = 'logging.level',
@@ -268,8 +266,6 @@ export interface SystemConfig {
};
watch: {
enabled: boolean;
usePolling: boolean;
interval: number;
};
};
server: {

View File

@@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveLibraryWatchPollingOption1709150004123 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'library.watch.usePolling'`);
await queryRunner.query(`DELETE FROM "system_config" WHERE key = 'library.watch.interval'`);
}
public async down(): Promise<void> {
// noop
}
}

View File

@@ -76,18 +76,7 @@ export class MediaRepository implements IMediaRepository {
transcode(input: string, output: string | Writable, options: TranscodeOptions): Promise<void> {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
const oldLdLibraryPath = process.env.LD_LIBRARY_PATH;
if (options.ldLibraryPath) {
// fluent ffmpeg does not allow to set environment variables, so we do it manually
process.env.LD_LIBRARY_PATH = this.chainPath(oldLdLibraryPath || '', options.ldLibraryPath);
}
try {
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
} finally {
if (options.ldLibraryPath) {
process.env.LD_LIBRARY_PATH = oldLdLibraryPath;
}
}
this.configureFfmpegCall(input, output, options).on('error', reject).on('end', resolve).run();
});
}
@@ -121,7 +110,6 @@ export class MediaRepository implements IMediaRepository {
configureFfmpegCall(input: string, output: string | Writable, options: TranscodeOptions) {
return ffmpeg(input, { niceness: 10 })
.setFfmpegPath(options.ffmpegPath || 'ffmpeg')
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)
.output(output)

View File

@@ -40,11 +40,11 @@ export class PersonRepository implements IPersonRepository {
}
async deleteAll(): Promise<void> {
await this.personRepository.delete({});
await this.personRepository.clear();
}
async deleteAllFaces(): Promise<void> {
await this.assetFaceRepository.delete({});
await this.assetFaceRepository.query('TRUNCATE TABLE asset_faces CASCADE');
}
getAllFaces(

View File

@@ -229,25 +229,17 @@ export class SearchRepository implements ISearchRepository {
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
await this.smartSearchRepository.manager.transaction(async (manager) => {
await manager.query(`DROP TABLE smart_search`);
await manager.query(`
CREATE TABLE smart_search (
"assetId" uuid PRIMARY KEY REFERENCES assets(id) ON DELETE CASCADE,
embedding vector(${dimSize}) NOT NULL )`);
await manager.query(`
CREATE INDEX clip_index ON smart_search
USING vectors (embedding vector_cos_ops) WITH (options = $$
[indexing.hnsw]
m = 16
ef_construction = 300
$$)`);
await manager.clear(SmartSearchEntity);
await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
});
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
}
deleteAllSearchEmbeddings(): Promise<void> {
return this.smartSearchRepository.clear();
}
private async getDimSize(): Promise<number> {
const res = await this.smartSearchRepository.manager.query(`
SELECT atttypmod as dimsize

View File

@@ -8,5 +8,6 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
searchFaces: jest.fn(),
upsert: jest.fn(),
searchPlaces: jest.fn(),
deleteAllSearchEmbeddings: jest.fn(),
};
};

View File

@@ -13,6 +13,7 @@ module.exports = {
sourceType: 'module',
ecmaVersion: 2022,
extraFileExtensions: ['.svelte'],
project: ['./tsconfig.json'],
},
env: {
browser: true,
@@ -32,13 +33,6 @@ module.exports = {
NodeJS: true,
},
rules: {
'unicorn/no-useless-undefined': 'off',
'unicorn/prefer-spread': 'off',
'unicorn/no-null': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-nested-ternary': 'off',
'unicorn/consistent-function-scoping': 'off',
'unicorn/prefer-top-level-await': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
@@ -48,5 +42,17 @@ module.exports = {
},
],
curly: 2,
'unicorn/no-useless-undefined': 'off',
'unicorn/prefer-spread': 'off',
'unicorn/no-null': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-nested-ternary': 'off',
'unicorn/consistent-function-scoping': 'off',
'unicorn/prefer-top-level-await': 'off',
// TODO: set recommended-type-checked and remove these rules
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'error',
},
};

346
web/package-lock.json generated
View File

@@ -1,67 +1,67 @@
{
"name": "immich-web",
"version": "1.1.1",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.1.1",
"version": "1.3.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.3.67",
"@photo-sphere-viewer/core": "^5.7.0",
"@zoom-image/svelte": "^0.2.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.7.1",
"@zoom-image/svelte": "^0.2.6",
"axios": "^1.6.7",
"buffer": "^6.0.3",
"copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.7",
"handlebars": "^4.7.8",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.2.1",
"socket.io-client": "^4.6.1",
"svelte-local-storage-store": "^0.6.0",
"svelte-maplibre": "^0.8.0",
"luxon": "^3.4.4",
"socket.io-client": "^4.7.4",
"svelte-local-storage-store": "^0.6.4",
"svelte-maplibre": "^0.8.1",
"thumbhash": "^0.1.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.0",
"@floating-ui/dom": "^1.5.1",
"@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.6.3",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
"@types/dom-to-image": "^2.6.4",
"@types/justified-layout": "^4.1.0",
"@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitest/coverage-v8": "^1.0.4",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^4.1.0",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
"@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.30.0",
"eslint-plugin-unicorn": "^51.0.0",
"factory.ts": "^1.3.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-unicorn": "^51.0.1",
"factory.ts": "^1.4.1",
"identity-obj-proxy": "^3.0.0",
"postcss": "^8.4.21",
"prettier": "^3.1.0",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-svelte": "^3.2.1",
"rollup-plugin-visualizer": "^5.12.0",
"svelte": "^4.2.11",
"svelte-check": "^3.6.4",
"tailwindcss": "^3.2.7",
"tslib": "^2.5.0",
"svelte": "^4.2.12",
"svelte-check": "^3.6.5",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.1",
"vitest": "^1.0.4"
"vite": "^5.1.4",
"vitest": "^1.3.1"
}
},
"../open-api/typescript-sdk": {
@@ -898,9 +898,9 @@
}
},
"node_modules/@eslint/js": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
"integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
@@ -948,13 +948,13 @@
"dev": true
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
"integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
"dev": true,
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.1",
"debug": "^4.1.1",
"@humanwhocodes/object-schema": "^2.0.2",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
"engines": {
@@ -975,9 +975,9 @@
}
},
"node_modules/@humanwhocodes/object-schema": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
"integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
"dev": true
},
"node_modules/@img/sharp-darwin-arm64": {
@@ -1859,9 +1859,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.1.tgz",
"integrity": "sha512-TKj08o3mJCoQNLTdRdGkHPePTCPUGTgkew65RDqjVU3MtPVxljsofXQYfXndHfq0P7KoPRO/0/reF6HesU0Djw==",
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.2.tgz",
"integrity": "sha512-1Pm2lsBYURQsjnLyZa+jw75eVD4gYHxGRwPyFe4DAmB3FjTVR8vRNWGeuDLGFcKMh/B1ij6FTUrc9GrerogCng==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -2284,9 +2284,9 @@
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.7.tgz",
"integrity": "sha512-/wdoPq1QqkSj9/QOeKkFquEuPzQbHTWAMPH/PaUMB+JuR31lXhlWXRZ52IpfDYVlDOUBvX09uBrPwxGT1hjNBg==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/supercluster": {
@@ -2298,16 +2298,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.1.tgz",
"integrity": "sha512-OLvgeBv3vXlnnJGIAgCLYKjgMEU+wBGj07MQ/nxAaON+3mLzX7mJbhRYrVGiVvFiXtwFlkcBa/TtmglHy0UbzQ==",
"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==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/type-utils": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.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",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -2366,15 +2366,15 @@
"dev": true
},
"node_modules/@typescript-eslint/parser": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.0.1.tgz",
"integrity": "sha512-8GcRRZNzaHxKzBPU3tKtFNing571/GwPBeCvmAUw0yBtfE2XVd0zFKJIMSWkHJcPQi0ekxjIts6L/rrZq5cxGQ==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.0.tgz",
"integrity": "sha512-V1EknKUubZ1gWFjiOZhDSNToOjs63/9O0puCgGS8aDOgpZY326fzFu15QAUjwaXzRZjf/qdsdBrckYdv9YxB8w==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "7.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@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",
"debug": "^4.3.4"
},
"engines": {
@@ -2394,13 +2394,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.0.1.tgz",
"integrity": "sha512-v7/T7As10g3bcWOOPAcbnMDuvctHzCFYCG/8R4bK4iYzdFqsZTbXGln0cZNVcwQcwewsYU2BJLay8j0/4zOk4w==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.0.tgz",
"integrity": "sha512-6TmN4OJiohHfoOdGZ3huuLhpiUgOGTpgXNUPJgeZOZR3DnIpdSgtt83RS35OYNNXxM4TScVlpVKC9jyQSETR1A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1"
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/visitor-keys": "7.1.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -2411,13 +2411,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.0.1.tgz",
"integrity": "sha512-YtT9UcstTG5Yqy4xtLiClm1ZpM/pWVGFnkAa90UfdkkZsR1eP2mR/1jbHeYp8Ay1l1JHPyGvoUYR6o3On5Nhmw==",
"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==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/utils": "7.0.1",
"@typescript-eslint/typescript-estree": "7.1.0",
"@typescript-eslint/utils": "7.1.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@@ -2438,9 +2438,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.0.1.tgz",
"integrity": "sha512-uJDfmirz4FHib6ENju/7cz9SdMSkeVvJDK3VcMFvf/hAShg8C74FW+06MaQPODHfDJp/z/zHfgawIJRjlu0RLg==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.0.tgz",
"integrity": "sha512-qTWjWieJ1tRJkxgZYXx6WUYtWlBc48YRxgY2JN1aGeVpkhmnopq+SUC8UEVGNXIvWH7XyuTjwALfG6bFEgCkQA==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -2451,13 +2451,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.1.tgz",
"integrity": "sha512-SO9wHb6ph0/FN5OJxH4MiPscGah5wjOd0RRpaLvuBv9g8565Fgu0uMySFEPqwPHiQU90yzJ2FjRYKGrAhS1xig==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.0.tgz",
"integrity": "sha512-k7MyrbD6E463CBbSpcOnwa8oXRdHzH1WiVzOipK3L5KSML92ZKgUBrTlehdi7PEIMT8k0bQixHUGXggPAlKnOQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/visitor-keys": "7.0.1",
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/visitor-keys": "7.1.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -2536,17 +2536,17 @@
"dev": true
},
"node_modules/@typescript-eslint/utils": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.0.1.tgz",
"integrity": "sha512-oe4his30JgPbnv+9Vef1h48jm0S6ft4mNwi9wj7bX10joGn07QRfqIqFHoMiajrtoU88cIhXf8ahwgrcbNLgPA==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.0.tgz",
"integrity": "sha512-WUFba6PZC5OCGEmbweGpnNJytJiLG7ZvDBJJoUcX4qZYf1mGZ97mO2Mps6O2efxJcJdRNpqweCistDbZMwIVHw==",
"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.0.1",
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/typescript-estree": "7.0.1",
"@typescript-eslint/scope-manager": "7.1.0",
"@typescript-eslint/types": "7.1.0",
"@typescript-eslint/typescript-estree": "7.1.0",
"semver": "^7.5.4"
},
"engines": {
@@ -2594,12 +2594,12 @@
"dev": true
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.1.tgz",
"integrity": "sha512-hwAgrOyk++RTXrP4KzCg7zB2U0xt7RUU0ZdMSCsqF3eKUwkdXUMyTb0qdCuji7VIbcpG62kKTU9M1J1c9UpFBw==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.0.tgz",
"integrity": "sha512-FhUqNWluiGNzlvnDZiXad4mZRhtghdoKW6e98GoEOYSu5cND+E39rG5KwJMUzeENwm1ztYBRqof8wMLP+wNPIA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "7.0.1",
"@typescript-eslint/types": "7.1.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@@ -2617,24 +2617,23 @@
"dev": true
},
"node_modules/@vitest/browser": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.0.4.tgz",
"integrity": "sha512-qMT1NhClex73eA2sOwnlwLcSIVCW8B7NFVzIKuXLKxSJD3LsNq8PCKhwOkBxklbSAcZdkOgL/d3/gzQT7k9eng==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-1.3.1.tgz",
"integrity": "sha512-pRof8G8nqRWwg3ouyIctyhfIVk5jXgF056uF//sqdi37+pVtDz9kBI/RMu0xlc8tgCyJ2aEMfbgJZPUydlEVaQ==",
"dev": true,
"optional": true,
"peer": true,
"dependencies": {
"estree-walker": "^3.0.3",
"@vitest/utils": "1.3.1",
"magic-string": "^0.30.5",
"sirv": "^2.0.3"
"sirv": "^2.0.4"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"playwright": "*",
"safaridriver": "*",
"vitest": "^1.0.0",
"vitest": "1.3.1",
"webdriverio": "*"
},
"peerDependenciesMeta": {
@@ -2650,9 +2649,9 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.2.2.tgz",
"integrity": "sha512-IHyKnDz18SFclIEEAHb9Y4Uxx0sPKC2VO1kdDCs1BF6Ip4S8rQprs971zIsooLUn7Afs71GRxWMWpkCGZpRMhw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.3.1.tgz",
"integrity": "sha512-UuBnkSJUNE9rdHjDCPyJ4fYuMkoMtnghes1XohYa4At0MS3OQSAo97FrbwSLRshYsXThMZy1+ybD/byK5llyIg==",
"dev": true,
"dependencies": {
"@ampproject/remapping": "^2.2.1",
@@ -2673,17 +2672,17 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "^1.0.0"
"vitest": "1.3.1"
}
},
"node_modules/@vitest/expect": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.2.2.tgz",
"integrity": "sha512-3jpcdPAD7LwHUUiT2pZTj2U82I2Tcgg2oVPvKxhn6mDI2On6tfvPQTjAI4628GUGDZrCm4Zna9iQHm5cEexOAg==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.3.1.tgz",
"integrity": "sha512-xofQFwIzfdmLLlHa6ag0dPV8YsnKOCP1KdAeVVh34vSjN2dcUiXYCD9htu/9eM7t8Xln4v03U9HLxLpPlsXdZw==",
"dev": true,
"dependencies": {
"@vitest/spy": "1.2.2",
"@vitest/utils": "1.2.2",
"@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.1",
"chai": "^4.3.10"
},
"funding": {
@@ -2691,12 +2690,12 @@
}
},
"node_modules/@vitest/runner": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.2.2.tgz",
"integrity": "sha512-JctG7QZ4LSDXr5CsUweFgcpEvrcxOV1Gft7uHrvkQ+fsAVylmWQvnaAr/HDp3LAH1fztGMQZugIheTWjaGzYIg==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.3.1.tgz",
"integrity": "sha512-5FzF9c3jG/z5bgCnjr8j9LNq/9OxV2uEBAITOXfoe3rdZJTdO7jzThth7FXv/6b+kdY65tpRQB7WaKhNZwX+Kg==",
"dev": true,
"dependencies": {
"@vitest/utils": "1.2.2",
"@vitest/utils": "1.3.1",
"p-limit": "^5.0.0",
"pathe": "^1.1.1"
},
@@ -2732,9 +2731,9 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.2.2.tgz",
"integrity": "sha512-SmGY4saEw1+bwE1th6S/cZmPxz/Q4JWsl7LvbQIky2tKE35US4gd0Mjzqfr84/4OD0tikGWaWdMja/nWL5NIPA==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.3.1.tgz",
"integrity": "sha512-EF++BZbt6RZmOlE3SuTPu/NfwBF6q4ABS37HHXzs2LUVPBLx2QoY/K0fKpRChSo8eLiuxcbCVfqKgx/dplCDuQ==",
"dev": true,
"dependencies": {
"magic-string": "^0.30.5",
@@ -2778,9 +2777,9 @@
"dev": true
},
"node_modules/@vitest/spy": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.2.2.tgz",
"integrity": "sha512-k9Gcahssw8d7X3pSLq3e3XEu/0L78mUkCjivUqCQeXJm9clfXR/Td8+AP+VC1O6fKPIDLcHDTAmBOINVuv6+7g==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.3.1.tgz",
"integrity": "sha512-xAcW+S099ylC9VLU7eZfdT9myV67Nor9w9zhf0mGCYJSO+zM2839tOeROTdikOi/8Qeusffvxb/MyBSOja1Uig==",
"dev": true,
"dependencies": {
"tinyspy": "^2.2.0"
@@ -2790,9 +2789,9 @@
}
},
"node_modules/@vitest/utils": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.2.2.tgz",
"integrity": "sha512-WKITBHLsBHlpjnDQahr+XK6RE7MiAsgrIkr0pGhQ9ygoxBfUeG0lUG5iLlzqjmKSlBv3+j5EGsriBzh+C3Tq9g==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.3.1.tgz",
"integrity": "sha512-d3Waie/299qqRyHTm2DjADeTaNdNSVsnwHPWrs20JMpjh6eiVq7ggggweO8rc4arhf6rRkWuHKwvxGvejUXZZQ==",
"dev": true,
"dependencies": {
"diff-sequences": "^29.6.3",
@@ -2837,9 +2836,9 @@
"dev": true
},
"node_modules/@zoom-image/core": {
"version": "0.32.1",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.32.1.tgz",
"integrity": "sha512-R56D749Ck+/1yLWlEJ2FctxjdpTQEje3jPhOAbeEZGzLndIumskO42UqRNixcER6sAzCi01oYopmqnCpDElF0g==",
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.33.0.tgz",
"integrity": "sha512-wkMV8+aE7PeknLFhpIb/6vwRl09Z2gWM4UqKdnXO6Mb0pP9BiuDLcLvGGGB4o++uAPINgDwmNn+Loo641XSjDA==",
"dependencies": {
"@namnode/store": "^0.1.0"
},
@@ -2849,11 +2848,11 @@
}
},
"node_modules/@zoom-image/svelte": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.4.tgz",
"integrity": "sha512-rgfgn7Q60VrwmE4MPBzDWaFplc+411Lxg1nMdAnq/UTv4HTWSpiwm1IOg8gQZjRp92a8RXcRmUYXU+wFKEMjSg==",
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/@zoom-image/svelte/-/svelte-0.2.6.tgz",
"integrity": "sha512-dEpA/egmTjVcptwhtcKHvkhVMTzQCpH17erfcXuJByt+nn5Oo4LnZOxE8gwSVEdPp65Ns6Y/byYD0GSQ/vv+DQ==",
"dependencies": {
"@zoom-image/core": "0.32.1"
"@zoom-image/core": "0.33.0"
},
"funding": {
"type": "github",
@@ -4055,16 +4054,16 @@
}
},
"node_modules/eslint": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4",
"@eslint/js": "8.56.0",
"@humanwhocodes/config-array": "^0.11.13",
"@eslint/js": "8.57.0",
"@humanwhocodes/config-array": "^0.11.14",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
@@ -7991,17 +7990,23 @@
}
},
"node_modules/strip-literal": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz",
"integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.0.0.tgz",
"integrity": "sha512-f9vHgsCWBq2ugHAkGMiiYY+AYG0D/cbloKKg0nhaaaSNsujdGIpVXCNsrJpCKr5M0f4aI31mr13UjY6GAuXCKA==",
"dev": true,
"dependencies": {
"acorn": "^8.10.0"
"js-tokens": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/strip-literal/node_modules/js-tokens": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-8.0.3.tgz",
"integrity": "sha512-UfJMcSJc+SEXEl9lH/VLHSZbThQyLpw1vLO1Lb+j4RWDvG3N2f7yj3PVQA3cmkTBNldJ9eFnM+xEXxHIXrYiJw==",
"dev": true
},
"node_modules/sucrase": {
"version": "3.34.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz",
@@ -8077,9 +8082,9 @@
}
},
"node_modules/svelte": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.11.tgz",
"integrity": "sha512-YIQk3J4X89wOLhjsqIW8tqY3JHPuBdtdOIkASP2PZeAMcSW9RsIjQzMesCrxOF3gdWYC0mKknlKF7OqmLM+Zqg==",
"version": "4.2.12",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.12.tgz",
"integrity": "sha512-d8+wsh5TfPwqVzbm4/HCXC783/KPHV60NvwitJnyTA5lWn1elhXMNWhXGCJ7PwPa8qFUnyJNIyuIRt2mT0WMug==",
"dependencies": {
"@ampproject/remapping": "^2.2.1",
"@jridgewell/sourcemap-codec": "^1.4.15",
@@ -8101,9 +8106,9 @@
}
},
"node_modules/svelte-check": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.4.tgz",
"integrity": "sha512-mY/dqucqm46p72M8yZmn81WPZx9mN6uuw8UVfR3ZKQeLxQg5HDGO3HHm5AZuWZPYNMLJ+TRMn+TeN53HfQ/vsw==",
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.5.tgz",
"integrity": "sha512-5aLgoQEdadvp8ypvKQ2avhnQ+V9YPQQaWrTFlXFw5g/v8xIQBvo+X/WqxTyD+V/ItDqXg3+abUA53rdDHgUjCA==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.17",
@@ -8173,9 +8178,9 @@
}
},
"node_modules/svelte-maplibre": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.8.0.tgz",
"integrity": "sha512-sRSj/zQa7LTfHNIzKcYe+sa9qHClt/OAXcdPQ0w3ksLbCMmVHGk4B2yIXHCVk0g4sc18M85N8KGsHVtZoNC+Mw==",
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/svelte-maplibre/-/svelte-maplibre-0.8.1.tgz",
"integrity": "sha512-CTm/s0+mJzBHSoO5zPKBo3ORmUyiWS3Ex4xvVdNgVg+sDesHasEAJ0N1/NUrd56S33zgRdFZGzRnRguCnKFAzw==",
"dependencies": {
"d3-geo": "^3.1.0",
"just-compare": "^2.3.0",
@@ -8456,9 +8461,9 @@
"integrity": "sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA=="
},
"node_modules/tinyspy": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz",
"integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
"integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
"dev": true,
"engines": {
"node": ">=14.0.0"
@@ -8733,9 +8738,9 @@
}
},
"node_modules/vite": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz",
"integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==",
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.4.tgz",
"integrity": "sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
@@ -8801,9 +8806,9 @@
}
},
"node_modules/vite-node": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.2.2.tgz",
"integrity": "sha512-1as4rDTgVWJO3n1uHmUYqq7nsFgINQ9u+mRcXpjeOMJUmviqNKjcZB7UfRZrlM7MjYXMKpuWp5oGkjaFLnjawg==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.3.1.tgz",
"integrity": "sha512-azbRrqRxlWTJEVbzInZCTchx0X69M/XPTCz4H+TLvlTcR/xH/3hkRqhOakT41fMJCMzXTu4UvegkZiEoJAWvng==",
"dev": true,
"dependencies": {
"cac": "^6.7.14",
@@ -8837,18 +8842,17 @@
}
},
"node_modules/vitest": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.2.2.tgz",
"integrity": "sha512-d5Ouvrnms3GD9USIK36KG8OZ5bEvKEkITFtnGv56HFaSlbItJuYr7hv2Lkn903+AvRAgSixiamozUVfORUekjw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-1.3.1.tgz",
"integrity": "sha512-/1QJqXs8YbCrfv/GPQ05wAZf2eakUPLPa18vkJAKE7RXOKfVHqMZZ1WlTjiwl6Gcn65M5vpNUB6EFLnEdRdEXQ==",
"dev": true,
"dependencies": {
"@vitest/expect": "1.2.2",
"@vitest/runner": "1.2.2",
"@vitest/snapshot": "1.2.2",
"@vitest/spy": "1.2.2",
"@vitest/utils": "1.2.2",
"@vitest/expect": "1.3.1",
"@vitest/runner": "1.3.1",
"@vitest/snapshot": "1.3.1",
"@vitest/spy": "1.3.1",
"@vitest/utils": "1.3.1",
"acorn-walk": "^8.3.2",
"cac": "^6.7.14",
"chai": "^4.3.10",
"debug": "^4.3.4",
"execa": "^8.0.1",
@@ -8857,11 +8861,11 @@
"pathe": "^1.1.1",
"picocolors": "^1.0.0",
"std-env": "^3.5.0",
"strip-literal": "^1.3.0",
"strip-literal": "^2.0.0",
"tinybench": "^2.5.1",
"tinypool": "^0.8.2",
"vite": "^5.0.0",
"vite-node": "1.2.2",
"vite-node": "1.3.1",
"why-is-node-running": "^2.2.2"
},
"bin": {
@@ -8876,8 +8880,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "^1.0.0",
"@vitest/ui": "^1.0.0",
"@vitest/browser": "1.3.1",
"@vitest/ui": "1.3.1",
"happy-dom": "*",
"jsdom": "*"
},

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.1.1",
"version": "1.3.0",
"license": "GNU Affero General Public License version 3",
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000",
@@ -22,59 +22,59 @@
"prepare": "svelte-kit sync"
},
"devDependencies": {
"@faker-js/faker": "^8.0.0",
"@floating-ui/dom": "^1.5.1",
"@faker-js/faker": "^8.4.1",
"@floating-ui/dom": "^1.6.3",
"@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/enhanced-img": "^0.1.8",
"@sveltejs/kit": "^2.5.1",
"@sveltejs/kit": "^2.5.2",
"@sveltejs/vite-plugin-svelte": "^3.0.2",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/svelte": "^4.0.3",
"@types/dom-to-image": "^2.6.4",
"@types/justified-layout": "^4.1.0",
"@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.2.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"@vitest/coverage-v8": "^1.0.4",
"autoprefixer": "^10.4.13",
"eslint": "^8.34.0",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^4.1.0",
"@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4",
"@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"@vitest/coverage-v8": "^1.3.1",
"autoprefixer": "^10.4.17",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.30.0",
"eslint-plugin-unicorn": "^51.0.0",
"factory.ts": "^1.3.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-unicorn": "^51.0.1",
"factory.ts": "^1.4.1",
"identity-obj-proxy": "^3.0.0",
"postcss": "^8.4.21",
"prettier": "^3.1.0",
"postcss": "^8.4.35",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^3.2.4",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-svelte": "^3.2.1",
"rollup-plugin-visualizer": "^5.12.0",
"svelte": "^4.2.11",
"svelte-check": "^3.6.4",
"tailwindcss": "^3.2.7",
"tslib": "^2.5.0",
"svelte": "^4.2.12",
"svelte-check": "^3.6.5",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.1",
"vitest": "^1.0.4"
"vite": "^5.1.4",
"vitest": "^1.3.1"
},
"type": "module",
"dependencies": {
"@immich/sdk": "file:../open-api/typescript-sdk",
"@mdi/js": "^7.3.67",
"@photo-sphere-viewer/core": "^5.7.0",
"@zoom-image/svelte": "^0.2.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.7.1",
"@zoom-image/svelte": "^0.2.6",
"axios": "^1.6.7",
"buffer": "^6.0.3",
"copy-image-clipboard": "^2.1.2",
"dom-to-image": "^2.6.0",
"handlebars": "^4.7.7",
"handlebars": "^4.7.8",
"justified-layout": "^4.1.0",
"lodash-es": "^4.17.21",
"luxon": "^3.2.1",
"socket.io-client": "^4.6.1",
"svelte-local-storage-store": "^0.6.0",
"svelte-maplibre": "^0.8.0",
"luxon": "^3.4.4",
"socket.io-client": "^4.7.4",
"svelte-local-storage-store": "^0.6.4",
"svelte-maplibre": "^0.8.1",
"thumbhash": "^0.1.1"
}
}

View File

@@ -1,7 +1,6 @@
import { isHttpError } from '@immich/sdk';
import type { HandleClientError } from '@sveltejs/kit';
const LOG_PREFIX = '[hooks.client.ts]';
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
const parseError = (error: unknown) => {
@@ -23,6 +22,6 @@ const parseError = (error: unknown) => {
export const handleError: HandleClientError = ({ error }) => {
const result = parseError(error);
console.error(`${LOG_PREFIX}:handleError ${result.message}`);
console.error(`[hooks.client.ts]:handleError ${result.message}`);
return result;
};

View File

@@ -48,11 +48,11 @@
await handleCommand(jobId, dto);
};
const onConfirm = () => {
const onConfirm = async () => {
if (!confirmJob) {
return;
}
handleCommand(confirmJob, { command: JobCommand.Start, force: true });
await handleCommand(confirmJob, { command: JobCommand.Start, force: true });
confirmJob = null;
};

View File

@@ -54,7 +54,7 @@
});
};
const resetToDefault = async (configKeys: Array<keyof SystemConfigDto>) => {
const resetToDefault = (configKeys: Array<keyof SystemConfigDto>) => {
for (const key of configKeys) {
config = { ...config, [key]: defaultConfig[key] };
}

View File

@@ -42,28 +42,6 @@
subtitle="Watch external libraries for file changes"
bind:checked={config.library.watch.enabled}
/>
<SettingSwitch
title="Use filesystem polling (EXPERIMENTAL)"
disabled={disabled || !config.library.watch.enabled}
subtitle="Use polling instead of native filesystem watching. This is required for network shares but can be very resource intensive. Use with care!"
bind:checked={config.library.watch.usePolling}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
required={config.library.watch.usePolling}
disabled={disabled || !config.library.watch.usePolling || !config.library.watch.enabled}
label="Polling interval"
bind:value={config.library.watch.interval}
isEdited={config.library.watch.interval !== savedConfig.library.watch.interval}
>
<svelte:fragment slot="desc">
<p class="text-sm dark:text-immich-dark-fg">
Interval of filesystem polling, in milliseconds. Lower values will result in higher CPU usage.
</p>
</svelte:fragment>
</SettingInputField>
</div>
<div class="ml-4">

View File

@@ -0,0 +1,45 @@
<script lang="ts">
import { autoGrowHeight } from '$lib/utils/autogrow';
import { updateAlbumInfo } from '@immich/sdk';
import { handleError } from '$lib/utils/handle-error';
export let id: string;
export let description: string;
export let isOwned: boolean;
$: newDescription = description;
const handleUpdateDescription = async () => {
if (newDescription === description) {
return;
}
try {
await updateAlbumInfo({
id,
updateAlbumDto: {
description: newDescription,
},
});
} catch (error) {
handleError(error, 'Error updating album description');
return;
}
description = newDescription;
};
</script>
{#if isOwned}
<textarea
class="w-full mt-2 resize-none overflow-hidden text-black dark:text-white border-b-2 border-transparent border-gray-500 bg-transparent text-base outline-none transition-all focus:border-b-2 focus:border-immich-primary disabled:border-none dark:focus:border-immich-dark-primary hover:border-gray-400"
bind:value={newDescription}
on:input={(e) => autoGrowHeight(e.currentTarget)}
on:focusout={handleUpdateDescription}
use:autoGrowHeight
placeholder="Add description"
/>
{:else if description}
<p class="break-words whitespace-pre-line w-full text-black dark:text-white text-base">
{description}
</p>
{/if}

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