Compare commits

..

39 Commits

Author SHA1 Message Date
Jason Rasmussen
8af7a9c1b9 refactor: orientation 2024-09-19 12:06:20 -04:00
Jason Rasmussen
0b02fda4e0 fix(web): only show the timezone when it is known (#12779) 2024-09-18 17:07:50 -04:00
Daniel Dietzler
96516ae4b9 fix: creating tags with leading/traling slashes (#12778) 2024-09-18 19:25:13 +00:00
Jason Rasmussen
caa9b1a041 fix(web): asset grid with invalid asset id (at) (#12772)
fix(web): asset grid
2024-09-18 15:22:34 -04:00
Jason Rasmussen
65dcf9b655 fix(web): upgrade old style theme preference (#12775) 2024-09-18 12:14:44 -04:00
Fynn Petersen-Frey
0ceb773865 fix(mobile): profileChangedAt compatiblity with old servers (#12774)
* fix(mobile): profileChangedAt compatiblity with old servers

* chore: add breaks

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2024-09-18 15:59:30 +00:00
Fynn Petersen-Frey
6995cc2b38 refactor(mobile): encapsulate most access to photomanager in repository (#12754)
* refactor(mobile): encapsulate most access to photomanager in repository
2024-09-18 17:15:52 +02:00
Jason Rasmussen
6740c67ed8 fix: empty and restore over 1,000 items (#12751) 2024-09-18 09:57:52 -04:00
Jason Rasmussen
4f25cec6df refactor(server): metadata repository (#12759) 2024-09-18 08:44:22 -04:00
renovate[bot]
ab5dd4d66a chore(deps): update typescript-projects (#12536)
* chore(deps): update typescript-projects

* chore: import

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2024-09-17 22:31:12 +00:00
renovate[bot]
7ce8f845b2 chore(deps): update dependency vite to v5.4.6 [security] (#12757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-17 17:11:26 -04:00
Jason Rasmussen
efe45fd0aa chore: remove unused album repository methods (#12758) 2024-09-17 21:02:11 +00:00
Jason Rasmussen
1e6ef5c9e4 refactor(server): view repository (#12755) 2024-09-17 16:32:09 -04:00
renovate[bot]
f53e4721cf chore(deps): update base-image to v20240917 (major) (#12750)
chore(deps): update base-image to v20240917

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-17 10:06:56 -04:00
Min Idzelis
7a755a089b fix(server): debug port (#12745)
Fix debug port
2024-09-17 03:13:38 +00:00
martin
c468da589a fix: update the profile picture in the navigation-bar (#12723)
* fix: update the profile picture in the navigation-bar

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2024-09-17 01:48:15 +00:00
renovate[bot]
b0aafce16b fix(deps): update machine-learning (#12740) 2024-09-16 21:09:24 -04:00
Johannes Kastl ARS
de0fd06f43 docs: add hint for healthcheck.start_interval (#12643)
* docs/docs/install/docker-compose.mdx: add hint for healthcheck.start_interval (https://github.com/immich-app/immich/discussions/12638)

* Update docs/docs/install/docker-compose.mdx

Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2024-09-16 20:53:43 +00:00
Alex
186b4e1333 feat(web): improve UI/UX for settings pages (#12626)
* fix(web): local date time for buckets

* feat(web): improve UI/UX for setting pages

* search admin settings and icon

* clean up

* fix translation file

* Update web/src/routes/admin/system-settings/+page.svelte

Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com>

* Update web/src/lib/components/shared-components/settings/setting-accordion.svelte

Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com>

* better search bar on smaller screen

* lint

* template syntax

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
Co-authored-by: Ben <45583362+ben-basten@users.noreply.github.com>
2024-09-16 16:51:03 -04:00
Jason Rasmussen
b74b20824a feat: tag cleanup job (#12654) 2024-09-16 16:49:12 -04:00
Fynn Petersen-Frey
4a1ff6abce refactor(mobile): repositories for album service (#12701)
* refactor(mobile): repositories for album service

* review feedback, first service unit test
2024-09-16 16:26:14 -04:00
Weblate (bot)
edb085691a chore(web): update translations (#12590)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ca/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/cs/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/da/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/de/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/he/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/hr/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ko/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nb_NO/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/nl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ro/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ru/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Cyrl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/sr_Latn/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/uk/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/vi/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

Co-authored-by: Bezruchenko Simon <worcposj44@gmail.com>
Co-authored-by: Boris Garmev <borisgarmev912@proton.me>
Co-authored-by: David Abner Ciuhan <dciuhan@gmail.com>
Co-authored-by: Dean Cvjetanović <forteee@gmail.com>
Co-authored-by: Denis Pacquier <denis.pacquier@gmail.com>
Co-authored-by: Eero Jääskeläinen <eero.jaaskelainen@gmail.com>
Co-authored-by: Javier Montón <jmlarraz@gmail.com>
Co-authored-by: Junghyuk Kwon <kwon@junghy.uk>
Co-authored-by: Michal Micech <michal.micech@gmail.com>
Co-authored-by: Miki Mrvos <medolino2009@gmail.com>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Owen Higgins <owen@higgins.id>
Co-authored-by: Pat Oakly <pat.oakly@gmail.com>
Co-authored-by: Poramate Homprakob <batmaster_kn@hotmail.com>
Co-authored-by: Riccardo <riccardorizzato06@gmail.com>
Co-authored-by: RoanV <roanvogelzang@gmail.com>
Co-authored-by: Roger Veciana Rovira <rveciana@gmail.com>
Co-authored-by: Rémi Saurel <remi@saurel.eu>
Co-authored-by: Sam Smith <ja49619@gmail.com>
Co-authored-by: Vladimir Petrov (Vlado) <mr.vlado@gmail.com>
Co-authored-by: Xo <xocodokie@users.noreply.hosted.weblate.org>
Co-authored-by: aarhor <aaron.horstmann9916@gmail.com>
Co-authored-by: chapvic <victor@chapaev.org>
Co-authored-by: dvbthien <dvbthien@dvbthien.onmicrosoft.com>
Co-authored-by: kiwinho <kiwicaja@gmail.com>
Co-authored-by: pyccl <changcongliang@163.com>
Co-authored-by: pyorot <FMasic@hotmail.co.uk>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
2024-09-16 12:19:57 -04:00
bo0tzz
3e12b10866 fix: remove bad examples of 'from' domain for emails (#12728)
* fix: use example.com domain for from_address_description

* fix: remove unnecessary screenshot from docs
2024-09-16 12:05:34 -04:00
Tom Vincent
4735db8e79 chore(mobile): add isar lock file (#12705) 2024-09-15 19:20:09 +00:00
Alex
b06ea687b4 chore(web): small cleanup for full screen modal (#12680) 2024-09-15 11:23:35 -05:00
aryiu
e6bc831c97 fix(mobile): fix mn-MN.json file name (#12558)
* Update and rename ca.json to ca-CA.json

* Add mn-MN.json

* Delete mobile/assets/i18n/mn.json

* Update mn-MN.json

* Update localizely.yml
2024-09-15 16:11:52 +00:00
Alex
e73dc3dc72 fix(server): fix modify date extraction (#12658)
* fix(server): fix modify date extraction

* add unit test
2024-09-14 03:30:06 +00:00
Alex
f22338f36f fix(web): scrollbar shows when not need (#12659) 2024-09-14 03:20:40 +00:00
Jason Rasmussen
7893dca733 chore: add date/time issue to template (#12651) 2024-09-13 12:33:58 -04:00
Jason Rasmussen
c717fd2131 fix(server): increase person search limit (#12619) 2024-09-13 12:33:16 -04:00
Jason Rasmussen
a373d50c31 fix(web): memory viewer (#12649)
refactor(web): memory viewer
2024-09-13 12:27:10 -04:00
Alex
cdbc673a59 chore(mobile): post release task (#12615) 2024-09-13 13:25:43 +01:00
Zack Pollard
98cbf94388 ci: migrate off deprecated (and broken) value field on cloudflare_record (#12648) 2024-09-13 12:20:15 +00:00
Jason Rasmussen
15f9ff1fcb fix(web): bucket by localDateTime (#12612)
fix(web): local date time for buckets
2024-09-12 15:30:28 -04:00
Saschl
92811190a8 fix(web): load original panorama if specified in user settings (#12123)
* fix: load original panorama if specified in user settings

* fixes after merge

* chore: cleanup

---------

Co-authored-by: Saschl <noreply@saschl.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2024-09-12 19:30:21 +00:00
bo0tzz
6cbdb4c90d docs: scaling immich guide (#12593) 2024-09-12 21:15:38 +02:00
Jason Rasmussen
ba57646f9f refactor(server): client emit events (#12606)
* refactor(server): client emit events

* chore: test coverage
2024-09-12 14:12:39 -04:00
Jason Rasmussen
7b737786b3 fix(server): include partner assets in random endpoint (#12599) 2024-09-12 13:56:38 -04:00
Jason Rasmussen
d03e97f650 fix(web): better merge direction (#12601) 2024-09-12 13:54:34 -04:00
247 changed files with 8313 additions and 6350 deletions

View File

@@ -1,11 +1,14 @@
blank_issues_enabled: false
contact_links:
- name: I have a question or need support
- name: I have a question or need support
url: https://discord.immich.app
about: We use GitHub for tracking bugs, please check out our Discord channel for freaky fast support.
- name: Feature Request
- name: 📷 My photo or video has a date, time, or timezone problem
url: https://github.com/immich-app/immich/discussions/12650
about: Upload a sample file to this discussion and we will take a look
- name: 🌟 Feature request
url: https://github.com/immich-app/immich/discussions/new?category=feature-request
about: Please use our GitHub Discussion for making feature requests.
- name: I'm unsure where to go
- name: 🫣 I'm unsure where to go
url: https://discord.immich.app
about: If you are unsure where to go, then joining our Discord is recommended; Just ask!

View File

@@ -56,6 +56,10 @@ jobs:
run: dart format lib/ --set-exit-if-changed
working-directory: ./mobile
- name: Run dart custom_lint
run: dart run custom_lint
working-directory: ./mobile
# Enable after riverpod generator migration is completed
# - name: Run dart custom lint
# run: dart run custom_lint

8
.vscode/launch.json vendored
View File

@@ -5,8 +5,8 @@
"type": "node",
"request": "attach",
"restart": true,
"port": 9230,
"name": "Immich Server",
"port": 9231,
"name": "Immich API Server",
"remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server"
},
@@ -14,8 +14,8 @@
"type": "node",
"request": "attach",
"restart": true,
"port": 9231,
"name": "Immich Microservices",
"port": 9230,
"name": "Immich Workers",
"remoteRoot": "/usr/src/app",
"localRoot": "${workspaceFolder}/server"
}

496
cli/package-lock.json generated
View File

@@ -825,9 +825,9 @@
}
},
"node_modules/@eslint/js": {
"version": "9.9.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.9.1.tgz",
"integrity": "sha512-xIDQRsfg5hNBqHz04H1R3scSVwmI+KUbqjsQKHKQ1DAUSaUjYPReZZmS/5PNiKu1fUvzDd6H7DEDKACSEhu+TQ==",
"version": "9.10.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.10.0.tgz",
"integrity": "sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==",
"dev": true,
"license": "MIT",
"engines": {
@@ -844,6 +844,19 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.1.0.tgz",
"integrity": "sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"levn": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -1340,17 +1353,17 @@
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.3.0.tgz",
"integrity": "sha512-FLAIn63G5KH+adZosDYiutqkOkYEx0nvcwNNfJAf+c7Ae/H35qWwTYvPZUKFj5AS+WfHG/WJJfWnDnyNUlp8UA==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.5.0.tgz",
"integrity": "sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.3.0",
"@typescript-eslint/type-utils": "8.3.0",
"@typescript-eslint/utils": "8.3.0",
"@typescript-eslint/visitor-keys": "8.3.0",
"@typescript-eslint/scope-manager": "8.5.0",
"@typescript-eslint/type-utils": "8.5.0",
"@typescript-eslint/utils": "8.5.0",
"@typescript-eslint/visitor-keys": "8.5.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@@ -1374,16 +1387,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.3.0.tgz",
"integrity": "sha512-h53RhVyLu6AtpUzVCYLPhZGL5jzTD9fZL+SYf/+hYOx2bDkyQXztXSc4tbvKYHzfMXExMLiL9CWqJmVz6+78IQ==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz",
"integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "8.3.0",
"@typescript-eslint/types": "8.3.0",
"@typescript-eslint/typescript-estree": "8.3.0",
"@typescript-eslint/visitor-keys": "8.3.0",
"@typescript-eslint/scope-manager": "8.5.0",
"@typescript-eslint/types": "8.5.0",
"@typescript-eslint/typescript-estree": "8.5.0",
"@typescript-eslint/visitor-keys": "8.5.0",
"debug": "^4.3.4"
},
"engines": {
@@ -1403,14 +1416,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.3.0.tgz",
"integrity": "sha512-mz2X8WcN2nVu5Hodku+IR8GgCOl4C0G/Z1ruaWN4dgec64kDBabuXyPAr+/RgJtumv8EEkqIzf3X2U5DUKB2eg==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz",
"integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.3.0",
"@typescript-eslint/visitor-keys": "8.3.0"
"@typescript-eslint/types": "8.5.0",
"@typescript-eslint/visitor-keys": "8.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1421,14 +1434,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.3.0.tgz",
"integrity": "sha512-wrV6qh//nLbfXZQoj32EXKmwHf4b7L+xXLrP3FZ0GOUU72gSvLjeWUl5J5Ue5IwRxIV1TfF73j/eaBapxx99Lg==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.5.0.tgz",
"integrity": "sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.3.0",
"@typescript-eslint/utils": "8.3.0",
"@typescript-eslint/typescript-estree": "8.5.0",
"@typescript-eslint/utils": "8.5.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@@ -1446,9 +1459,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.3.0.tgz",
"integrity": "sha512-y6sSEeK+facMaAyixM36dQ5NVXTnKWunfD1Ft4xraYqxP0lC0POJmIaL/mw72CUMqjY9qfyVfXafMeaUj0noWw==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz",
"integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1460,14 +1473,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.3.0.tgz",
"integrity": "sha512-Mq7FTHl0R36EmWlCJWojIC1qn/ZWo2YiWYc1XVtasJ7FIgjo0MVv9rZWXEE7IK2CGrtwe1dVOxWwqXUdNgfRCA==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz",
"integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "8.3.0",
"@typescript-eslint/visitor-keys": "8.3.0",
"@typescript-eslint/types": "8.5.0",
"@typescript-eslint/visitor-keys": "8.5.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@@ -1489,16 +1502,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.3.0.tgz",
"integrity": "sha512-F77WwqxIi/qGkIGOGXNBLV7nykwfjLsdauRB/DOFPdv6LTF3BHHkBpq81/b5iMPSF055oO2BiivDJV4ChvNtXA==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.5.0.tgz",
"integrity": "sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.3.0",
"@typescript-eslint/types": "8.3.0",
"@typescript-eslint/typescript-estree": "8.3.0"
"@typescript-eslint/scope-manager": "8.5.0",
"@typescript-eslint/types": "8.5.0",
"@typescript-eslint/typescript-estree": "8.5.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -1512,13 +1525,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.3.0.tgz",
"integrity": "sha512-RmZwrTbQ9QveF15m/Cl28n0LXD6ea2CjkhH5rQ55ewz3H24w+AMCJHPVYaZ8/0HoG8Z3cLLFFycRXxeO2tz9FA==",
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz",
"integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.3.0",
"@typescript-eslint/types": "8.5.0",
"eslint-visitor-keys": "^3.4.3"
},
"engines": {
@@ -1530,19 +1543,20 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.0.5.tgz",
"integrity": "sha512-qeFcySCg5FLO2bHHSa0tAZAOnAUbp4L6/A5JDuj9+bt53JREl8hpLjLHEWF0e/gWc8INVpJaqA7+Ene2rclpZg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.0.tgz",
"integrity": "sha512-yqCkr2nrV4o58VcVMxTVkS6Ggxzy7pmSD8JbTbhbH5PsQfUIES1QT716VUzo33wf2lX9EcWYdT3Vl2MMmjR59g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^0.2.3",
"debug": "^4.3.5",
"debug": "^4.3.6",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
"istanbul-reports": "^3.1.7",
"magic-string": "^0.30.10",
"magic-string": "^0.30.11",
"magicast": "^0.3.4",
"std-env": "^3.7.0",
"test-exclude": "^7.0.1",
@@ -1552,17 +1566,24 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"vitest": "2.0.5"
"@vitest/browser": "2.1.0",
"vitest": "2.1.0"
},
"peerDependenciesMeta": {
"@vitest/browser": {
"optional": true
}
}
},
"node_modules/@vitest/expect": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.0.5.tgz",
"integrity": "sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.0.tgz",
"integrity": "sha512-N3/xR4fSu0+6sVZETEtPT1orUs2+Y477JOXTcU3xKuu3uBlsgbD7/7Mz2LZ1Jr1XjwilEWlrIgSCj4N1+5ZmsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "2.0.5",
"@vitest/utils": "2.0.5",
"@vitest/spy": "2.1.0",
"@vitest/utils": "2.1.0",
"chai": "^5.1.1",
"tinyrainbow": "^1.2.0"
},
@@ -1570,11 +1591,40 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.0.5.tgz",
"integrity": "sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==",
"node_modules/@vitest/mocker": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.0.tgz",
"integrity": "sha512-ZxENovUqhzl+QiOFpagiHUNUuZ1qPd5yYTCYHomGIZOFArzn4mgX2oxZmiAItJWAaXHG6bbpb/DpSPhlk5DgtA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "^2.1.0-beta.1",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.11"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/spy": "2.1.0",
"msw": "^2.3.5",
"vite": "^5.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.1.tgz",
"integrity": "sha512-SjxPFOtuINDUW8/UkElJYQSFtnWX7tMksSGW0vfjxMneFqxVr8YJ979QpMbDW7g+BIiq88RAGDjf7en6rvLPPQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
@@ -1583,12 +1633,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.0.5.tgz",
"integrity": "sha512-TfRfZa6Bkk9ky4tW0z20WKXFEwwvWhRY+84CnSEtq4+3ZvDlJyY32oNTJtM7AW9ihW90tX/1Q78cb6FjoAs+ig==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.0.tgz",
"integrity": "sha512-D9+ZiB8MbMt7qWDRJc4CRNNUlne/8E1X7dcKhZVAbcOKG58MGGYVDqAq19xlhNfMFZsW0bpVKgztBwks38Ko0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "2.0.5",
"@vitest/utils": "2.1.0",
"pathe": "^1.1.2"
},
"funding": {
@@ -1596,24 +1647,39 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.0.5.tgz",
"integrity": "sha512-SgCPUeDFLaM0mIUHfaArq8fD2WbaXG/zVXjRupthYfYGzc8ztbFbu6dUNOblBG7XLMR1kEhS/DNnfCZ2IhdDew==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.0.tgz",
"integrity": "sha512-x69CygGMzt9VCO283K2/FYQ+nBrOj66OTKpsPykjCR4Ac3lLV+m85hj9reaIGmjBSsKzVvbxWmjWE3kF5ha3uQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.0.5",
"magic-string": "^0.30.10",
"@vitest/pretty-format": "2.1.0",
"magic-string": "^0.30.11",
"pathe": "^1.1.2"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.0.5.tgz",
"integrity": "sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==",
"node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz",
"integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.0.tgz",
"integrity": "sha512-IXX5NkbdgTYTog3F14i2LgnBc+20YmkXMx0IWai84mcxySUDRgm0ihbOfR4L0EVRBDFG85GjmQQEZNNKVVpkZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyspy": "^3.0.0"
},
@@ -1622,13 +1688,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.0.5.tgz",
"integrity": "sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.0.tgz",
"integrity": "sha512-rreyfVe0PuNqJfKYUwfPDfi6rrp0VSu0Wgvp5WBqJonP+4NvXHk48X6oBam1Lj47Hy6jbJtnMj3OcRdrkTP0tA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "2.0.5",
"estree-walker": "^3.0.3",
"@vitest/pretty-format": "2.1.0",
"loupe": "^3.1.1",
"tinyrainbow": "^1.2.0"
},
@@ -1636,6 +1702,19 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils/node_modules/@vitest/pretty-format": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.0.tgz",
"integrity": "sha512-7sxf2F3DNYatgmzXXcTh6cq+/fxwB47RIQqZJFoSH883wnVAoccSRT6g+dTKemUBo8Q5N4OYYj1EBXLuRKvp3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^1.2.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.12.1",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
@@ -1710,6 +1789,7 @@
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
@@ -1800,6 +1880,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -1838,6 +1919,7 @@
"resolved": "https://registry.npmjs.org/chai/-/chai-5.1.1.tgz",
"integrity": "sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==",
"dev": true,
"license": "MIT",
"dependencies": {
"assertion-error": "^2.0.1",
"check-error": "^2.1.1",
@@ -1870,6 +1952,7 @@
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
"integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 16"
}
@@ -2014,6 +2097,7 @@
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
@@ -2112,9 +2196,9 @@
}
},
"node_modules/eslint": {
"version": "9.9.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.9.1.tgz",
"integrity": "sha512-dHvhrbfr4xFQ9/dq+jcVneZMyRYLjggWjk6RVsIiHsP8Rz6yZ8LvZ//iU4TrZF+SXWG+JkNF2OyiZRvzgRDqMg==",
"version": "9.10.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.10.0.tgz",
"integrity": "sha512-Y4D0IgtBZfOcOUAIQTSXBKoNGfY0REGqHJG6+Q81vNippW5YlKjHFj4soMxamKK1NXHUWuBZTLdU3Km+L/pcHw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2122,7 +2206,8 @@
"@eslint-community/regexpp": "^4.11.0",
"@eslint/config-array": "^0.18.0",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "9.9.1",
"@eslint/js": "9.10.0",
"@eslint/plugin-kit": "^0.1.0",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.3.0",
"@nodelib/fs.walk": "^1.2.8",
@@ -2145,7 +2230,6 @@
"is-glob": "^4.0.0",
"is-path-inside": "^3.0.3",
"json-stable-stringify-without-jsonify": "^1.0.1",
"levn": "^0.4.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
@@ -2383,6 +2467,7 @@
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
@@ -2396,29 +2481,6 @@
"node": ">=0.10.0"
}
},
"node_modules/execa": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
"integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
"dev": true,
"dependencies": {
"cross-spawn": "^7.0.3",
"get-stream": "^8.0.1",
"human-signals": "^5.0.0",
"is-stream": "^3.0.0",
"merge-stream": "^2.0.0",
"npm-run-path": "^5.1.0",
"onetime": "^6.0.0",
"signal-exit": "^4.1.0",
"strip-final-newline": "^3.0.0"
},
"engines": {
"node": ">=16.17"
},
"funding": {
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -2582,22 +2644,11 @@
"resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
"integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/get-stream": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
"dev": true,
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
@@ -2688,15 +2739,6 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
"node_modules/human-signals": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
"integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
"dev": true,
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/ignore": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
@@ -2818,18 +2860,6 @@
"node": ">=8"
}
},
"node_modules/is-stream": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
"integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
"dev": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3016,6 +3046,7 @@
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.1.tgz",
"integrity": "sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==",
"dev": true,
"license": "MIT",
"dependencies": {
"get-func-name": "^2.0.1"
}
@@ -3061,12 +3092,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
"integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
"dev": true
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3087,18 +3112,6 @@
"node": ">=8.6"
}
},
"node_modules/mimic-fn": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
"integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -3219,48 +3232,6 @@
"semver": "bin/semver"
}
},
"node_modules/npm-run-path": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
"integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
"dev": true,
"dependencies": {
"path-key": "^4.0.0"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/npm-run-path/node_modules/path-key": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
"integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/onetime": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
"integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
"dev": true,
"dependencies": {
"mimic-fn": "^4.0.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.3",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
@@ -3397,21 +3368,23 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
"integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
"dev": true
"dev": true,
"license": "MIT"
},
"node_modules/pathval": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
"integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16"
}
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"dev": true,
"license": "ISC"
},
@@ -3436,9 +3409,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.41",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz",
"integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==",
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"dev": true,
"funding": [
{
@@ -3457,8 +3430,8 @@
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.0",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -3827,10 +3800,11 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
@@ -3933,18 +3907,6 @@
"node": ">=8"
}
},
"node_modules/strip-final-newline": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
"integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -4031,10 +3993,18 @@
"dev": true
},
"node_modules/tinybench": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz",
"integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==",
"dev": true
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.0.tgz",
"integrity": "sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinypool": {
"version": "1.0.0",
@@ -4055,10 +4025,11 @@
}
},
"node_modules/tinyspy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.0.tgz",
"integrity": "sha512-q5nmENpTHgiPVd1cJDDc9cVoYN5x4vCvwT3FMilvKPKneCBZAxn2YWQjDF0UMcE9k0Cay1gBiDfTMU0g+mPMQA==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
"integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
@@ -4140,9 +4111,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -4210,14 +4181,14 @@
}
},
"node_modules/vite": {
"version": "5.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.2.tgz",
"integrity": "sha512-dDrQTRHp5C1fTFzcSaMxjk6vdpKvT+2/mIdE07Gw2ykehT49O0z/VHS3zZ8iV/Gh8BJJKHWOe5RjaNrW5xf/GA==",
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz",
"integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.41",
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
@@ -4270,15 +4241,15 @@
}
},
"node_modules/vite-node": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.0.5.tgz",
"integrity": "sha512-LdsW4pxj0Ot69FAoXZ1yTnA9bjGohr2yNBU7QKRxpz8ITSkhuDl6h3zS/tvgz4qrNjeRnvrWeXQ8ZF7Um4W00Q==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.0.tgz",
"integrity": "sha512-+ybYqBVUjYyIscoLzMWodus2enQDZOpGhcU6HdOVD6n8WZdk12w1GFL3mbnxLs7hPtRtqs1Wo5YF6/Tsr6fmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cac": "^6.7.14",
"debug": "^4.3.5",
"debug": "^4.3.6",
"pathe": "^1.1.2",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0"
},
"bin": {
@@ -4312,29 +4283,30 @@
}
},
"node_modules/vitest": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.0.5.tgz",
"integrity": "sha512-8GUxONfauuIdeSl5f9GTgVEpg5BTOlplET4WEDaeY2QBiN8wSm68vxN/tb5z405OwppfoCavnwXafiaYBC/xOA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.0.tgz",
"integrity": "sha512-XuuEeyNkqbfr0FtAvd9vFbInSSNY1ykCQTYQ0sj9wPy4hx+1gR7gqVNdW0AX2wrrM1wWlN5fnJDjF9xG6mYRSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@ampproject/remapping": "^2.3.0",
"@vitest/expect": "2.0.5",
"@vitest/pretty-format": "^2.0.5",
"@vitest/runner": "2.0.5",
"@vitest/snapshot": "2.0.5",
"@vitest/spy": "2.0.5",
"@vitest/utils": "2.0.5",
"@vitest/expect": "2.1.0",
"@vitest/mocker": "2.1.0",
"@vitest/pretty-format": "^2.1.0",
"@vitest/runner": "2.1.0",
"@vitest/snapshot": "2.1.0",
"@vitest/spy": "2.1.0",
"@vitest/utils": "2.1.0",
"chai": "^5.1.1",
"debug": "^4.3.5",
"execa": "^8.0.1",
"magic-string": "^0.30.10",
"debug": "^4.3.6",
"magic-string": "^0.30.11",
"pathe": "^1.1.2",
"std-env": "^3.7.0",
"tinybench": "^2.8.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.0",
"tinypool": "^1.0.0",
"tinyrainbow": "^1.2.0",
"vite": "^5.0.0",
"vite-node": "2.0.5",
"vite-node": "2.1.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
@@ -4349,8 +4321,8 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/node": "^18.0.0 || >=20.0.0",
"@vitest/browser": "2.0.5",
"@vitest/ui": "2.0.5",
"@vitest/browser": "2.1.0",
"@vitest/ui": "2.1.0",
"happy-dom": "*",
"jsdom": "*"
},
@@ -4535,9 +4507,9 @@
}
},
"node_modules/yaml": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz",
"integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
"dev": true,
"license": "ISC",
"bin": {

View File

@@ -18,7 +18,7 @@ output "immich_app_branch_subdomain" {
}
output "immich_app_branch_pages_hostname" {
value = cloudflare_record.immich_app_branch_subdomain.value
value = cloudflare_record.immich_app_branch_subdomain.content
}
output "pages_project_name" {

View File

@@ -43,6 +43,7 @@ services:
ports:
- 3001:3001
- 9230:9230
- 9231:9231
depends_on:
- redis
- database

View File

@@ -8,13 +8,11 @@ Immich supports the option to send notifications via Email for the following eve
## SMTP settings
You can access the settings panel from the web at `Administration -> Settings -> Notification settings`
You can access the settings panel from the web at `Administration -> Settings -> Notification settings`.
Under Email, enter the following details to connect with SMTP servers.
Under Email, enter the required details to connect with an SMTP server.
You can use the following [guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server.
<img src={require('./img/email-settings.png').default} width="80%" title="SMTP settings" />
You can use [this guide](/docs/guides/smtp-gmail) to use Gmail's SMTP server.
## User's notifications settings

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

View File

@@ -0,0 +1,19 @@
# Scaling Immich
Immich is built with modern deployment practices in mind, and the backend is designed to be able to run multiple instances in parallel. When doing this, the only requirement you need to be aware of is that every instance needs to be connected to the shared infrastructure. That means they should all have access to the same Postgres and Redis instances, and have the same files mounted into the containers.
Scaling can be useful for many reasons. Maybe you have a gaming PC that you want to use for transcoding and thumbnail generation, or perhaps you run a Kubernetes cluster across a handful of powerful servers that you want to make use of.
:::info
If you only have a single machine to run Immich on, scaling to multiple containers is unlikely to provide any benefit. An Immich container will run multiple background tasks at once, and you can increase their number from the admin panel.
:::
The details of how to scale across multiple machines will vary widely between different environments and require some knowledge to set up, and as such this guide gives no specific instructions. In some cases scaling up can be as easy as incrementing the amount of replicas on a Kubernetes deployment, in others it might need you to configure network tunnels or NFS mounts. The details are left as an exercise for the reader ;)
## Workers
By default, each running `immich-server` container comes with multiple internal workers. If you're scaling up only to handle more background tasks, you can choose to disable the worker responsible for the API. See [workers](../administration/jobs-workers.md) for more detail.
## Scaling down
In the same way you can scale up to multiple containers, you can also choose to scale down. All state is stored in Postgres, Redis, and the filesystem so there is no risk in stopping a running immich-server container, for example if you want to use your GPU to play some games. As long as there is an API worker running you will still be able to browse Immich, and jobs will wait to be processed until there is a worker available for them.

View File

@@ -80,6 +80,10 @@ The Compose file './docker-compose.yml' is invalid because:
See the previous paragraph about installing from the official docker repository.
:::
:::info Health check start interval
If you get an error `can't set healthcheck.start_interval as feature require Docker Engine v25 or later`, it helps to comment out the line for `start_interval` in the `database` section of the `docker-compose.yml` file.
:::
:::tip
For more information on how to use the application, please refer to the [Post Installation](/docs/install/post-install.mdx) guide.
:::

34
docs/package-lock.json generated
View File

@@ -6068,9 +6068,10 @@
}
},
"node_modules/docusaurus-lunr-search": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.4.0.tgz",
"integrity": "sha512-GfllnNXCLgTSPH9TAKWmbn8VMfwpdOAZ1xl3T2GgX8Pm26qSDLfrrdVwjguaLfMJfzciFL97RKrAJlgrFM48yw==",
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/docusaurus-lunr-search/-/docusaurus-lunr-search-3.5.0.tgz",
"integrity": "sha512-k3zN4jYMi/prWInJILGKOxE+BVcgYinwj9+gcECsYm52tS+4ZKzXQzbPnVJAEXmvKOfFMcDFvS3MSmm6cEaxIQ==",
"license": "MIT",
"dependencies": {
"autocomplete.js": "^0.37.0",
"clsx": "^1.2.1",
@@ -6097,14 +6098,16 @@
}
},
"node_modules/docusaurus-lunr-search/node_modules/@types/unist": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA=="
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/docusaurus-lunr-search/node_modules/bail": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz",
"integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -6114,6 +6117,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
"integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
@@ -6122,6 +6126,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz",
"integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
@@ -6130,6 +6135,7 @@
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz",
"integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
@@ -6139,6 +6145,7 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz",
"integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==",
"license": "MIT",
"dependencies": {
"bail": "^1.0.0",
"extend": "^3.0.0",
@@ -6156,6 +6163,7 @@
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz",
"integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.2"
},
@@ -6168,6 +6176,7 @@
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz",
"integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"is-buffer": "^2.0.0",
@@ -6183,6 +6192,7 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz",
"integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2.0.0",
"unist-util-stringify-position": "^2.0.0"
@@ -16081,9 +16091,9 @@
}
},
"node_modules/tailwindcss": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz",
"integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==",
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.11.tgz",
"integrity": "sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==",
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -16443,9 +16453,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",

727
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1052,7 +1052,7 @@ describe('/asset', () => {
dateTimeOriginal: '2010-07-20T17:27:12.000Z',
latitude: null,
longitude: null,
orientation: '1',
orientation: 1,
},
},
},

View File

@@ -34,8 +34,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const { status, body } = await request(app)
.post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
@@ -51,8 +54,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true, isArchived: true }));
const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const { status, body } = await request(app)
.post('/trash/empty')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId });
@@ -76,8 +82,11 @@ describe('/trash', () => {
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: true }));
const { status } = await request(app).post('/trash/restore').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const { status, body } = await request(app)
.post('/trash/restore')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isTrashed: false }));
@@ -99,11 +108,12 @@ describe('/trash', () => {
const before = await utils.getAssetInfo(admin.accessToken, assetId);
expect(before.isTrashed).toBe(true);
const { status } = await request(app)
const { status, body } = await request(app)
.post('/trash/restore/assets')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ ids: [assetId] });
expect(status).toBe(204);
expect(status).toBe(200);
expect(body).toEqual({ count: 1 });
const after = await utils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(false);

View File

@@ -94,6 +94,7 @@ export const signupResponseDto = {
quotaSizeInBytes: null,
status: 'active',
license: null,
profileChangedAt: expect.any(String),
},
};

View File

@@ -52,7 +52,7 @@ download:
locale_code: nb-NO
- file: mobile/assets/i18n/sv-SE.json
locale_code: sv-SE
- file: mobile/assets/i18n/mn.json
- file: mobile/assets/i18n/mn-MN.json
locale_code: mn
- file: mobile/assets/i18n/ko-KR.json
locale_code: ko-KR

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:3cd9b520be95c671135ea1318f32be6912876024ee16d0f472669d3878801651 AS builder-cpu
FROM python:3.11-bookworm@sha256:157a371e60389919fe4a72dff71ce86eaa5234f59114c23b0b346d0d02c74d39 AS builder-cpu
FROM builder-cpu AS builder-openvino
@@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
FROM python:3.11-slim-bookworm@sha256:50ec89bdac0a845ec1751f91cb6187a3d8adb2b919d6e82d17acf48d1a9743fc AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:669bbd08353610485a94d5d0c976b4b6498c55280fe42c00f7581f85ee9f3121 AS prod-cpu
FROM prod-cpu AS prod-openvino

View File

@@ -1,4 +1,4 @@
FROM mambaorg/micromamba:bookworm-slim@sha256:b10f75974a30a6889b03519ac48d3e1510fd13d0689468c2c443033a15d84f1b AS builder
FROM mambaorg/micromamba:bookworm-slim@sha256:5f32c5742e2248f2ca07ccae6861371321aba37372bf8e1a80d6f728f1ab4418 AS builder
ENV TRANSFORMERS_CACHE=/cache \
PYTHONDONTWRITEBYTECODE=1 \

View File

@@ -680,13 +680,13 @@ test = ["pytest (>=6)"]
[[package]]
name = "fastapi-slim"
version = "0.114.0"
version = "0.114.2"
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
optional = false
python-versions = ">=3.8"
files = [
{file = "fastapi_slim-0.114.0-py3-none-any.whl", hash = "sha256:83c8e95301c75c6575f7f6c4b885bf42a4c0b4a85e936e2faca25055470d0afe"},
{file = "fastapi_slim-0.114.0.tar.gz", hash = "sha256:2299d5e0b8818f264725bd13dd91c80b904589be06c98c3d8115132576e5e2dd"},
{file = "fastapi_slim-0.114.2-py3-none-any.whl", hash = "sha256:52ae76c53a30ad0fa96beb84c1bf4bef9c72e88c2f7c0473e836f01d7ac3ca6b"},
{file = "fastapi_slim-0.114.2.tar.gz", hash = "sha256:76d0a450826fb0fa740268be55ef04c44807da87a94fbbf5f16338b5a4a2d321"},
]
[package.dependencies]
@@ -2473,13 +2473,13 @@ files = [
[[package]]
name = "pytest"
version = "8.3.2"
version = "8.3.3"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"},
{file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"},
{file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"},
{file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"},
]
[package.dependencies]
@@ -2816,13 +2816,13 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "rich"
version = "13.8.0"
version = "13.8.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "rich-13.8.0-py3-none-any.whl", hash = "sha256:2e85306a063b9492dffc86278197a60cbece75bcb766022f3436f567cae11bdc"},
{file = "rich-13.8.0.tar.gz", hash = "sha256:a5ac1f1cd448ade0d59cc3356f7db7a7ccda2c8cbae9c7a90c28ff463d3e91f4"},
{file = "rich-13.8.1-py3-none-any.whl", hash = "sha256:1760a3c0848469b97b558fc61c85233e3dafb69c7a071b4d60c38099d3cd4c06"},
{file = "rich-13.8.1.tar.gz", hash = "sha256:8260cda28e3db6bf04d2d1ef4dbc03ba80a824c88b0e7668a0f23126a424844a"},
]
[package.dependencies]

859
mobile/.isar-cargo.lock Normal file
View File

@@ -0,0 +1,859 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "autocfg"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0"
[[package]]
name = "bindgen"
version = "0.63.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36d860121800b2a9a94f9b5604b332d5cffb234ce17609ea479d723dbc9d3885"
dependencies = [
"bitflags 1.3.2",
"cexpr",
"clang-sys",
"lazy_static",
"lazycell",
"peeking_take_while",
"proc-macro2",
"quote",
"regex",
"rustc-hash",
"shlex",
"syn 1.0.109",
]
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[package]]
name = "block"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50"
[[package]]
name = "cc"
version = "1.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d74707dde2ba56f86ae90effb3b43ddd369504387e718014de010cec7959800"
dependencies = [
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cexpr"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
dependencies = [
"nom",
]
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clang-sys"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
"libloading",
]
[[package]]
name = "cmake"
version = "0.1.51"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb1e43aa7fd152b1f968787f7dbcdeb306d1867ff373c69955211876c053f91a"
dependencies = [
"cc",
]
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80"
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]]
name = "doc-comment"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10"
[[package]]
name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
[[package]]
name = "enum_dispatch"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd"
dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.77",
]
[[package]]
name = "float_next_after"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fc612c5837986b7104a87a0df74a5460931f1c5274be12f8d0f40aa2f30d632"
dependencies = [
"num-traits",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "glob"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "intmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee87fd093563344074bacf24faa0bb0227fb6969fb223e922db798516de924d6"
[[package]]
name = "isar"
version = "0.0.0"
dependencies = [
"dirs",
"intmap",
"isar-core",
"itertools",
"jni",
"ndk-context",
"objc",
"objc-foundation",
"once_cell",
"paste",
"serde_json",
"threadpool",
"unicode-segmentation",
]
[[package]]
name = "isar-core"
version = "0.0.0"
dependencies = [
"byteorder",
"cfg-if",
"crossbeam-channel",
"enum_dispatch",
"float_next_after",
"intmap",
"itertools",
"libc",
"mdbx-sys",
"once_cell",
"paste",
"rand",
"serde",
"serde_json",
"snafu",
"widestring",
"xxhash-rust",
]
[[package]]
name = "itertools"
version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b"
[[package]]
name = "jni"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "039022cdf4d7b1cf548d31f60ae783138e5fd42013f6271049d7df7afadef96c"
dependencies = [
"cesu8",
"combine",
"jni-sys",
"log",
"thiserror",
"walkdir",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "lazycell"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55"
[[package]]
name = "libc"
version = "0.2.158"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
[[package]]
name = "libloading"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
dependencies = [
"cfg-if",
"windows-targets",
]
[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
"bitflags 2.6.0",
"libc",
]
[[package]]
name = "log"
version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "malloc_buf"
version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
dependencies = [
"libc",
]
[[package]]
name = "mdbx-sys"
version = "0.0.0"
dependencies = [
"bindgen",
"cc",
"cmake",
"libc",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "ndk-context"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "objc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
dependencies = [
"malloc_buf",
]
[[package]]
name = "objc-foundation"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9"
dependencies = [
"block",
"objc",
"objc_id",
]
[[package]]
name = "objc_id"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b"
dependencies = [
"objc",
]
[[package]]
name = "once_cell"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ea5043e58958ee56f3e15a90aee535795cd7dfd319846288d93c5b57d85cbe"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "peeking_take_while"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099"
[[package]]
name = "ppv-lite86"
version = "0.2.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom",
"libredox",
"thiserror",
]
[[package]]
name = "regex"
version = "1.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "ryu"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "serde"
version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.210"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.77",
]
[[package]]
name = "serde_json"
version = "1.0.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "snafu"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4de37ad025c587a29e8f3f5605c00f70b98715ef90b9061a815b9e59e9042d6"
dependencies = [
"doc-comment",
"snafu-derive",
]
[[package]]
name = "snafu-derive"
version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990079665f075b699031e9c08fd3ab99be5029b96f3b78dc0709e8f77e4efebf"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.77",
]
[[package]]
name = "threadpool"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa"
dependencies = [
"num_cpus",
]
[[package]]
name = "unicode-ident"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe"
[[package]]
name = "unicode-segmentation"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "widestring"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7219d36b6eac893fa81e84ebe06485e7dcbb616177469b142df14f1f4deb1311"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "xxhash-rust"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a5cbf750400958819fb6178eaa83bee5cd9c29a26a40cc241df8c70fdd46984"
[[package]]
name = "zerocopy"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
dependencies = [
"byteorder",
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.77",
]

View File

@@ -36,8 +36,31 @@ analyzer:
- openapi/**
- lib/generated_plugin_registrant.dart
plugins:
- custom_lint
plugins:
- custom_lint
custom_lint:
debug: true
rules:
- avoid_build_context_in_providers: false
- avoid_public_notifier_properties: false
- avoid_manual_providers_as_generated_provider_dependency: false
- unsupported_provider_value: false
- photo_manager:
exclude:
# required / wanted
- album_media.repository.dart
- asset_media.repository.dart
- file_media.repository.dart
# acceptable exceptions for the time being
- asset.entity.dart # to provide local AssetEntity for now
- immich_local_image_provider.dart # accesses thumbnails via PhotoManager
- immich_local_thumbnail_provider.dart # accesses thumbnails via PhotoManager
# refactor to make the providers and services testable
- backup.provider.dart # uses only PMProgressHandler
- manual_upload.provider.dart # uses only PMProgressHandler
- background.service.dart # uses only PMProgressHandler
- backup.service.dart # uses only PMProgressHandler
dart_code_metrics:
metrics:

View File

@@ -589,4 +589,4 @@
"viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack"
}
}

View File

@@ -0,0 +1 @@
include: package:lints/recommended.yaml

View File

@@ -0,0 +1,49 @@
import 'dart:collection';
import 'package:analyzer/error/listener.dart';
import 'package:analyzer/error/error.dart' show ErrorSeverity;
import 'package:custom_lint_builder/custom_lint_builder.dart';
PluginBase createPlugin() => ImmichLinter();
class ImmichLinter extends PluginBase {
@override
List<LintRule> getLintRules(CustomLintConfigs configs) => [
PhotoManagerRule(configs.rules[PhotoManagerRule._code.name]),
];
}
class PhotoManagerRule extends DartLintRule {
PhotoManagerRule(LintOptions? options) : super(code: _code) {
final excludeOption = options?.json["exclude"];
if (excludeOption is String) {
_excludePaths.add(excludeOption);
} else if (excludeOption is List) {
_excludePaths.addAll(excludeOption.map((option) => option));
}
}
final Set<String> _excludePaths = HashSet();
static const _code = LintCode(
name: 'photo_manager',
problemMessage:
'photo_manager library must only be used in MediaRepository',
errorSeverity: ErrorSeverity.WARNING,
);
@override
void run(
CustomLintResolver resolver,
ErrorReporter reporter,
CustomLintContext context,
) {
if (_excludePaths.contains(resolver.source.shortName)) return;
context.registry.addImportDirective((node) {
if (node.uri.stringValue?.startsWith("package:photo_manager") == true) {
reporter.atNode(node, code);
}
});
}
}

View File

@@ -0,0 +1,370 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77"
url: "https://pub.dev"
source: hosted
version: "73.0.0"
_macros:
dependency: transitive
description: dart
source: sdk
version: "0.3.2"
analyzer:
dependency: "direct main"
description:
name: analyzer
sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a"
url: "https://pub.dev"
source: hosted
version: "6.8.0"
analyzer_plugin:
dependency: "direct main"
description:
name: analyzer_plugin
sha256: "9661b30b13a685efaee9f02e5d01ed9f2b423bd889d28a304d02d704aee69161"
url: "https://pub.dev"
source: hosted
version: "0.11.3"
args:
dependency: transitive
description:
name: args
sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a"
url: "https://pub.dev"
source: hosted
version: "2.5.0"
async:
dependency: transitive
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
url: "https://pub.dev"
source: hosted
version: "2.11.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
url: "https://pub.dev"
source: hosted
version: "2.0.3"
ci:
dependency: transitive
description:
name: ci
sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
cli_util:
dependency: transitive
description:
name: cli_util
sha256: c05b7406fdabc7a49a3929d4af76bcaccbbffcbcdcf185b082e1ae07da323d19
url: "https://pub.dev"
source: hosted
version: "0.4.1"
collection:
dependency: transitive
description:
name: collection
sha256: a1ace0a119f20aabc852d165077c036cd864315bd99b7eaa10a60100341941bf
url: "https://pub.dev"
source: hosted
version: "1.19.0"
convert:
dependency: transitive
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: ec30d999af904f33454ba22ed9a86162b35e52b44ac4807d1d93c288041d7d27
url: "https://pub.dev"
source: hosted
version: "3.0.5"
custom_lint:
dependency: transitive
description:
name: custom_lint
sha256: "6e1ec47427ca968f22bce734d00028ae7084361999b41673291138945c5baca0"
url: "https://pub.dev"
source: hosted
version: "0.6.7"
custom_lint_builder:
dependency: "direct main"
description:
name: custom_lint_builder
sha256: ba2f90fff4eff71d202d097eb14b14f87087eaaef742e956208c0eb9d3a40a21
url: "https://pub.dev"
source: hosted
version: "0.6.7"
custom_lint_core:
dependency: transitive
description:
name: custom_lint_core
sha256: "4ddbbdaa774265de44c97054dcec058a83d9081d071785ece601e348c18c267d"
url: "https://pub.dev"
source: hosted
version: "0.6.5"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "7856d364b589d1f08986e140938578ed36ed948581fbc3bc9aef1805039ac5ab"
url: "https://pub.dev"
source: hosted
version: "2.3.7"
file:
dependency: transitive
description:
name: file
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: "25517a4deb0c03aa0f32fd12db525856438902d9c16536311e76cdc57b31d7d1"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
freezed_annotation:
dependency: transitive
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
glob:
dependency: transitive
description:
name: glob
sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
hotreloader:
dependency: transitive
description:
name: hotreloader
sha256: ed56fdc1f3a8ac924e717257621d09e9ec20e308ab6352a73a50a1d7a4d9158e
url: "https://pub.dev"
source: hosted
version: "4.2.0"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
lints:
dependency: "direct dev"
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
macros:
dependency: transitive
description:
name: macros
sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536"
url: "https://pub.dev"
source: hosted
version: "0.1.2-main.4"
matcher:
dependency: transitive
description:
name: matcher
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
url: "https://pub.dev"
source: hosted
version: "0.12.16+1"
meta:
dependency: transitive
description:
name: meta
sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7
url: "https://pub.dev"
source: hosted
version: "1.15.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path:
dependency: transitive
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: c799b721d79eb6ee6fa56f00c04b472dcd44a30d258fac2174a6ec57302678f8
url: "https://pub.dev"
source: hosted
version: "1.3.0"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c"
url: "https://pub.dev"
source: hosted
version: "1.10.0"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev"
source: hosted
version: "2.1.2"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84
url: "https://pub.dev"
source: hosted
version: "1.2.1"
test_api:
dependency: transitive
description:
name: test_api
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
url: "https://pub.dev"
source: hosted
version: "0.7.3"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c
url: "https://pub.dev"
source: hosted
version: "1.3.2"
uuid:
dependency: transitive
description:
name: uuid
sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
url: "https://pub.dev"
source: hosted
version: "4.5.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5c5f338a667b4c644744b661f309fb8080bb94b18a7e91ef1dbd343bed00ed6d"
url: "https://pub.dev"
source: hosted
version: "14.2.5"
watcher:
dependency: transitive
description:
name: watcher
sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5"
url: "https://pub.dev"
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.4.0 <4.0.0"

View File

@@ -0,0 +1,13 @@
name: immich_mobile_immich_lint
publish_to: none
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
analyzer: ^6.8.0
analyzer_plugin: ^0.11.3
custom_lint_builder: ^0.6.4
dev_dependencies:
lints: ^4.0.0

View File

@@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173;
CURRENT_PROJECT_VERSION = 175;
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 = 173;
CURRENT_PROJECT_VERSION = 175;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -571,7 +571,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173;
CURRENT_PROJECT_VERSION = 175;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -58,11 +58,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.114.0</string>
<string>1.115.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>173</string>
<string>175</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -1,11 +1,9 @@
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.entity.g.dart';
@@ -43,6 +41,9 @@ class Album {
final IsarLinks<User> sharedUsers = IsarLinks<User>();
final IsarLinks<Asset> assets = IsarLinks<Asset>();
@ignore
bool isAll = false;
@ignore
bool get isRemote => remoteId != null;
@@ -70,6 +71,9 @@ class Album {
return name.join(' ');
}
@ignore
String get eTagKeyAssetCount => "device-album-$localId-asset-count";
@override
bool operator ==(other) {
if (other is! Album) return false;
@@ -112,19 +116,6 @@ class Album {
sharedUsers.length.hashCode ^
assets.length.hashCode;
static Album local(AssetPathEntity ape) {
final Album a = Album(
name: ape.name,
createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
a.owner.value = Store.get(StoreKey.currentUser);
a.localId = ape.id;
return a;
}
static Future<Album> remote(AlbumResponseDto dto) async {
final Isar db = Isar.getInstance()!;
final Album a = Album(
@@ -164,19 +155,16 @@ class Album {
}
extension AssetsHelper on IsarCollection<Album> {
Future<void> store(Album a) async {
Future<Album> store(Album a) async {
await put(a);
await a.owner.save();
await a.thumbnail.save();
await a.sharedUsers.save();
await a.assets.save();
return a;
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
}
extension AssetPathEntityHelper on AssetPathEntity {
String get eTagKeyAssetCount => "device-album-$id-asset-count";
}

View File

@@ -1,11 +1,10 @@
import 'dart:convert';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show AssetEntity;
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:path/path.dart' as p;
@@ -42,33 +41,6 @@ class Asset {
stackId = remote.stack?.id,
thumbhash = remote.thumbhash;
Asset.local(AssetEntity local, List<int> hash)
: localId = local.id,
checksum = base64.encode(hash),
durationInSeconds = local.duration,
type = AssetType.values[local.typeInt],
height = local.height,
width = local.width,
fileName = local.title!,
ownerId = Store.get(StoreKey.currentUser).isarId,
fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
isTrashed = false,
isOffline = false,
stackCount = 0,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
}
if (local.latitude != null) {
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
_local = local;
assert(hash.length == 20, "invalid SHA1 hash");
}
Asset({
this.id = Isar.autoIncrement,
required this.checksum,
@@ -115,6 +87,8 @@ class Asset {
return _local;
}
set local(AssetEntity? assetEntity) => _local = assetEntity;
Id id = Isar.autoIncrement;
/// stores the raw SHA1 bytes as a base64 String
@@ -210,6 +184,10 @@ class Asset {
@ignore
Duration get duration => Duration(seconds: durationInSeconds);
// ignore: invalid_annotation_target
@ignore
set byteHash(List<int> hash) => checksum = base64.encode(hash);
@override
bool operator ==(other) {
if (other is! Asset) return false;

View File

@@ -0,0 +1,21 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IAlbumRepository {
Future<int> count({bool? local});
Future<Album> create(Album album);
Future<Album?> getById(int id);
Future<Album?> getByName(
String name, {
bool? shared,
bool? remote,
});
Future<Album> update(Album album);
Future<void> delete(int albumId);
Future<List<Album>> getAll({bool? shared});
Future<void> removeUsers(Album album, List<User> users);
Future<void> addAssets(Album album, List<Asset> assets);
Future<void> removeAssets(Album album, List<Asset> assets);
Future<Album> recalculateMetadata(Album album);
}

View File

@@ -0,0 +1,21 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAlbumMediaRepository {
Future<List<Album>> getAll();
Future<List<String>> getAssetIds(String albumId);
Future<int> getAssetCount(String albumId);
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
});
Future<Album> get(String id);
}

View File

@@ -0,0 +1,8 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IAssetRepository {
Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy});
Future<void> deleteById(List<int> ids);
}

View File

@@ -0,0 +1,7 @@
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IAssetMediaRepository {
Future<List<String>> deleteAll(List<String> ids);
Future<Asset?> get(String id);
}

View File

@@ -0,0 +1,5 @@
import 'package:immich_mobile/entities/backup_album.entity.dart';
abstract interface class IBackupRepository {
Future<List<String>> getIdsBySelection(BackupSelection backup);
}

View File

@@ -0,0 +1,30 @@
import 'dart:io';
import 'dart:typed_data';
import 'package:immich_mobile/entities/asset.entity.dart';
abstract interface class IFileMediaRepository {
Future<Asset?> saveImage(
Uint8List data, {
required String title,
String? relativePath,
});
Future<Asset?> saveVideo(
File file, {
required String title,
String? relativePath,
});
Future<Asset?> saveLivePhoto({
required File image,
required File video,
required String title,
});
Future<void> clearFileCache();
Future<void> enableBackgroundAccess();
Future<void> requestExtendedPermissions();
}

View File

@@ -0,0 +1,5 @@
import 'package:immich_mobile/entities/user.entity.dart';
abstract interface class IUserRepository {
Future<List<User>> getByIds(List<String> ids);
}

View File

@@ -1,45 +1,47 @@
import 'dart:typed_data';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/album.entity.dart';
class AvailableAlbum {
final AssetPathEntity albumEntity;
final Album album;
final int assetCount;
final DateTime? lastBackup;
AvailableAlbum({
required this.albumEntity,
required this.album,
required this.assetCount,
this.lastBackup,
});
AvailableAlbum copyWith({
AssetPathEntity? albumEntity,
Album? album,
int? assetCount,
DateTime? lastBackup,
Uint8List? thumbnailData,
}) {
return AvailableAlbum(
albumEntity: albumEntity ?? this.albumEntity,
album: album ?? this.album,
assetCount: assetCount ?? this.assetCount,
lastBackup: lastBackup ?? this.lastBackup,
);
}
String get name => albumEntity.name;
String get name => album.name;
Future<int> get assetCount => albumEntity.assetCountAsync;
String get id => album.localId!;
String get id => albumEntity.id;
bool get isAll => albumEntity.isAll;
bool get isAll => album.isAll;
@override
String toString() =>
'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)';
'AvailableAlbum(albumEntity: $album, lastBackup: $lastBackup)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is AvailableAlbum && other.albumEntity == albumEntity;
return other is AvailableAlbum && other.album == album;
}
@override
int get hashCode => albumEntity.hashCode;
int get hashCode => album.hashCode;
}

View File

@@ -1,9 +1,9 @@
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class BackupCandidate {
BackupCandidate({required this.asset, required this.albumNames});
AssetEntity asset;
Asset asset;
List<String> albumNames;
@override

View File

@@ -1,11 +1,11 @@
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class ErrorUploadAsset {
final String id;
final DateTime fileCreatedAt;
final String fileName;
final String fileType;
final AssetEntity asset;
final Asset asset;
final String errorMessage;
const ErrorUploadAsset({
@@ -22,7 +22,7 @@ class ErrorUploadAsset {
DateTime? fileCreatedAt,
String? fileName,
String? fileType,
AssetEntity? asset,
Asset? asset,
String? errorMessage,
}) {
return ErrorUploadAsset(

View File

@@ -1,28 +1,27 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class AlbumPreviewPage extends HookConsumerWidget {
final AssetPathEntity album;
final Album album;
const AlbumPreviewPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<AssetEntity>>([]);
final assets = useState<List<Asset>>([]);
getAssetsInAlbum() async {
assets.value = await album.getAssetListRange(
start: 0,
end: await album.assetCountAsync,
);
assets.value = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.localId!);
}
useEffect(
@@ -68,30 +67,10 @@ class AlbumPreviewPage extends HookConsumerWidget {
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
Future<Uint8List?> thumbData =
assets.value[index].thumbnailDataWithSize(
const ThumbnailSize(200, 200),
quality: 50,
);
return FutureBuilder<Uint8List?>(
future: thumbData,
builder: ((context, snapshot) {
if (snapshot.hasData && snapshot.data != null) {
return Image.memory(
snapshot.data!,
width: 100,
height: 100,
fit: BoxFit.cover,
);
}
return const SizedBox(
width: 100,
height: 100,
child: ImmichLoadingIndicator(),
);
}),
return ImmichThumbnail(
asset: assets.value[index],
width: 100,
height: 100,
);
},
),

View File

@@ -3,9 +3,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager_image_provider/photo_manager_image_provider.dart';
@RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget {
@@ -70,11 +69,10 @@ class FailedBackupStatusPage extends HookConsumerWidget {
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: AssetEntityImageProvider(
errorAsset.asset,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(512),
thumbnailFormat: ThumbnailFormat.jpeg,
image: ImmichLocalThumbnailProvider(
asset: errorAsset.asset,
height: 512,
width: 512,
),
),
),

View File

@@ -8,11 +8,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:auto_route/auto_route.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:path/path.dart' as p;
@@ -67,10 +67,10 @@ class EditImagePage extends ConsumerWidget {
) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
await PhotoManager.editor.saveImage(
imageData,
title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
);
await ref.read(fileMediaRepositoryProvider).saveImage(
imageData,
title: "${p.withoutExtension(asset.fileName)}_edited.jpg",
);
await ref.read(albumProvider.notifier).getDeviceAlbums();
Navigator.of(context).popUntil((route) => route.isFirst);
ImmichToast.show(

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@@ -15,7 +16,6 @@ import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/renderlist_generator.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class AssetNotifier extends StateNotifier<bool> {
final AssetService _assetService;
@@ -257,7 +257,7 @@ class AssetNotifier extends StateNotifier<bool> {
// Delete asset from device
if (local.isNotEmpty) {
try {
return await PhotoManager.editor.deleteWithIds(local);
return await _ref.read(assetMediaRepositoryProvider).deleteAll(local);
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}

View File

@@ -5,6 +5,9 @@ import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
@@ -13,6 +16,8 @@ import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/services/backup.service.dart';
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
@@ -28,7 +33,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(
@@ -38,6 +43,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
this._backgroundService,
this._galleryPermissionNotifier,
this._db,
this._albumMediaRepository,
this._fileMediaRepository,
this.ref,
) : super(
BackUpState(
@@ -86,6 +93,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackgroundService _backgroundService;
final GalleryPermissionNotifier _galleryPermissionNotifier;
final Isar _db;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
final Ref ref;
///
@@ -224,22 +233,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Stopwatch stopwatch = Stopwatch()..start();
// Get all albums on the device
List<AvailableAlbum> availableAlbums = [];
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(
hasAll: true,
type: RequestType.common,
);
List<Album> albums = await _albumMediaRepository.getAll();
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
Map<String, Album> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
for (Album album in albums) {
AvailableAlbum availableAlbum = AvailableAlbum(
album: album,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.localId!),
);
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
albumMap[album.localId!] = album;
}
state = state.copyWith(availableAlbums: availableAlbums);
@@ -248,14 +259,18 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final List<BackupAlbum> selectedBackupAlbums =
await _backupService.selectedAlbumsQuery().findAll();
// Generate AssetPathEntity from id to add to local state
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
AvailableAlbum(
album: albumAsset,
assetCount:
await _albumMediaRepository.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Selected album not found');
@@ -268,7 +283,13 @@ class BackupNotifier extends StateNotifier<BackUpState> {
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
AvailableAlbum(
album: albumAsset,
assetCount: await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(albumAsset.localId!),
lastBackup: ba.lastBackup,
),
);
} else {
log.severe('Excluded album not found');
@@ -297,23 +318,24 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final Set<BackupCandidate> assetsFromExcludedAlbums = {};
for (final album in state.selectedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: assetCount,
);
final assets = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.album.localId!);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [album.name];
final existingAsset = assetsFromSelectedAlbums.firstWhereOrNull(
(a) => a.asset.id == asset.id,
(a) => a.asset.localId == asset.localId,
);
if (existingAsset != null) {
@@ -331,16 +353,17 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
for (final album in state.excludedBackupAlbums) {
final assetCount = await album.albumEntity.assetCountAsync;
final assetCount = await ref
.read(albumMediaRepositoryProvider)
.getAssetCount(album.album.localId!);
if (assetCount == 0) {
continue;
}
final assets = await album.albumEntity.getAssetListRange(
start: 0,
end: assetCount,
);
final assets = await ref
.read(albumMediaRepositoryProvider)
.getAssets(album.album.localId!);
for (final asset in assets) {
assetsFromExcludedAlbums.add(
@@ -360,14 +383,14 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Find asset that were backup from selected albums
final Set<String> selectedAlbumsBackupAssets =
Set.from(allUniqueAssets.map((e) => e.asset.id));
Set.from(allUniqueAssets.map((e) => e.asset.localId));
selectedAlbumsBackupAssets
.removeWhere((assetId) => !allAssetsInDatabase.contains(assetId));
// Remove duplicated asset from all unique assets
allUniqueAssets.removeWhere(
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
);
if (allUniqueAssets.isEmpty) {
@@ -454,7 +477,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final hasPermission = _galleryPermissionNotifier.hasPermission;
if (hasPermission) {
await PhotoManager.clearFileCache();
await _fileMediaRepository.clearFileCache();
if (state.allUniqueAssets.isEmpty) {
log.info("No Asset On Device - Abort Backup Process");
@@ -465,7 +488,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
Set<BackupCandidate> assetsWillBeBackup = Set.from(state.allUniqueAssets);
// Remove item that has already been backed up
for (final assetId in state.allAssetsInDatabase) {
assetsWillBeBackup.removeWhere((e) => e.asset.id == assetId);
assetsWillBeBackup.removeWhere((e) => e.asset.localId == assetId);
}
if (assetsWillBeBackup.isEmpty) {
@@ -531,7 +554,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(
allUniqueAssets: state.allUniqueAssets
.where(
(candidate) => candidate.asset.id != result.candidate.asset.id,
(candidate) =>
candidate.asset.localId != result.candidate.asset.localId,
)
.toSet(),
);
@@ -539,11 +563,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith(
selectedAlbumsBackupAssetsIds: {
...state.selectedAlbumsBackupAssetsIds,
result.candidate.asset.id,
result.candidate.asset.localId!,
},
allAssetsInDatabase: [
...state.allAssetsInDatabase,
result.candidate.asset.id,
result.candidate.asset.localId!,
],
);
}
@@ -552,7 +576,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state.selectedAlbumsBackupAssetsIds.length ==
0) {
final latestAssetBackup = state.allUniqueAssets
.map((candidate) => candidate.asset.modifiedDateTime)
.map((candidate) => candidate.asset.fileModifiedAt)
.reduce(
(v, e) => e.isAfter(v) ? e : v,
);
@@ -741,6 +765,8 @@ final backupProvider =
ref.watch(backgroundServiceProvider),
ref.watch(galleryPermissionNotifier.notifier),
ref.watch(dbProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
ref,
);
});

View File

@@ -8,6 +8,7 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
@@ -27,7 +28,7 @@ import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final manualUploadProvider =
StateNotifierProvider<ManualUploadNotifier, ManualUploadState>((ref) {
@@ -193,17 +194,10 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
_backupProvider.updateBackupProgress(BackUpProgressEnum.manualInProgress);
if (ref.read(galleryPermissionNotifier.notifier).hasPermission) {
await PhotoManager.clearFileCache();
await ref.read(fileMediaRepositoryProvider).clearFileCache();
// We do not have 1:1 mapping of all AssetEntity fields to Asset. This results in cases
// where platform specific fields such as `subtype` used to detect platform specific assets such as
// LivePhoto in iOS is lost when we directly fetch the local asset from Asset using Asset.local
List<AssetEntity?> allAssetsFromDevice = await Future.wait(
allManualUploads
// Filter local only assets
.where((e) => e.isLocal && !e.isRemote)
.map((e) => e.local!.obtainForNewProperties()),
);
final allAssetsFromDevice =
allManualUploads.where((e) => e.isLocal && !e.isRemote).toList();
if (allAssetsFromDevice.length != allManualUploads.length) {
_log.warning(
@@ -221,11 +215,17 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
await _backupService.buildUploadCandidates(
selectedBackupAlbums,
excludedBackupAlbums,
useTimeFilter: false,
);
// Extrack candidate from allAssetsFromDevice.nonNulls
final uploadAssets = candidates
.where((e) => allAssetsFromDevice.nonNulls.contains(e.asset));
// Extrack candidate from allAssetsFromDevice
final uploadAssets = candidates.where(
(candidate) =>
allAssetsFromDevice.firstWhereOrNull(
(asset) => asset.localId == candidate.asset.localId,
) !=
null,
);
if (uploadAssets.isEmpty) {
debugPrint("[_startUpload] No Assets to upload - Abort Process");

View File

@@ -9,7 +9,7 @@ import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset
class ImmichLocalImageProvider extends ImageProvider<ImmichLocalImageProvider> {

View File

@@ -6,7 +6,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show ThumbnailSize;
/// The local image provider for an asset
/// Only viable

View File

@@ -0,0 +1,85 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
final albumRepositoryProvider =
Provider((ref) => AlbumRepository(ref.watch(dbProvider)));
class AlbumRepository implements IAlbumRepository {
final Isar _db;
AlbumRepository(
this._db,
);
@override
Future<int> count({bool? local}) {
if (local == true) return _db.albums.where().localIdIsNotNull().count();
if (local == false) return _db.albums.where().remoteIdIsNotNull().count();
return _db.albums.count();
}
@override
Future<Album> create(Album album) =>
_db.writeTxn(() => _db.albums.store(album));
@override
Future<Album?> getByName(String name, {bool? shared, bool? remote}) {
var query = _db.albums.filter().nameEqualTo(name);
if (shared != null) {
query = query.sharedEqualTo(shared);
}
if (remote == true) {
query = query.localIdIsNull();
} else if (remote == false) {
query = query.remoteIdIsNull();
}
return query.findFirst();
}
@override
Future<Album> update(Album album) =>
_db.writeTxn(() => _db.albums.store(album));
@override
Future<void> delete(int albumId) =>
_db.writeTxn(() => _db.albums.delete(albumId));
@override
Future<List<Album>> getAll({bool? shared}) {
final baseQuery = _db.albums.filter();
QueryBuilder<Album, Album, QAfterFilterCondition>? query;
if (shared != null) {
query = baseQuery.sharedEqualTo(true);
}
return query?.findAll() ?? _db.albums.where().findAll();
}
@override
Future<Album?> getById(int id) => _db.albums.get(id);
@override
Future<void> removeUsers(Album album, List<User> users) =>
_db.writeTxn(() => album.sharedUsers.update(unlink: users));
@override
Future<void> addAssets(Album album, List<Asset> assets) =>
_db.writeTxn(() => album.assets.update(link: assets));
@override
Future<void> removeAssets(Album album, List<Asset> assets) =>
_db.writeTxn(() => album.assets.update(unlink: assets));
@override
Future<Album> recalculateMetadata(Album album) async {
album.startDate = await album.assets.filter().fileCreatedAtProperty().min();
album.endDate = await album.assets.filter().fileCreatedAtProperty().max();
album.lastModifiedAssetTimestamp =
await album.assets.filter().updatedAtProperty().max();
return album;
}
}

View File

@@ -0,0 +1,93 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final albumMediaRepositoryProvider = Provider((ref) => AlbumMediaRepository());
class AlbumMediaRepository implements IAlbumMediaRepository {
@override
Future<List<Album>> getAll() async {
final List<AssetPathEntity> assetPathEntities =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
return assetPathEntities.map(_toAlbum).toList();
}
@override
Future<List<String>> getAssetIds(String albumId) async {
final album = await AssetPathEntity.fromId(albumId);
final List<AssetEntity> assets =
await album.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
return assets.map((e) => e.id).toList();
}
@override
Future<int> getAssetCount(String albumId) async {
final album = await AssetPathEntity.fromId(albumId);
return album.assetCountAsync;
}
@override
Future<List<Asset>> getAssets(
String albumId, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
bool orderByModificationDate = false,
}) async {
final onDevice = await AssetPathEntity.fromId(
albumId,
filterOption: FilterOptionGroup(
containsPathModified: true,
orders: orderByModificationDate
? [const OrderOption(type: OrderOptionType.updateDate)]
: [],
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
updateTimeCond: modifiedFrom == null && modifiedUntil == null
? null
: DateTimeCond(
min: modifiedFrom ?? DateTime.utc(-271820),
max: modifiedUntil ?? DateTime.utc(275760),
),
),
);
final List<AssetEntity> assets =
await onDevice.getAssetListRange(start: start, end: end);
return assets.map(AssetMediaRepository.toAsset).toList().cast();
}
@override
Future<Album> get(
String id, {
DateTime? modifiedFrom,
DateTime? modifiedUntil,
}) async {
final assetPathEntity = await AssetPathEntity.fromId(id);
return _toAlbum(assetPathEntity);
}
static Album _toAlbum(AssetPathEntity assetPathEntity) {
final Album album = Album(
name: assetPathEntity.name,
createdAt:
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
modifiedAt:
assetPathEntity.lastModified?.toUtc() ?? DateTime.now().toUtc(),
shared: false,
activityEnabled: false,
);
album.owner.value = Store.get(StoreKey.currentUser);
album.localId = assetPathEntity.id;
album.isAll = assetPathEntity.isAll;
return album;
}
}

View File

@@ -0,0 +1,31 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
final assetRepositoryProvider =
Provider((ref) => AssetRepository(ref.watch(dbProvider)));
class AssetRepository implements IAssetRepository {
final Isar _db;
AssetRepository(
this._db,
);
@override
Future<List<Asset>> getByAlbum(Album album, {User? notOwnedBy}) {
var query = album.assets.filter();
if (notOwnedBy != null) {
query = query.not().ownerIdEqualTo(notOwnedBy.isarId);
}
return query.findAll();
}
@override
Future<void> deleteById(List<int> ids) =>
_db.writeTxn(() => _db.assets.deleteAll(ids));
}

View File

@@ -0,0 +1,46 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final assetMediaRepositoryProvider = Provider((ref) => AssetMediaRepository());
class AssetMediaRepository implements IAssetMediaRepository {
@override
Future<List<String>> deleteAll(List<String> ids) =>
PhotoManager.editor.deleteWithIds(ids);
@override
Future<Asset?> get(String id) async {
final entity = await AssetEntity.fromId(id);
return toAsset(entity);
}
static Asset? toAsset(AssetEntity? local) {
if (local == null) return null;
final Asset asset = Asset(
checksum: "",
localId: local.id,
ownerId: Store.get(StoreKey.currentUser).isarId,
fileCreatedAt: local.createDateTime,
fileModifiedAt: local.modifiedDateTime,
updatedAt: local.modifiedDateTime,
durationInSeconds: local.duration,
type: AssetType.values[local.typeInt],
fileName: local.title!,
width: local.width,
height: local.height,
isFavorite: local.isFavorite,
);
if (asset.fileCreatedAt.year == 1970) {
asset.fileCreatedAt = asset.fileModifiedAt;
}
if (local.latitude != null) {
asset.exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
asset.local = local;
return asset;
}
}

View File

@@ -0,0 +1,20 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
final backupRepositoryProvider =
Provider((ref) => BackupRepository(ref.watch(dbProvider)));
class BackupRepository implements IBackupRepository {
final Isar _db;
BackupRepository(
this._db,
);
@override
Future<List<String>> getIdsBySelection(BackupSelection backup) =>
_db.backupAlbums.filter().selectionEqualTo(backup).idProperty().findAll();
}

View File

@@ -0,0 +1,62 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
final fileMediaRepositoryProvider = Provider((ref) => FileMediaRepository());
class FileMediaRepository implements IFileMediaRepository {
@override
Future<Asset?> saveImage(
Uint8List data, {
required String title,
String? relativePath,
}) async {
final entity = await PhotoManager.editor
.saveImage(data, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<Asset?> saveLivePhoto({
required File image,
required File video,
required String title,
}) async {
final entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: image,
videoFile: video,
title: title,
);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<Asset?> saveVideo(
File file, {
required String title,
String? relativePath,
}) async {
final entity = await PhotoManager.editor.saveVideo(
file,
title: title,
relativePath: relativePath,
);
return AssetMediaRepository.toAsset(entity);
}
@override
Future<void> clearFileCache() => PhotoManager.clearFileCache();
@override
Future<void> enableBackgroundAccess() =>
PhotoManager.setIgnorePermissionCheck(true);
@override
Future<void> requestExtendedPermissions() =>
PhotoManager.requestPermissionExtend();
}

View File

@@ -0,0 +1,20 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:isar/isar.dart';
final userRepositoryProvider =
Provider((ref) => UserRepository(ref.watch(dbProvider)));
class UserRepository implements IUserRepository {
final Isar _db;
UserRepository(
this._db,
);
@override
Future<List<User>> getByIds(List<String> ids) async =>
(await _db.users.getAllById(ids)).cast();
}

View File

@@ -63,7 +63,6 @@ import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:isar/isar.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
import 'package:photo_manager/photo_manager.dart' hide LatLng;
part 'router.gr.dart';

View File

@@ -185,7 +185,7 @@ class AlbumOptionsRouteArgs {
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
AlbumPreviewRoute({
Key? key,
required AssetPathEntity album,
required Album album,
List<PageRouteInfo>? children,
}) : super(
AlbumPreviewRoute.name,
@@ -218,7 +218,7 @@ class AlbumPreviewRouteArgs {
final Key? key;
final AssetPathEntity album;
final Album album;
@override
String toString() {

View File

@@ -5,6 +5,11 @@ import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/models/albums/album_add_asset_response.model.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/album.entity.dart';
@@ -12,21 +17,27 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider(
(ref) => AlbumService(
ref.watch(apiServiceProvider),
ref.watch(userServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
ref.watch(albumRepositoryProvider),
ref.watch(assetRepositoryProvider),
ref.watch(userRepositoryProvider),
ref.watch(backupRepositoryProvider),
ref.watch(albumMediaRepositoryProvider),
),
);
@@ -34,7 +45,11 @@ class AlbumService {
final ApiService _apiService;
final UserService _userService;
final SyncService _syncService;
final Isar _db;
final IAlbumRepository _albumRepository;
final IAssetRepository _assetRepository;
final IUserRepository _userRepository;
final IBackupRepository _backupAlbumRepository;
final IAlbumMediaRepository _albumMediaRepository;
final Logger _log = Logger('AlbumService');
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
@@ -43,16 +58,13 @@ class AlbumService {
this._apiService,
this._userService,
this._syncService,
this._db,
this._albumRepository,
this._assetRepository,
this._userRepository,
this._backupAlbumRepository,
this._albumMediaRepository,
);
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
selectedAlbumsQuery() =>
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.select);
QueryBuilder<BackupAlbum, BackupAlbum, QAfterFilterCondition>
excludedAlbumsQuery() =>
_db.backupAlbums.filter().selectionEqualTo(BackupSelection.exclude);
/// Checks all selected device albums for changes of albums and their assets
/// Updates the local database and returns `true` if there were any changes
Future<bool> refreshDeviceAlbums() async {
@@ -65,22 +77,18 @@ class AlbumService {
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
final List<String> excludedIds =
await excludedAlbumsQuery().idProperty().findAll();
final List<String> selectedIds =
await selectedAlbumsQuery().idProperty().findAll();
final List<String> excludedIds = await _backupAlbumRepository
.getIdsBySelection(BackupSelection.exclude);
final List<String> selectedIds = await _backupAlbumRepository
.getIdsBySelection(BackupSelection.select);
if (selectedIds.isEmpty) {
final numLocal = await _db.albums.where().localIdIsNotNull().count();
final numLocal = await _albumRepository.count(local: true);
if (numLocal > 0) {
_syncService.removeAllLocalAlbumsAndAssets();
}
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
final List<Album> onDevice = await _albumMediaRepository.getAll();
_log.info("Found ${onDevice.length} device albums");
Set<String>? excludedAssets;
if (excludedIds.isNotEmpty) {
@@ -96,13 +104,15 @@ class AlbumService {
_log.info("Found ${excludedAssets.length} assets to exclude");
}
// remove all excluded albums
onDevice.removeWhere((e) => excludedIds.contains(e.id));
onDevice.removeWhere((e) => excludedIds.contains(e.localId));
_log.info(
"Ignoring ${excludedIds.length} excluded albums resulting in ${onDevice.length} device albums",
);
}
final hasAll = selectedIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.map(
(id) => onDevice.firstWhereOrNull((album) => album.localId == id),
)
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
@@ -114,7 +124,7 @@ class AlbumService {
}
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !selectedIds.contains(e.id));
onDevice.removeWhere((e) => !selectedIds.contains(e.localId));
_log.info("'Recents' is not selected, keeping only selected albums");
}
changes =
@@ -128,15 +138,15 @@ class AlbumService {
}
Future<Set<String>> _loadExcludedAssetIds(
List<AssetPathEntity> albums,
List<Album> albums,
List<String> excludedAlbumIds,
) async {
final Set<String> result = HashSet<String>();
for (AssetPathEntity a in albums) {
if (excludedAlbumIds.contains(a.id)) {
final List<AssetEntity> assets =
await a.getAssetListRange(start: 0, end: 0x7fffffffffffffff);
result.addAll(assets.map((e) => e.id));
for (Album album in albums) {
if (excludedAlbumIds.contains(album.localId)) {
final assetIds =
await _albumMediaRepository.getAssetIds(album.localId!);
result.addAll(assetIds);
}
}
return result;
@@ -194,8 +204,8 @@ class AlbumService {
),
);
if (remote != null) {
Album album = await Album.remote(remote);
await _db.writeTxn(() => _db.albums.store(album));
final Album album = await Album.remote(remote);
await _albumRepository.create(album);
return album;
}
} catch (e) {
@@ -212,8 +222,7 @@ class AlbumService {
for (int round = 0;; round++) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (null ==
await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
if (null == await _albumRepository.getByName(proposedName)) {
return proposedName;
}
}
@@ -268,20 +277,15 @@ class AlbumService {
Future<void> _updateAssets(
int albumId, {
Iterable<Asset> add = const [],
Iterable<Asset> remove = const [],
}) {
return _db.writeTxn(() async {
final album = await _db.albums.get(albumId);
if (album == null) return;
await album.assets.update(link: add, unlink: remove);
album.startDate =
await album.assets.filter().fileCreatedAtProperty().min();
album.endDate = await album.assets.filter().fileCreatedAtProperty().max();
album.lastModifiedAssetTimestamp =
await album.assets.filter().updatedAtProperty().max();
await _db.albums.put(album);
});
List<Asset> add = const [],
List<Asset> remove = const [],
}) async {
final album = await _albumRepository.getById(albumId);
if (album == null) return;
await _albumRepository.addAssets(album, add);
await _albumRepository.removeAssets(album, remove);
await _albumRepository.recalculateMetadata(album);
await _albumRepository.update(album);
}
Future<bool> addAdditionalUserToAlbum(
@@ -298,13 +302,9 @@ class AlbumService {
AddUsersDto(albumUsers: albumUsers),
);
if (result != null) {
album.sharedUsers
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
album.sharedUsers.addAll(await _userRepository.getByIds(sharedUserIds));
album.shared = result.shared;
await _db.writeTxn(() async {
await _db.albums.put(album);
await album.sharedUsers.save();
});
await _albumRepository.update(album);
return true;
}
} catch (e) {
@@ -321,7 +321,7 @@ class AlbumService {
);
if (result != null) {
album.activityEnabled = enabled;
await _db.writeTxn(() => _db.albums.put(album));
await _albumRepository.update(album);
return true;
}
} catch (e) {
@@ -332,29 +332,29 @@ class AlbumService {
Future<bool> deleteAlbum(Album album) async {
try {
final userId = Store.get(StoreKey.currentUser).isarId;
if (album.owner.value?.isarId == userId) {
final user = Store.get(StoreKey.currentUser);
if (album.owner.value?.isarId == user.isarId) {
await _apiService.albumsApi.deleteAlbum(album.remoteId!);
}
if (album.shared) {
final foreignAssets =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
await _db.writeTxn(() => _db.albums.delete(album.id));
final List<Album> albums =
await _db.albums.filter().sharedEqualTo(true).findAll();
await _assetRepository.getByAlbum(album, notOwnedBy: user);
await _albumRepository.delete(album.id);
final List<Album> albums = await _albumRepository.getAll(shared: true);
final List<Asset> existing = [];
for (Album a in albums) {
for (Album album in albums) {
existing.addAll(
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
await _assetRepository.getByAlbum(album, notOwnedBy: user),
);
}
final List<int> idsToRemove =
_syncService.sharedAssetsToRemove(foreignAssets, existing);
if (idsToRemove.isNotEmpty) {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
await _assetRepository.deleteById(idsToRemove);
}
} else {
await _db.writeTxn(() => _db.albums.delete(album.id));
await _albumRepository.delete(album.id);
}
return true;
} catch (e) {
@@ -390,7 +390,7 @@ class AlbumService {
: response
.where((e) => e.success)
.map((e) => assets.firstWhere((a) => a.remoteId == e.id));
await _updateAssets(album.id, remove: toRemove);
await _updateAssets(album.id, remove: toRemove.toList());
return true;
}
} catch (e) {
@@ -410,12 +410,10 @@ class AlbumService {
);
album.sharedUsers.remove(user);
await _db.writeTxn(() async {
await album.sharedUsers.update(unlink: [user]);
final a = await _db.albums.get(album.id);
// trigger watcher
await _db.albums.put(a!);
});
await _albumRepository.removeUsers(album, [user]);
final a = await _albumRepository.getById(album.id);
// trigger watcher
await _albumRepository.update(a!);
return true;
} catch (e) {
@@ -436,7 +434,7 @@ class AlbumService {
),
);
album.name = newAlbumTitle;
await _db.writeTxn(() => _db.albums.put(album));
await _albumRepository.update(album);
return true;
} catch (e) {
@@ -445,14 +443,8 @@ class AlbumService {
}
}
Future<Album?> getAlbumByName(String name, bool remoteOnly) async {
return _db.albums
.filter()
.optional(remoteOnly, (q) => q.localIdIsNull())
.nameEqualTo(name)
.sharedEqualTo(false)
.findFirst();
}
Future<Album?> getAlbumByName(String name, bool remoteOnly) =>
_albumRepository.getByName(name, remote: remoteOnly ? true : null);
///
/// Add the uploaded asset to the selected albums

View File

@@ -321,7 +321,7 @@ class AssetService {
for (BackupCandidate candidate in candidates) {
final asset = remoteAssets.firstWhereOrNull(
(a) => a.localId == candidate.asset.id,
(a) => a.localId == candidate.asset.localId,
);
if (asset != null) {

View File

@@ -12,6 +12,12 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/main.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/repositories/album.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/backup.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/localization.service.dart';
@@ -30,7 +36,7 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:isar/isar.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backgroundServiceProvider = Provider(
(ref) => BackgroundService(),
@@ -355,14 +361,34 @@ class BackgroundService {
AppSettingsService settingService = AppSettingsService();
AppSettingsService settingsService = AppSettingsService();
PartnerService partnerService = PartnerService(apiService, db);
HashService hashService = HashService(db, this);
SyncService syncSerive = SyncService(db, hashService);
AlbumRepository albumRepository = AlbumRepository(db);
AssetRepository assetRepository = AssetRepository(db);
UserRepository userRepository = UserRepository(db);
BackupRepository backupAlbumRepository = BackupRepository(db);
AlbumMediaRepository albumMediaRepository = AlbumMediaRepository();
FileMediaRepository fileMediaRepository = FileMediaRepository();
HashService hashService = HashService(db, this, albumMediaRepository);
SyncService syncSerive = SyncService(db, hashService, albumMediaRepository);
UserService userService =
UserService(apiService, db, syncSerive, partnerService);
AlbumService albumService =
AlbumService(apiService, userService, syncSerive, db);
BackupService backupService =
BackupService(apiService, db, settingService, albumService);
AlbumService albumService = AlbumService(
apiService,
userService,
syncSerive,
albumRepository,
assetRepository,
userRepository,
backupAlbumRepository,
albumMediaRepository,
);
BackupService backupService = BackupService(
apiService,
db,
settingService,
albumService,
albumMediaRepository,
fileMediaRepository,
);
final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync();
final excludedAlbums = backupService.excludedAlbumsQuery().findAllSync();
@@ -370,7 +396,7 @@ class BackgroundService {
return true;
}
await PhotoManager.setIgnorePermissionCheck(true);
await fileMediaRepository.enableBackgroundAccess();
do {
final bool backupOk = await _runBackup(

View File

@@ -6,9 +6,13 @@ import 'package:cancellation_token_http/http.dart' as http;
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/models/backup/backup_candidate.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/models/backup/error_upload_asset.model.dart';
@@ -16,6 +20,8 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@@ -24,7 +30,7 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:permission_handler/permission_handler.dart' as pm;
import 'package:photo_manager/photo_manager.dart';
import 'package:photo_manager/photo_manager.dart' show PMProgressHandler;
final backupServiceProvider = Provider(
(ref) => BackupService(
@@ -32,6 +38,8 @@ final backupServiceProvider = Provider(
ref.watch(dbProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(albumMediaRepositoryProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
@@ -42,12 +50,16 @@ class BackupService {
final Logger _log = Logger("BackupService");
final AppSettingsService _appSetting;
final AlbumService _albumService;
final IAlbumMediaRepository _albumMediaRepository;
final IFileMediaRepository _fileMediaRepository;
BackupService(
this._apiService,
this._db,
this._appSetting,
this._albumService,
this._albumMediaRepository,
this._fileMediaRepository,
);
Future<List<String>?> getDeviceBackupAsset() async {
@@ -86,44 +98,17 @@ class BackupService {
List<BackupAlbum> excludedBackupAlbums, {
bool useTimeFilter = true,
}) async {
final filter = FilterOptionGroup(
containsPathModified: true,
orders: [const OrderOption(type: OrderOptionType.updateDate)],
// title is needed to create Assets
imageOption: const FilterOption(needTitle: true),
videoOption: const FilterOption(needTitle: true),
);
final now = DateTime.now();
final List<AssetPathEntity?> selectedAlbums =
await _loadAlbumsWithTimeFilter(
selectedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
if (selectedAlbums.every((e) => e == null)) {
return {};
}
final List<AssetPathEntity?> excludedAlbums =
await _loadAlbumsWithTimeFilter(
excludedBackupAlbums,
filter,
now,
useTimeFilter: useTimeFilter,
);
final Set<BackupCandidate> toAdd = await _fetchAssetsAndUpdateLastBackup(
selectedAlbums,
selectedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
);
if (toAdd.isEmpty) return {};
final Set<BackupCandidate> toRemove = await _fetchAssetsAndUpdateLastBackup(
excludedAlbums,
excludedBackupAlbums,
now,
useTimeFilter: useTimeFilter,
@@ -132,92 +117,62 @@ class BackupService {
return toAdd.difference(toRemove);
}
Future<List<AssetPathEntity?>> _loadAlbumsWithTimeFilter(
List<BackupAlbum> albums,
FilterOptionGroup filter,
DateTime now, {
bool useTimeFilter = true,
}) async {
List<AssetPathEntity?> result = [];
for (BackupAlbum backupAlbum in albums) {
try {
final optionGroup = useTimeFilter
? filter.copyWith(
updateTimeCond: DateTimeCond(
// subtract 2 seconds to prevent missing assets due to rounding issues
min: backupAlbum.lastBackup
.subtract(const Duration(seconds: 2)),
max: now,
),
)
: filter;
final AssetPathEntity album =
await AssetPathEntity.obtainPathFromProperties(
id: backupAlbum.id,
optionGroup: optionGroup,
maxDateTimeToNow: false,
);
result.add(album);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
}
}
return result;
}
Future<Set<BackupCandidate>> _fetchAssetsAndUpdateLastBackup(
List<AssetPathEntity?> localAlbums,
List<BackupAlbum> backupAlbums,
DateTime now, {
bool useTimeFilter = true,
}) async {
Set<BackupCandidate> candidate = {};
Set<BackupCandidate> candidates = {};
for (int i = 0; i < localAlbums.length; i++) {
final localAlbum = localAlbums[i];
if (localAlbum == null) {
for (final BackupAlbum backupAlbum in backupAlbums) {
final Album localAlbum;
try {
localAlbum = await _albumMediaRepository.get(backupAlbum.id);
} on StateError {
// the album no longer exists
continue;
}
if (useTimeFilter &&
localAlbum.lastModified?.isBefore(backupAlbums[i].lastBackup) ==
true) {
localAlbum.modifiedAt.isBefore(backupAlbum.lastBackup)) {
continue;
}
final List<Asset> assets;
try {
assets = await _albumMediaRepository.getAssets(
backupAlbum.id,
modifiedFrom: useTimeFilter
?
// subtract 2 seconds to prevent missing assets due to rounding issues
backupAlbum.lastBackup.subtract(const Duration(seconds: 2))
: null,
modifiedUntil: useTimeFilter ? now : null,
);
} on StateError {
// either there are no assets matching the filter criteria OR the album no longer exists
continue;
}
final assets = await localAlbum.getAssetListRange(
start: 0,
end: await localAlbum.assetCountAsync,
);
// Add album's name to the asset info
for (final asset in assets) {
List<String> albumNames = [localAlbum.name];
final existingAsset = candidate.firstWhereOrNull(
(a) => a.asset.id == asset.id,
final existingAsset = candidates.firstWhereOrNull(
(candidate) => candidate.asset.localId == asset.localId,
);
if (existingAsset != null) {
albumNames.addAll(existingAsset.albumNames);
candidate.remove(existingAsset);
candidates.remove(existingAsset);
}
candidate.add(
BackupCandidate(
asset: asset,
albumNames: albumNames,
),
);
candidates.add(BackupCandidate(asset: asset, albumNames: albumNames));
}
backupAlbums[i].lastBackup = now;
backupAlbum.lastBackup = now;
}
return candidate;
return candidates;
}
/// Returns a new list of assets not yet uploaded
@@ -230,7 +185,7 @@ class BackupService {
final Set<String> duplicatedAssetIds = await getDuplicatedAssetIds();
candidates.removeWhere(
(candidate) => duplicatedAssetIds.contains(candidate.asset.id),
(candidate) => duplicatedAssetIds.contains(candidate.asset.localId),
);
if (candidates.isEmpty) {
@@ -243,7 +198,7 @@ class BackupService {
final CheckExistingAssetsResponseDto? duplicates =
await _apiService.assetsApi.checkExistingAssets(
CheckExistingAssetsDto(
deviceAssetIds: candidates.map((c) => c.asset.id).toList(),
deviceAssetIds: candidates.map((c) => c.asset.localId!).toList(),
deviceId: deviceId,
),
);
@@ -259,7 +214,7 @@ class BackupService {
}
if (existing.isNotEmpty) {
candidates.removeWhere((c) => existing.contains(c.asset.id));
candidates.removeWhere((c) => existing.contains(c.asset.localId));
}
return candidates;
@@ -278,7 +233,7 @@ class BackupService {
// DON'T KNOW WHY BUT THIS HELPS BACKGROUND BACKUP TO WORK ON IOS
if (Platform.isIOS) {
await PhotoManager.requestPermissionExtend();
await _fileMediaRepository.requestExtendedPermissions();
}
return true;
@@ -289,9 +244,9 @@ class BackupService {
List<BackupCandidate> _sortPhotosFirst(List<BackupCandidate> candidates) {
return candidates.sorted(
(a, b) {
final cmp = a.asset.typeInt - b.asset.typeInt;
final cmp = a.asset.type.index - b.asset.type.index;
if (cmp != 0) return cmp;
return a.asset.createDateTime.compareTo(b.asset.createDateTime);
return a.asset.fileCreatedAt.compareTo(b.asset.fileCreatedAt);
},
);
}
@@ -325,13 +280,13 @@ class BackupService {
}
for (final candidate in candidates) {
final AssetEntity entity = candidate.asset;
final Asset asset = candidate.asset;
File? file;
File? livePhotoFile;
try {
final isAvailableLocally =
await entity.isLocallyAvailable(isOrigin: true);
await asset.local!.isLocallyAvailable(isOrigin: true);
// Handle getting files from iCloud
if (!isAvailableLocally && Platform.isIOS) {
@@ -342,39 +297,41 @@ class BackupService {
onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
fileName: await entity.titleAsync,
fileType: _getAssetType(entity.type),
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt.year == 1970
? asset.fileModifiedAt
: asset.fileCreatedAt,
fileName: asset.fileName,
fileType: _getAssetType(asset.type),
iCloudAsset: true,
),
);
file = await entity.loadFile(progressHandler: pmProgressHandler);
if (entity.isLivePhoto) {
livePhotoFile = await entity.loadFile(
file =
await asset.local!.loadFile(progressHandler: pmProgressHandler);
if (asset.local!.isLivePhoto) {
livePhotoFile = await asset.local!.loadFile(
withSubtype: true,
progressHandler: pmProgressHandler,
);
}
} else {
if (entity.type == AssetType.video) {
file = await entity.originFile;
if (asset.type == AssetType.video) {
file = await asset.local!.originFile;
} else {
file = await entity.originFile.timeout(const Duration(seconds: 5));
if (entity.isLivePhoto) {
livePhotoFile = await entity.originFileWithSubtype
file = await asset.local!.originFile
.timeout(const Duration(seconds: 5));
if (asset.local!.isLivePhoto) {
livePhotoFile = await asset.local!.originFileWithSubtype
.timeout(const Duration(seconds: 5));
}
}
}
if (file != null) {
String originalFileName = await entity.titleAsync;
String originalFileName = asset.fileName;
if (entity.isLivePhoto) {
if (asset.local!.isLivePhoto) {
if (livePhotoFile == null) {
_log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName",
@@ -398,31 +355,31 @@ class BackupService {
baseRequest.headers.addAll(ApiService.getRequestHeaders());
baseRequest.headers["Transfer-Encoding"] = "chunked";
baseRequest.fields['deviceAssetId'] = entity.id;
baseRequest.fields['deviceAssetId'] = asset.localId!;
baseRequest.fields['deviceId'] = deviceId;
baseRequest.fields['fileCreatedAt'] =
entity.createDateTime.toUtc().toIso8601String();
asset.fileCreatedAt.toUtc().toIso8601String();
baseRequest.fields['fileModifiedAt'] =
entity.modifiedDateTime.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = entity.isFavorite.toString();
baseRequest.fields['duration'] = entity.videoDuration.toString();
asset.fileModifiedAt.toUtc().toIso8601String();
baseRequest.fields['isFavorite'] = asset.isFavorite.toString();
baseRequest.fields['duration'] = asset.duration.toString();
baseRequest.files.add(assetRawUploadData);
onCurrentAsset(
CurrentUploadAsset(
id: entity.id,
fileCreatedAt: entity.createDateTime.year == 1970
? entity.modifiedDateTime
: entity.createDateTime,
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt.year == 1970
? asset.fileModifiedAt
: asset.fileCreatedAt,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
fileType: _getAssetType(asset.type),
fileSize: file.lengthSync(),
iCloudAsset: false,
),
);
String? livePhotoVideoId;
if (entity.isLivePhoto && livePhotoFile != null) {
if (asset.local!.isLivePhoto && livePhotoFile != null) {
livePhotoVideoId = await uploadLivePhotoVideo(
originalFileName,
livePhotoFile,
@@ -448,16 +405,16 @@ class BackupService {
final errorMessage = error['message'] ?? error['error'];
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}",
"Error(${error['statusCode']}) uploading ${asset.localId} | $originalFileName | Created on ${asset.fileCreatedAt} | ${error['error']}",
);
onError(
ErrorUploadAsset(
asset: entity,
id: entity.id,
fileCreatedAt: entity.createDateTime,
asset: asset,
id: asset.localId!,
fileCreatedAt: asset.fileCreatedAt,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
fileType: _getAssetType(candidate.asset.type),
errorMessage: errorMessage,
),
);
@@ -473,7 +430,7 @@ class BackupService {
bool isDuplicate = false;
if (response.statusCode == 200) {
isDuplicate = true;
duplicatedAssetIds.add(entity.id);
duplicatedAssetIds.add(asset.localId!);
}
onSuccess(

View File

@@ -8,17 +8,19 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:photo_manager/photo_manager.dart' show PhotoManager;
/// Finds duplicates originating from missing EXIF information
class BackupVerificationService {
final Isar _db;
final IFileMediaRepository _fileMediaRepository;
BackupVerificationService(this._db);
BackupVerificationService(this._db, this._fileMediaRepository);
/// Returns at most [limit] assets that were backed up without exif
Future<List<Asset>> findWronglyBackedUpAssets({int limit = 100}) async {
@@ -71,6 +73,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
final upper = compute(
@@ -81,6 +84,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
toDelete = await lower + await upper;
@@ -93,6 +97,7 @@ class BackupVerificationService {
auth: Store.get(StoreKey.accessToken),
endpoint: Store.get(StoreKey.serverEndpoint),
rootIsolateToken: isolateToken,
fileMediaRepository: _fileMediaRepository,
),
);
}
@@ -106,12 +111,13 @@ class BackupVerificationService {
String auth,
String endpoint,
RootIsolateToken rootIsolateToken,
IFileMediaRepository fileMediaRepository,
}) tuple,
) async {
assert(tuple.deleteCandidates.length == tuple.originals.length);
final List<Asset> result = [];
BackgroundIsolateBinaryMessenger.ensureInitialized(tuple.rootIsolateToken);
await PhotoManager.setIgnorePermissionCheck(true);
await tuple.fileMediaRepository.enableBackgroundAccess();
final ApiService apiService = ApiService();
apiService.setEndpoint(tuple.endpoint);
apiService.setAccessToken(tuple.auth);
@@ -228,5 +234,6 @@ class BackupVerificationService {
final backupVerificationServiceProvider = Provider(
(ref) => BackupVerificationService(
ref.watch(dbProvider),
ref.watch(fileMediaRepositoryProvider),
),
);

View File

@@ -2,6 +2,9 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
@@ -11,38 +14,46 @@ import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
class HashService {
HashService(this._db, this._backgroundService);
HashService(this._db, this._backgroundService, this._albumMediaRepository);
final Isar _db;
final BackgroundService _backgroundService;
final IAlbumMediaRepository _albumMediaRepository;
final _log = Logger('HashService');
/// Returns all assets that were successfully hashed
Future<List<Asset>> getHashedAssets(
AssetPathEntity album, {
Album album, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
Set<String>? excludedAssets,
}) async {
final entities = await album.getAssetListRange(start: start, end: end);
final entities = await _albumMediaRepository.getAssets(
album.localId!,
start: start,
end: end,
modifiedFrom: modifiedFrom,
modifiedUntil: modifiedUntil,
);
final filtered = excludedAssets == null
? entities
: entities.where((e) => !excludedAssets.contains(e.id)).toList();
: entities.where((e) => !excludedAssets.contains(e.localId!)).toList();
return _hashAssets(filtered);
}
/// Converts a list of [AssetEntity]s to [Asset]s including only those
/// Processes a list of local [Asset]s, storing their hash and returning only those
/// that were successfully hashed. Hashes are looked up in a DB table
/// [AndroidDeviceAsset] / [IOSDeviceAsset] by local id. Only missing
/// entries are newly hashed and added to the DB table.
Future<List<Asset>> _hashAssets(List<AssetEntity> assetEntities) async {
Future<List<Asset>> _hashAssets(List<Asset> assets) async {
const int batchFileCount = 128;
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
final ids = assetEntities
.map(Platform.isAndroid ? (a) => a.id.toInt() : (a) => a.id)
final ids = assets
.map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!)
.toList();
final List<DeviceAsset?> hashes = await _lookupHashes(ids);
final List<DeviceAsset> toAdd = [];
@@ -50,22 +61,16 @@ class HashService {
int bytes = 0;
for (int i = 0; i < assetEntities.length; i++) {
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null) {
continue;
}
final file = await assetEntities[i].originFile;
final file = await assets[i].local!.originFile;
if (file == null) {
final fileName = await assetEntities[i].titleAsync.catchError((error) {
_log.warning(
"Failed to get title for asset ${assetEntities[i].id}",
);
return "";
});
final fileName = assets[i].fileName;
_log.warning(
"Failed to get file for asset ${assetEntities[i].id}, name: $fileName, created on: ${assetEntities[i].createDateTime}, skipping",
"Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping",
);
continue;
}
@@ -86,7 +91,7 @@ class HashService {
if (toHash.isNotEmpty) {
await _processBatch(toHash, toAdd);
}
return _mapAllHashedAssets(assetEntities, hashes);
return _getHashedAssets(assets, hashes);
}
/// Lookup hashes of assets by their local ID
@@ -133,15 +138,16 @@ class HashService {
return hashes;
}
/// Converts [AssetEntity]s that were successfully hashed to [Asset]s
List<Asset> _mapAllHashedAssets(
List<AssetEntity> assets,
/// Returns all successfully hashed [Asset]s with their hash value set
List<Asset> _getHashedAssets(
List<Asset> assets,
List<DeviceAsset?> hashes,
) {
final List<Asset> result = [];
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null && hashes[i]!.hash.isNotEmpty) {
result.add(Asset.local(assets[i], hashes[i]!.hash));
assets[i].byteHash = hashes[i]!.hash;
result.add(assets[i]);
}
}
return result;
@@ -152,5 +158,6 @@ final hashServiceProvider = Provider(
(ref) => HashService(
ref.watch(dbProvider),
ref.watch(backgroundServiceProvider),
ref.watch(albumMediaRepositoryProvider),
),
);

View File

@@ -3,21 +3,27 @@ import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/response_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:path_provider/path_provider.dart';
final imageViewerServiceProvider =
Provider((ref) => ImageViewerService(ref.watch(apiServiceProvider)));
final imageViewerServiceProvider = Provider(
(ref) => ImageViewerService(
ref.watch(apiServiceProvider),
ref.watch(fileMediaRepositoryProvider),
),
);
class ImageViewerService {
final ApiService _apiService;
final IFileMediaRepository _fileMediaRepository;
final Logger _log = Logger("ImageViewerService");
ImageViewerService(this._apiService);
ImageViewerService(this._apiService, this._fileMediaRepository);
Future<bool> downloadAsset(Asset asset) async {
File? imageFile;
@@ -46,7 +52,7 @@ class ImageViewerService {
return false;
}
AssetEntity? entity;
Asset? resultAsset;
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/livephoto.mov').create();
@@ -54,24 +60,21 @@ class ImageViewerService {
videoFile.writeAsBytesSync(motionResponse.bodyBytes);
imageFile.writeAsBytesSync(imageResponse.bodyBytes);
entity = await PhotoManager.editor.darwin.saveLivePhoto(
imageFile: imageFile,
videoFile: videoFile,
resultAsset = await _fileMediaRepository.saveLivePhoto(
image: imageFile,
video: videoFile,
title: asset.fileName,
);
if (entity == null) {
if (resultAsset == null) {
_log.warning(
"Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
);
entity = await PhotoManager.editor.saveImage(
imageResponse.bodyBytes,
title: asset.fileName,
);
resultAsset = await _fileMediaRepository
.saveImage(imageResponse.bodyBytes, title: asset.fileName);
}
return entity != null;
return resultAsset != null;
} else {
var res = await _apiService.assetsApi
.downloadAssetWithHttpInfo(asset.remoteId!);
@@ -81,11 +84,11 @@ class ImageViewerService {
return false;
}
final AssetEntity? entity;
final Asset? resultAsset;
final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
if (asset.isImage) {
entity = await PhotoManager.editor.saveImage(
resultAsset = await _fileMediaRepository.saveImage(
res.bodyBytes,
title: asset.fileName,
relativePath: relativePath,
@@ -94,13 +97,13 @@ class ImageViewerService {
final tempDir = await getTemporaryDirectory();
videoFile = await File('${tempDir.path}/${asset.fileName}').create();
videoFile.writeAsBytesSync(res.bodyBytes);
entity = await PhotoManager.editor.saveVideo(
resultAsset = await _fileMediaRepository.saveVideo(
videoFile,
title: asset.fileName,
relativePath: relativePath,
);
}
return entity != null;
return resultAsset != null;
}
} catch (error, stack) {
_log.severe("Error saving downloaded asset", error, stack);

View File

@@ -8,7 +8,9 @@ import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/exif_info.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
@@ -17,19 +19,23 @@ import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final syncServiceProvider = Provider(
(ref) => SyncService(ref.watch(dbProvider), ref.watch(hashServiceProvider)),
(ref) => SyncService(
ref.watch(dbProvider),
ref.watch(hashServiceProvider),
ref.watch(albumMediaRepositoryProvider),
),
);
class SyncService {
final Isar _db;
final HashService _hashService;
final IAlbumMediaRepository _albumMediaRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
SyncService(this._db, this._hashService);
SyncService(this._db, this._hashService, this._albumMediaRepository);
// public methods:
@@ -68,7 +74,7 @@ class SyncService {
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
List<Album> onDevice, [
Set<String>? excludedAssets,
]) =>
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice, excludedAssets));
@@ -492,7 +498,7 @@ class SyncService {
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(
List<AssetPathEntity> onDevice, [
List<Album> onDevice, [
Set<String>? excludedAssets,
]) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
@@ -504,16 +510,15 @@ class SyncService {
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
both: (AssetPathEntity ape, Album album) => _syncAlbumInDbAndOnDevice(
ape,
album,
compare: (Album a, Album b) => a.localId!.compareTo(b.localId!),
both: (Album a, Album b) => _syncAlbumInDbAndOnDevice(
a,
b,
deleteCandidates,
existing,
excludedAssets,
),
onlyFirst: (AssetPathEntity ape) =>
_addAlbumFromDevice(ape, existing, excludedAssets),
onlyFirst: (Album a) => _addAlbumFromDevice(a, existing, excludedAssets),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
);
_log.fine(
@@ -541,58 +546,65 @@ class SyncService {
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
Album album,
Album deviceAlbum,
Album dbAlbum,
List<Asset> deleteCandidates,
List<Asset> existing, [
Set<String>? excludedAssets,
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
_log.fine("Local album ${ape.name} has not changed. Skipping sync.");
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
_log.fine(
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
);
return false;
}
if (!forceRefresh &&
excludedAssets == null &&
await _syncDeviceAlbumFast(ape, album)) {
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
return true;
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
final inDb = await album.assets
final inDb = await dbAlbum.assets
.filter()
.ownerIdEqualTo(Store.get(StoreKey.currentUser).isarId)
.sortByChecksum()
.findAll();
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final int assetCountOnDevice = await ape.assetCountAsync;
final List<Asset> onDevice =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
final int assetCountOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final List<Asset> onDevice = await _hashService.getHashedAssets(
deviceAlbum,
excludedAssets: excludedAssets,
);
_removeDuplicates(onDevice);
// _removeDuplicates sorts `onDevice` by checksum
final (toAdd, toUpdate, toDelete) = _diffAssets(onDevice, inDb);
if (toAdd.isEmpty &&
toUpdate.isEmpty &&
toDelete.isEmpty &&
album.name == ape.name &&
ape.lastModified != null &&
album.modifiedAt.isAtSameMomentAs(ape.lastModified!)) {
dbAlbum.name == deviceAlbum.name &&
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
// changes only affeted excluded albums
_log.fine(
"Only excluded assets in local album ${ape.name} changed. Stopping sync.",
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
);
if (assetCountOnDevice !=
_db.eTags.getByIdSync(ape.eTagKeyAssetCount)?.assetCount) {
_db.eTags.getByIdSync(deviceAlbum.eTagKeyAssetCount)?.assetCount) {
await _db.writeTxn(
() => _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
),
);
}
return false;
}
_log.fine(
"Syncing local album ${ape.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
"Syncing local album ${deviceAlbum.name}. ${toAdd.length} assets to add, ${toUpdate.length} to update, ${toDelete.length} to delete",
);
final (existingInDb, updated) = await _linkWithExistingFromDb(toAdd);
_log.fine(
@@ -600,28 +612,31 @@ class SyncService {
);
deleteCandidates.addAll(toDelete);
existing.addAll(existingInDb);
album.name = ape.name;
album.modifiedAt = ape.lastModified ?? DateTime.now();
if (album.thumbnail.value != null &&
toDelete.contains(album.thumbnail.value)) {
album.thumbnail.value = null;
dbAlbum.name = deviceAlbum.name;
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
if (dbAlbum.thumbnail.value != null &&
toDelete.contains(dbAlbum.thumbnail.value)) {
dbAlbum.thumbnail.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await _db.assets.putAll(toUpdate);
await album.assets
await dbAlbum.assets
.update(link: existingInDb + updated, unlink: toDelete);
await _db.albums.put(album);
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
await _db.albums.put(dbAlbum);
dbAlbum.thumbnail.value ??= await dbAlbum.assets.filter().findFirst();
await dbAlbum.thumbnail.save();
await _db.eTags.put(
ETag(id: ape.eTagKeyAssetCount, assetCount: assetCountOnDevice),
ETag(
id: deviceAlbum.eTagKeyAssetCount,
assetCount: assetCountOnDevice,
),
);
});
_log.info("Synced changes of local album ${ape.name} to DB");
_log.info("Synced changes of local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to update synced album ${ape.name} in DB", e);
_log.severe("Failed to update synced album ${deviceAlbum.name} in DB", e);
}
return true;
@@ -629,45 +644,45 @@ class SyncService {
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
if (!(ape.lastModified ?? DateTime.now()).isAfter(album.modifiedAt)) {
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
return false;
}
final int totalOnDevice = await ape.assetCountAsync;
final int totalOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final int lastKnownTotal =
(await _db.eTags.getById(ape.eTagKeyAssetCount))?.assetCount ?? 0;
final AssetPathEntity? modified = totalOnDevice > lastKnownTotal
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified ?? DateTime.now(),
),
),
)
: null;
if (modified == null) {
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))?.assetCount ??
0;
if (totalOnDevice <= lastKnownTotal) {
return false;
}
final List<Asset> newAssets = await _hashService.getHashedAssets(modified);
final List<Asset> newAssets = await _hashService.getHashedAssets(
deviceAlbum,
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
modifiedUntil: deviceAlbum.modifiedAt,
);
if (totalOnDevice != lastKnownTotal + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified ?? DateTime.now();
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
_removeDuplicates(newAssets);
final (existingInDb, updated) = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(updated);
await album.assets.update(link: existingInDb + updated);
await _db.albums.put(album);
await _db.eTags
.put(ETag(id: ape.eTagKeyAssetCount, assetCount: totalOnDevice));
await dbAlbum.assets.update(link: existingInDb + updated);
await _db.albums.put(dbAlbum);
await _db.eTags.put(
ETag(id: deviceAlbum.eTagKeyAssetCount, assetCount: totalOnDevice),
);
});
_log.info("Fast synced local album ${ape.name} to DB");
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
} on IsarError catch (e) {
_log.severe("Failed to fast sync local album ${ape.name} to DB", e);
_log.severe(
"Failed to fast sync local album ${deviceAlbum.name} to DB",
e,
);
return false;
}
@@ -677,14 +692,15 @@ class SyncService {
/// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice(
AssetPathEntity ape,
Album album,
List<Asset> existing, [
Set<String>? excludedAssets,
]) async {
_log.info("Syncing a new local album to DB: ${ape.name}");
final Album a = Album.local(ape);
final assets =
await _hashService.getHashedAssets(ape, excludedAssets: excludedAssets);
_log.info("Syncing a new local album to DB: ${album.name}");
final assets = await _hashService.getHashedAssets(
album,
excludedAssets: excludedAssets,
);
_removeDuplicates(assets);
final (existingInDb, updated) = await _linkWithExistingFromDb(assets);
_log.info(
@@ -692,15 +708,15 @@ class SyncService {
);
await upsertAssetsWithExif(updated);
existing.addAll(existingInDb);
a.assets.addAll(existingInDb);
a.assets.addAll(updated);
album.assets.addAll(existingInDb);
album.assets.addAll(updated);
final thumb = existingInDb.firstOrNull ?? updated.firstOrNull;
a.thumbnail.value = thumb;
album.thumbnail.value = thumb;
try {
await _db.writeTxn(() => _db.albums.store(a));
_log.info("Added a new local album to DB: ${ape.name}");
await _db.writeTxn(() => _db.albums.store(album));
_log.info("Added a new local album to DB: ${album.name}");
} on IsarError catch (e) {
_log.severe("Failed to add new local album ${ape.name} to DB", e);
_log.severe("Failed to add new local album ${album.name} to DB", e);
}
}
@@ -798,12 +814,15 @@ class SyncService {
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
return a.name != b.name ||
a.lastModified == null ||
!a.lastModified!.isAtSameMomentAs(b.modifiedAt) ||
await a.assetCountAsync !=
(await _db.eTags.getById(a.eTagKeyAssetCount))?.assetCount;
Future<bool> _hasAlbumChangeOnDevice(
Album deviceAlbum,
Album dbAlbum,
) async {
return deviceAlbum.name != dbAlbum.name ||
!deviceAlbum.modifiedAt.isAtSameMomentAs(dbAlbum.modifiedAt) ||
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!) !=
(await _db.eTags.getById(deviceAlbum.eTagKeyAssetCount))
?.assetCount;
}
Future<bool> _removeAllLocalAlbumsAndAssets() async {

View File

@@ -12,6 +12,16 @@ dynamic upgradeDto(dynamic value, String targetType) {
addDefault(value, 'tags', TagsResponse().toJson());
}
break;
case 'UserResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
case 'UserAdminResponseDto':
if (value is Map) {
addDefault(value, 'profileChangedAt', DateTime.now().toIso8601String());
}
break;
}
}

View File

@@ -183,23 +183,13 @@ class AlbumInfoCard extends HookConsumerWidget {
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: FutureBuilder(
builder: ((context, snapshot) {
if (snapshot.hasData) {
return Text(
snapshot.data.toString() +
(album.isAll
? " (${'backup_all'.tr()})"
: ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
);
}
return const Text("0");
}),
future: album.assetCount,
child: Text(
album.assetCount.toString() +
(album.isAll ? " (${'backup_all'.tr()})" : ""),
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
),
],
@@ -208,7 +198,7 @@ class AlbumInfoCard extends HookConsumerWidget {
IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: album.albumEntity),
AlbumPreviewRoute(album: album.album),
);
},
icon: Icon(

View File

@@ -1,6 +1,5 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@@ -24,19 +23,10 @@ class AlbumInfoListTile extends HookConsumerWidget {
ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded =
ref.watch(backupProvider).excludedBackupAlbums.contains(album);
final assetCount = useState(0);
final syncAlbum = ref
.watch(appSettingsServiceProvider)
.getSetting(AppSettingsEnum.syncAlbums);
useEffect(
() {
album.assetCount.then((value) => assetCount.value = value);
return null;
},
[album],
);
buildTileColor() {
if (isSelected) {
return context.isDarkTheme
@@ -117,11 +107,11 @@ class AlbumInfoListTile extends HookConsumerWidget {
fontWeight: FontWeight.bold,
),
),
subtitle: Text(assetCount.value.toString()),
subtitle: Text(album.assetCount.toString()),
trailing: IconButton(
onPressed: () {
context.pushRoute(
AlbumPreviewRoute(album: album.albumEntity),
AlbumPreviewRoute(album: album.album),
);
},
icon: Icon(

View File

@@ -2,18 +2,19 @@ import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
const CurrentUploadingAssetInfoBox({super.key});
@@ -148,17 +149,6 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
);
}
buildAssetThumbnail() async {
var assetEntity = await AssetEntity.fromId(asset.id);
if (assetEntity != null) {
return assetEntity.thumbnailDataWithSize(
const ThumbnailSize(500, 500),
quality: 100,
);
}
}
buildiCloudDownloadProgerssBar() {
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
return Padding(
@@ -239,8 +229,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
);
}
return FutureBuilder<Uint8List?>(
future: buildAssetThumbnail(),
return FutureBuilder<Asset?>(
future: ref.read(assetMediaRepositoryProvider).get(asset.id),
builder: (context, thumbnail) => ListTile(
isThreeLine: true,
leading: AnimatedCrossFade(
@@ -250,9 +240,8 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
child: thumbnail.hasData
? ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Image.memory(
thumbnail.data!,
fit: BoxFit.cover,
child: ImmichThumbnail(
asset: thumbnail.data,
width: 50,
height: 50,
),

View File

@@ -124,6 +124,7 @@ Class | Method | HTTP request | Description
*FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix |
*FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports |
*FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum |
*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs |
*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs |
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} |
*LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries |
@@ -330,6 +331,7 @@ Class | Method | HTTP request | Description
- [JobCommand](doc//JobCommand.md)
- [JobCommandDto](doc//JobCommandDto.md)
- [JobCountsDto](doc//JobCountsDto.md)
- [JobCreateDto](doc//JobCreateDto.md)
- [JobName](doc//JobName.md)
- [JobSettingsDto](doc//JobSettingsDto.md)
- [JobStatusDto](doc//JobStatusDto.md)
@@ -341,6 +343,7 @@ Class | Method | HTTP request | Description
- [LoginCredentialDto](doc//LoginCredentialDto.md)
- [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md)
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
- [MapTheme](doc//MapTheme.md)
@@ -449,6 +452,7 @@ Class | Method | HTTP request | Description
- [ToneMapping](doc//ToneMapping.md)
- [TranscodeHWAccel](doc//TranscodeHWAccel.md)
- [TranscodePolicy](doc//TranscodePolicy.md)
- [TrashResponseDto](doc//TrashResponseDto.md)
- [UpdateAlbumDto](doc//UpdateAlbumDto.md)
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)

View File

@@ -144,6 +144,7 @@ part 'model/image_format.dart';
part 'model/job_command.dart';
part 'model/job_command_dto.dart';
part 'model/job_counts_dto.dart';
part 'model/job_create_dto.dart';
part 'model/job_name.dart';
part 'model/job_settings_dto.dart';
part 'model/job_status_dto.dart';
@@ -155,6 +156,7 @@ part 'model/log_level.dart';
part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart';
part 'model/map_theme.dart';
@@ -263,6 +265,7 @@ part 'model/time_bucket_size.dart';
part 'model/tone_mapping.dart';
part 'model/transcode_hw_accel.dart';
part 'model/transcode_policy.dart';
part 'model/trash_response_dto.dart';
part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart';

View File

@@ -16,6 +16,45 @@ class JobsApi {
final ApiClient apiClient;
/// Performs an HTTP 'POST /jobs' operation and returns the [Response].
/// Parameters:
///
/// * [JobCreateDto] jobCreateDto (required):
Future<Response> createJobWithHttpInfo(JobCreateDto jobCreateDto,) async {
// ignore: prefer_const_declarations
final path = r'/jobs';
// ignore: prefer_final_locals
Object? postBody = jobCreateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [JobCreateDto] jobCreateDto (required):
Future<void> createJob(JobCreateDto jobCreateDto,) async {
final response = await createJobWithHttpInfo(jobCreateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /jobs' operation and returns the [Response].
Future<Response> getAllJobsStatusWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@@ -42,11 +42,19 @@ class TrashApi {
);
}
Future<void> emptyTrash() async {
Future<TrashResponseDto?> emptyTrash() async {
final response = await emptyTrashWithHttpInfo();
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /trash/restore/assets' operation and returns the [Response].
@@ -81,11 +89,19 @@ class TrashApi {
/// Parameters:
///
/// * [BulkIdsDto] bulkIdsDto (required):
Future<void> restoreAssets(BulkIdsDto bulkIdsDto,) async {
Future<TrashResponseDto?> restoreAssets(BulkIdsDto bulkIdsDto,) async {
final response = await restoreAssetsWithHttpInfo(bulkIdsDto,);
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /trash/restore' operation and returns the [Response].
@@ -114,10 +130,18 @@ class TrashApi {
);
}
Future<void> restoreTrash() async {
Future<TrashResponseDto?> restoreTrash() async {
final response = await restoreTrashWithHttpInfo();
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'TrashResponseDto',) as TrashResponseDto;
}
return null;
}
}

View File

@@ -343,6 +343,8 @@ class ApiClient {
return JobCommandDto.fromJson(value);
case 'JobCountsDto':
return JobCountsDto.fromJson(value);
case 'JobCreateDto':
return JobCreateDto.fromJson(value);
case 'JobName':
return JobNameTypeTransformer().decode(value);
case 'JobSettingsDto':
@@ -365,6 +367,8 @@ class ApiClient {
return LoginResponseDto.fromJson(value);
case 'LogoutResponseDto':
return LogoutResponseDto.fromJson(value);
case 'ManualJobName':
return ManualJobNameTypeTransformer().decode(value);
case 'MapMarkerResponseDto':
return MapMarkerResponseDto.fromJson(value);
case 'MapReverseGeocodeResponseDto':
@@ -581,6 +585,8 @@ class ApiClient {
return TranscodeHWAccelTypeTransformer().decode(value);
case 'TranscodePolicy':
return TranscodePolicyTypeTransformer().decode(value);
case 'TrashResponseDto':
return TrashResponseDto.fromJson(value);
case 'UpdateAlbumDto':
return UpdateAlbumDto.fromJson(value);
case 'UpdateAlbumUserDto':

View File

@@ -97,6 +97,9 @@ String parameterToString(dynamic value) {
if (value is LogLevel) {
return LogLevelTypeTransformer().encode(value).toString();
}
if (value is ManualJobName) {
return ManualJobNameTypeTransformer().encode(value).toString();
}
if (value is MapTheme) {
return MapThemeTypeTransformer().encode(value).toString();
}

View File

@@ -13,30 +13,36 @@ part of openapi.api;
class CreateProfileImageResponseDto {
/// Returns a new [CreateProfileImageResponseDto] instance.
CreateProfileImageResponseDto({
required this.profileChangedAt,
required this.profileImagePath,
required this.userId,
});
DateTime profileChangedAt;
String profileImagePath;
String userId;
@override
bool operator ==(Object other) => identical(this, other) || other is CreateProfileImageResponseDto &&
other.profileChangedAt == profileChangedAt &&
other.profileImagePath == profileImagePath &&
other.userId == userId;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(profileChangedAt.hashCode) +
(profileImagePath.hashCode) +
(userId.hashCode);
@override
String toString() => 'CreateProfileImageResponseDto[profileImagePath=$profileImagePath, userId=$userId]';
String toString() => 'CreateProfileImageResponseDto[profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, userId=$userId]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
json[r'userId'] = this.userId;
return json;
@@ -50,6 +56,7 @@ class CreateProfileImageResponseDto {
final json = value.cast<String, dynamic>();
return CreateProfileImageResponseDto(
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
userId: mapValueOfType<String>(json, r'userId')!,
);
@@ -99,6 +106,7 @@ class CreateProfileImageResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'profileChangedAt',
'profileImagePath',
'userId',
};

View File

@@ -71,7 +71,7 @@ class ExifResponseDto {
DateTime? modifyDate;
String? orientation;
ExifResponseDtoOrientationEnum? orientation;
String? projectionType;
@@ -289,7 +289,9 @@ class ExifResponseDto {
make: mapValueOfType<String>(json, r'make'),
model: mapValueOfType<String>(json, r'model'),
modifyDate: mapDateTime(json, r'modifyDate', r''),
orientation: mapValueOfType<String>(json, r'orientation'),
orientation: json[r'orientation'] == null
? null
: ExifResponseDtoOrientationEnum.parse('${json[r'orientation']}'),
projectionType: mapValueOfType<String>(json, r'projectionType'),
rating: json[r'rating'] == null
? null
@@ -346,3 +348,95 @@ class ExifResponseDto {
};
}
class ExifResponseDtoOrientationEnum {
/// Instantiate a new enum with the provided [value].
const ExifResponseDtoOrientationEnum._(this.value);
/// The underlying value of this enum member.
final num value;
@override
String toString() => value.toString();
num toJson() => value;
static const n1 = ExifResponseDtoOrientationEnum._('1');
static const n2 = ExifResponseDtoOrientationEnum._('2');
static const n8 = ExifResponseDtoOrientationEnum._('8');
static const n7 = ExifResponseDtoOrientationEnum._('7');
static const n3 = ExifResponseDtoOrientationEnum._('3');
static const n4 = ExifResponseDtoOrientationEnum._('4');
static const n6 = ExifResponseDtoOrientationEnum._('6');
static const n5 = ExifResponseDtoOrientationEnum._('5');
/// List of all possible values in this [enum][ExifResponseDtoOrientationEnum].
static const values = <ExifResponseDtoOrientationEnum>[
n1,
n2,
n8,
n7,
n3,
n4,
n6,
n5,
];
static ExifResponseDtoOrientationEnum? fromJson(dynamic value) => ExifResponseDtoOrientationEnumTypeTransformer().decode(value);
static List<ExifResponseDtoOrientationEnum> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ExifResponseDtoOrientationEnum>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ExifResponseDtoOrientationEnum.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ExifResponseDtoOrientationEnum] to num,
/// and [decode] dynamic data back to [ExifResponseDtoOrientationEnum].
class ExifResponseDtoOrientationEnumTypeTransformer {
factory ExifResponseDtoOrientationEnumTypeTransformer() => _instance ??= const ExifResponseDtoOrientationEnumTypeTransformer._();
const ExifResponseDtoOrientationEnumTypeTransformer._();
num encode(ExifResponseDtoOrientationEnum data) => data.value;
/// Decodes a [dynamic value][data] to a ExifResponseDtoOrientationEnum.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ExifResponseDtoOrientationEnum? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case '1': return ExifResponseDtoOrientationEnum.n1;
case '2': return ExifResponseDtoOrientationEnum.n2;
case '8': return ExifResponseDtoOrientationEnum.n8;
case '7': return ExifResponseDtoOrientationEnum.n7;
case '3': return ExifResponseDtoOrientationEnum.n3;
case '4': return ExifResponseDtoOrientationEnum.n4;
case '6': return ExifResponseDtoOrientationEnum.n6;
case '5': return ExifResponseDtoOrientationEnum.n5;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ExifResponseDtoOrientationEnumTypeTransformer] instance.
static ExifResponseDtoOrientationEnumTypeTransformer? _instance;
}

View File

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

View File

@@ -0,0 +1,88 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 ManualJobName {
/// Instantiate a new enum with the provided [value].
const ManualJobName._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const personCleanup = ManualJobName._(r'person-cleanup');
static const tagCleanup = ManualJobName._(r'tag-cleanup');
static const userCleanup = ManualJobName._(r'user-cleanup');
/// List of all possible values in this [enum][ManualJobName].
static const values = <ManualJobName>[
personCleanup,
tagCleanup,
userCleanup,
];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
static List<ManualJobName> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ManualJobName>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ManualJobName.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [ManualJobName] to String,
/// and [decode] dynamic data back to [ManualJobName].
class ManualJobNameTypeTransformer {
factory ManualJobNameTypeTransformer() => _instance ??= const ManualJobNameTypeTransformer._();
const ManualJobNameTypeTransformer._();
String encode(ManualJobName data) => data.value;
/// Decodes a [dynamic value][data] to a ManualJobName.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
ManualJobName? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'person-cleanup': return ManualJobName.personCleanup;
case r'tag-cleanup': return ManualJobName.tagCleanup;
case r'user-cleanup': return ManualJobName.userCleanup;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [ManualJobNameTypeTransformer] instance.
static ManualJobNameTypeTransformer? _instance;
}

View File

@@ -18,6 +18,7 @@ class PartnerResponseDto {
required this.id,
this.inTimeline,
required this.name,
required this.profileChangedAt,
required this.profileImagePath,
});
@@ -37,6 +38,8 @@ class PartnerResponseDto {
String name;
DateTime profileChangedAt;
String profileImagePath;
@override
@@ -46,6 +49,7 @@ class PartnerResponseDto {
other.id == id &&
other.inTimeline == inTimeline &&
other.name == name &&
other.profileChangedAt == profileChangedAt &&
other.profileImagePath == profileImagePath;
@override
@@ -56,10 +60,11 @@ class PartnerResponseDto {
(id.hashCode) +
(inTimeline == null ? 0 : inTimeline!.hashCode) +
(name.hashCode) +
(profileChangedAt.hashCode) +
(profileImagePath.hashCode);
@override
String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, email=$email, id=$id, inTimeline=$inTimeline, name=$name, profileImagePath=$profileImagePath]';
String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, email=$email, id=$id, inTimeline=$inTimeline, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -72,6 +77,7 @@ class PartnerResponseDto {
// json[r'inTimeline'] = null;
}
json[r'name'] = this.name;
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
return json;
}
@@ -89,6 +95,7 @@ class PartnerResponseDto {
id: mapValueOfType<String>(json, r'id')!,
inTimeline: mapValueOfType<bool>(json, r'inTimeline'),
name: mapValueOfType<String>(json, r'name')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
);
}
@@ -141,6 +148,7 @@ class PartnerResponseDto {
'email',
'id',
'name',
'profileChangedAt',
'profileImagePath',
};
}

View File

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

View File

@@ -22,6 +22,7 @@ class UserAdminResponseDto {
required this.license,
required this.name,
required this.oauthId,
required this.profileChangedAt,
required this.profileImagePath,
required this.quotaSizeInBytes,
required this.quotaUsageInBytes,
@@ -49,6 +50,8 @@ class UserAdminResponseDto {
String oauthId;
DateTime profileChangedAt;
String profileImagePath;
int? quotaSizeInBytes;
@@ -74,6 +77,7 @@ class UserAdminResponseDto {
other.license == license &&
other.name == name &&
other.oauthId == oauthId &&
other.profileChangedAt == profileChangedAt &&
other.profileImagePath == profileImagePath &&
other.quotaSizeInBytes == quotaSizeInBytes &&
other.quotaUsageInBytes == quotaUsageInBytes &&
@@ -94,6 +98,7 @@ class UserAdminResponseDto {
(license == null ? 0 : license!.hashCode) +
(name.hashCode) +
(oauthId.hashCode) +
(profileChangedAt.hashCode) +
(profileImagePath.hashCode) +
(quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) +
(quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) +
@@ -103,7 +108,7 @@ class UserAdminResponseDto {
(updatedAt.hashCode);
@override
String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -124,6 +129,7 @@ class UserAdminResponseDto {
}
json[r'name'] = this.name;
json[r'oauthId'] = this.oauthId;
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
if (this.quotaSizeInBytes != null) {
json[r'quotaSizeInBytes'] = this.quotaSizeInBytes;
@@ -163,6 +169,7 @@ class UserAdminResponseDto {
license: UserLicense.fromJson(json[r'license']),
name: mapValueOfType<String>(json, r'name')!,
oauthId: mapValueOfType<String>(json, r'oauthId')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
quotaSizeInBytes: mapValueOfType<int>(json, r'quotaSizeInBytes'),
quotaUsageInBytes: mapValueOfType<int>(json, r'quotaUsageInBytes'),
@@ -226,6 +233,7 @@ class UserAdminResponseDto {
'license',
'name',
'oauthId',
'profileChangedAt',
'profileImagePath',
'quotaSizeInBytes',
'quotaUsageInBytes',

View File

@@ -17,6 +17,7 @@ class UserResponseDto {
required this.email,
required this.id,
required this.name,
required this.profileChangedAt,
required this.profileImagePath,
});
@@ -28,6 +29,8 @@ class UserResponseDto {
String name;
DateTime profileChangedAt;
String profileImagePath;
@override
@@ -36,6 +39,7 @@ class UserResponseDto {
other.email == email &&
other.id == id &&
other.name == name &&
other.profileChangedAt == profileChangedAt &&
other.profileImagePath == profileImagePath;
@override
@@ -45,10 +49,11 @@ class UserResponseDto {
(email.hashCode) +
(id.hashCode) +
(name.hashCode) +
(profileChangedAt.hashCode) +
(profileImagePath.hashCode);
@override
String toString() => 'UserResponseDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileImagePath=$profileImagePath]';
String toString() => 'UserResponseDto[avatarColor=$avatarColor, email=$email, id=$id, name=$name, profileChangedAt=$profileChangedAt, profileImagePath=$profileImagePath]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -56,6 +61,7 @@ class UserResponseDto {
json[r'email'] = this.email;
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'profileChangedAt'] = this.profileChangedAt.toUtc().toIso8601String();
json[r'profileImagePath'] = this.profileImagePath;
return json;
}
@@ -72,6 +78,7 @@ class UserResponseDto {
email: mapValueOfType<String>(json, r'email')!,
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
profileChangedAt: mapDateTime(json, r'profileChangedAt', r'')!,
profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!,
);
}
@@ -124,6 +131,7 @@ class UserResponseDto {
'email',
'id',
'name',
'profileChangedAt',
'profileImagePath',
};
}

View File

@@ -836,6 +836,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
immich_mobile_immich_lint:
dependency: "direct dev"
description:
path: immich_lint
relative: true
source: path
version: "0.0.0"
integration_test:
dependency: "direct dev"
description: flutter

View File

@@ -94,10 +94,12 @@ dev_dependencies:
isar_generator: ^3.1.0+1
integration_test:
sdk: flutter
custom_lint: ^0.6.0
custom_lint: ^0.6.4
riverpod_lint: ^2.3.7
riverpod_generator: ^2.3.9
mocktail: ^1.0.3
immich_mobile_immich_lint:
path: './immich_lint'
flutter:
uses-material-design: true

6
mobile/scripts/fdroid_build_isar.sh Normal file → Executable file
View File

@@ -1,6 +1,8 @@
#!/usr/bin/env sh
cd .isar || exit
test -d .isar || exit
cp .isar-cargo.lock .isar/Cargo.lock
(cd .isar || exit
bash tool/build_android.sh x86
bash tool/build_android.sh x64
bash tool/build_android.sh armv7
@@ -13,4 +15,4 @@ mv libisar_android_x64.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86_64/
mv libisar_android_x86.so libisar.so
mv libisar.so ../.pub-cache/hosted/pub.dev/isar_flutter_libs-*/android/src/main/jniLibs/x86/
cd ..
)

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env sh
isar_version="$(awk '/isar: /{gsub(/\^/, "", $2); print $2}' pubspec.yaml)"
checked_out_version="$(git -C .isar describe --tags)"
if [ "$isar_version" = "$checked_out_version" ]; then
echo "isar is up-to-date."
exit 0
fi
echo "Updating from version $checked_out_version to $isar_version."
git -C .isar checkout "$isar_version"
cargo generate-lockfile --manifest-path .isar/Cargo.toml
mv .isar/Cargo.lock .isar-cargo.lock

View File

@@ -1,11 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:mocktail/mocktail.dart';
class MockHashService extends Mock implements HashService {}
class MockCurrentUserProvider extends StateNotifier<User?>
with Mock
implements CurrentUserProvider {

View File

@@ -7,8 +7,9 @@ import 'package:immich_mobile/services/immich_logger.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:isar/isar.dart';
import '../../repository.mocks.dart';
import '../../service.mocks.dart';
import '../../test_utils.dart';
import 'shared_mocks.dart';
void main() {
Asset makeAsset({
@@ -38,6 +39,8 @@ void main() {
group('Test SyncService grouped', () {
late final Isar db;
final MockHashService hs = MockHashService();
final MockAlbumMediaRepository albumMediaRepository =
MockAlbumMediaRepository();
final owner = User(
id: "1",
updatedAt: DateTime.now(),
@@ -67,7 +70,7 @@ void main() {
});
});
test('test inserting existing assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
@@ -85,7 +88,7 @@ void main() {
});
test('test inserting new assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "2-1"),
@@ -106,7 +109,7 @@ void main() {
});
test('test syncing duplicate assets', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> remoteAssets = [
makeAsset(checksum: "a", remoteId: "0-1"),
makeAsset(checksum: "b", remoteId: "1-1"),
@@ -154,7 +157,7 @@ void main() {
});
test('test efficient sync', () async {
SyncService s = SyncService(db, hs);
SyncService s = SyncService(db, hs, albumMediaRepository);
final List<Asset> toUpsert = [
makeAsset(checksum: "a", remoteId: "0-1"), // changed
makeAsset(checksum: "f", remoteId: "0-2"), // new

View File

@@ -0,0 +1,22 @@
import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_media.interface.dart';
import 'package:immich_mobile/interfaces/backup.interface.dart';
import 'package:immich_mobile/interfaces/file_media.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:mocktail/mocktail.dart';
class MockAlbumRepository extends Mock implements IAlbumRepository {}
class MockAssetRepository extends Mock implements IAssetRepository {}
class MockUserRepository extends Mock implements IUserRepository {}
class MockBackupRepository extends Mock implements IBackupRepository {}
class MockAlbumMediaRepository extends Mock implements IAlbumMediaRepository {}
class MockAssetMediaRepository extends Mock implements IAssetMediaRepository {}
class MockFileMediaRepository extends Mock implements IFileMediaRepository {}

View File

@@ -0,0 +1,13 @@
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/services/user.service.dart';
import 'package:mocktail/mocktail.dart';
class MockApiService extends Mock implements ApiService {}
class MockUserService extends Mock implements UserService {}
class MockSyncService extends Mock implements SyncService {}
class MockHashService extends Mock implements HashService {}

View File

@@ -0,0 +1,73 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:mocktail/mocktail.dart';
import '../fixtures/album.stub.dart';
import '../repository.mocks.dart';
import '../service.mocks.dart';
void main() {
late AlbumService sut;
late MockApiService apiService;
late MockUserService userService;
late MockSyncService syncService;
late MockAlbumRepository albumRepository;
late MockAssetRepository assetRepository;
late MockUserRepository userRepository;
late MockBackupRepository backupRepository;
late MockAlbumMediaRepository albumMediaRepository;
setUp(() {
apiService = MockApiService();
userService = MockUserService();
syncService = MockSyncService();
albumRepository = MockAlbumRepository();
assetRepository = MockAssetRepository();
userRepository = MockUserRepository();
backupRepository = MockBackupRepository();
albumMediaRepository = MockAlbumMediaRepository();
sut = AlbumService(
apiService,
userService,
syncService,
albumRepository,
assetRepository,
userRepository,
backupRepository,
albumMediaRepository,
);
});
group('refreshDeviceAlbums', () {
test('empty selection with one album in db', () async {
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude))
.thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
.thenAnswer((_) async => []);
when(() => albumRepository.count(local: true)).thenAnswer((_) async => 1);
when(() => syncService.removeAllLocalAlbumsAndAssets())
.thenAnswer((_) async => true);
final result = await sut.refreshDeviceAlbums();
expect(result, false);
verify(() => syncService.removeAllLocalAlbumsAndAssets());
});
test('one selected albums, two on device', () async {
when(() => backupRepository.getIdsBySelection(BackupSelection.exclude))
.thenAnswer((_) async => []);
when(() => backupRepository.getIdsBySelection(BackupSelection.select))
.thenAnswer((_) async => [AlbumStub.oneAsset.localId!]);
when(() => albumMediaRepository.getAll())
.thenAnswer((_) async => [AlbumStub.oneAsset, AlbumStub.twoAsset]);
when(() => syncService.syncLocalAlbumAssetsToDb(any(), any()))
.thenAnswer((_) async => true);
final result = await sut.refreshDeviceAlbums();
expect(result, true);
verify(
() => syncService.syncLocalAlbumAssetsToDb([AlbumStub.oneAsset], null),
).called(1);
verifyNoMoreInteractions(syncService);
});
});
}

View File

@@ -2561,6 +2561,39 @@
"tags": [
"Jobs"
]
},
"post": {
"operationId": "createJob",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobCreateDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Jobs"
]
}
},
"/jobs/{id}": {
@@ -6806,7 +6839,14 @@
"operationId": "emptyTrash",
"parameters": [],
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrashResponseDto"
}
}
},
"description": ""
}
},
@@ -6831,7 +6871,14 @@
"operationId": "restoreTrash",
"parameters": [],
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrashResponseDto"
}
}
},
"description": ""
}
},
@@ -6866,7 +6913,14 @@
"required": true
},
"responses": {
"204": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TrashResponseDto"
}
}
},
"description": ""
}
},
@@ -8746,6 +8800,10 @@
},
"CreateProfileImageResponseDto": {
"properties": {
"profileChangedAt": {
"format": "date-time",
"type": "string"
},
"profileImagePath": {
"type": "string"
},
@@ -8754,6 +8812,7 @@
}
},
"required": [
"profileChangedAt",
"profileImagePath",
"userId"
],
@@ -9015,8 +9074,18 @@
},
"orientation": {
"default": null,
"enum": [
1,
2,
8,
7,
3,
4,
6,
5
],
"nullable": true,
"type": "string"
"type": "number"
},
"projectionType": {
"default": null,
@@ -9269,6 +9338,17 @@
],
"type": "object"
},
"JobCreateDto": {
"properties": {
"name": {
"$ref": "#/components/schemas/ManualJobName"
}
},
"required": [
"name"
],
"type": "object"
},
"JobName": {
"enum": [
"thumbnailGeneration",
@@ -9511,6 +9591,14 @@
],
"type": "object"
},
"ManualJobName": {
"enum": [
"person-cleanup",
"tag-cleanup",
"user-cleanup"
],
"type": "string"
},
"MapMarkerResponseDto": {
"properties": {
"city": {
@@ -9963,6 +10051,10 @@
"name": {
"type": "string"
},
"profileChangedAt": {
"format": "date-time",
"type": "string"
},
"profileImagePath": {
"type": "string"
}
@@ -9972,6 +10064,7 @@
"email",
"id",
"name",
"profileChangedAt",
"profileImagePath"
],
"type": "object"
@@ -12192,6 +12285,17 @@
],
"type": "string"
},
"TrashResponseDto": {
"properties": {
"count": {
"type": "integer"
}
},
"required": [
"count"
],
"type": "object"
},
"UpdateAlbumDto": {
"properties": {
"albumName": {
@@ -12402,6 +12506,10 @@
"oauthId": {
"type": "string"
},
"profileChangedAt": {
"format": "date-time",
"type": "string"
},
"profileImagePath": {
"type": "string"
},
@@ -12440,6 +12548,7 @@
"license",
"name",
"oauthId",
"profileChangedAt",
"profileImagePath",
"quotaSizeInBytes",
"quotaUsageInBytes",
@@ -12601,6 +12710,10 @@
"name": {
"type": "string"
},
"profileChangedAt": {
"format": "date-time",
"type": "string"
},
"profileImagePath": {
"type": "string"
}
@@ -12610,6 +12723,7 @@
"email",
"id",
"name",
"profileChangedAt",
"profileImagePath"
],
"type": "object"

View File

@@ -32,9 +32,9 @@
}
},
"node_modules/typescript": {
"version": "5.5.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
"integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.2.tgz",
"integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@@ -19,6 +19,7 @@ export type UserResponseDto = {
email: string;
id: string;
name: string;
profileChangedAt: string;
profileImagePath: string;
};
export type ActivityResponseDto = {
@@ -53,6 +54,7 @@ export type UserAdminResponseDto = {
license: (UserLicense) | null;
name: string;
oauthId: string;
profileChangedAt: string;
profileImagePath: string;
quotaSizeInBytes: number | null;
quotaUsageInBytes: number | null;
@@ -193,7 +195,7 @@ export type ExifResponseDto = {
make?: string | null;
model?: string | null;
modifyDate?: string | null;
orientation?: string | null;
orientation?: Orientation | null;
projectionType?: string | null;
rating?: number | null;
state?: string | null;
@@ -548,6 +550,9 @@ export type AllJobStatusResponseDto = {
thumbnailGeneration: JobStatusDto;
videoConversion: JobStatusDto;
};
export type JobCreateDto = {
name: ManualJobName;
};
export type JobCommandDto = {
command: JobCommand;
force: boolean;
@@ -666,6 +671,7 @@ export type PartnerResponseDto = {
id: string;
inTimeline?: boolean;
name: string;
profileChangedAt: string;
profileImagePath: string;
};
export type UpdatePartnerDto = {
@@ -1240,6 +1246,9 @@ export type TimeBucketResponseDto = {
count: number;
timeBucket: string;
};
export type TrashResponseDto = {
count: number;
};
export type UserUpdateMeDto = {
email?: string;
name?: string;
@@ -1249,6 +1258,7 @@ export type CreateProfileImageDto = {
file: Blob;
};
export type CreateProfileImageResponseDto = {
profileChangedAt: string;
profileImagePath: string;
userId: string;
};
@@ -1941,6 +1951,15 @@ export function getAllJobsStatus(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function createJob({ jobCreateDto }: {
jobCreateDto: JobCreateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/jobs", oazapfts.json({
...opts,
method: "POST",
body: jobCreateDto
})));
}
export function sendJobCommand({ id, jobCommandDto }: {
id: JobName;
jobCommandDto: JobCommandDto;
@@ -3057,13 +3076,19 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
}));
}
export function emptyTrash(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/trash/empty", {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TrashResponseDto;
}>("/trash/empty", {
...opts,
method: "POST"
}));
}
export function restoreTrash(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/trash/restore", {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TrashResponseDto;
}>("/trash/restore", {
...opts,
method: "POST"
}));
@@ -3071,7 +3096,10 @@ export function restoreTrash(opts?: Oazapfts.RequestOpts) {
export function restoreAssets({ bulkIdsDto }: {
bulkIdsDto: BulkIdsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/trash/restore/assets", oazapfts.json({
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: TrashResponseDto;
}>("/trash/restore/assets", oazapfts.json({
...opts,
method: "POST",
body: bulkIdsDto
@@ -3238,6 +3266,16 @@ export enum AlbumUserRole {
Editor = "editor",
Viewer = "viewer"
}
export enum Orientation {
$1 = 1,
$2 = 2,
$8 = 8,
$7 = 7,
$3 = 3,
$4 = 4,
$6 = 6,
$5 = 5
}
export enum SourceType {
MachineLearning = "machine-learning",
Exif = "exif"
@@ -3364,6 +3402,11 @@ export enum EntityType {
Asset = "ASSET",
Album = "ALBUM"
}
export enum ManualJobName {
PersonCleanup = "person-cleanup",
TagCleanup = "tag-cleanup",
UserCleanup = "user-cleanup"
}
export enum JobName {
ThumbnailGeneration = "thumbnailGeneration",
MetadataExtraction = "metadataExtraction",

View File

@@ -1,4 +1,5 @@
import { defaults } from './fetch-client.js';
export { Orientation as LameGeneratedOrientation } from './fetch-client.js';
export * from './fetch-client.js';
export * from './fetch-errors.js';
@@ -8,6 +9,17 @@ export interface InitOptions {
apiKey: string;
}
export enum Orientation {
Rotate0 = 1,
Rotate0Mirrored = 2,
Rotate90 = 8,
Rotate90Mirrored = 7,
Rotate180 = 3,
Rotate180Mirrored = 4,
Rotate270 = 6,
Rotate270Mirrored = 5,
}
export const init = ({ baseUrl, apiKey }: InitOptions) => {
setBaseUrl(baseUrl);
setApiKey(apiKey);

View File

@@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20240910@sha256:3fd455fe051bef63b1440753596e2afa34ff0513fe30aa71a5b76ebb2d751e9f AS dev
FROM ghcr.io/immich-app/base-server-dev:20240917@sha256:3d92952d37cd68f5bf641aa80e5cc034e0d11f3774147f5db8db93138cfa5b3b AS dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@@ -41,7 +41,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20240910@sha256:4e03fe801b74eede63e91d2d9bce3a7b05699f536c211391f2d82a83c1f63470
FROM ghcr.io/immich-app/base-server-prod:20240917@sha256:67a40250f03812fe1e6f6b6345a3c7b71b3a9f24c65ed4862e82be8b3e53d23a
WORKDIR /usr/src/app
ENV NODE_ENV=production \

3885
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -46,11 +46,11 @@
"@nestjs/swagger": "^7.1.8",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.2.2",
"@opentelemetry/auto-instrumentations-node": "^0.49.0",
"@opentelemetry/auto-instrumentations-node": "^0.50.0",
"@opentelemetry/context-async-hooks": "^1.24.0",
"@opentelemetry/exporter-prometheus": "^0.53.0",
"@opentelemetry/sdk-node": "^0.53.0",
"@react-email/components": "^0.0.24",
"@react-email/components": "^0.0.25",
"@socket.io/redis-adapter": "^8.3.0",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { JobService } from 'src/services/job.service';
@@ -15,6 +15,12 @@ export class JobController {
return this.service.getAllJobsStatus();
}
@Post()
@Authenticated({ admin: true })
createJob(@Body() dto: JobCreateDto): Promise<void> {
return this.service.create(dto);
}
@Put(':id')
@Authenticated({ admin: true })
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {

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