Compare commits

...

43 Commits

Author SHA1 Message Date
Alex The Bot
3f1cf44717 Version v1.71.0 2023-07-28 18:32:33 +00:00
martin
2d83ac4125 fix(web): focus & clear individual search term (#3452) 2023-07-28 13:03:23 -05:00
cfitzw
7147486b6a Sort album assets from newest to oldest (#3444) 2023-07-28 09:47:27 -05:00
Alex
89ddbac8bc chore: build report 2023-07-28 09:06:25 -05:00
Dmitry Brazhenko
e071b82e8a feat (web/server) 360 degrees Web panoramas [attempt 2] (#3412)
* commit 1 (isPanorama: boolean)

* working solution for projectiontypeenum

* fix

* format fix

* fix

* fix

* fix

* fix

* enum projectiontype

* working solution with exif

* fix

* reverted >

* fix format

* reverted auto-magic api.ts prettification

* fix

* reverted api.ts autogenerated

* api ts regenerated

* Update web/src/lib/components/assets/thumbnail/thumbnail.svelte

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* Update web/src/lib/components/asset-viewer/asset-viewer.svelte

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* exifProjectionType

* Update server/src/microservices/processors/metadata-extraction.processor.ts

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* projectionType?: string = ProjectionType.NONE;

* not null

* projectionType!: ProjectionType;

* opeapi generator fix

* fixes

* fix

* fix

* generate api

* asset.exifInifo?.projectionType

* Update server/src/domain/asset/response-dto/exif-response.dto.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Update server/src/microservices/processors/metadata-extraction.processor.ts

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* enum -> varchar;projectiontypeenum->projectiontype

* asset-viewer fixed prettiffier

* @Column({}) single line

* enum | string

* make api

* enum | string

* enum | str fix

* fix

* chore: use string instead of enum

* chore: open api

* fix: checks

---------

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-07-27 23:29:09 -05:00
martin
13b2b2fc4e fix(web): clickable items in the searchbar (#3441) 2023-07-27 22:06:42 -05:00
Dhrumil Shah
fe9ef1a3ea feat(mobile) - Add better offline support (#3279)
* WIP: Adding init support for offline-loading

* WIP: found bug and fixed with offline browing adv setting

* WIP: big some bugs with first login

* WIP: static analysis fixes

* PR: Removed setting for offline browing

* PR: static analysis - remove imports

* PR: Refactored user login state

* PR: changed logger log level as it happens a lot

* PR: change log var to _log

* PR: addressing comments

* WIP: bug fixes

* WIP: static analysis on the logger variable
2023-07-27 22:05:27 -05:00
martin
afb0d0f54d feat(web): suggest to merge people faces when renaming a person name (#3399)
* feat: propose to merge faced based on the name

* responsive

* drop down menu

* add border

* improvements

* improvements

* improvements

* add comments

* responsive

* responsive

* feat: use FullScreenModal

* responsive

* pr feeback

* pr feeback

* pr feeback

* responsive

* pr feeback

* pr feeback

* styling

* fix test

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-28 03:04:20 +00:00
martin
26085ff82b fix: clicking outside the shortcuts pannel (#3433)
* use FullScreenModal for ShowShortcuts

* pr feeback
2023-07-27 14:42:27 -05:00
Alex The Bot
2872886e77 Version v1.70.0 2023-07-27 03:40:16 +00:00
martin
a21112e4ab fix: people in shared assets (#3431)
* fix: people in shared assets

* use empty array
2023-07-26 21:14:50 -05:00
Jason Rasmussen
f3edf43158 chore: log listen address (#3428) 2023-07-26 18:29:35 +00:00
martin
1c5926553a fix: dialog overflow when creating a user (#3422) 2023-07-25 09:29:40 -05:00
faupau
05fa3092bf fix(web): fixes previous pull request: set asset as profile image (#3415)
* set photoviewer 100% width, fixes transparent ede

* remove unnecessary class

* format fix

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-25 05:17:59 +00:00
martyfuhry
7d3ec8af37 fix(mobile): Memory lane now updates to the correct day if the app is resumed the next day (#3414)
* Adds todayProvider to memory lane

* Revert "Adds todayProvider to memory lane"

This reverts commit 67ae58b513.

* Invalidate memory provider on app resume
2023-07-24 11:39:10 -05:00
Mark Monteiro
8db008ef0b Remove unnecessary PG_DATA environement variable from docker-compose.yml (#3394)
* Remove unnecessary PG_DATA environement variable from docker-compose.yml

There is no need to set the PostgreSQL data directory to the default location, it just adds an additional unnecessary line to the docker-compose file.

In addition, the PG_DATA isn't even the correct environment variable name (it should be PGDATA, see: https://hub.docker.com/_/postgres/), so this environment variable was never doing anything to begin with.

* Update docker-compose.dev.yml

* Update docker-compose.prod.yml

* Update docker-compose.test.yml
2023-07-23 21:11:27 -05:00
Alex
e493e05e99 fix(server): better facial recognition order (#3386) 2023-07-23 21:10:56 -05:00
martin
b83e535010 feat(web): show available shortcuts (#3342)
* feat(web): show available shortcuts

* pr feeback

* feat: new shortcut for deselect

* fix: remove new shortcut

* responsive
2023-07-23 21:09:06 -05:00
Daniele Ricci
111372edc1 fix(cli): fix wording in usage guide (#3378) 2023-07-23 21:06:27 -05:00
Mark Monteiro
625a899f64 Update environment-variables.md (#3402)
Add documentation for the environment variables that enable Docker secrets support

Support for these variables was implemented in #1254 and #3282
2023-07-23 17:53:52 -05:00
Alex
aaf0496f74 chore(server): Update Immich CLI version (#3403) 2023-07-23 17:53:20 -05:00
Alex
4977926c88 post mobile release 2023-07-23 13:51:48 -05:00
Alex The Bot
f41e1159d1 Version v1.69.0 2023-07-23 17:40:02 +00:00
Brian Di Palma
670107373b Update backup-and-restore.md (#3389)
* Update backup-and-restore.md

* Update backup-and-restore.md

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-07-23 12:01:36 -05:00
Brian Di Palma
e660f05c31 Update _storage-template.md (#3388)
* Update _storage-template.md

* Update _storage-template.md

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2023-07-23 11:53:58 -05:00
Dhrumil Shah
baf1ea313e feat(mobile): render login fields/buttons based on server configuration (#3339)
* WIP: Show login fields/buttons based on server configuration

* PR: change login disabled message to use translation

* added localization string)

* text

---------

Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
2023-07-22 23:04:17 -05:00
martin
ed64c91da6 fix: hide faces (#3352)
* fix: hide faces

* remove unused variable

* fix: work even if one fails

* better style for hidden people

* add hide face in the menu dropdown

* add buttons to toggle visibility for all faces

* add server test

* close modal with escape key

* fix: explore page

* improve show & hide faces modal

* keep name on people card

* simplify layout

* sticky app bar in show-hide page

* fix format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-22 22:00:43 -05:00
Alex Phillips
c40aa4399b fix(cli): read-only and sidecar support for import (#3372)
* added flag to support toggling read-only mode (read-only true by default), added sidecarPath to import request payload

* removed default on --no-read-only to prevent confusion since true is the default
2023-07-22 21:11:00 -05:00
Mert
8f08100a30 moved deps (#3379) 2023-07-22 15:04:52 -05:00
shalong-tanwen
337cd33042 fix(mobile): Decrease ScrollBar Fade timeout to a second (#3370) 2023-07-22 15:00:22 -05:00
martyfuhry
1e8fc7266c Fixes hide controls when zoomed and shows them when not zoomed (#3366) 2023-07-22 14:56:49 -05:00
martyfuhry
7f35583c2c feat(mobile): Precaches next image in memories (#3365)
* Precaches images in memories

* Fixes jumps and precaches images

* refactors to move precacheAsset over to ImmichImage to keep logic in same place

---------

Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
2023-07-22 14:51:25 -05:00
Alex
ace755f264 fix(server): merged faces cannot be recognized in new photos (#3381)
* fix(server): merged faces cannot be recognized in new photos

* fix: delete stale documents
2023-07-22 14:42:12 -05:00
martyfuhry
7b25c9d0a7 fix(mobile): Uses gray box placeholder for loading images by default and fixes odd spinner (#3364)
* image loading

* Use gray box placeholder by default and fix odd spinner with normal spinner

* Progress indicator is separate

* Fixes loading for cached network image too

---------

Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
2023-07-22 10:03:31 -05:00
shalong-tanwen
c0bee2a6b7 fix(mobile): AssetCount reset and Elliptical progress in Memories (#3355)
* fix: Constraint CircularProgressIndicator in Memories

* fix(mobile): Asset count reset when scroll cancelled midway in Memories
2023-07-21 23:56:49 -05:00
oddlama
b48d5cab22 fix(cli): move runtime dependencies to correct section (#3371)
and add missing dependency on typescript-eslint/parser
2023-07-21 23:01:20 -05:00
Simon Shields
4f59e6c7ab feat(server): Google Pixel motion photos (#3175)
* feat(server): Google Pixel motion photos

Add support for motion photos taken on Pixel phones. They have the exif
property 'MotionPhoto' set to 1, and an embedded mp4 file appended to
the JPEG file.

The implementation works like this:

- on metadata extraction, if a live photo is detected, examine the
  metadata to determine where in the file the embedded MP4 is.
- extract this MP4 and write it next to the JPEG.
- link it using the existing mechanism for live photos.

There is a "MotionPhotoPresentationTimestampUs" exif property, which we
don't do anything with - I imagine that it refers to the timepoint in
the video that the photo was taken at, but it probably warrants more
investigation.

* fix format

* fix test

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-21 22:52:59 -05:00
Alex
82a5d54d2c fix(web): cannot upload file with uppercase extension (#3374)
* fix(web): cannot upload file with uppercase extension

* actual fix

* remove console log
2023-07-21 22:52:28 -05:00
Alex
5e6d830ecd fix(ml): startup issue in dev (#3373) 2023-07-21 21:10:08 -05:00
oddlama
f700f3427b feat(cli): add build script and unify tests (#3369) 2023-07-21 13:10:01 -05:00
dependabot[bot]
0c07c0ba4e chore(deps): bump stumpylog/image-cleaner-action from 0.1.0 to 0.2.0 (#3361)
Bumps [stumpylog/image-cleaner-action](https://github.com/stumpylog/image-cleaner-action) from 0.1.0 to 0.2.0.
- [Release notes](https://github.com/stumpylog/image-cleaner-action/releases)
- [Changelog](https://github.com/stumpylog/image-cleaner-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/stumpylog/image-cleaner-action/compare/v0.1.0...v0.2.0)

---
updated-dependencies:
- dependency-name: stumpylog/image-cleaner-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>
2023-07-21 11:32:12 -05:00
bt90
bc885f3644 fix(server): properly handle SIGTERM (#3350)
* use tini init

* Move python into CMD

* Use tini as entrypoint

* Toggle executable bit

* Avoid compose changes

* Adapt web entrypoint
2023-07-21 09:20:04 -05:00
Mert
6668964d92 transcode live photos on upload (#3354) 2023-07-20 20:21:38 -05:00
116 changed files with 3320 additions and 1164 deletions

View File

@@ -38,7 +38,7 @@ jobs:
-
name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.1.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.2.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"
@@ -70,7 +70,7 @@ jobs:
-
name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.1.0
uses: stumpylog/image-cleaner-action/untagged@v0.2.0
with:
token: "${{ env.TOKEN }}"
owner: "immich-app"

View File

View File

@@ -1,8 +0,0 @@
import type { Config } from 'jest';
const config: Config = {
preset: 'ts-jest',
setupFilesAfterEnv: ['jest-extended/all'],
};
export default config;

222
cli/package-lock.json generated
View File

@@ -8,9 +8,14 @@
"name": "immich-cli",
"dependencies": {
"axios": "^1.4.0",
"byte-size": "^8.1.1",
"cli-progress": "^3.12.0",
"commander": "^11.0.0",
"form-data": "^4.0.0",
"mime-types": "^2.1.35",
"systeminformation": "^5.18.4"
"glob": "^10.3.1",
"picomatch": "^2.3.1",
"systeminformation": "^5.18.4",
"yaml": "^2.3.1"
},
"devDependencies": {
"@types/byte-size": "^8.1.0",
@@ -22,28 +27,23 @@
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"byte-size": "^8.1.1",
"@typescript-eslint/parser": "^5.48.1",
"chai": "^4.3.7",
"cli-progress": "^3.12.0",
"commander": "^11.0.0",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unicorn": "^47.0.0",
"glob": "^10.3.1",
"jest": "^29.5.0",
"jest-extended": "^4.0.0",
"jest-message-util": "^29.5.0",
"jest-mock-axios": "^4.7.2",
"jest-when": "^3.5.2",
"mock-fs": "^5.2.0",
"picomatch": "^2.3.1",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"tslib": "^2.5.3",
"typescript": "^4.9.4",
"yaml": "^2.3.1"
"typescript": "^4.9.4"
}
},
"node_modules/@ampproject/remapping": {
@@ -111,9 +111,9 @@
}
},
"node_modules/@babel/core/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@@ -154,9 +154,9 @@
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@@ -772,7 +772,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
@@ -789,7 +788,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
"integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==",
"dev": true,
"engines": {
"node": ">=12"
},
@@ -801,7 +799,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"engines": {
"node": ">=12"
},
@@ -812,14 +809,12 @@
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
@@ -836,7 +831,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"dependencies": {
"ansi-regex": "^6.0.1"
},
@@ -851,7 +845,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
@@ -1347,7 +1340,6 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"optional": true,
"engines": {
"node": ">=14"
@@ -1664,7 +1656,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.60.1.tgz",
"integrity": "sha512-pHWlc3alg2oSMGwsU/Is8hbm3XFbcrb6P5wIxcQW9NsYBfnrubl/GhVVD/Jm/t8HXhA2WncoIRfBtnCgRGV96Q==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "5.60.1",
"@typescript-eslint/types": "5.60.1",
@@ -1692,7 +1683,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.60.1.tgz",
"integrity": "sha512-Dn/LnN7fEoRD+KspEOV0xDMynEmR3iSHdgNsarlXNLGGtcUok8L4N71dxUgt3YvlO8si7E+BJ5Fe3wb5yUw7DQ==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/types": "5.60.1",
"@typescript-eslint/visitor-keys": "5.60.1"
@@ -1710,7 +1700,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.60.1.tgz",
"integrity": "sha512-zDcDx5fccU8BA0IDZc71bAtYIcG9PowaOwaD8rjYbqwK7dpe/UMQl3inJ4UtUK42nOCT41jTSCwg76E62JpMcg==",
"dev": true,
"peer": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@@ -1724,7 +1713,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.60.1.tgz",
"integrity": "sha512-hkX70J9+2M2ZT6fhti5Q2FoU9zb+GeZK2SLP1WZlvUDqdMbEKhexZODD1WodNRyO8eS+4nScvT0dts8IdaBzfw==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/types": "5.60.1",
"@typescript-eslint/visitor-keys": "5.60.1",
@@ -1752,7 +1740,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.60.1.tgz",
"integrity": "sha512-xEYIxKcultP6E/RMKqube11pGjXH1DCo60mQoWhVYyKfLkwbIVVjYxmOenNMxILx0TjCujPTjjnTIVzm09TXIw==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/types": "5.60.1",
"eslint-visitor-keys": "^3.3.0"
@@ -2036,7 +2023,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -2045,7 +2031,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -2211,8 +2196,7 @@
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
},
"node_modules/brace-expansion": {
"version": "1.1.11",
@@ -2311,7 +2295,6 @@
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/byte-size/-/byte-size-8.1.1.tgz",
"integrity": "sha512-tUkzZWK0M/qdoLEqikxBWe4kumyuwjl3HO6zHTr4yEI23EojPtLYXdG1+AQY7MN0cGyNDvEaJ8wiYQm6P2bPxg==",
"dev": true,
"engines": {
"node": ">=12.17"
}
@@ -2464,7 +2447,6 @@
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
"integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
"dev": true,
"dependencies": {
"string-width": "^4.2.3"
},
@@ -2506,7 +2488,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -2517,8 +2498,7 @@
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/combined-stream": {
"version": "1.0.8",
@@ -2535,7 +2515,6 @@
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.0.0.tgz",
"integrity": "sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==",
"dev": true,
"engines": {
"node": ">=16"
}
@@ -2562,7 +2541,6 @@
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"dev": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -2675,8 +2653,7 @@
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="
},
"node_modules/electron-to-chromium": {
"version": "1.4.440",
@@ -2699,8 +2676,7 @@
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/error-ex": {
"version": "1.3.2",
@@ -2801,9 +2777,9 @@
}
},
"node_modules/eslint-plugin-jest": {
"version": "27.2.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.2.2.tgz",
"integrity": "sha512-euzbp06F934Z7UDl5ZUaRPLAc9MKjh0rMPERrHT7UhlCEwgb25kBj37TvMgWeHZVkR5I9CayswrpoaqZU1RImw==",
"version": "27.2.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-27.2.3.tgz",
"integrity": "sha512-sRLlSCpICzWuje66Gl9zvdF6mwD5X86I4u55hJyFBsxYOsBCmT5+kSUjf+fkFWVMMgpzNEupjW8WzUqi83hJAQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/utils": "^5.10.0"
@@ -2812,7 +2788,7 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
},
"peerDependencies": {
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/eslint-plugin": "^5.0.0 || ^6.0.0",
"eslint": "^7.0.0 || ^8.0.0",
"jest": "*"
},
@@ -3203,7 +3179,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz",
"integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.0",
"signal-exit": "^4.0.1"
@@ -3219,7 +3194,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz",
"integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==",
"dev": true,
"engines": {
"node": ">=14"
},
@@ -3318,7 +3292,6 @@
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.3.1.tgz",
"integrity": "sha512-9BKYcEeIs7QwlCYs+Y3GBvqAMISufUS0i2ELd11zpZjxI5V9iyRj0HgzB5/cLf2NY4vcYBTYzJ7GIui7j/4DOw==",
"dev": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^2.0.3",
@@ -3352,7 +3325,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -3361,7 +3333,6 @@
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.2.tgz",
"integrity": "sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -3458,6 +3429,12 @@
"node": ">=8"
}
},
"node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -3597,7 +3574,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -3656,8 +3632,7 @@
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="
},
"node_modules/istanbul-lib-coverage": {
"version": "3.2.0",
@@ -3685,9 +3660,9 @@
}
},
"node_modules/istanbul-lib-instrument/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@@ -3750,7 +3725,6 @@
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.2.1.tgz",
"integrity": "sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==",
"dev": true,
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
@@ -3933,24 +3907,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest-config/node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/jest-diff": {
"version": "29.5.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.5.0.tgz",
@@ -4571,9 +4527,9 @@
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@@ -4675,7 +4631,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-6.0.2.tgz",
"integrity": "sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==",
"dev": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -4719,6 +4674,27 @@
"integrity": "sha512-QzsYKWhXTWx8h1kIvqfnC++o0pEmpRQA/aenALsL2F4pqNVr7YzcdMlDij5WBnwftRbJCNJL/O7zdKaxKPHqgQ==",
"dev": true
},
"node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
"integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
"dev": true,
"dependencies": {
"hosted-git-info": "^2.1.4",
"resolve": "^1.10.0",
"semver": "2 || 3 || 4 || 5",
"validate-npm-package-license": "^3.0.1"
}
},
"node_modules/normalize-package-data/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -4838,6 +4814,24 @@
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -4860,7 +4854,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -4875,7 +4868,6 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.0.tgz",
"integrity": "sha512-tZFEaRQbMLjwrsmidsGJ6wDMv0iazJWk6SfIKnY4Xru8auXgmJkOBa5DUbYFcFD2Rzk2+KDlIiF0GVXNCbgC7g==",
"dev": true,
"dependencies": {
"lru-cache": "^9.1.1 || ^10.0.0",
"minipass": "^5.0.0 || ^6.0.2"
@@ -4891,7 +4883,6 @@
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.0.tgz",
"integrity": "sha512-svTf/fzsKHffP42sujkO/Rjs37BCIsQVRCeNYIm9WN8rgT7ffoUnRtZCqU+6BqcSBdv8gwJeTz8knJpgACeQMw==",
"dev": true,
"engines": {
"node": "14 || >=16.14"
}
@@ -4924,7 +4915,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"engines": {
"node": ">=8.6"
},
@@ -5239,51 +5229,6 @@
"node": ">=8"
}
},
"node_modules/read-pkg/node_modules/hosted-git-info": {
"version": "2.8.9",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
"integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
"dev": true
},
"node_modules/read-pkg/node_modules/normalize-package-data": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
"integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
"dev": true,
"dependencies": {
"hosted-git-info": "^2.1.4",
"resolve": "^1.10.0",
"semver": "2 || 3 || 4 || 5",
"validate-npm-package-license": "^3.0.1"
}
},
"node_modules/read-pkg/node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"dev": true,
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/read-pkg/node_modules/semver": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
"integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
"dev": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/read-pkg/node_modules/type-fest": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
@@ -5502,7 +5447,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -5514,7 +5458,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -5635,7 +5578,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -5650,7 +5592,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -5664,7 +5605,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -5677,7 +5617,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -6111,7 +6050,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -6123,9 +6061,9 @@
}
},
"node_modules/word-wrap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.4.tgz",
"integrity": "sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -6153,7 +6091,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@@ -6204,7 +6141,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz",
"integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==",
"dev": true,
"engines": {
"node": ">= 14"
}

View File

@@ -2,9 +2,14 @@
"name": "immich-cli",
"dependencies": {
"axios": "^1.4.0",
"byte-size": "^8.1.1",
"cli-progress": "^3.12.0",
"commander": "^11.0.0",
"form-data": "^4.0.0",
"mime-types": "^2.1.35",
"systeminformation": "^5.18.4"
"glob": "^10.3.1",
"picomatch": "^2.3.1",
"systeminformation": "^5.18.4",
"yaml": "^2.3.1"
},
"devDependencies": {
"@types/byte-size": "^8.1.0",
@@ -16,34 +21,48 @@
"@types/mock-fs": "^4.13.1",
"@types/node": "^20.3.1",
"@typescript-eslint/eslint-plugin": "^5.60.1",
"byte-size": "^8.1.1",
"@typescript-eslint/parser": "^5.48.1",
"chai": "^4.3.7",
"cli-progress": "^3.12.0",
"commander": "^11.0.0",
"eslint": "^8.43.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-unicorn": "^47.0.0",
"glob": "^10.3.1",
"jest": "^29.5.0",
"jest-extended": "^4.0.0",
"jest-message-util": "^29.5.0",
"jest-mock-axios": "^4.7.2",
"jest-when": "^3.5.2",
"mock-fs": "^5.2.0",
"picomatch": "^2.3.1",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"tslib": "^2.5.3",
"typescript": "^4.9.4",
"yaml": "^2.3.1"
"typescript": "^4.9.4"
},
"scripts": {
"build": "tsc --project tsconfig.build.json",
"lint": "eslint \"src/**/*.ts\" --max-warnings 0",
"prepack": "yarn build ",
"test": "jest",
"test:cov": "jest --coverage",
"format": "prettier --check ."
},
"jest": {
"clearMocks": true,
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": ".",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"collectCoverageFrom": [
"<rootDir>/src/**/*.(t|j)s"
],
"coverageDirectory": "./coverage",
"testEnvironment": "node"
}
}

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.68.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1304,6 +1304,12 @@ export interface ExifResponseDto {
* @memberof ExifResponseDto
*/
'description'?: string | null;
/**
*
* @type {string}
* @memberof ExifResponseDto
*/
'projectionType'?: string | null;
}
/**
*
@@ -1802,6 +1808,50 @@ export interface PeopleResponseDto {
*/
'people': Array<PersonResponseDto>;
}
/**
*
* @export
* @interface PeopleUpdateDto
*/
export interface PeopleUpdateDto {
/**
*
* @type {Array<PeopleUpdateItem>}
* @memberof PeopleUpdateDto
*/
'people': Array<PeopleUpdateItem>;
}
/**
*
* @export
* @interface PeopleUpdateItem
*/
export interface PeopleUpdateItem {
/**
* Person id.
* @type {string}
* @memberof PeopleUpdateItem
*/
'id': string;
/**
* Person name.
* @type {string}
* @memberof PeopleUpdateItem
*/
'name'?: string;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}
* @memberof PeopleUpdateItem
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PeopleUpdateItem
*/
'isHidden'?: boolean;
}
/**
*
* @export
@@ -8896,6 +8946,50 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions,
};
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople: async (peopleUpdateDto: PeopleUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'peopleUpdateDto' is not null or undefined
assertParamExists('updatePeople', 'peopleUpdateDto', peopleUpdateDto)
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(peopleUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@@ -9005,6 +9099,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updatePeople(peopleUpdateDto: PeopleUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updatePeople(peopleUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@@ -9071,6 +9175,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
mergePerson(requestParameters: PersonApiMergePersonRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople(requestParameters: PersonApiUpdatePeopleRequest, options?: AxiosRequestConfig): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.updatePeople(requestParameters.peopleUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.
@@ -9160,6 +9273,20 @@ export interface PersonApiMergePersonRequest {
readonly mergePersonDto: MergePersonDto
}
/**
* Request parameters for updatePeople operation in PersonApi.
* @export
* @interface PersonApiUpdatePeopleRequest
*/
export interface PersonApiUpdatePeopleRequest {
/**
*
* @type {PeopleUpdateDto}
* @memberof PersonApiUpdatePeople
*/
readonly peopleUpdateDto: PeopleUpdateDto
}
/**
* Request parameters for updatePerson operation in PersonApi.
* @export
@@ -9243,6 +9370,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public updatePeople(requestParameters: PersonApiUpdatePeopleRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).updatePeople(requestParameters.peopleUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.68.0
* The version of the OpenAPI document: 1.71.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.68.0
* The version of the OpenAPI document: 1.71.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.68.0
* The version of the OpenAPI document: 1.71.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.68.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -70,11 +70,13 @@ export default class Upload extends BaseCommand {
if (options.import) {
const importData = {
assetPath: asset.path,
sidecarPath: asset.sidecarPath,
deviceAssetId: asset.deviceAssetId,
deviceId: this.deviceId,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: false,
isReadOnly: options.readOnly,
};
if (!this.dryRun) {

View File

@@ -5,4 +5,5 @@ export class UploadOptionsDto {
skipHash = false;
delete = false;
import = false;
readOnly = true;
}

View File

@@ -35,9 +35,11 @@ program
.default(false),
)
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default(false))
.argument('[paths...]', 'One or more paths to assets to be uploaded')
.addOption(new Option('--no-read-only', 'Import files without read-only protection, allowing Immich to manage them'))
.argument('[paths...]', 'One or more paths to assets to be imported')
.action((paths, options) => {
options.import = true;
options.excludePatterns = options.ignore;
new Upload().run(paths, options);
});

View File

@@ -1,7 +0,0 @@
{
"extends": "../tsconfig",
"compilerOptions": {
"noEmit": true
},
"references": [{ "path": ".." }]
}

4
cli/tsconfig.build.json Normal file
View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["dist", "node_modules", "upload", "test", "**/*spec.ts"]
}

View File

@@ -31,7 +31,6 @@ services:
build:
context: ../machine-learning
dockerfile: Dockerfile
command: python main.py
ports:
- 3003:3003
volumes:
@@ -116,7 +115,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
ports:

View File

@@ -85,7 +85,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
restart: always

View File

@@ -37,7 +37,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- /var/lib/postgresql/data
networks:

View File

@@ -69,7 +69,6 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
restart: always

View File

@@ -2,6 +2,10 @@
## Database
:::caution
Immich saves [file paths in the database](https://github.com/immich-app/immich/discussions/3299), it does not scan the library folder to update the database so backups are crucial.
:::
:::info
Refer to the official [postgres documentation](https://www.postgresql.org/docs/current/backup.html) for details about backing up and restoring a postgres database.
:::

View File

@@ -184,3 +184,24 @@ Typesense URL example JSON before encoding:
| `MACHINE_LEARNING_CLASSIFICATION_MODEL` | Classification Model | `microsoft/resnet-50` | machine learning |
| `MACHINE_LEARNING_CACHE_FOLDER` | ML Cache Location | `/cache` | machine learning |
| `TRANSFORMERS_CACHE` | ML Transformers Cache Location | `/cache` | machine learning |
## Docker Secrets
The following variables support the use of [Docker secrets](https://docs.docker.com/engine/swarm/secrets/) for additional security.
To use any of these, replace the regular environment variable with the equivalent `_FILE` environment variable. The value of
the `_FILE` variable should be set to the path of a file containing the variable value.
| Regular Variable | Equivalent Docker Secrets '\_FILE' Variable |
| :----------------: | :-----------------------------------------: |
| `DB_HOSTNAME` | `DB_HOSTNAME_FILE`<sup>\*1</sup> |
| `DB_DATABASE_NAME` | `DB_DATABASE_NAME_FILE`<sup>\*1</sup> |
| `DB_USERNAME` | `DB_USERNAME_FILE`<sup>\*1</sup> |
| `DB_PASSWORD` | `DB_PASSWORD_FILE`<sup>\*1</sup> |
| `REDIS_PASSWORD` | `REDIS_PASSWORD_FILE`<sup>\*2</sup> |
\*1: See the [official documentation](https://github.com/docker-library/docs/tree/master/postgres#docker-secrets) for
details on how to use Docker Secrets in the Postgres image.
\*2: See [this comment](https://github.com/docker-library/redis/issues/46#issuecomment-335326234) for an example of how
to use use a Docker secret for the password in the Redis container.

View File

@@ -1,6 +1,6 @@
Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level.
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text.
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text. If the template produces [multiple files with the same filename, they won't be overwritten](https://github.com/immich-app/immich/discussions/3324) as a sequence number is appended to the filename.
```bash title="Default template"
Year/Year-Month-Day/Filename.Extension
@@ -8,4 +8,4 @@ Year/Year-Month-Day/Filename.Extension
<img src={require('./img/storage-template.png').default} width="100%" title="Storage Template Setting" />
Immich also provides a mechanism to migrate between template so that if the template you set now doesn't work in the future, you can always migrate all the existing files to the new template. The mechanism is run as a job in the Job page.
Immich also provides a mechanism to migrate between templates so that if the template you set now doesn't work in the future, you can always migrate all the existing files to the new template. The mechanism is run as a job on the Job page.

View File

@@ -15,6 +15,8 @@ RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
FROM python:3.11.4-slim-bullseye@sha256:91d194f58f50594cda71dcd2e8fdefd90e7ecc57d07823813b67c8521e565dcd
RUN apt-get update && apt-get install -y --no-install-recommends tini && rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \
@@ -25,4 +27,5 @@ ENV NODE_ENV=production \
COPY --from=builder /opt/venv /opt/venv
COPY app .
ENTRYPOINT ["python", "-m", "app.main"]
ENTRYPOINT ["tini", "--"]
CMD ["python", "-m", "app.main"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.68.0"
version = "1.71.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@@ -22,8 +22,6 @@ fastapi = "^0.95.2"
uvicorn = {extras = ["standard"], version = "^0.22.0"}
pydantic = "^1.10.8"
aiocache = "^0.12.1"
pytest-cov = "^4.1.0"
ruff = "^0.0.272"
[tool.poetry.group.dev.dependencies]
mypy = "^1.3.0"
@@ -33,6 +31,8 @@ locust = "^2.15.1"
gunicorn = "^20.1.0"
httpx = "^0.24.1"
pytest-asyncio = "^0.21.0"
pytest-cov = "^4.1.0"
ruff = "^0.0.272"
[[tool.poetry.source]]
name = "pytorch-cpu"

View File

@@ -13,4 +13,4 @@ key.properties
**/*.jks
# Fastlane
/fastlane/report.xml
fastlane/report.xml

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 91,
"android.injected.version.name" => "1.68.0",
"android.injected.version.code" => 94,
"android.injected.version.name" => "1.71.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.000296">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000239">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="64.042552">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="68.788432">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.676557">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="29.76592">
</testcase>

View File

@@ -186,6 +186,7 @@
"login_form_save_login": "Stay logged in",
"login_form_server_empty": "Enter a server URL.",
"login_form_server_error": "Could not connect to server.",
"login_disabled": "Login has been disabled",
"monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "Motion Photos",
"notification_permission_dialog_cancel": "Cancel",
@@ -290,4 +291,4 @@
"version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89"
}
}

View File

@@ -31,4 +31,6 @@ Runner/GeneratedPluginRegistrant.*
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
!default.perspectivev3
fastlane/report.xml

View File

@@ -157,4 +157,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1

View File

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

View File

@@ -59,11 +59,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.57.0</string>
<string>1.70.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>97</string>
<string>110</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.68.0"
version_number: "1.71.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.000407">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000211">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.988375">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="2.108738">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="45.42439">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="28.952846">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.381359">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="1.821481">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="94.653021">
<testcase classname="fastlane.lanes" name="4: build_app" time="99.212621">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="58.237354">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="68.366701">
</testcase>

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart'
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -156,6 +157,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
ref.read(iOSBackgroundSettingsProvider.notifier).refresh();
ref.invalidate(memoryFutureProvider);
break;
case AppLifecycleState.inactive:

View File

@@ -499,6 +499,7 @@ class GalleryViewerPage extends HookConsumerWidget {
PhotoViewGallery.builder(
scaleStateChangedCallback: (state) {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
},
pageController: controller,
scrollPhysics: isZoomed.value

View File

@@ -341,8 +341,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
backgroundColor: Theme.of(context).hintColor,
labelTextBuilder: _labelBuilder,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(seconds: 1),
scrollbarTimeToFade: const Duration(seconds: 4),
scrollbarAnimationDuration: const Duration(milliseconds: 300),
scrollbarTimeToFade: const Duration(milliseconds: 1000),
child: listWidget,
)
: listWidget;

View File

@@ -37,6 +37,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final ApiService _apiService;
final Isar _db;
final _log = Logger("AuthenticationNotifier");
Future<bool> login(
String email,
@@ -145,38 +146,66 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Future<bool> setSuccessLoginInfo({
required String accessToken,
required String serverUrl,
bool offlineLogin = false,
}) async {
_apiService.setAccessToken(accessToken);
UserResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
// Get the deviceid from the store if it exists, otherwise generate a new one
String deviceId =
Store.tryGet(StoreKey.deviceId) ?? await FlutterUdid.consistentUdid;
bool shouldChangePassword = false;
User? user;
bool retResult = false;
User? offlineUser = Store.tryGet(StoreKey.currentUser);
// If the user is offline and there is a user saved on the device,
// if not try an online login
if (offlineLogin && offlineUser != null) {
user = offlineUser;
retResult = false;
} else {
UserResponseDto? userResponseDto;
try {
userResponseDto = await _apiService.userApi.getMyUserInfo();
} on ApiException catch (e) {
if (e.innerException is SocketException) {
state = state.copyWith(isAuthenticated: true);
}
}
if (userResponseDto != null) {
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
shouldChangePassword = userResponseDto.shouldChangePassword;
user = User.fromDto(userResponseDto);
retResult = true;
}
else {
_log.severe("Unable to get user information from the server.");
return false;
}
}
if (userResponseDto != null) {
final deviceId = await FlutterUdid.consistentUdid;
Store.put(StoreKey.deviceId, deviceId);
Store.put(StoreKey.deviceIdHash, fastHash(deviceId));
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
Store.put(StoreKey.serverUrl, serverUrl);
Store.put(StoreKey.accessToken, accessToken);
state = state.copyWith(
isAuthenticated: true,
userId: user.id,
userEmail: user.email,
firstName: user.firstName,
lastName: user.lastName,
profileImagePath: user.profileImagePath,
isAdmin: user.isAdmin,
shouldChangePassword: shouldChangePassword,
deviceId: deviceId,
);
state = state.copyWith(
isAuthenticated: true,
userId: userResponseDto.id,
userEmail: userResponseDto.email,
firstName: userResponseDto.firstName,
lastName: userResponseDto.lastName,
profileImagePath: userResponseDto.profileImagePath,
isAdmin: userResponseDto.isAdmin,
shouldChangePassword: userResponseDto.shouldChangePassword,
deviceId: deviceId,
);
}
return true;
return retResult;
}
}

View File

@@ -36,6 +36,7 @@ class LoginForm extends HookConsumerWidget {
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final isPasswordLoginEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
final logoAnimationController = useAnimationController(
duration: const Duration(seconds: 60),
@@ -69,9 +70,11 @@ class LoginForm extends HookConsumerWidget {
if (loginConfig != null) {
isOauthEnable.value = loginConfig.enabled;
isPasswordLoginEnable.value = loginConfig.passwordLoginEnabled;
oAuthButtonLabel.value = loginConfig.buttonText ?? 'OAuth';
} else {
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
}
serverEndpoint.value = endpoint;
@@ -82,6 +85,7 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
return false;
} catch (e) {
@@ -91,6 +95,7 @@ class LoginForm extends HookConsumerWidget {
toastType: ToastType.error,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
return false;
}
@@ -262,18 +267,20 @@ class LoginForm extends HookConsumerWidget {
style: Theme.of(context).textTheme.displaySmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 18),
EmailInput(
controller: usernameController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(
controller: passwordController,
focusNode: passwordFocusNode,
onSubmit: login,
),
if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18),
EmailInput(
controller: usernameController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(
controller: passwordController,
focusNode: passwordFocusNode,
onSubmit: login,
),
],
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
@@ -295,19 +302,21 @@ class LoginForm extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
LoginButton(onPressed: login),
if (isPasswordLoginEnable.value)
LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
if (isPasswordLoginEnable.value)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
),
child: Divider(
color: Brightness.dark ==
Theme.of(context).brightness
? Colors.white
: Colors.black,
),
),
child: Divider(
color:
Brightness.dark == Theme.of(context).brightness
? Colors.white
: Colors.black,
),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
@@ -317,6 +326,10 @@ class LoginForm extends HookConsumerWidget {
],
],
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value)
Center(
child: const Text('login_disabled').tr(),
),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),

View File

@@ -5,7 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/memories/models/memory.dart';
import 'package:immich_mobile/modules/memories/ui/memory_card.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:intl/intl.dart';
import 'package:openapi/api.dart' as api;
class MemoryPage extends HookConsumerWidget {
final List<Memory> memories;
@@ -22,6 +25,7 @@ class MemoryPage extends HookConsumerWidget {
final memoryPageController = usePageController(initialPage: memoryIndex);
final memoryAssetPageController = usePageController();
final currentMemory = useState(memories[memoryIndex]);
final previousMemoryIndex = useState(memoryIndex);
final currentAssetPage = useState(0);
final assetProgress = useState(
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
@@ -36,11 +40,16 @@ class MemoryPage extends HookConsumerWidget {
}
toNextAsset(int currentAssetIndex) {
(currentAssetIndex + 1 < currentMemory.value.assets.length)
? memoryAssetPageController.jumpToPage(
(currentAssetIndex + 1),
)
: toNextMemory();
if (currentAssetIndex + 1 < currentMemory.value.assets.length) {
// Go to the next asset
memoryAssetPageController.nextPage(
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 500),
);
} else {
// Go to the next memory since we are at the end of our assets
toNextMemory();
}
}
updateProgressText() {
@@ -48,21 +57,71 @@ class MemoryPage extends HookConsumerWidget {
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
}
onMemoryChanged(int otherIndex) {
HapticFeedback.mediumImpact();
currentMemory.value = memories[otherIndex];
currentAssetPage.value = 0;
updateProgressText();
/// Downloads and caches the image for the asset at this [currentMemory]'s index
precacheAsset(int index) async {
// Guard index out of range
if (index < 0) {
return;
}
late Asset asset;
if (index < currentMemory.value.assets.length) {
// Uses the next asset in this current memory
asset = currentMemory.value.assets[index];
} else {
// Precache the first asset in the next memory if available
final currentMemoryIndex = memories.indexOf(currentMemory.value);
// Guard no memory found
if (currentMemoryIndex == -1) {
return;
}
final nextMemoryIndex = currentMemoryIndex + 1;
// Guard no next memory
if (nextMemoryIndex >= memories.length) {
return;
}
// Get the first asset from the next memory
asset = memories[nextMemoryIndex].assets.first;
}
// Gets the thumbnail url and precaches it
final precaches = <Future<dynamic>>[];
precaches.add(
ImmichImage.precacheAsset(
asset,
context,
type: api.ThumbnailFormat.WEBP,
),
);
precaches.add(
ImmichImage.precacheAsset(
asset,
context,
type: api.ThumbnailFormat.JPEG,
),
);
await Future.wait(precaches);
}
// Precache the next page right away if we are on the first page
if (currentAssetPage.value == 0) {
Future.delayed(const Duration(milliseconds: 200))
.then((_) => precacheAsset(1));
}
onAssetChanged(int otherIndex) {
HapticFeedback.selectionClick();
currentAssetPage.value = otherIndex;
precacheAsset(otherIndex + 1);
updateProgressText();
}
buildBottomInfo() {
buildBottomInfo(Memory memory) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
@@ -71,7 +130,7 @@ class MemoryPage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentMemory.value.title,
memory.title,
style: TextStyle(
color: Colors.grey[400],
fontSize: 11.0,
@@ -80,7 +139,7 @@ class MemoryPage extends HookConsumerWidget {
),
Text(
DateFormat.yMMMMd().format(
currentMemory.value.assets[0].fileCreatedAt,
memory.assets[0].fileCreatedAt,
),
style: const TextStyle(
color: Colors.white,
@@ -95,44 +154,66 @@ class MemoryPage extends HookConsumerWidget {
);
}
return Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: PageView.builder(
scrollDirection: Axis.vertical,
controller: memoryPageController,
onPageChanged: onMemoryChanged,
itemCount: memories.length,
itemBuilder: (context, mIndex) {
// Build horizontal page
return Column(
children: [
Expanded(
child: PageView.builder(
controller: memoryAssetPageController,
onPageChanged: onAssetChanged,
scrollDirection: Axis.horizontal,
itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index];
return Container(
color: Colors.black,
child: MemoryCard(
asset: asset,
onTap: () => toNextAsset(index),
onClose: () => AutoRouter.of(context).pop(),
rightCornerText: assetProgress.value,
title: memories[mIndex].title,
showTitle: index == 0,
),
);
},
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
* when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
* page during the end of scroll is different than the current page
*/
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
if (notification.depth == 0) {
var currentPageNumber = memoryPageController.page!.toInt();
currentMemory.value = memories[currentPageNumber];
if (notification is ScrollStartNotification) {
assetProgress.value = "";
} else if (notification is ScrollEndNotification) {
HapticFeedback.mediumImpact();
if (currentPageNumber != previousMemoryIndex.value) {
currentAssetPage.value = 0;
previousMemoryIndex.value = currentPageNumber;
}
updateProgressText();
}
}
return false;
},
child: Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: PageView.builder(
scrollDirection: Axis.vertical,
controller: memoryPageController,
itemCount: memories.length,
itemBuilder: (context, mIndex) {
// Build horizontal page
return Column(
children: [
Expanded(
child: PageView.builder(
controller: memoryAssetPageController,
onPageChanged: onAssetChanged,
scrollDirection: Axis.horizontal,
itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index];
return Container(
color: Colors.black,
child: MemoryCard(
asset: asset,
onTap: () => toNextAsset(index),
onClose: () => AutoRouter.of(context).pop(),
rightCornerText: assetProgress.value,
title: memories[mIndex].title,
showTitle: index == 0,
),
);
},
),
),
),
buildBottomInfo(),
],
);
},
buildBottomInfo(memories[mIndex]),
],
);
},
),
),
),
);

View File

@@ -4,29 +4,40 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class AuthGuard extends AutoRouteGuard {
final ApiService _apiService;
final _log = Logger("AuthGuard");
AuthGuard(this._apiService);
@override
void onNavigation(NavigationResolver resolver, StackRouter router) async {
resolver.next(true);
try {
var res = await _apiService.authenticationApi.validateAccessToken();
if (res != null && res.authStatus) {
resolver.next(true);
} else {
if (res == null || res.authStatus != true) {
// If the access token is invalid, take user back to login
_log.fine("User token is invalid. Redirecting to login");
router.replaceAll([const LoginRoute()]);
}
} on ApiException catch (e) {
if (e.code == HttpStatus.badRequest &&
e.innerException is SocketException) {
// offline?
resolver.next(true);
_log.fine(
"Unable to validate user token. User may be offline and offline browsing is allowed.",
);
} else {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]);
return;
}
} catch (e) {
debugPrint("Error [onNavigation] ${e.toString()}");
router.replaceAll([const LoginRoute()]);
return;
}
}

View File

@@ -16,6 +16,7 @@ class User {
required this.isAdmin,
this.isPartnerSharedBy = false,
this.isPartnerSharedWith = false,
this.profileImagePath = '',
});
Id get isarId => fastHash(id);
@@ -28,6 +29,7 @@ class User {
lastName = dto.lastName,
isPartnerSharedBy = false,
isPartnerSharedWith = false,
profileImagePath = dto.profileImagePath,
isAdmin = dto.isAdmin;
@Index(unique: true, replace: false, type: IndexType.hash)
@@ -39,6 +41,7 @@ class User {
bool isPartnerSharedBy;
bool isPartnerSharedWith;
bool isAdmin;
String profileImagePath;
@Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@Backlink(to: 'sharedUsers')
@@ -54,6 +57,7 @@ class User {
lastName == other.lastName &&
isPartnerSharedBy == other.isPartnerSharedBy &&
isPartnerSharedWith == other.isPartnerSharedWith &&
profileImagePath == other.profileImagePath &&
isAdmin == other.isAdmin;
}
@@ -67,5 +71,6 @@ class User {
lastName.hashCode ^
isPartnerSharedBy.hashCode ^
isPartnerSharedWith.hashCode ^
profileImagePath.hashCode ^
isAdmin.hashCode;
}

View File

@@ -1,4 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/store.dart';
@@ -62,6 +64,10 @@ class ApiService {
Future<String> _resolveEndpoint(String serverUrl) async {
final url = sanitizeUrl(serverUrl);
if (!await _isEndpointAvailable(serverUrl)) {
throw ApiException(503, "Server is not reachable");
}
// Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint;
@@ -70,6 +76,29 @@ class ApiService {
return url;
}
Future<bool> _isEndpointAvailable(String serverUrl) async {
final Client client = Client();
if (!serverUrl.endsWith('/api')) {
serverUrl += '/api';
}
// Throw Socket or Timeout exceptions,
// we do not care if the endpoints hits an HTTP error
try {
await client
.get(
Uri.parse(serverUrl),
)
.timeout(const Duration(seconds: 5));
} on TimeoutException catch (_) {
return false;
} on SocketException catch (_) {
return false;
}
return true;
}
Future<String> _getWellKnownEndpoint(String baseUrl) async {
final Client client = Client();

View File

@@ -16,11 +16,13 @@ class ImmichImage extends StatelessWidget {
this.height,
this.fit = BoxFit.cover,
this.useGrayBoxPlaceholder = false,
this.useProgressIndicator = false,
this.type = api.ThumbnailFormat.WEBP,
super.key,
});
final Asset? asset;
final bool useGrayBoxPlaceholder;
final bool useProgressIndicator;
final double? width;
final double? height;
final BoxFit fit;
@@ -58,17 +60,23 @@ class ImmichImage extends StatelessWidget {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return (useGrayBoxPlaceholder
? const SizedBox.square(
// Show loading if desired
return Stack(
children: [
if (useGrayBoxPlaceholder)
const SizedBox.square(
dimension: 250,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
),
)
: Transform.scale(
scale: 0.2,
child: const CircularProgressIndicator(),
));
),
if (useProgressIndicator)
const Center(
child: CircularProgressIndicator(),
),
],
);
},
errorBuilder: (context, error, stackTrace) {
if (error is PlatformException &&
@@ -102,16 +110,27 @@ class ImmichImage extends StatelessWidget {
fit: fit,
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator.adaptive(
value: downloadProgress.progress,
),
// Show loading if desired
return Stack(
children: [
if (useGrayBoxPlaceholder)
const SizedBox.square(
dimension: 250,
child: DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
),
),
if (useProgressIndicator)
Transform.scale(
scale: 2,
child: Center(
child: CircularProgressIndicator.adaptive(
strokeWidth: 1,
value: downloadProgress.progress,
),
),
),
],
);
},
errorWidget: (context, url, error) {
@@ -128,4 +147,46 @@ class ImmichImage extends StatelessWidget {
},
);
}
/// Precaches this asset for instant load the next time it is shown
static Future<void> precacheAsset(
Asset asset,
BuildContext context, {
type = api.ThumbnailFormat.WEBP,
}) {
final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
if (type == api.ThumbnailFormat.WEBP) {
final thumbnailUrl = getThumbnailUrl(asset);
final thumbnailCacheKey = getThumbnailCacheKey(asset);
final thumbnailProvider = CachedNetworkImageProvider(
thumbnailUrl,
cacheKey: thumbnailCacheKey,
headers: {"Authorization": authToken},
);
return precacheImage(thumbnailProvider, context);
}
// Precache the local image
if (!asset.isRemote &&
(asset.isLocal || !Store.get(StoreKey.preferRemoteImage, false))) {
final provider = AssetEntityImageProvider(
asset.local!,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
);
return precacheImage(provider, context);
} else {
// Precache the remote image since we are not using local images
final url = getThumbnailUrl(asset, type: api.ThumbnailFormat.JPEG);
final cacheKey =
getThumbnailCacheKey(asset, type: api.ThumbnailFormat.JPEG);
final provider = CachedNetworkImageProvider(
url,
cacheKey: cacheKey,
headers: {"Authorization": authToken},
);
return precacheImage(provider, context);
}
}
}

View File

@@ -8,6 +8,8 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class SplashScreenPage extends HookConsumerWidget {
const SplashScreenPage({Key? key}) : super(key: key);
@@ -17,24 +19,41 @@ class SplashScreenPage extends HookConsumerWidget {
final apiService = ref.watch(apiServiceProvider);
final serverUrl = Store.tryGet(StoreKey.serverUrl);
final accessToken = Store.tryGet(StoreKey.accessToken);
final log = Logger("SplashScreenPage");
void performLoggingIn() async {
bool isSuccess = false;
bool deviceIsOffline = false;
if (accessToken != null && serverUrl != null) {
try {
// Resolve API server endpoint from user provided serverUrl
await apiService.resolveAndSetEndpoint(serverUrl);
} catch (e) {
} on ApiException catch (e) {
// okay, try to continue anyway if offline
if (e.code == 503) {
deviceIsOffline = true;
log.fine("Device seems to be offline upon launch");
} else {
log.severe(e);
}
} catch (e) {
log.severe(e);
}
isSuccess =
await ref.read(authenticationProvider.notifier).setSuccessLoginInfo(
accessToken: accessToken,
serverUrl: serverUrl,
offlineLogin: deviceIsOffline,
);
}
if (isSuccess) {
// If the device is offline and there is a currentUser stored locallly
// Proceed into the app
if (deviceIsOffline && Store.tryGet(StoreKey.currentUser) != null) {
AutoRouter.of(context).replace(const TabControllerRoute());
} else if (isSuccess) {
// If device was able to login through the internet successfully
final hasPermission =
await ref.read(galleryPermissionNotifier.notifier).hasPermission;
if (hasPermission) {
@@ -43,6 +62,7 @@ class SplashScreenPage extends HookConsumerWidget {
}
AutoRouter.of(context).replace(const TabControllerRoute());
} else {
// User was unable to login through either offline or online methods
AutoRouter.of(context).replace(const LoginRoute());
}
}

View File

@@ -72,6 +72,8 @@ doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/PartnerApi.md
doc/PeopleResponseDto.md
doc/PeopleUpdateDto.md
doc/PeopleUpdateItem.md
doc/PersonApi.md
doc/PersonResponseDto.md
doc/PersonUpdateDto.md
@@ -210,6 +212,8 @@ lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
lib/model/people_response_dto.dart
lib/model/people_update_dto.dart
lib/model/people_update_item.dart
lib/model/person_response_dto.dart
lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart
@@ -325,6 +329,8 @@ test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/partner_api_test.dart
test/people_response_dto_test.dart
test/people_update_dto_test.dart
test/people_update_item_test.dart
test/person_api_test.dart
test/person_response_dto_test.dart
test/person_update_dto_test.dart

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.68.0
- API version: 1.71.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -134,6 +134,7 @@ Class | Method | HTTP request | Description
*PersonApi* | [**getPersonAssets**](doc//PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
*PersonApi* | [**getPersonThumbnail**](doc//PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
*PersonApi* | [**mergePerson**](doc//PersonApi.md#mergeperson) | **POST** /person/{id}/merge |
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person |
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} |
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore |
*SearchApi* | [**getSearchConfig**](doc//SearchApi.md#getsearchconfig) | **GET** /search/config |
@@ -239,6 +240,8 @@ Class | Method | HTTP request | Description
- [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
- [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PeopleUpdateDto](doc//PeopleUpdateDto.md)
- [PeopleUpdateItem](doc//PeopleUpdateItem.md)
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)

View File

@@ -28,6 +28,7 @@ Name | Type | Description | Notes
**state** | **String** | | [optional]
**country** | **String** | | [optional]
**description** | **String** | | [optional]
**projectionType** | **String** | | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

15
mobile/openapi/doc/PeopleUpdateDto.md generated Normal file
View File

@@ -0,0 +1,15 @@
# openapi.model.PeopleUpdateDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**people** | [**List<PeopleUpdateItem>**](PeopleUpdateItem.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

18
mobile/openapi/doc/PeopleUpdateItem.md generated Normal file
View File

@@ -0,0 +1,18 @@
# openapi.model.PeopleUpdateItem
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**id** | **String** | Person id. |
**name** | **String** | Person name. | [optional]
**featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional]
**isHidden** | **bool** | Person visibility | [optional]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -14,6 +14,7 @@ Method | HTTP request | Description
[**getPersonAssets**](PersonApi.md#getpersonassets) | **GET** /person/{id}/assets |
[**getPersonThumbnail**](PersonApi.md#getpersonthumbnail) | **GET** /person/{id}/thumbnail |
[**mergePerson**](PersonApi.md#mergeperson) | **POST** /person/{id}/merge |
[**updatePeople**](PersonApi.md#updatepeople) | **PUT** /person |
[**updatePerson**](PersonApi.md#updateperson) | **PUT** /person/{id} |
@@ -294,6 +295,61 @@ Name | Type | Description | Notes
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updatePeople**
> List<BulkIdResponseDto> updatePeople(peopleUpdateDto)
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure API key authorization: cookie
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
// TODO Configure API key authorization: api_key
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PersonApi();
final peopleUpdateDto = PeopleUpdateDto(); // PeopleUpdateDto |
try {
final result = api_instance.updatePeople(peopleUpdateDto);
print(result);
} catch (e) {
print('Exception when calling PersonApi->updatePeople: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**peopleUpdateDto** | [**PeopleUpdateDto**](PeopleUpdateDto.md)| |
### Return type
[**List<BulkIdResponseDto>**](BulkIdResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **Accept**: application/json
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **updatePerson**
> PersonResponseDto updatePerson(id, personUpdateDto)

View File

@@ -105,6 +105,8 @@ part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart';
part 'model/o_auth_config_response_dto.dart';
part 'model/people_response_dto.dart';
part 'model/people_update_dto.dart';
part 'model/people_update_item.dart';
part 'model/person_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/queue_status_dto.dart';

View File

@@ -269,6 +269,56 @@ class PersonApi {
return null;
}
/// Performs an HTTP 'PUT /person' operation and returns the [Response].
/// Parameters:
///
/// * [PeopleUpdateDto] peopleUpdateDto (required):
Future<Response> updatePeopleWithHttpInfo(PeopleUpdateDto peopleUpdateDto,) async {
// ignore: prefer_const_declarations
final path = r'/person';
// ignore: prefer_final_locals
Object? postBody = peopleUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [PeopleUpdateDto] peopleUpdateDto (required):
Future<List<BulkIdResponseDto>?> updatePeople(PeopleUpdateDto peopleUpdateDto,) async {
final response = await updatePeopleWithHttpInfo(peopleUpdateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<BulkIdResponseDto>') as List)
.cast<BulkIdResponseDto>()
.toList();
}
return null;
}
/// Performs an HTTP 'PUT /person/{id}' operation and returns the [Response].
/// Parameters:
///

View File

@@ -305,6 +305,10 @@ class ApiClient {
return OAuthConfigResponseDto.fromJson(value);
case 'PeopleResponseDto':
return PeopleResponseDto.fromJson(value);
case 'PeopleUpdateDto':
return PeopleUpdateDto.fromJson(value);
case 'PeopleUpdateItem':
return PeopleUpdateItem.fromJson(value);
case 'PersonResponseDto':
return PersonResponseDto.fromJson(value);
case 'PersonUpdateDto':

View File

@@ -33,6 +33,7 @@ class ExifResponseDto {
this.state,
this.country,
this.description,
this.projectionType,
});
int? fileSizeInByte;
@@ -75,6 +76,8 @@ class ExifResponseDto {
String? description;
String? projectionType;
@override
bool operator ==(Object other) => identical(this, other) || other is ExifResponseDto &&
other.fileSizeInByte == fileSizeInByte &&
@@ -96,7 +99,8 @@ class ExifResponseDto {
other.city == city &&
other.state == state &&
other.country == country &&
other.description == description;
other.description == description &&
other.projectionType == projectionType;
@override
int get hashCode =>
@@ -120,10 +124,11 @@ class ExifResponseDto {
(city == null ? 0 : city!.hashCode) +
(state == null ? 0 : state!.hashCode) +
(country == null ? 0 : country!.hashCode) +
(description == null ? 0 : description!.hashCode);
(description == null ? 0 : description!.hashCode) +
(projectionType == null ? 0 : projectionType!.hashCode);
@override
String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, description=$description]';
String toString() => 'ExifResponseDto[fileSizeInByte=$fileSizeInByte, make=$make, model=$model, exifImageWidth=$exifImageWidth, exifImageHeight=$exifImageHeight, orientation=$orientation, dateTimeOriginal=$dateTimeOriginal, modifyDate=$modifyDate, timeZone=$timeZone, lensModel=$lensModel, fNumber=$fNumber, focalLength=$focalLength, iso=$iso, exposureTime=$exposureTime, latitude=$latitude, longitude=$longitude, city=$city, state=$state, country=$country, description=$description, projectionType=$projectionType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -227,6 +232,11 @@ class ExifResponseDto {
} else {
// json[r'description'] = null;
}
if (this.projectionType != null) {
json[r'projectionType'] = this.projectionType;
} else {
// json[r'projectionType'] = null;
}
return json;
}
@@ -272,6 +282,7 @@ class ExifResponseDto {
state: mapValueOfType<String>(json, r'state'),
country: mapValueOfType<String>(json, r'country'),
description: mapValueOfType<String>(json, r'description'),
projectionType: mapValueOfType<String>(json, r'projectionType'),
);
}
return null;

View File

@@ -0,0 +1,98 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PeopleUpdateDto {
/// Returns a new [PeopleUpdateDto] instance.
PeopleUpdateDto({
this.people = const [],
});
List<PeopleUpdateItem> people;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateDto &&
other.people == people;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(people.hashCode);
@override
String toString() => 'PeopleUpdateDto[people=$people]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'people'] = this.people;
return json;
}
/// Returns a new [PeopleUpdateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PeopleUpdateDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PeopleUpdateDto(
people: PeopleUpdateItem.listFromJson(json[r'people']),
);
}
return null;
}
static List<PeopleUpdateDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PeopleUpdateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PeopleUpdateDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PeopleUpdateDto> mapFromJson(dynamic json) {
final map = <String, PeopleUpdateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PeopleUpdateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PeopleUpdateDto-objects as value to a dart map
static Map<String, List<PeopleUpdateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PeopleUpdateDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PeopleUpdateDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'people',
};
}

View File

@@ -0,0 +1,153 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class PeopleUpdateItem {
/// Returns a new [PeopleUpdateItem] instance.
PeopleUpdateItem({
required this.id,
this.name,
this.featureFaceAssetId,
this.isHidden,
});
/// Person id.
String id;
/// Person name.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? name;
/// Asset is used to get the feature face thumbnail.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? featureFaceAssetId;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isHidden;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem &&
other.id == id &&
other.name == name &&
other.featureFaceAssetId == featureFaceAssetId &&
other.isHidden == isHidden;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(name == null ? 0 : name!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode);
@override
String toString() => 'PeopleUpdateItem[id=$id, name=$name, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
if (this.name != null) {
json[r'name'] = this.name;
} else {
// json[r'name'] = null;
}
if (this.featureFaceAssetId != null) {
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
} else {
// json[r'featureFaceAssetId'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
// json[r'isHidden'] = null;
}
return json;
}
/// Returns a new [PeopleUpdateItem] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PeopleUpdateItem? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PeopleUpdateItem(
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name'),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
);
}
return null;
}
static List<PeopleUpdateItem> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PeopleUpdateItem>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PeopleUpdateItem.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PeopleUpdateItem> mapFromJson(dynamic json) {
final map = <String, PeopleUpdateItem>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PeopleUpdateItem.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PeopleUpdateItem-objects as value to a dart map
static Map<String, List<PeopleUpdateItem>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PeopleUpdateItem>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PeopleUpdateItem.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'id',
};
}

View File

@@ -116,6 +116,11 @@ void main() {
// TODO
});
// String projectionType
test('to test the property `projectionType`', () async {
// TODO
});
});

View File

@@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for PeopleUpdateDto
void main() {
// final instance = PeopleUpdateDto();
group('test PeopleUpdateDto', () {
// List<PeopleUpdateItem> people (default value: const [])
test('to test the property `people`', () async {
// TODO
});
});
}

View File

@@ -0,0 +1,46 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for PeopleUpdateItem
void main() {
// final instance = PeopleUpdateItem();
group('test PeopleUpdateItem', () {
// Person id.
// String id
test('to test the property `id`', () async {
// TODO
});
// Person name.
// String name
test('to test the property `name`', () async {
// TODO
});
// Asset is used to get the feature face thumbnail.
// String featureFaceAssetId
test('to test the property `featureFaceAssetId`', () async {
// TODO
});
// Person visibility
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
});
}

View File

@@ -42,6 +42,11 @@ void main() {
// TODO
});
//Future<List<BulkIdResponseDto>> updatePeople(PeopleUpdateDto peopleUpdateDto) async
test('test updatePeople', () async {
// TODO
});
//Future<PersonResponseDto> updatePerson(String id, PersonUpdateDto personUpdateDto) async
test('test updatePerson', () async {
// TODO

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.68.0+91
version: 1.71.0+94
isar_version: &isar_version 3.1.0+1
environment:

View File

@@ -22,7 +22,7 @@ ENV NODE_ENV=production
WORKDIR /usr/src/app
RUN apk add --no-cache ffmpeg imagemagick-dev libraw-dev perl vips-dev vips-heif vips-jxl vips-magick
RUN apk add --no-cache ffmpeg imagemagick-dev libraw-dev perl tini vips-dev vips-heif vips-jxl vips-magick
COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist
@@ -39,4 +39,4 @@ VOLUME /usr/src/app/upload
EXPOSE 3001
ENTRYPOINT ["/bin/sh"]
ENTRYPOINT ["tini", "--", "/bin/sh"]

View File

@@ -2546,6 +2546,49 @@
"api_key": []
}
]
},
"put": {
"operationId": "updatePeople",
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PeopleUpdateDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BulkIdResponseDto"
}
}
}
}
}
},
"tags": [
"Person"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/person/{id}": {
@@ -4396,7 +4439,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.68.0",
"version": "1.71.0",
"contact": {}
},
"tags": [],
@@ -5028,13 +5071,13 @@
"type": "boolean"
},
"error": {
"type": "string",
"enum": [
"duplicate",
"no_permission",
"not_found",
"unknown"
]
],
"type": "string"
}
},
"required": [
@@ -5510,6 +5553,11 @@
"type": "string",
"nullable": true,
"default": null
},
"projectionType": {
"type": "string",
"nullable": true,
"default": null
}
}
},
@@ -5906,6 +5954,44 @@
"people"
]
},
"PeopleUpdateDto": {
"type": "object",
"properties": {
"people": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PeopleUpdateItem"
}
}
},
"required": [
"people"
]
},
"PeopleUpdateItem": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "Person id."
},
"name": {
"type": "string",
"description": "Person name."
},
"featureFaceAssetId": {
"type": "string",
"description": "Asset is used to get the feature face thumbnail."
},
"isHidden": {
"type": "boolean",
"description": "Person visibility"
}
},
"required": [
"id"
]
},
"PersonResponseDto": {
"type": "object",
"properties": {

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.68.0",
"version": "1.71.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.68.0",
"version": "1.71.0",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",
@@ -28,12 +28,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "^19.0.0",
"exiftool-vendored": "^22.0.0",
"exiftool-vendored.pl": "^12.54.0",
"fluent-ffmpeg": "^2.1.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"immich": "^0.39.0",
"immich": "^0.40.1",
"ioredis": "^5.3.1",
"joi": "^17.5.0",
"local-reverse-geocoder": "0.12.5",
@@ -4064,9 +4064,9 @@
}
},
"node_modules/batch-cluster": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-11.0.0.tgz",
"integrity": "sha512-8iwqa+rKTaakOHkqdcXDT5L5117pa+FoP8/yAKpNdL44ZnC4V2NEA/sIg0ZO0O9NkpdjLk0A3efRFM5nVizqHw==",
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
"integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg==",
"engines": {
"node": ">=14"
}
@@ -5976,25 +5976,25 @@
}
},
"node_modules/exiftool-vendored": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-19.0.0.tgz",
"integrity": "sha512-Zes7TZrYWxts92mbF2Gs3drtWZucm4qsaeYaE6A+OOqmeD9UGaGisqIbyh9MilJrLi+ZHzWEJZtDj37QFf6xsA==",
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-22.0.0.tgz",
"integrity": "sha512-gBOQ4C2GLjxKPDPRuUbMOz91mG6IFA22L+Z/IQzFotFu20vc7YroqHALf/ophCbANA5sNSArbVDPijP7n/20Jg==",
"dependencies": {
"@photostructure/tz-lookup": "^7.0.0",
"@types/luxon": "^3.2.0",
"batch-cluster": "^11.0.0",
"@types/luxon": "^3.3.0",
"batch-cluster": "^12.1.0",
"he": "^1.2.0",
"luxon": "^3.2.1"
"luxon": "^3.3.0"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.54.0",
"exiftool-vendored.pl": "12.54.0"
"exiftool-vendored.exe": "12.62.0",
"exiftool-vendored.pl": "12.62.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.54.0.tgz",
"integrity": "sha512-Dc4W6e0NtQfYuJIYK4piHfDJnd2jvA04e0aaq9R3Q1oO34KC5e+L1D2C7lFuZXqPQLYC1x3GYc/GVv5e+SkkrQ==",
"version": "12.62.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.62.0.tgz",
"integrity": "sha512-xNFkvmjwnMg6ivtmkc67w1qD23fIy06nRpMpGuBpTwTqAVatHV+vk7T75zyvLoXRRpd1rKID9XAVLGJCE/iiMQ==",
"optional": true,
"os": [
"win32"
@@ -6008,15 +6008,6 @@
"!win32"
]
},
"node_modules/exiftool-vendored/node_modules/exiftool-vendored.pl": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.54.0.tgz",
"integrity": "sha512-RBBowsYcM6EvbWoBkg2dOqHpH3WIzN7bIzHc+o+LquqCTo3doZwECClD/6PNHVSMQsl2Z0fEf75sNq2msooMSg==",
"optional": true,
"os": [
"!win32"
]
},
"node_modules/exit": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
@@ -7003,9 +6994,9 @@
}
},
"node_modules/immich": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz",
"integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==",
"version": "0.40.1",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.40.1.tgz",
"integrity": "sha512-pU0Ua+FAsOiqrPC8NbSA521QW0k56Sw0GZ5rrPyqEMb2dcYPDOqEFcEk/1INqoQpPxy+CF9ZOCHNWxsEc7L1Rw==",
"dependencies": {
"axios": "^0.26.0",
"chalk": "^2.4.1",
@@ -15584,9 +15575,9 @@
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog=="
},
"batch-cluster": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-11.0.0.tgz",
"integrity": "sha512-8iwqa+rKTaakOHkqdcXDT5L5117pa+FoP8/yAKpNdL44ZnC4V2NEA/sIg0ZO0O9NkpdjLk0A3efRFM5nVizqHw=="
"version": "12.1.0",
"resolved": "https://registry.npmjs.org/batch-cluster/-/batch-cluster-12.1.0.tgz",
"integrity": "sha512-whGyJU4tr7kyg2USByu0/51mML5HsLAeNz5s03kMDYZNsQsGgDJgI47RdY3r7MciCjPkTaTD5O4eOVqOfEO7pg=="
},
"bcrypt": {
"version": "5.1.0",
@@ -17011,31 +17002,23 @@
}
},
"exiftool-vendored": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-19.0.0.tgz",
"integrity": "sha512-Zes7TZrYWxts92mbF2Gs3drtWZucm4qsaeYaE6A+OOqmeD9UGaGisqIbyh9MilJrLi+ZHzWEJZtDj37QFf6xsA==",
"version": "22.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-22.0.0.tgz",
"integrity": "sha512-gBOQ4C2GLjxKPDPRuUbMOz91mG6IFA22L+Z/IQzFotFu20vc7YroqHALf/ophCbANA5sNSArbVDPijP7n/20Jg==",
"requires": {
"@photostructure/tz-lookup": "^7.0.0",
"@types/luxon": "^3.2.0",
"batch-cluster": "^11.0.0",
"exiftool-vendored.exe": "12.54.0",
"exiftool-vendored.pl": "12.54.0",
"@types/luxon": "^3.3.0",
"batch-cluster": "^12.1.0",
"exiftool-vendored.exe": "12.62.0",
"exiftool-vendored.pl": "12.62.0",
"he": "^1.2.0",
"luxon": "^3.2.1"
},
"dependencies": {
"exiftool-vendored.pl": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.54.0.tgz",
"integrity": "sha512-RBBowsYcM6EvbWoBkg2dOqHpH3WIzN7bIzHc+o+LquqCTo3doZwECClD/6PNHVSMQsl2Z0fEf75sNq2msooMSg==",
"optional": true
}
"luxon": "^3.3.0"
}
},
"exiftool-vendored.exe": {
"version": "12.54.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.54.0.tgz",
"integrity": "sha512-Dc4W6e0NtQfYuJIYK4piHfDJnd2jvA04e0aaq9R3Q1oO34KC5e+L1D2C7lFuZXqPQLYC1x3GYc/GVv5e+SkkrQ==",
"version": "12.62.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.62.0.tgz",
"integrity": "sha512-xNFkvmjwnMg6ivtmkc67w1qD23fIy06nRpMpGuBpTwTqAVatHV+vk7T75zyvLoXRRpd1rKID9XAVLGJCE/iiMQ==",
"optional": true
},
"exiftool-vendored.pl": {
@@ -17786,9 +17769,9 @@
"dev": true
},
"immich": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.39.0.tgz",
"integrity": "sha512-FoIj/ZV7QrjuBC7F6o6YZ8jqLZDJCZwrr80CxkzERPI7qX8YrSjR1GM4ocA/9oT7p7iA+dIxT//BF5MKNPkn4g==",
"version": "0.40.1",
"resolved": "https://registry.npmjs.org/immich/-/immich-0.40.1.tgz",
"integrity": "sha512-pU0Ua+FAsOiqrPC8NbSA521QW0k56Sw0GZ5rrPyqEMb2dcYPDOqEFcEk/1INqoQpPxy+CF9ZOCHNWxsEc7L1Rw==",
"requires": {
"axios": "^0.26.0",
"chalk": "^2.4.1",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.68.0",
"version": "1.71.0",
"description": "",
"author": "",
"private": true,
@@ -58,12 +58,12 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"exiftool-vendored": "^19.0.0",
"exiftool-vendored": "^22.0.0",
"exiftool-vendored.pl": "^12.54.0",
"fluent-ffmpeg": "^2.1.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"immich": "^0.39.0",
"immich": "^0.40.1",
"ioredis": "^5.3.1",
"joi": "^17.5.0",
"local-reverse-geocoder": "0.12.5",

View File

@@ -11,6 +11,7 @@ export interface AssetStatsOptions {
export interface AssetSearchOptions {
isVisible?: boolean;
type?: AssetType;
order?: 'ASC' | 'DESC';
}
export interface LivePhotoSearchOptions {

View File

@@ -24,6 +24,7 @@ export class ExifResponseDto {
state?: string | null = null;
country?: string | null = null;
description?: string | null = null;
projectionType?: string | null = null;
}
export function mapExif(entity: ExifEntity): ExifResponseDto {
@@ -48,5 +49,6 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
state: entity.state,
country: entity.country,
description: entity.description,
projectionType: entity.projectionType,
};
}

View File

@@ -29,7 +29,7 @@ export class FacialRecognitionService {
async handleQueueRecognizeFaces({ force }: IBaseJob) {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination)
? this.assetRepository.getAll(pagination, { order: 'DESC' })
: this.assetRepository.getWithout(pagination, WithoutProperty.FACES);
});

View File

@@ -172,6 +172,8 @@ export class JobService {
if (asset) {
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: item.data });
} else if (asset.livePhotoVideoId) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.livePhotoVideoId } });
}
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
}

View File

@@ -1,6 +1,6 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { Transform, Type } from 'class-transformer';
import { IsArray, IsBoolean, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
import { toBoolean, ValidateUUID } from '../domain.util';
export class PersonUpdateDto {
@@ -26,6 +26,43 @@ export class PersonUpdateDto {
isHidden?: boolean;
}
export class PeopleUpdateDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => PeopleUpdateItem)
people!: PeopleUpdateItem[];
}
export class PeopleUpdateItem {
/**
* Person id.
*/
@IsString()
@IsNotEmpty()
id!: string;
/**
* Person name.
*/
@IsOptional()
@IsString()
name?: string;
/**
* Asset is used to get the feature face thumbnail.
*/
@IsOptional()
@IsString()
featureFaceAssetId?: string;
/**
* Person visibility
*/
@IsOptional()
@IsBoolean()
isHidden?: boolean;
}
export class MergePersonDto {
@ValidateUUID({ each: true })
ids!: string[];

View File

@@ -188,6 +188,16 @@ describe(PersonService.name, () => {
});
});
describe('updateAll', () => {
it('should throw an error when personId is invalid', async () => {
personMock.getById.mockResolvedValue(null);
await expect(
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
expect(personMock.update).not.toHaveBeenCalled();
});
});
describe('handlePersonCleanup', () => {
it('should delete people without faces', async () => {
personMock.getAllWithoutFaces.mockResolvedValue([personStub.noName]);

View File

@@ -8,6 +8,7 @@ import {
mapPerson,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonResponseDto,
PersonSearchDto,
PersonUpdateDto,
@@ -96,6 +97,24 @@ export class PersonService {
return mapPerson(person);
}
async updatePeople(authUser: AuthUserDto, dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
const results: BulkIdResponseDto[] = [];
for (const person of dto.people) {
try {
await this.update(authUser, person.id, {
isHidden: person.isHidden,
name: person.name,
featureFaceAssetId: person.featureFaceAssetId,
}),
results.push({ id: person.id, success: true });
} catch (error: Error | any) {
this.logger.error(`Unable to update ${person.id} : ${error}`, error?.stack);
results.push({ id: person.id, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
async handlePersonCleanup() {
const people = await this.repository.getAllWithoutFaces();
for (const person of people) {
@@ -145,6 +164,9 @@ export class PersonService {
}
}
// Re-index all faces in typesense for up-to-date search results
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACES });
return results;
}

View File

@@ -199,6 +199,7 @@ export class SearchService {
if (!this.enabled) {
return false;
}
await this.searchRepository.deleteAllFaces();
// TODO: do this in batches based on searchIndexVersion
const faces = this.patchFaces(await this.faceRepository.getAll());

View File

@@ -36,7 +36,7 @@ export class AlbumRepository implements IAlbumRepository {
},
order: {
assets: {
fileCreatedAt: 'ASC',
fileCreatedAt: 'DESC',
},
},
});

View File

@@ -207,12 +207,13 @@ export class AssetService {
const allowExif = this.getExifPermission(authUser);
const asset = await this._assetRepository.getById(assetId);
const data = allowExif ? mapAsset(asset) : mapAssetWithoutExif(asset);
if (allowExif) {
return mapAsset(asset);
} else {
return mapAssetWithoutExif(asset);
if (data.ownerId !== authUser.id) {
data.people = [];
}
return data;
}
public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {

View File

@@ -5,6 +5,7 @@ import {
ImmichReadStream,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonResponseDto,
PersonSearchDto,
PersonService,
@@ -32,6 +33,11 @@ export class PersonController {
return this.service.getAll(authUser, withHidden);
}
@Put()
updatePeople(@AuthUser() authUser: AuthUserDto, @Body() dto: PeopleUpdateDto): Promise<BulkIdResponseDto[]> {
return this.service.updatePeople(authUser, dto);
}
@Get(':id')
getPerson(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<PersonResponseDto> {
return this.service.getById(authUser, id);

View File

@@ -31,5 +31,5 @@ export async function bootstrap() {
const server = await app.listen(port);
server.requestTimeout = 30 * 60 * 1000;
logger.log(`Immich Server is listening on ${port} [v${SERVER_VERSION}] [${envName}] `);
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${SERVER_VERSION}] [${envName}] `);
}

View File

@@ -43,6 +43,9 @@ export class ExifEntity {
@Column({ type: 'float', nullable: true })
longitude!: number | null;
@Column({ type: 'varchar', nullable: true })
projectionType!: string | null;
@Column({ type: 'varchar', nullable: true })
city!: string | null;

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class Panoramas1690217088596 implements MigrationInterface {
name = 'Panoramas1690217088596';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" ADD "projectionType" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "projectionType"`);
}
}

View File

@@ -116,7 +116,7 @@ export class AssetRepository implements IAssetRepository {
},
order: {
// Ensures correct order when paginating
createdAt: 'ASC',
createdAt: options.order ?? 'ASC',
},
});
}

View File

@@ -17,5 +17,5 @@ export async function bootstrap() {
await app.get(AppService).init();
await app.listen(port);
logger.log(`Immich Microservices is listening on ${port} [v${SERVER_VERSION}] [${envName}] `);
logger.log(`Immich Microservices is listening on ${await app.getUrl()} [v${SERVER_VERSION}] [${envName}] `);
}

View File

@@ -1,12 +1,16 @@
import {
IAssetRepository,
IBaseJob,
ICryptoRepository,
IEntityJob,
IGeocodingRepository,
IJobRepository,
IStorageRepository,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
QueueName,
StorageCore,
StorageFolder,
usePagination,
WithoutProperty,
} from '@app/domain';
@@ -19,6 +23,7 @@ import { exiftool, Tags } from 'exiftool-vendored';
import ffmpeg, { FfprobeData } from 'fluent-ffmpeg';
import { Duration } from 'luxon';
import fs from 'node:fs';
import path from 'node:path';
import sharp from 'sharp';
import { Repository } from 'typeorm/repository/Repository';
import { promisify } from 'util';
@@ -29,18 +34,35 @@ import { toNumberOrNull } from '../utils/numbers';
const ffprobe = promisify<string, FfprobeData>(ffmpeg.ffprobe);
interface DirectoryItem {
Length?: number;
Mime: string;
Padding?: number;
Semantic?: string;
}
interface DirectoryEntry {
Item: DirectoryItem;
}
interface ImmichTags extends Tags {
ContentIdentifier?: string;
MotionPhoto?: number;
MotionPhotoVersion?: number;
MotionPhotoPresentationTimestampUs?: number;
}
export class MetadataExtractionProcessor {
private logger = new Logger(MetadataExtractionProcessor.name);
private reverseGeocodingEnabled: boolean;
private storageCore = new StorageCore();
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IGeocodingRepository) private geocodingRepository: IGeocodingRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
configService: ConfigService,
@@ -100,6 +122,131 @@ export class MetadataExtractionProcessor {
}
}
async addExtractedLivePhoto(sourceAsset: AssetEntity, video: string, created: Date | null): Promise<AssetEntity> {
if (sourceAsset.livePhotoVideoId) {
const [liveAsset] = await this.assetRepository.getByIds([sourceAsset.livePhotoVideoId]);
// already exists so no need to generate ID.
if (liveAsset.originalPath == video) {
return liveAsset;
}
liveAsset.originalPath = video;
return this.assetRepository.save(liveAsset);
}
const liveAsset = await this.assetRepository.save({
ownerId: sourceAsset.ownerId,
owner: sourceAsset.owner,
checksum: await this.cryptoRepository.hashFile(video),
originalPath: video,
fileCreatedAt: created ?? sourceAsset.fileCreatedAt,
fileModifiedAt: sourceAsset.fileModifiedAt,
deviceAssetId: 'NONE',
deviceId: 'NONE',
type: AssetType.VIDEO,
isFavorite: false,
isArchived: sourceAsset.isArchived,
duration: null,
isVisible: false,
livePhotoVideo: null,
resizePath: null,
webpPath: null,
thumbhash: null,
encodedVideoPath: null,
tags: [],
sharedLinks: [],
originalFileName: path.parse(video).name,
faces: [],
sidecarPath: null,
isReadOnly: sourceAsset.isReadOnly,
});
sourceAsset.livePhotoVideoId = liveAsset.id;
await this.assetRepository.save(sourceAsset);
return liveAsset;
}
private async extractNewPixelLivePhoto(
asset: AssetEntity,
directory: DirectoryEntry[],
fileCreatedAt: Date | null,
): Promise<AssetEntity | null> {
if (asset.livePhotoVideoId) {
// Already extracted, don't try again.
const [ret] = await this.assetRepository.getByIds([asset.livePhotoVideoId]);
this.logger.log(`Already extracted asset ${ret.originalPath}.`);
return ret;
}
let foundMotionPhoto = false;
let motionPhotoOffsetFromEnd = 0;
let motionPhotoLength = 0;
// Look for the directory entry with semantic label "MotionPhoto", which is the embedded video.
// Then, determine the length from the end of the file to the start of the embedded video.
for (const entry of directory) {
if (entry.Item.Semantic == 'MotionPhoto') {
if (foundMotionPhoto) {
this.logger.error(`Asset ${asset.originalPath} has more than one motion photo.`);
continue;
}
foundMotionPhoto = true;
motionPhotoLength = entry.Item.Length ?? 0;
}
if (foundMotionPhoto) {
motionPhotoOffsetFromEnd += entry.Item.Length ?? 0;
motionPhotoOffsetFromEnd += entry.Item.Padding ?? 0;
}
}
if (!foundMotionPhoto || motionPhotoLength == 0) {
return null;
}
return this.extractEmbeddedVideo(asset, motionPhotoOffsetFromEnd, motionPhotoLength, fileCreatedAt);
}
private async extractEmbeddedVideo(
asset: AssetEntity,
offsetFromEnd: number,
length: number | null,
fileCreatedAt: Date | null,
) {
let file = null;
try {
file = await fs.promises.open(asset.originalPath);
let extracted = null;
// Read in embedded video.
const stat = await file.stat();
if (length == null) {
length = offsetFromEnd;
}
const offset = stat.size - offsetFromEnd;
extracted = await file.read({
buffer: Buffer.alloc(length),
position: offset,
length: length,
});
// Write out extracted video, and add it to the asset repository.
const encodedVideoFolder = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId);
this.storageRepository.mkdirSync(encodedVideoFolder);
const livePhotoPath = path.join(encodedVideoFolder, path.parse(asset.originalPath).name + '.mp4');
await fs.promises.writeFile(livePhotoPath, extracted.buffer);
const result = await this.addExtractedLivePhoto(asset, livePhotoPath, fileCreatedAt);
await this.handleMetadataExtraction({ id: result.id });
return result;
} catch (e) {
this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${e}`);
return null;
} finally {
if (file) {
await file.close();
}
}
}
private async handlePhotoMetadataExtraction(asset: AssetEntity) {
const mediaExifData = await exiftool.read<ImmichTags>(asset.originalPath).catch((error: any) => {
this.logger.warn(
@@ -163,6 +310,37 @@ export class MetadataExtractionProcessor {
const longitude = getExifProperty('GPSLongitude');
newExif.latitude = latitude !== null ? parseLatitude(latitude) : null;
newExif.longitude = longitude !== null ? parseLongitude(longitude) : null;
if (getExifProperty('MotionPhoto')) {
// Seen on more recent Pixel phones: starting as early as Pixel 4a, possibly earlier.
const rawDirectory = getExifProperty('Directory');
if (Array.isArray(rawDirectory)) {
// exiftool-vendor thinks directory is a string, but actually it's an array of DirectoryEntry.
const directory = rawDirectory as DirectoryEntry[];
await this.extractNewPixelLivePhoto(asset, directory, fileCreatedAt);
} else {
this.logger.warn(`Failed to get Pixel motionPhoto information: directory: ${JSON.stringify(rawDirectory)}`);
}
} else if (getExifProperty('MicroVideo')) {
// Seen on earlier Pixel phones - Pixel 2 and earlier, possibly Pixel 3.
let offset = getExifProperty('MicroVideoOffset'); // offset from end of file.
if (typeof offset == 'string') {
offset = parseInt(offset);
}
if (Number.isNaN(offset) || offset == null) {
this.logger.warn(
`Failed to get MicroVideo information for ${asset.originalPath}, offset=${getExifProperty(
'MicroVideoOffset',
)}`,
);
} else {
await this.extractEmbeddedVideo(asset, offset, null, fileCreatedAt);
}
}
const projectionType = getExifProperty('ProjectionType');
if (projectionType) {
newExif.projectionType = String(projectionType).toUpperCase();
}
newExif.livePhotoCID = getExifProperty('MediaGroupUUID');
if (newExif.livePhotoCID && !asset.livePhotoVideoId) {

View File

@@ -625,6 +625,7 @@ const assetInfo: ExifResponseDto = {
state: 'state',
country: 'country',
description: 'description',
projectionType: null,
};
const assetResponse: AssetResponseDto = {
@@ -882,6 +883,7 @@ export const sharedLinkStub = {
livePhotoVideoId: null,
originalFileName: 'asset_1.jpeg',
exifInfo: {
projectionType: null,
livePhotoCID: null,
assetId: 'id_1',
description: 'description',

View File

@@ -3,7 +3,7 @@ FROM node:18.16.0-alpine3.18@sha256:9036ddb8252ba7089c2c83eb2b0dcaf74ff1069e8ddf
WORKDIR /usr/src/app
EXPOSE 3000
RUN apk add --no-cache setpriv
RUN apk add --no-cache setpriv tini
FROM base as builder
@@ -39,6 +39,6 @@ COPY --from=prod /usr/src/app/build ./build
COPY package.json package-lock.json ./
COPY entrypoint.sh ./
ENTRYPOINT ["/bin/sh"]
ENTRYPOINT ["tini", "--", "/bin/sh"]
CMD ["entrypoint.sh"]

0
web/entrypoint.sh Normal file → Executable file
View File

103
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "immich-web",
"version": "1.0.0",
"dependencies": {
"@egjs/svelte-view360": "^4.0.0-beta.7",
"@zoom-image/svelte": "^0.1.8",
"axios": "^0.27.2",
"buffer": "^6.0.3",
@@ -1840,6 +1841,47 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"node_modules/@cfcs/core": {
"version": "0.0.24",
"resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.24.tgz",
"integrity": "sha512-feB38qu+eDk0Pggh/yR7gjaNmvUYA2uCxHP3Pz2MLE4LZ/9jPdtu8bzCSI47yTEhWyZCF5Pk698hdz8IN2mTjA==",
"dependencies": {
"@egjs/component": "^3.0.4"
}
},
"node_modules/@egjs/component": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.4.tgz",
"integrity": "sha512-sXA7bGbIeLF2OAw/vpka66c6QBBUPcA4UUhR4WGJfnp2XWdiI8QrnJGJMr/UxpE/xnevX9tN3jvNPlW8WkHl3g=="
},
"node_modules/@egjs/imready": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@egjs/imready/-/imready-1.4.1.tgz",
"integrity": "sha512-JIOBs4lB7FYdsKi5uvz2j3SObX8eShtZjtqlOH41tm185aJOQZwiKBK8+V4MxzG4X6DqVhpdN8UcuVwBbElfsg==",
"dependencies": {
"@cfcs/core": "^0.0.24",
"@egjs/component": "^3.0.1"
}
},
"node_modules/@egjs/svelte-view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/svelte-view360/-/svelte-view360-4.0.0-beta.7.tgz",
"integrity": "sha512-qFNbLNME8H7QU2lg8SCKUTPoBXVdBcM5m8zmlDRE72esCTguDzUq2szXD7L1JWcb2lYPTFl3HVp/sZlcQ/1HpQ==",
"dependencies": {
"@egjs/view360": "4.0.0-beta.7"
}
},
"node_modules/@egjs/view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/view360/-/view360-4.0.0-beta.7.tgz",
"integrity": "sha512-prVTTxuQ1/k59NM7G0tm58k2vPHGoaExoFr5E7MoJaSGF56Otj4okQHAxxosXH87aQLN0feZMtBlsKz0b/7zEw==",
"dependencies": {
"@egjs/component": "^3.0.2",
"@egjs/imready": "^1.3.0",
"@types/webxr": "^0.5.1",
"gl-matrix": "^3.4.3"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.17.19",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
@@ -3821,6 +3863,11 @@
"integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==",
"dev": true
},
"node_modules/@types/webxr": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.2.tgz",
"integrity": "sha512-szL74BnIcok9m7QwYtVmQ+EdIKwbjPANudfuvDrAF8Cljg9MKUlIoc1w5tjj9PMpeSH3U1Xnx//czQybJ0EfSw=="
},
"node_modules/@types/yargs": {
"version": "17.0.22",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz",
@@ -6290,6 +6337,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/gl-matrix": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
"integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
},
"node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
@@ -13334,6 +13386,47 @@
"integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
"dev": true
},
"@cfcs/core": {
"version": "0.0.24",
"resolved": "https://registry.npmjs.org/@cfcs/core/-/core-0.0.24.tgz",
"integrity": "sha512-feB38qu+eDk0Pggh/yR7gjaNmvUYA2uCxHP3Pz2MLE4LZ/9jPdtu8bzCSI47yTEhWyZCF5Pk698hdz8IN2mTjA==",
"requires": {
"@egjs/component": "^3.0.4"
}
},
"@egjs/component": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@egjs/component/-/component-3.0.4.tgz",
"integrity": "sha512-sXA7bGbIeLF2OAw/vpka66c6QBBUPcA4UUhR4WGJfnp2XWdiI8QrnJGJMr/UxpE/xnevX9tN3jvNPlW8WkHl3g=="
},
"@egjs/imready": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@egjs/imready/-/imready-1.4.1.tgz",
"integrity": "sha512-JIOBs4lB7FYdsKi5uvz2j3SObX8eShtZjtqlOH41tm185aJOQZwiKBK8+V4MxzG4X6DqVhpdN8UcuVwBbElfsg==",
"requires": {
"@cfcs/core": "^0.0.24",
"@egjs/component": "^3.0.1"
}
},
"@egjs/svelte-view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/svelte-view360/-/svelte-view360-4.0.0-beta.7.tgz",
"integrity": "sha512-qFNbLNME8H7QU2lg8SCKUTPoBXVdBcM5m8zmlDRE72esCTguDzUq2szXD7L1JWcb2lYPTFl3HVp/sZlcQ/1HpQ==",
"requires": {
"@egjs/view360": "4.0.0-beta.7"
}
},
"@egjs/view360": {
"version": "4.0.0-beta.7",
"resolved": "https://registry.npmjs.org/@egjs/view360/-/view360-4.0.0-beta.7.tgz",
"integrity": "sha512-prVTTxuQ1/k59NM7G0tm58k2vPHGoaExoFr5E7MoJaSGF56Otj4okQHAxxosXH87aQLN0feZMtBlsKz0b/7zEw==",
"requires": {
"@egjs/component": "^3.0.2",
"@egjs/imready": "^1.3.0",
"@types/webxr": "^0.5.1",
"gl-matrix": "^3.4.3"
}
},
"@esbuild/android-arm": {
"version": "0.17.19",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.19.tgz",
@@ -14748,6 +14841,11 @@
"integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==",
"dev": true
},
"@types/webxr": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.2.tgz",
"integrity": "sha512-szL74BnIcok9m7QwYtVmQ+EdIKwbjPANudfuvDrAF8Cljg9MKUlIoc1w5tjj9PMpeSH3U1Xnx//czQybJ0EfSw=="
},
"@types/yargs": {
"version": "17.0.22",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.22.tgz",
@@ -16510,6 +16608,11 @@
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"dev": true
},
"gl-matrix": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.3.tgz",
"integrity": "sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA=="
},
"glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",

View File

@@ -60,6 +60,7 @@
},
"type": "module",
"dependencies": {
"@egjs/svelte-view360": "^4.0.0-beta.7",
"@zoom-image/svelte": "^0.1.8",
"axios": "^0.27.2",
"buffer": "^6.0.3",

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.68.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -1304,6 +1304,12 @@ export interface ExifResponseDto {
* @memberof ExifResponseDto
*/
'description'?: string | null;
/**
*
* @type {string}
* @memberof ExifResponseDto
*/
'projectionType'?: string | null;
}
/**
*
@@ -1802,6 +1808,50 @@ export interface PeopleResponseDto {
*/
'people': Array<PersonResponseDto>;
}
/**
*
* @export
* @interface PeopleUpdateDto
*/
export interface PeopleUpdateDto {
/**
*
* @type {Array<PeopleUpdateItem>}
* @memberof PeopleUpdateDto
*/
'people': Array<PeopleUpdateItem>;
}
/**
*
* @export
* @interface PeopleUpdateItem
*/
export interface PeopleUpdateItem {
/**
* Person id.
* @type {string}
* @memberof PeopleUpdateItem
*/
'id': string;
/**
* Person name.
* @type {string}
* @memberof PeopleUpdateItem
*/
'name'?: string;
/**
* Asset is used to get the feature face thumbnail.
* @type {string}
* @memberof PeopleUpdateItem
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PeopleUpdateItem
*/
'isHidden'?: boolean;
}
/**
*
* @export
@@ -8940,6 +8990,50 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
options: localVarRequestOptions,
};
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople: async (peopleUpdateDto: PeopleUpdateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'peopleUpdateDto' is not null or undefined
assertParamExists('updatePeople', 'peopleUpdateDto', peopleUpdateDto)
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(peopleUpdateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
@@ -9049,6 +9143,16 @@ export const PersonApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.mergePerson(id, mergePersonDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updatePeople(peopleUpdateDto: PeopleUpdateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<BulkIdResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.updatePeople(peopleUpdateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@@ -9116,6 +9220,15 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
mergePerson(id: string, mergePersonDto: MergePersonDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.mergePerson(id, mergePersonDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {PeopleUpdateDto} peopleUpdateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatePeople(peopleUpdateDto: PeopleUpdateDto, options?: any): AxiosPromise<Array<BulkIdResponseDto>> {
return localVarFp.updatePeople(peopleUpdateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
@@ -9206,6 +9319,20 @@ export interface PersonApiMergePersonRequest {
readonly mergePersonDto: MergePersonDto
}
/**
* Request parameters for updatePeople operation in PersonApi.
* @export
* @interface PersonApiUpdatePeopleRequest
*/
export interface PersonApiUpdatePeopleRequest {
/**
*
* @type {PeopleUpdateDto}
* @memberof PersonApiUpdatePeople
*/
readonly peopleUpdateDto: PeopleUpdateDto
}
/**
* Request parameters for updatePerson operation in PersonApi.
* @export
@@ -9289,6 +9416,17 @@ export class PersonApi extends BaseAPI {
return PersonApiFp(this.configuration).mergePerson(requestParameters.id, requestParameters.mergePersonDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public updatePeople(requestParameters: PersonApiUpdatePeopleRequest, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).updatePeople(requestParameters.peopleUpdateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {PersonApiUpdatePersonRequest} requestParameters Request parameters.

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.68.0
* The version of the OpenAPI document: 1.71.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.68.0
* The version of the OpenAPI document: 1.71.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.68.0
* The version of the OpenAPI document: 1.71.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.68.0
* The version of the OpenAPI document: 1.71.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@@ -12,6 +12,8 @@
import DetailPanel from './detail-panel.svelte';
import PhotoViewer from './photo-viewer.svelte';
import VideoViewer from './video-viewer.svelte';
import PanoramaViewer from './panorama-viewer.svelte';
import { ProjectionType } from '$lib/constants';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import ProfileImageCropper from '../shared-components/profile-image-cropper.svelte';
@@ -293,6 +295,8 @@
on:close={closeViewer}
on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
/>
{:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
<PanoramaViewer {publicSharedKey} {asset} />
{:else}
<PhotoViewer {publicSharedKey} {asset} on:close={closeViewer} />
{/if}

View File

@@ -0,0 +1,20 @@
.view360-container {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
touch-action: pan-y;
overflow: hidden;
}
.view360-canvas {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
-ms-user-select: none;
user-select: none;
-webkit-user-drag: none;
}

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { api, AssetResponseDto } from '@api';
import View360, { EquirectProjection } from '@egjs/svelte-view360';
import './panorama-viewer.css';
export let asset: AssetResponseDto;
export let publicSharedKey = '';
let dataUrl = '';
let errorMessage = '';
const loadAssetData = async () => {
try {
const { data } = await api.assetApi.serveFile(
{ id: asset.id, isThumb: false, isWeb: false, key: publicSharedKey },
{ responseType: 'blob' },
);
if (data instanceof Blob) {
dataUrl = URL.createObjectURL(data);
return dataUrl;
} else {
throw new Error('Invalid data format');
}
} catch (error) {
errorMessage = 'Failed to load asset';
return '';
}
};
</script>
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
{#await loadAssetData()}
<LoadingSpinner />
{:then assetData}
{#if assetData}
<View360 autoResize={true} initialZoom={0.5} projection={new EquirectProjection({ src: assetData })} />
{:else}
<p>{errorMessage}</p>
{/if}
{/await}
</div>

View File

@@ -14,16 +14,22 @@
export let shadow = false;
export let circle = false;
export let hidden = false;
export let border = false;
let complete = false;
export let eyeColor = 'white';
</script>
<img
style:width={widthStyle}
style:height={heightStyle}
style:filter={hidden ? 'grayscale(75%)' : 'none'}
style:filter={hidden ? 'grayscale(50%)' : 'none'}
style:opacity={hidden ? '0.5' : '1'}
src={url}
alt={altText}
class="object-cover transition duration-300"
class="object-cover transition duration-300 {border
? 'border-[3px] border-immich-dark-primary/80 hover:border-immich-primary'
: ''}"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
@@ -32,9 +38,10 @@
use:imageLoad
on:image-load|once={() => (complete = true)}
/>
{#if hidden}
<div class="absolute left-1/2 top-1/2 translate-x-[-50%] translate-y-[-50%] transform">
<EyeOffOutline size="2em" />
<EyeOffOutline size="2em" color={eyeColor} />
</div>
{/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { ProjectionType } from '$lib/constants';
import IntersectionObserver from '$lib/components/asset-viewer/intersection-observer.svelte';
import { timeToSeconds } from '$lib/utils/time-to-seconds';
import { api, AssetResponseDto, AssetTypeEnum, ThumbnailFormat } from '@api';
@@ -12,6 +13,7 @@
import { fade } from 'svelte/transition';
import ImageThumbnail from './image-thumbnail.svelte';
import VideoThumbnail from './video-thumbnail.svelte';
import Rotate360Icon from 'svelte-material-icons/Rotate360.svelte';
const dispatch = createEventDispatcher();
@@ -124,6 +126,14 @@
</div>
{/if}
{#if asset.type === AssetTypeEnum.Image && asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR}
<div class="absolute right-0 top-0 z-20 flex place-items-center gap-1 text-xs font-medium text-white">
<span class="pr-2 pt-2">
<Rotate360Icon size="24" />
</span>
</div>
{/if}
{#if asset.resized}
<ImageThumbnail
url={api.getAssetThumbnailUrl(asset.id, format, publicSharedKey)}

View File

@@ -25,7 +25,7 @@
$: unselectedPeople = people.filter((source) => !selectedPeople.includes(source) && source.id !== person.id);
onMount(async () => {
const { data } = await api.personApi.getAllPeople({ withHidden: true });
const { data } = await api.personApi.getAllPeople({ withHidden: false });
people = data.people;
});

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