Compare commits

...

48 Commits

Author SHA1 Message Date
Alex Tran
edac67acd2 update e2e package 2025-04-10 09:29:32 -05:00
Alex Tran
f410b58035 chore: update exiftool-vendor 2025-04-09 13:47:16 -05:00
Jason Rasmussen
8943ec23ba refactor: more database types (#17490) 2025-04-09 10:24:38 -04:00
Gagan Yadav
04b03f2924 fix(mobile): asset grid will infinitely scroll on iOS when select and… (#17469)
fix(mobile): asset grid will infinitely scroll on iOS when select and drag
2025-04-09 08:36:27 -05:00
Jason Rasmussen
cf2c0260a6 refactor: activity item (#17470)
* refactor: activity item

* fix query

* qualified columns

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-04-09 08:35:20 -04:00
Alex
ae8af84101 fix: no thumbnail generated for motion assets (#17472) 2025-04-08 16:07:10 -05:00
Jason Rasmussen
4794eeca88 refactor: database types (#17468) 2025-04-08 12:40:03 -04:00
Gagan Yadav
ac65d46ec6 fix(mobile): adds support for Internationalized Domain Name (IDN) (#17461) 2025-04-08 11:04:42 -05:00
Alex
e5ca79dd44 refactor: remove session entity (#17466)
* refactor: remove session entity

* fix: test

* update sql

* remote export
2025-04-08 16:04:07 +00:00
Jason Rasmussen
49be6d7fd8 refactor: more database enums (#17465) 2025-04-08 12:02:05 -04:00
Daniel Dietzler
15c6506aee fix: broken start/end dates on album update (#17467) 2025-04-08 15:47:44 +00:00
Jason Rasmussen
2c31a11e41 chore: replace generated enums with actual types (#17463) 2025-04-08 11:13:46 -04:00
Jason Rasmussen
b6c5a03533 refactor: remove tag entity (#17462) 2025-04-08 10:52:54 -04:00
Gagan Yadav
75bc32b47b fix(mobile): hide asset description text field if user is not owner (#17442)
* fix(mobile): hide asset description text field if user is not owner

* If user is not the owner and asset has no description then hide the text field

* Apply suggestions from code review

Co-authored-by: Alex <alex.tran1502@gmail.com>

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-08 09:18:33 -05:00
Jason Rasmussen
fdbe6d649f refactor: remove smart search entity (#17447)
refactor: smart search entity
2025-04-08 09:56:45 -04:00
Aleksandr
2b131fe935 feat: opt-in sync of deletes and restores from web to Android (#16732)
* Features: Local file movement to trash and restoration back to the album added. (Android)

* Comments fixes

* settings button marked as [EXPERIMENTAL]

* _moveToTrashMatchedAssets refactored, moveToTrash renamed.

* fix: bad merge

* Permission check and request for local storage added.

* Permission request added on settings switcher

* Settings button logic changed

* Method channel file_trash moved to BackgroundServicePlugin

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-04-08 08:50:40 -05:00
snek
6ae24fbbd4 feat(web): improve individual share ux (#17430) 2025-04-08 09:11:37 -04:00
renovate[bot]
7f116d8e98 chore(deps): update mcr.microsoft.com/devcontainers/typescript-node:22 docker digest to b0b88ef (#17453)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:32:14 +01:00
renovate[bot]
bd0840c411 chore(deps): update github/codeql-action digest to 45775bd (#17452)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:31:57 +01:00
renovate[bot]
a5123dec1a chore(deps): update grafana/grafana docker tag to v11.6.0 (#17460)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 13:31:46 +01:00
renovate[bot]
ffd18c5459 chore(deps): update dependency @types/node to ^22.14.0 (#17459)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-08 12:14:30 +02:00
PyKen
8242ff9bab fix(server): Exclude album assets in shared link payload (#17207)
* fix(server): Exclude album assets in shared link payload

* Fix e2e test
2025-04-08 00:19:06 -04:00
Jason Rasmussen
8203b6c450 refactor: stop using geodata entity type (#17444) 2025-04-08 00:15:43 -04:00
Jason Rasmussen
b352cf3336 refactor: remove natural earth countries enity (#17445) 2025-04-08 00:15:16 -04:00
bo0tzz
96ed9a8c4a fix: restore mangled footnotes (#17446)
I broke this in #17257
2025-04-07 18:03:32 -04:00
Jason Rasmussen
e7a5b96ed0 feat: extension, triggers, functions, comments, parameters management in sql-tools (#17269)
feat: sql-tools extension, triggers, functions, comments, parameters
2025-04-07 15:12:12 -04:00
renovate[bot]
51c2c60231 chore(deps): update dependency vite to v6.2.5 [security] (#17391)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-07 16:35:29 +01:00
shenlong
43d585ce55 fix(mobile): exifInfo not updated on sync (#17407)
* fix(mobile): exifInfo not updated on sync

* add tests

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-07 10:21:37 -05:00
shenlong
042da669d1 fix(mobile): use custom filter to fetch asset path entities (#17344)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-07 09:39:24 -05:00
Yaros
a724f147fe fix(mobile): items not deselecting on back button (#17403)
* fix: items not deselecting on back button

* chore: add comments
2025-04-07 09:35:27 -05:00
Sebastian Schneider
1e4b9ae5b7 fix(mobile): video player restarting when device rotates (#17362)
* fix(mobile): Video player restarting when device rotates

* use global key in state

* Implement suggestions from code review
2025-04-07 09:26:08 -05:00
Ruben Hensen
99cddf1fd6 feat: allow accounts with a quota of 0 GiB (#17413)
* Allow 0GiB quotas in user create/edit form, remove unused translations

* Make requireQuota check for null or 0

* Add unlimited quota change to the docs

* Fix user dto formatting

* Fix formating edit-user-form

* Regenerate open-api files

* Revert unnecessary i18n file changes

* Re-add newline en.json

* Resolve linting issues

* Fix formatting edit-user-form

* Re-add manifest
2025-04-07 09:22:56 -05:00
Weblate (bot)
30d33f968f chore(web): update translations (#17254)
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bg/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/bi/
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/de/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/el/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/es/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/et/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/fa/
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/gl/
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/hu/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/it/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ja/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ka/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/lv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ms/
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/pl/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/pt_BR/
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/sl/
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/sv/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/ta/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/te/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/th/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/tr/
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_Hant/
Translate-URL: https://hosted.weblate.org/projects/immich/immich/zh_SIMPLIFIED/
Translation: Immich/immich

Co-authored-by: Bonov <bonov@mail.ru>
Co-authored-by: C D <chinnidiwakar5@gmail.com>
Co-authored-by: Daniel Correa Lobato <daniel@lobato.org>
Co-authored-by: Emre Saraçoğlu <hello@emresaracoglu.com>
Co-authored-by: Fjuro <fjuro@users.noreply.hosted.weblate.org>
Co-authored-by: GND <jehende@jehende.fr>
Co-authored-by: Gocha Gulua <gocha.gulua@gmail.com>
Co-authored-by: Hurricane-32 <rodrigorimo@hotmail.com>
Co-authored-by: Indrek Haav <indrek.haav@hotmail.com>
Co-authored-by: Leigh van der merwe <palitu822@gmail.com>
Co-authored-by: LennartWeinzierl <lennart.weinzierl@gmx.de>
Co-authored-by: Leo Bottaro <github@leobottaro.com>
Co-authored-by: Luis Peregrina <luis.a.peregrina@gmail.com>
Co-authored-by: Matjaž T <matjaz@moj-svet.si>
Co-authored-by: Miki Mrvos <medolino2009@gmail.com>
Co-authored-by: Mārtiņš Bruņenieks <martinsb@gmail.com>
Co-authored-by: Oleksandr Zhukov <aleksandr.a.zhukov@gmail.com>
Co-authored-by: Passawish Paktiwong <passawishp@outlook.com>
Co-authored-by: Petri Hämäläinen <petri.hamalainen@mailbox.org>
Co-authored-by: Ruben Hensen <ruben.hensen@protonmail.com>
Co-authored-by: Runskrift <anders@rimfrost.nu>
Co-authored-by: Shawn <xiaxinx@gmail.com>
Co-authored-by: Stein-Aksel Basma <stein-aksel@basma.no>
Co-authored-by: Sylvain Pichon <service@spichon.fr>
Co-authored-by: Tachibana Saza <tachibanasaza@proton.me>
Co-authored-by: Temuri Doghonadze <temuri.doghonadze@gmail.com>
Co-authored-by: Theofilos Nikolaou <th.nikolaou@gmail.com>
Co-authored-by: User 123456789 <w0g-1es-5qq@cld3.com>
Co-authored-by: Vin <k3kelm4vw@mozmail.com>
Co-authored-by: aks-cadesign <aks@cadesignbase.dk>
Co-authored-by: eav5jhl0 <eav5jhl0@users.noreply.hosted.weblate.org>
Co-authored-by: grgergo <gergo_g@proton.me>
Co-authored-by: late <late@users.noreply.hosted.weblate.org>
Co-authored-by: millallo <millallo@tiscali.it>
Co-authored-by: przmkg <przemek@gasinski.eu>
Co-authored-by: thehijacker <thehijacker@gmail.com>
Co-authored-by: timmy61109 <qazzxcasdqwewsxedc@gmail.com>
Co-authored-by: waclaw66 <waclaw66@seznam.cz>
Co-authored-by: xuars <yago.rana.gayoso@gmail.com>
Co-authored-by: Вячеслав Лукьяненко <madeinchuguev@gmail.com>
2025-04-07 12:28:59 +01:00
Ben McCann
31ee19181a chore(web): switch to writable derived one more place (#17399) 2025-04-06 22:05:47 -05:00
shenlong
b58a450152 fix(mobile): prevent unnecessary reload on multi user timeline (#17418)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-06 22:04:13 -05:00
Zlendy
b87ba6865b fix(web): Video memories are played at 100% volume instead of respecting user preference (#17424) 2025-04-06 22:03:19 -05:00
Lorenzo Montanari
565cceb323 docs: fixed a wrong path in CLI docs page (#17369)
docs: fixed a wrong path in CLI page
2025-04-06 22:00:10 -05:00
Matthew Momjian
f096dd0cc0 fix(deployment): warning for database on network share (#17412)
Update example.env
2025-04-06 10:09:54 +02:00
Daniel Dietzler
a3c3f9cfcb fix: reset memories on logout (#17405) 2025-04-05 13:09:56 -04:00
Mert
7b6a4be30c chore: use valkey (#17396)
use valkey
2025-04-04 17:46:46 -05:00
martin
720189e2c2 fix: improve initial loading time (#17379) 2025-04-04 17:04:52 -04:00
shenlong
dfab32c8f2 fix(mobile): ignore invalid store keys (#17370)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-03 22:35:50 -05:00
shenlong
60174d662d fix(mobile): bump isar maxSize (#17372) 2025-04-03 21:49:50 -05:00
bo0tzz
8b6a765e12 chore: remove demo box spec from README.md (#17367) 2025-04-03 18:09:29 -04:00
Zack Pollard
2248a38567 fix: missing index and geodata import process uses normal table (#17343)
* chore: add geodata indexes to table definitions

* chore: rename incorrectly name geodata index

* fix: import into geodata places with correct index names
2025-04-03 21:32:33 +01:00
shenlong
97e52c5156 refactor(mobile): device asset entity to use modified time (#17064)
* refactor: device asset entity to use modified time

* chore: cleanup

* refactor: remove album media dependency from hashservice

* refactor: return updated copy of asset

* add hash service tests

* chore: rename hash batch constants

* chore: log the number of assets processed during migration

* chore: more logs

* refactor: use lookup and more tests

* use sort approach

* refactor hash service to use for loop instead

* refactor: rename to getByIds

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-03 14:42:35 -05:00
Mert
e8b4ac0522 fix(web): use original image if web compatible (#17347)
* use original image if web compatible

* add e2e

* fix shared link handling

* handle redirect in e2e

* fix size not being passed to thumbnail url

* test fullsize in e2e
2025-04-03 09:01:41 -05:00
Alex
548298b0c7 chore: post release tasks (#17341) 2025-04-03 08:47:52 -04:00
360 changed files with 9194 additions and 3487 deletions

View File

@@ -1,4 +1,4 @@
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:2ef23730ec68d8511ec8e6e0b82550ca728b256805d81f60ed890f3bfb21cfb9
ARG BASEIMAGE=mcr.microsoft.com/devcontainers/typescript-node:22@sha256:b0b88ef6a5abf21194343d2c5b2829dddd9be1142f65f6a5e4390a51d5a70dd8
FROM ${BASEIMAGE}
# Flutter SDK

View File

@@ -46,7 +46,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3
uses: github/codeql-action/init@45775bd8235c68ba998cffa5171334d58593da47 # v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -59,7 +59,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3
uses: github/codeql-action/autobuild@45775bd8235c68ba998cffa5171334d58593da47 # v3
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -72,6 +72,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@1b549b9259bda1cb5ddde3b41741a82a2d15a841 # v3
uses: github/codeql-action/analyze@45775bd8235c68ba998cffa5171334d58593da47 # v3
with:
category: '/language:${{matrix.language}}'

View File

@@ -518,7 +518,7 @@ jobs:
run: npm run build
- name: Run existing migrations
run: npm run typeorm:migrations:run
run: npm run migrations:run
- name: Test npm run schema:reset command works
run: npm run typeorm:schema:reset
@@ -532,7 +532,7 @@ jobs:
id: verify-changed-files
with:
files: |
server/src/migrations/
server/src
- name: Verify migration files have not changed
if: steps.verify-changed-files.outputs.files_changed == 'true'
run: |

View File

@@ -61,9 +61,7 @@
## Demo
Access the demo [here](https://demo.immich.app). The demo is running on a Free-tier Oracle VM in Amsterdam with a 2.4Ghz quad-core ARM64 CPU and 24GB RAM.
For the mobile app, you can use `https://demo.immich.app` for the `Server Endpoint URL`
Access the demo [here](https://demo.immich.app). For the mobile app, you can use `https://demo.immich.app` for the `Server Endpoint URL`.
### Login credentials

24
cli/package-lock.json generated
View File

@@ -27,7 +27,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -61,7 +61,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"typescript": "^5.3.3"
}
},
@@ -1362,13 +1362,13 @@
}
},
"node_modules/@types/node": {
"version": "22.13.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==",
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/normalize-package-data": {
@@ -4073,9 +4073,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
@@ -4144,9 +4144,9 @@
}
},
"node_modules/vite": {
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.5.tgz",
"integrity": "sha512-j023J/hCAa4pRIUH6J9HemwYfjB5llR2Ps0CWeikOtdR8+pAURAk0DoJC5/mm9kd+UgdnIy7d6HE4EAvlYhPhA==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -21,7 +21,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -116,7 +116,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8
image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8
image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -102,7 +102,7 @@ services:
command: [ './run.sh', '-disable-reporting' ]
ports:
- 3000:3000
image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb
image: grafana/grafana:11.6.0-ubuntu@sha256:fd8fa48213c624e1a95122f1d93abbf1cf1cbe85fc73212c1e599dbd76c63ff8
volumes:
- grafana-data:/var/lib/grafana

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8
image: docker.io/valkey/valkey:8-bookworm@sha256:42cba146593a5ea9a622002c1b7cba5da7be248650cbb64ecb9c6c33d29794b1
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -2,7 +2,8 @@
# The location where your uploaded files are stored
UPLOAD_LOCATION=./library
# The location where your database files are stored
# The location where your database files are stored. Network shares are not supported for the database
DB_DATA_LOCATION=./postgres
# To set a timezone, uncomment the next line and change Etc/UTC to a TZ identifier from this list: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -31,7 +31,7 @@ Admin can send a welcome email if the Email option is set, you can learn here ho
Admin can specify the storage quota for the user as the instance's admin; once the limit is reached, the user won't be able to upload to the instance anymore.
In order to select a storage quota, click on the pencil icon and enter the storage quota in GiB. You can choose an unlimited quota using the value 0 (default).
In order to select a storage quota, click on the pencil icon and enter the storage quota in GiB. You can choose an unlimited quota by leaving it empty (default).
:::tip
The system administrator can see the usage quota percentage of all users in Server Stats page.

View File

@@ -112,7 +112,7 @@ You begin by authenticating to your Immich server. For instance:
immich login http://192.168.1.216:2283/api HFEJ38DNSDUEG
```
This will store your credentials in a `auth.yml` file in the configuration directory which defaults to `~/.config/`. The directory can be set with the `-d` option or the environment variable `IMMICH_CONFIG_DIR`. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually.
This will store your credentials in a `auth.yml` file in the configuration directory which defaults to `~/.config/immich/`. The directory can be set with the `-d` option or the environment variable `IMMICH_CONFIG_DIR`. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually.
Once you are authenticated, you can upload assets to your Immich server.

View File

@@ -70,3 +70,6 @@ If you get an error `can't set healthcheck.start_interval as feature require Doc
## Next Steps
Read the [Post Installation](/docs/install/post-install.mdx) steps and [upgrade instructions](/docs/install/upgrading.md).
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env

View File

@@ -24,9 +24,6 @@ To clean up disk space, the old version's obsolete container images can be delet
docker image prune
```
[compose-file]: https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
[env-file]: https://github.com/immich-app/immich/releases/latest/download/example.env
[watchtower]: https://containrrr.dev/watchtower/
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[container-auth]: https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry
[releases]: https://github.com/immich-app/immich/releases

61
e2e/package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -25,7 +25,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^57.0.0",
"exiftool-vendored": "^28.3.1",
"exiftool-vendored": "^29.3.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
@@ -66,7 +66,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -100,7 +100,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"typescript": "^5.3.3"
}
},
@@ -1071,9 +1071,9 @@
}
},
"node_modules/@photostructure/tz-lookup": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.0.0.tgz",
"integrity": "sha512-QMV5/dWtY/MdVPXZs/EApqzyhnqDq1keYEqpS+Xj2uidyaqw2Nk/fWcsszdruIXjdqp1VoWNzsgrO6bUHU1mFw==",
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-11.2.0.tgz",
"integrity": "sha512-DwrvodcXHNSdGdeSF7SBL5o8aBlsaeuCuG7633F04nYsL3hn5Hxe3z/5kCqxv61J1q7ggKZ27GPylR3x0cPNXQ==",
"dev": true,
"license": "CC0-1.0"
},
@@ -1585,13 +1585,13 @@
"dev": true
},
"node_modules/@types/node": {
"version": "22.13.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.15.tgz",
"integrity": "sha512-imAbQEEbVni6i6h6Bd5xkCRwLqFc8hihCsi2GbtDoAtUcAFQ6Zs4pFXTZUUbroTkXdImczWM9AI8eZUuybXE3w==",
"version": "22.14.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz",
"integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/normalize-package-data": {
@@ -3312,27 +3312,27 @@
}
},
"node_modules/exiftool-vendored": {
"version": "28.8.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.8.0.tgz",
"integrity": "sha512-R7tirJLr9fWuH9JS/KFFLB+O7jNGKuPXGxREc6YybYangEudGb+X8ERsYXk9AifMiAWh/2agNfbgkbcQcF+MxA==",
"version": "29.3.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-29.3.0.tgz",
"integrity": "sha512-2N+QvQH3mH0yb89vpxJXaD+SXa8GXvDigytS6cro6FOrTx9Opav4H0QPP0V4r9dBhXy5poON7qo+p1KZv5wZqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@photostructure/tz-lookup": "^11.0.0",
"@types/luxon": "^3.4.2",
"@photostructure/tz-lookup": "^11.2.0",
"@types/luxon": "^3.6.0",
"batch-cluster": "^13.0.0",
"he": "^1.2.0",
"luxon": "^3.5.0"
"luxon": "^3.6.1"
},
"optionalDependencies": {
"exiftool-vendored.exe": "13.0.0",
"exiftool-vendored.pl": "13.0.1"
"exiftool-vendored.exe": "13.26.0",
"exiftool-vendored.pl": "13.26.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.0.0.tgz",
"integrity": "sha512-4zAMuFGgxZkOoyQIzZMHv1HlvgyJK3AkNqjAgm8A8V0UmOZO7yv3pH49cDV1OduzFJqgs6yQ6eG4OGydhKtxlg==",
"version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-13.26.0.tgz",
"integrity": "sha512-y5mLmNAeABbXhb1a77EeR+CYDELI64DawnNywhMiivIYIdBPXf/81UcBd3SQlcTAHJjJjKt20qdmdL4loMkRDA==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3341,15 +3341,18 @@
]
},
"node_modules/exiftool-vendored.pl": {
"version": "13.0.1",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.0.1.tgz",
"integrity": "sha512-+BRRzjselpWudKR0ltAW5SUt9T82D+gzQN8DdOQUgnSVWWp7oLCeTGBRptbQz+436Ihn/mPzmo/xnf0cv/Qw1A==",
"version": "13.26.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-13.26.0.tgz",
"integrity": "sha512-4L8b6TrZcrd/dOnoeyCgsIa4WgFygucd0KQzB7xgWpgeMDQ2xYeqAYoHTeKmLAv4DogvaVkZQgDNogscuKuM+Q==",
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"!win32"
]
],
"bin": {
"exiftool": "bin/exiftool"
}
},
"node_modules/expect-type": {
"version": "1.2.1",
@@ -6316,9 +6319,9 @@
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},

View File

@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.13.14",
"@types/node": "^22.14.0",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -35,7 +35,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^57.0.0",
"exiftool-vendored": "^28.3.1",
"exiftool-vendored": "^29.3.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",

View File

@@ -246,15 +246,7 @@ describe('/shared-links', () => {
const { status, body } = await request(app).get('/shared-links/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.assets[0]).toEqual(
expect.objectContaining({
originalFileName: 'example.png',
localDateTime: expect.any(String),
fileCreatedAt: expect.any(String),
exifInfo: expect.any(Object),
}),
);
expect(body.assets).toHaveLength(0);
expect(body.album).toBeDefined();
});

View File

@@ -8,12 +8,14 @@ function imageLocator(page: Page) {
test.describe('Photo Viewer', () => {
let admin: LoginResponseDto;
let asset: AssetMediaResponseDto;
let rawAsset: AssetMediaResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
asset = await utils.createAsset(admin.accessToken);
rawAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'test.arw' } });
});
test.beforeEach(async ({ context, page }) => {
@@ -36,7 +38,7 @@ test.describe('Photo Viewer', () => {
await expect(page.getByTestId('loading-spinner')).toBeVisible();
});
test('loads high resolution photo when zoomed', async ({ page }) => {
test('loads original photo when zoomed', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
@@ -44,6 +46,17 @@ test.describe('Photo Viewer', () => {
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('original');
});
test('loads fullsize image when zoomed and original is web-incompatible', async ({ page }) => {
await page.goto(`/photos/${rawAsset.id}`);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail');
const box = await imageLocator(page).boundingBox();
expect(box).toBeTruthy();
const { x, y, width, height } = box!;
await page.mouse.move(x + width / 2, y + height / 2);
await page.mouse.wheel(0, -1);
await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('fullsize');
});

View File

@@ -1374,4 +1374,4 @@
"yes": "Да",
"you_dont_have_any_shared_links": "Нямате споделени връзки",
"zoom_image": "Увеличаване на изображението"
}
}

View File

@@ -1,20 +1,22 @@
{
"account": "",
"account_settings": "",
"acknowledge": "",
"about": "abaot",
"account": "Akaont",
"account_settings": "Seting blo Akaont",
"acknowledge": "Akcept",
"action": "",
"actions": "",
"active": "",
"activity": "",
"add": "",
"add_a_description": "",
"add_a_location": "",
"add_a_name": "",
"add_a_title": "",
"add_exclusion_pattern": "",
"add_import_path": "",
"add_location": "",
"add_more_users": "",
"active": "Stap Mekem",
"activity": "Wanem hemi Mekem",
"activity_changed": "WAnem hemi Mekem hemi",
"add": "Ad",
"add_a_description": "Putem Description blo hem",
"add_a_location": "Putem place blo hem",
"add_a_name": "Putem nam blo hem",
"add_a_title": "Putem wan name blo hem",
"add_exclusion_pattern": "Putem wan paten wae hemi karem aot",
"add_import_path": "Putem wan pat blo import",
"add_location": "Putem wan place blo hem",
"add_more_users": "Putem mor man",
"add_partner": "",
"add_path": "",
"add_photos": "",

View File

@@ -1374,4 +1374,4 @@
"yes": "Sí",
"you_dont_have_any_shared_links": "No tens cap enllaç compartit",
"zoom_image": "Ampliar Imatge"
}
}

View File

@@ -66,8 +66,13 @@
"forcing_refresh_library_files": "Vynucení obnovy všech souborů knihovny",
"image_format": "Formát",
"image_format_description": "WebP vytváří menší soubory než JPEG, ale je pomalejší při kódování.",
"image_fullsize_description": "Obrázek v plné velikosti s odstraněnými metadaty, použito při přiblížení",
"image_fullsize_enabled": "Povolit generování obrázků v plné velikosti",
"image_fullsize_enabled_description": "Generovat obrázky v plné velikosti pro formáty, které nejsou vhodné pro web. Pokud je povolena možnost „Preferovat vložený náhled“, budou přímo použity vložené náhledy bez převodu. Neovlivňuje formáty vhodné pro web, jako je JPEG.",
"image_fullsize_quality_description": "Kvalita obrázku v plné velikosti od 1 do 100. Vyšší je lepší, ale vytváří větší soubory.",
"image_fullsize_title": "Nastavení obrázků v plné velikosti",
"image_prefer_embedded_preview": "Preferovat vložený náhled",
"image_prefer_embedded_preview_setting_description": "Použít vložené náhledy z RAW fotografií jako vstup pro zpracování snímků, pokud jsou k dispozici. U některých snímků tak lze dosáhnout přesnějších barev, ale kvalita náhledu závisí na fotoaparátu a snímek může obsahovat více kompresních artefaktů.",
"image_prefer_embedded_preview_setting_description": "Použít vložené náhledy z RAW fotografií jako vstup pro zpracování snímků a pokud jsou k dispozici. U některých snímků tak lze dosáhnout přesnějších barev, ale kvalita náhledu závisí na fotoaparátu a snímek může obsahovat více kompresních artefaktů.",
"image_prefer_wide_gamut": "Preferovat široký gamut",
"image_prefer_wide_gamut_setting_description": "Použít Display P3 pro miniatury. To lépe zachovává živost obrázků s širokým barevným prostorem, ale obrázky se mohou na starých zařízeních se starou verzí prohlížeče zobrazovat jinak. sRGB obrázky jsou ponechány jako sRGB, aby se zabránilo posunům barev.",
"image_preview_description": "Středně velký obrázek se zbavenými metadaty, který se používá při prohlížení jedné položky a pro strojové učení",
@@ -859,6 +864,7 @@
"loop_videos": "Videa ve smyčce",
"loop_videos_description": "Povolit automatickou smyčku videa v prohlížeči.",
"main_branch_warning": "Používáte vývojovou verzi; důrazně doporučujeme používat verzi z vydání!",
"main_menu": "Hlavní nabídka",
"make": "Výrobce",
"manage_shared_links": "Spravovat sdílené odkazy",
"manage_sharing_with_partners": "Správa sdílení s partnery",
@@ -1234,7 +1240,7 @@
"sort_oldest": "Nejstarší fotka",
"sort_people_by_similarity": "Seřadit lidi podle podobnosti",
"sort_recent": "Nejnovější fotka",
"sort_title": "Název",
"sort_title": "Název alba",
"source": "Zdroj",
"stack": "Seskupit",
"stack_duplicates": "Seskupit duplicity",

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "Erneutes Laden aller Bibliotheksdateien erzwingen",
"image_format": "Format",
"image_format_description": "WebP erzeugt kleinere Dateien als JPEG, ist aber etwas langsamer in der Erstellung.",
"image_fullsize_description": "Hochauflösendes Bild mit entfernten Metadaten, das beim Zoomen verwendet wird",
"image_fullsize_enabled": "Hochauflösende Vorschaubilder aktivieren",
"image_fullsize_enabled_description": "Generiere Hochauflösende Vorschaubilder in Originalauflösung für nicht web-kompatibel Formate. Wenn \"Eingebettete Vorschau bevorzugen\" aktiviert ist, werden eingebettete Vorschaubilder direkt verwendet. Hat keinen Einfluss auf web-kompatible Formate wie JPEG.",
"image_fullsize_quality_description": "Qualität der Hochauflösenden Vorschaubilder von 1-100. Höher ist besser, erzeugt aber größere Dateien.",
"image_fullsize_title": "Hochauflösende Vorschaueinstellungen",
"image_prefer_embedded_preview": "Eingebettete Vorschau bevorzugen",
"image_prefer_embedded_preview_setting_description": "Verwende eingebettete Vorschaubilder in RAW-Fotos als Grundlage für die Bildverarbeitung, sofern diese zur Verfügung stehen. Dies kann bei einigen Bildern genauere Farben erzeugen, allerdings ist die Qualität der Vorschau kameraabhängig und das Bild kann mehr Kompressionsartefakte aufweisen.",
"image_prefer_wide_gamut": "Breites Spektrum bevorzugen",
@@ -859,6 +864,7 @@
"loop_videos": "Loop-Videos",
"loop_videos_description": "Aktiviere diese Option, um eine automatische Videoschleife in der Detailansicht zu erstellen.",
"main_branch_warning": "Du benutzt eine Entwicklungsversion. Wir empfehlen dringend, eine Release-Version zu verwenden!",
"main_menu": "Hauptmenü",
"make": "Marke",
"manage_shared_links": "Freigegebene Links verwalten",
"manage_sharing_with_partners": "Gemeinsame Nutzung mit Partnern verwalten",

View File

@@ -388,7 +388,7 @@
"albums_count": "{count, plural, one {{count, number} Άλμπουμ} other {{count, number} Άλμπουμ}}",
"all": "Όλα",
"all_albums": "Όλα τα άλμπουμ",
"all_people": "Όλοι οι άνθρωποι",
"all_people": "Όλα τα άτομα",
"all_videos": "Όλα τα βίντεο",
"allow_dark_mode": "Επιτρέψτε τη σκοτεινή λειτουργία",
"allow_edits": "Επιτρέψτε τις τροποποιήσεις",
@@ -452,7 +452,7 @@
"camera_model": "Μοντέλο κάμερας",
"cancel": "Ακύρωση",
"cancel_search": "Ακύρωση αναζήτησης",
"cannot_merge_people": "Αδύνατη η συγχώνευση προσώπων",
"cannot_merge_people": "Αδύνατη η συγχώνευση ατόμων",
"cannot_undo_this_action": "Δεν μπορείτε να αναιρέσετε αυτήν την ενέργεια!",
"cannot_update_the_description": "Αδύνατη η ενημέρωση της περιγραφής",
"change_date": "Αλλαγή ημερομηνίας",
@@ -618,7 +618,7 @@
"cant_change_metadata_assets_count": "Δεν μπορείτε να αλλάξετε τα μεταδεδομένα του {count, plural, one {# αρχείου} other {# αρχείων}}",
"cant_get_faces": "Δεν είναι δυνατή η ανάκτηση προσώπων",
"cant_get_number_of_comments": "Δεν είναι δυνατή η ανάκτηση του αριθμού των σχολίων",
"cant_search_people": "Δεν μπορείτε να αναζητήσετε άτομα",
"cant_search_people": "Αδύνατη η αναζήτηση ατόμων",
"cant_search_places": "Δεν μπορείτε να αναζητήσετε τοποθεσίες",
"cleared_jobs": "Εκκαθαρισμένες εργασίες για: {job}",
"error_adding_assets_to_album": "Σφάλμα κατά την προσθήκη στοιχείων στο άλμπουμ",

View File

@@ -164,7 +164,6 @@
"no_pattern_added": "No pattern added",
"note_apply_storage_label_previous_assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
"note_cannot_be_changed_later": "NOTE: This cannot be changed later!",
"note_unlimited_quota": "Note: Enter 0 for unlimited quota",
"notification_email_from_address": "From address",
"notification_email_from_address_description": "Sender email address, for example: \"Immich Photo Server <noreply@example.com>\"",
"notification_email_host_description": "Host of the email server (e.g. smtp.immich.app)",
@@ -929,7 +928,6 @@
"no_shared_albums_message": "Create an album to share photos and videos with people in your network",
"not_in_any_album": "Not in any album",
"note_apply_storage_label_to_previously_uploaded assets": "Note: To apply the Storage Label to previously uploaded assets, run the",
"note_unlimited_quota": "Note: Enter 0 for unlimited quota",
"notes": "Notes",
"notification_toggle_setting_description": "Enable email notifications",
"notifications": "Notifications",
@@ -1384,4 +1382,4 @@
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"zoom_image": "Zoom Image"
}
}

View File

@@ -66,8 +66,13 @@
"forcing_refresh_library_files": "Forzando la recarga de todos los elementos en la biblioteca",
"image_format": "Formato",
"image_format_description": "WebP genera archivos más pequeños que JPEG, pero es más lento al codificarlos.",
"image_fullsize_description": "Imagen de tamaño completo con metadatos removidos, usado cuando se hace zoom",
"image_fullsize_enabled": "Activar generación de imágenes a tamaño completo",
"image_fullsize_enabled_description": "Generar imágenes a tamaño completo para formatos no aptos para web. Cuando \"Preferir vista previa incrustada\" está activada, las vistas previas incrustadas se utilizan directamente sin conversión. No afecta a los formatos aptos para la web, como JPEG.",
"image_fullsize_quality_description": "De 1 a 100, calidad de imágenes de tamaño completo. Mientras más alto es mejor, pero genera archivos de mayor tamaño.",
"image_fullsize_title": "Configuraciones de imágenes de tamaño completo",
"image_prefer_embedded_preview": "Preferir vista previa embebida",
"image_prefer_embedded_preview_setting_description": "Usar vistas previas embebidas en fotos RAW como entrada para el procesamiento de imágenes cuando estén disponibles. Esto puede producir colores más precisos en algunas imágenes, pero la calidad de la vista previa depende de la cámara y la imagen puede tener más artefactos de compresión.",
"image_prefer_embedded_preview_setting_description": "Usar vistas previas embebidas en fotos RAW como entrada para el procesamiento de imágenes y cuando estén disponibles. Esto puede producir colores más precisos en algunas imágenes, pero la calidad de la vista previa depende de la cámara y la imagen puede tener más artefactos de compresión.",
"image_prefer_wide_gamut": "Preferir 'gamut' amplio",
"image_prefer_wide_gamut_setting_description": "Usar \"Display P3\" para las miniaturas. Preserva mejor la vivacidad de las imágenes con espacios de color amplios pero las imágenes pueden aparecer de manera diferente en dispositivos antiguos con una versión antigua del navegador. Las imágenes sRGB se mantienen como sRGB para evitar cambios de color.",
"image_preview_description": "Imagen de tamaño mediano con metadatos eliminados. Es utilizado al visualizar un solo activo y para el aprendizaje automático",
@@ -414,7 +419,7 @@
"asset_description_updated": "La descripción del elemento ha sido actualizada",
"asset_filename_is_offline": "El archivo {filename} está offline",
"asset_has_unassigned_faces": "El archivo no tiene rostros asignados",
"asset_hashing": "Hashing…",
"asset_hashing": "Calculando hash…",
"asset_offline": "Archivos sin conexión",
"asset_offline_description": "Este activo externo ya no se encuentra en el disco. Por favor, póngase en contacto con su administrador de Immich para obtener ayuda.",
"asset_skipped": "Omitido",
@@ -442,7 +447,7 @@
"blurred_background": "Fondo borroso",
"bugs_and_feature_requests": "Errores y solicitudes de funciones",
"build": "Compilación",
"build_image": "Crear imagen",
"build_image": "Imagen de compilación",
"bulk_delete_duplicates_confirmation": "¿Estás seguro de que deseas eliminar de forma masiva {count, plural, one {# elemento duplicado} other {# elementos duplicados}}? Esto mantendrá el activo más grande de cada grupo y eliminará permanentemente todos los demás duplicados. ¡Esta acción no se puede deshacer!",
"bulk_keep_duplicates_confirmation": "¿Estas seguro de que desea mantener {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto resolverá todos los grupos duplicados sin borrar nada.",
"bulk_trash_duplicates_confirmation": "¿Estas seguro de que desea eliminar masivamente {count, plural, one {# duplicate asset} other {# duplicate assets}} archivos duplicados? Esto mantendrá el archivo más grande de cada grupo y eliminará todos los demás duplicados.",
@@ -859,6 +864,7 @@
"loop_videos": "Vídeos en bucle",
"loop_videos_description": "Habilite la reproducción automática de un video en el visor de detalles.",
"main_branch_warning": "Estás ejecutando una compilación desde la rama principal. ¡Recomendamos encarecidamente usar una versión de lanzamiento!",
"main_menu": "Menú principal",
"make": "Marca",
"manage_shared_links": "Administrar enlaces compartidos",
"manage_sharing_with_partners": "Administrar el uso compartido con invitados",
@@ -1241,7 +1247,7 @@
"stack_select_one_photo": "Selecciona una imagen principal para la pila",
"stack_selected_photos": "Apilar fotos seleccionadas",
"stacked_assets_count": "Apilado(s) {count, plural, one {# activo} other {# activos}}",
"stacktrace": "Stacktrace",
"stacktrace": "Seguimiento de pila",
"start": "Inicio",
"start_date": "Fecha de inicio",
"state": "Estado",
@@ -1350,7 +1356,7 @@
"version_announcement_closing": "Tu amigo, Alex",
"version_announcement_message": "¡Hola! Hay una nueva versión de Immich disponible. Tómese un tiempo para leer las <link> notas de la versión </link> para asegurarse de que su configuración esté actualizada y evitar errores de configuración, especialmente si utiliza WatchTower o cualquier mecanismo que se encargue de actualizar su instancia de Immich automáticamente.",
"version_history": "Historial de versiones",
"version_history_item": "Instalada la {version} el {date}",
"version_history_item": "Instalada {version} el {date}",
"video": "Vídeo",
"video_hover_setting": "Iniciar vídeo al pasar por encima",
"video_hover_setting_description": "Reproducir el vídeo cuando el ratón está encima de un vídeo. Aunque esté desactivado, se iniciará cuando el cursor del ratón esté sobre el icono de \"reproducir\".",

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "Kogu kõigi failide sundvärskendamine",
"image_format": "Formaat",
"image_format_description": "WebP failid on väiksemad kui JPEG, aga kodeerimine on aeglasem.",
"image_fullsize_description": "Täismõõdus pilt ilma metaandmeteta, kasutatakse sisse suumimisel",
"image_fullsize_enabled": "Luba täismõõdus piltide genereerimine",
"image_fullsize_enabled_description": "Genereeri mitte-veebisõbralike formaatide jaoks täismõõdus pilt. Kui \"Eelista manustatud eelvaadet\" on lubatud, kasutatakse manustatud eelvaateid otse ilma teisendamiseta. Ei mõjuta veebisõbralikke formaate nagu JPEG.",
"image_fullsize_quality_description": "Täismõõdus pildi kvaliteet vahemikus 1-100. Kõrgem väärtus on parem, aga tulemuseks on suuremad failid.",
"image_fullsize_title": "Täismõõdus pildi seaded",
"image_prefer_embedded_preview": "Eelista manustatud eelvaadet",
"image_prefer_embedded_preview_setting_description": "Kasuta pilditöötluse sisendina võimalusel RAW fotodesse manustatud eelvaateid. See võib mõnede piltide puhul anda tulemuseks täpsemad värvid, aga eelvaate kvaliteet sõltub konkreetsest kaamerast ning pildis võib olla rohkem tihendusmüra.",
"image_prefer_wide_gamut": "Eelista laia värvigammat",
@@ -859,6 +864,7 @@
"loop_videos": "Taasesita videod",
"loop_videos_description": "Lülita sisse, et detailvaates videot automaatselt taasesitada.",
"main_branch_warning": "Sa kasutad arendusversiooni; soovitame tungivalt kasutada väljalaskeversiooni!",
"main_menu": "Peamenüü",
"make": "Mark",
"manage_shared_links": "Halda jagatud linke",
"manage_sharing_with_partners": "Halda partneritega jagamist",

View File

@@ -926,4 +926,4 @@
"yes": "بله",
"you_dont_have_any_shared_links": "",
"zoom_image": "بزرگنمایی تصویر"
}
}

View File

@@ -219,7 +219,7 @@
"reset_settings_to_default": "Nollaa asetukset oletuksille",
"reset_settings_to_recent_saved": "Palauta aiemmin tallennetut asetukset",
"scanning_library": "Kirjastoa skannataan",
"search_jobs": "Etsi tehtäviä...",
"search_jobs": "Etsi tehtäviä",
"send_welcome_email": "Lähetä tervetuloviesti",
"server_external_domain_settings": "Ulkoinen osoite",
"server_external_domain_settings_description": "Osoite julkisille linkeille, http(s):// mukaan lukien",
@@ -406,11 +406,11 @@
"are_these_the_same_person": "Ovatko he sama henkilö?",
"are_you_sure_to_do_this": "Haluatko varmasti tehdä tämän?",
"asset_added_to_album": "Lisätty albumiin",
"asset_adding_to_album": "Lisätään albumiin...",
"asset_adding_to_album": "Lisätään albumiin",
"asset_description_updated": "Kohteen kuvaus on päivitetty",
"asset_filename_is_offline": "Kohde {filename} on offline-tilassa",
"asset_has_unassigned_faces": "Kohteella on määrittämättömiä kasvoja",
"asset_hashing": "Hajautetaan...",
"asset_hashing": "Hajautetaan",
"asset_offline": "Aineisto offline-tilassa",
"asset_offline_description": "Tätä ulkoista resurssia ei enää löydy levyltä. Ole hyvä ja ota yhteyttä Immich-järjestelmänvalvojaan saadaksesi apua.",
"asset_skipped": "Ohitettu",
@@ -1352,4 +1352,4 @@
"yes": "Kyllä",
"you_dont_have_any_shared_links": "Sinulla ei ole jaettuja linkkejä",
"zoom_image": "Zoomaa kuvaa"
}
}

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "Forcer le rafraîchissement de tous les fichiers de la bibliothèque",
"image_format": "Format",
"image_format_description": "WebP produit des fichiers plus petits que JPEG, mais son encodage est plus lent.",
"image_fullsize_description": "Image en taille réelle, sans métadonnées, utilisée lors d'un zoom",
"image_fullsize_enabled": "Activer la génération d'image en taille d'origine",
"image_fullsize_enabled_description": "Générer une image en taille réelle pour les formats non compatibles avec le web. Lorsque l'option « Préférer l'aperçu intégré » est activée, les aperçus intégrés sont utilisés directement sans conversion. Cette option n'affecte pas les formats compatibles avec le web tels que JPEG.",
"image_fullsize_quality_description": "Qualité de l'image en taille réelle de 1 à 100. Une valeur plus élevée est meilleure, mais produit des fichiers plus volumineux.",
"image_fullsize_title": "Paramètres des images en taille réelle",
"image_prefer_embedded_preview": "Préférer l'aperçu intégré",
"image_prefer_embedded_preview_setting_description": "Utiliser les miniatures intégrées dans les photos au format RAW comme entrées pour le traitement d'image quand elles sont disponibles. Cela peut donner des couleurs plus justes pour certaines images, mais la qualité des miniatures est dépendant de l'appareil photo et l'image peut avoir des artéfacts de compression.",
"image_prefer_wide_gamut": "Préférer une gamme de couleurs étendue",
@@ -859,6 +864,7 @@
"loop_videos": "Vidéos en boucle",
"loop_videos_description": "Activer pour voir la vidéo en boucle dans le lecteur détaillé.",
"main_branch_warning": "Vous utilisez une version de développement. Nous vous recommandons fortement d'utiliser une version stable!",
"main_menu": "Menu principal",
"make": "Marque",
"manage_shared_links": "Gérer les liens partagés",
"manage_sharing_with_partners": "Gérer le partage avec les partenaires",

View File

@@ -1 +1,57 @@
{}
{
"about": "Acerca de",
"account": "Conta",
"account_settings": "Configuración da conta",
"acknowledge": "De acordo",
"action": "Acción",
"actions": "Accións",
"active": "Activo",
"activity": "Actividade",
"activity_changed": "A actividade está {enabled, select, true {habilitada} other {deshabilitada}}",
"add": "Engadir",
"add_a_description": "Engadir unha descrición",
"add_a_location": "Engadir unha localización",
"add_a_name": "Engadir un nome",
"add_a_title": "Engadir un título",
"add_exclusion_pattern": "Engadir patrón de exclusión",
"add_import_path": "Engadir ruta de importación",
"add_location": "Engadir localización",
"add_more_users": "Engadir máis usuarios",
"add_partner": "Engadir compañeiro",
"add_path": "Engadir ruta",
"add_photos": "Engadir fotos",
"add_to": "Engadir a…",
"add_to_album": "Engadir ao álbum",
"add_to_shared_album": "Engadir ao álbum compartido",
"add_url": "Engadir URL",
"added_to_archive": "Engadido ao arquivo",
"added_to_favorites": "Engadido a favoritos",
"added_to_favorites_count": "Engadidos {count, number} a favoritos",
"admin": {
"authentication_settings": "Configuración de autenticación",
"authentication_settings_description": "Xestionar contrasinal, OAuth e outros parámetros de autenticación",
"authentication_settings_disable_all": "Estás seguro de deshabilitar todos os métodos de inicio de sesión? Iniciar a sesión quedará completamente deshabilitado.",
"authentication_settings_reenable": "Para rehabilitala, usa un <link>Comando do servidor</link>.",
"background_task_job": "Tarefas en segundo plano",
"backup_database": "Respaldo da base de datos",
"backup_database_enable_description": "Habilitar as copias de seguridade da base de datos",
"backup_keep_last_amount": "Cantidade de copias de seguridade previas a manter",
"backup_settings": "Configuración de copias de seguridade",
"backup_settings_description": "Xestionar a configuración das copias de seguridade da base de datos",
"check_all": "Comprobar todo",
"cleared_jobs": "Traballos borrados para: {job}",
"config_set_by_file": "As configuracións están actualmente seleccionadas por un ficheiro de configuracións",
"confirm_delete_library": "Estás seguro de que queres eliminar a biblioteca {library}?",
"exclusion_pattern_description": "Os patróns de exclusión permítenche ignorar ficheiros e cartafoles ao escanear a túa biblioteca. Isto é útil se tes cartafoles que conteñen ficheiros que non queres importar, coma ficheiros RAW.",
"external_library_created_at": "Biblioteca externa (creada o {date})",
"external_library_management": "Xestión de bibliotecas externas",
"face_detection": "Detección de caras",
"job_settings": "Configuración de tarefas",
"job_settings_description": "Administrar tarefas simultáneas",
"job_status": "Estado da tarefa",
"jobs_failed": "{jobCount, one {# errado}, plural, other {# errados}}"
},
"year": "Ano",
"yes": "Si",
"zoom_image": "Acercar imaxe"
}

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "כפיית רענון של כל קבצי הספרייה",
"image_format": "פורמט",
"image_format_description": "WebP מפיק קבצים קטנים יותר מ JPEG, אך הוא איטי יותר לקידוד.",
"image_fullsize_description": "תמונה בגודל מלא עם מטא נתונים מוסרים, בעת שימוש בהגדלה",
"image_fullsize_enabled": "אפשר יצירה של תמונות באיכות מלאה",
"image_fullsize_enabled_description": "צור תמונה בגודל מלא עבור פורמטים שאינם ידידותיים לאינטרנט. כאשר \"העדף תצוגה מקדימה מוטמעת\" מופעלת, תצוגות מקדימות מוטמעות משמשות ישירות ללא המרה. זה לא משפיע על פורמטים ידידותיים לאינטרנט כמו JPEG.",
"image_fullsize_quality_description": "תמונה בגודל מלא באיכות מ 1-100. גבוהה יותר טוב יותר, אך מייצר קובץ גדול יותר.",
"image_fullsize_title": "הגדרות תמונה בגודל מלא",
"image_prefer_embedded_preview": "העדף תצוגה מקדימה מוטמעת",
"image_prefer_embedded_preview_setting_description": "השתמש בתצוגות מקדימות מוטמעות בתמונות RAW כקלט לעיבוד תמונה כאשר זמינות. זה יכול להפיק צבעים מדויקים יותר עבור תמונות מסוימות, אבל האיכות של התצוגה המקדימה היא תלוית מצלמה ולתמונה עשויים להיות יותר פגמי דחיסה.",
"image_prefer_wide_gamut": "העדף סולם צבעים רחב",
@@ -859,6 +864,7 @@
"loop_videos": "הפעלה חוזרת של סרטונים",
"loop_videos_description": "אפשר הפעלה חוזרת אוטומטית של סרטון במציג הפרטים.",
"main_branch_warning": "את/ה משתמש/ת בגרסת פיתוח; אנחנו ממליצים בחום להשתמש בגרסה יציבה!",
"main_menu": "תפריט ראשי",
"make": "תוצרת",
"manage_shared_links": "ניהול קישורים משותפים",
"manage_sharing_with_partners": "ניהול שיתוף עם שותפים",

View File

@@ -1253,4 +1253,4 @@
"yes": "",
"you_dont_have_any_shared_links": "",
"zoom_image": ""
}
}

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "A képtár összes fájljának frissítése",
"image_format": "Formátum",
"image_format_description": "WebP a JPEG-nél kisebb fájlokat készít, de lassabban.",
"image_fullsize_description": "Teljes méretű kép eltávolított metaadatokkal, nagyításkor használva",
"image_fullsize_enabled": "Teljes méretű képgenerálás engedélyezése",
"image_fullsize_enabled_description": "Teljes méretű kép generálása nem webbarát formátumokhoz. Ha a „Beágyazott előnézet preferálása” engedélyezve van, a beágyazott előnézetek közvetlenül, átalakítás nélkül kerülnek felhasználásra. Nem érinti a webbarát formátumokat, például a JPEG-et.",
"image_fullsize_quality_description": "Teljes méretű képminőség 1-100 között. A magasabb érték jobb minőséget eredményez, de nagyobb fájlméretet is.",
"image_fullsize_title": "Teljes méretű képbeállítások",
"image_prefer_embedded_preview": "Beágyazott előnézeti kép előnyben részesítése",
"image_prefer_embedded_preview_setting_description": "Nyers (RAW) fotók esetén használja a beépített előnézeti képet (ha van) a képek feldogozásához. Ez néhány kép esetében pontosabb színeket eredményezhet, de az előnézeti kép minősége erősen fényképezőgép függő, és a képen előfordulhatnak tömörítési hibák.",
"image_prefer_wide_gamut": "Széles színtér preferálása",
@@ -859,6 +864,7 @@
"loop_videos": "Videók ismétlése",
"loop_videos_description": "Engedélyezi a videók folyamatosan ismételt lejátszását.",
"main_branch_warning": "Fejlesztői verziót használsz. Javasoljuk a stabil verzió használatát!",
"main_menu": "Főmenü",
"make": "Gyártó",
"manage_shared_links": "Megosztási linkek kezelése",
"manage_sharing_with_partners": "Partnerekkel való megosztás kezelése",

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "Forzando l'aggiornamento completo della libreria",
"image_format": "Formato",
"image_format_description": "WebP produce file più piccoli rispetto a JPEG, ma l'encoding è più lento.",
"image_fullsize_description": "Le immagini con dimensioni reali senza metadati sono utilizzate durante lo zoom",
"image_fullsize_enabled": "Abilita la generazione delle immagini con dimensioni reali",
"image_fullsize_enabled_description": "Genera immagini con dimensioni reali per i formati non web-friendly. Quando \"Preferisci l'anteprima integrata\" è abilitata, le anteprime integrate saranno usate senza conversione. Non riguarda le immagini web-friendly come il JPEG.",
"image_fullsize_quality_description": "Qualità delle immagini con dimensioni reali da 1 a 100. Più è alto il valore più la qualità sarà alta come anche la grandezza dei file.",
"image_fullsize_title": "Impostazioni Immagini con dimensioni reali",
"image_prefer_embedded_preview": "Preferisci l'anteprima integrata",
"image_prefer_embedded_preview_setting_description": "Usa l'anteprima integrata nelle foto RAW come input per l'elaborazione delle immagini, se disponibile. Questo permette un miglioramento dei colori per alcune immagini, ma la qualità delle anteprime dipende dalla macchina fotografica. Inoltre le immagini potrebbero presentare artefatti di compressione.",
"image_prefer_wide_gamut": "Preferisci gamut più ampio",
@@ -859,6 +864,7 @@
"loop_videos": "Riproduci video in loop",
"loop_videos_description": "Abilita per riprodurre automaticamente un video in loop nella vista dettagli.",
"main_branch_warning": "Stai usando una versione di sviluppo. Consigliamo vivamente di utilizzare una versione di rilascio!",
"main_menu": "Menu Principale",
"make": "Produttore",
"manage_shared_links": "Gestisci link condivisi",
"manage_sharing_with_partners": "Gestisci la condivisione con i compagni",

View File

@@ -66,6 +66,9 @@
"forcing_refresh_library_files": "すべてのライブラリファイルを強制更新",
"image_format": "フォーマット",
"image_format_description": "WebPはJPEGよりもファイルサイズが小さいですが、エンコードに時間がかかります。",
"image_fullsize_enabled": "原寸大画像生成を有効にする",
"image_fullsize_quality_description": "1から100まで原寸大画像の質です。高いほうがいいがファイルが大きくなります。",
"image_fullsize_title": "原寸大画像設定",
"image_prefer_embedded_preview": "埋め込みプレビューを優先",
"image_prefer_embedded_preview_setting_description": "RAW写真の埋め込みプレビューが利用可能な場合に画像処理の入力として使用します。これにより、いくつかの画像でより正確な色を得ることができますが、プレビューの品質はカメラによって異なり、画像により多くの圧縮アーティファクトが含まれる場合があります。",
"image_prefer_wide_gamut": "広色域に対応させる",
@@ -859,6 +862,7 @@
"loop_videos": "動画をループ",
"loop_videos_description": "有効にすると詳細表示で自動的に動画がループします。",
"main_branch_warning": "開発版を使っているようです。リリース版の使用を強く推奨します!",
"main_menu": "メインメニュー",
"make": "メーカー",
"manage_shared_links": "共有済みのリンクを管理",
"manage_sharing_with_partners": "パートナーとの共有を管理します",

View File

@@ -1 +1,165 @@
{}
{
"about": "შესახებ",
"account": "ანგარიში",
"account_settings": "ანგარიშის პარამეტრები",
"acknowledge": "მიღება",
"action": "ქმედება",
"actions": "ქმედებები",
"active": "აქტიური",
"activity": "აქტივობა",
"add": "დამატება",
"add_a_description": "დაამატე აღწერა",
"add_a_location": "დაამატე ადგილი",
"add_a_name": "დაამატე სახელი",
"add_a_title": "დაასათაურე",
"add_import_path": "დაამატე საიმპორტო მისამართი",
"add_location": "დაამატე ადგილი",
"add_more_users": "დაამატე მომხმარებლები",
"add_partner": "დაამატე პარტნიორი",
"add_path": "დაამატე მისამართი",
"add_photos": "დაამატე ფოტოები",
"add_to_album": "დაამატე ალბომში",
"add_to_shared_album": "დაამატე საზიარო ალბომში",
"add_url": "დაამატე URL",
"added_to_archive": "დაარქივდა",
"added_to_favorites": "დაამატე რჩეულებში",
"added_to_favorites_count": "{count, number} დაემატა რჩეულებში",
"admin": {
"authentication_settings": "ავთენტიკაციის პარამეტრები",
"authentication_settings_description": "პაროლის, OAuth-ის და სხვა ავტენთიფიკაციის პარამეტრების მართვა",
"authentication_settings_disable_all": "ნამდვილად გინდა ავტორიზაციის ყველა მეთოდის გამორთვა? ავტორიზაციას ვეღარანაირად შეძლებ.",
"authentication_settings_reenable": "რეაქტივაციისთვის, გამოიყენე <link>სერვერის ბრძანება</link>.",
"background_task_job": "ფონური დავალებები",
"backup_database": "შექმენი სარეზერვო ასლი",
"backup_database_enable_description": "ჩართე სარეზერვო ასლების ფუნქცია",
"backup_keep_last_amount": "შესანახი სარეზერვო ასლების რაოდენობა",
"backup_settings": "სარეზერვო ასლების პარამეტრები",
"backup_settings_description": "მონაცემთა ბაზის სარეზერვო ასლების პარამეტრების მართვა",
"check_all": "შეამოწმე ყველა",
"cleanup": "გასუფთავება",
"confirm_delete_library": "ნამდვილად გინდა {library} ბიბლიოთეკის წაშლა?",
"confirm_email_below": "დასადასტურებლად, ქვემოთ აკრიფე \"{email}\"",
"confirm_user_password_reset": "ნამდვილად გინდა {user}-(ი)ს პაროლის დარესეტება?",
"disable_login": "გამორთე ავტორიზაცია",
"external_library_management": "გარე ბიბლიოთეკების მართვა",
"face_detection": "სახის ამოცნობა",
"image_format": "ფორმატი",
"image_fullsize_title": "სრული ზომის გამოსახულების პარამეტრები",
"image_quality": "ხარისხი",
"image_resolution": "გაფართოება",
"image_settings": "გამოსახულების პარამეტრები",
"image_settings_description": "გენერირებული ფოტოების ხარისხისა და რეზოლუციის მართვა",
"image_thumbnail_description": "მინიატურა მეტაინფორმაციის გარეშე, რომელიც ფოტოები ჯგუფურად თვალიერებისას გამოიყენება(მაგ. მთავარ თაიმლაინზე)",
"image_thumbnail_quality_description": "მინიატურის ხარისხი 1-დან 100-მდე. დიდი რიცხვი შეესაბამება უკეთეს ხარისხს, თუმცა, უფრო დიდ ფაილებს და აპლიკაციის შესაძლო შენელებას.",
"image_thumbnail_title": "მინიატურის პარამეტრები",
"library_created": "შეიქმნა ბიბლიოთეკა: {library}",
"library_deleted": "ბიბლიოთეკა წაიშალა",
"library_import_path_description": "აირჩიე დასაიმპორტებელი საქაღალდე. ფოტოები და ვიდეოები მოიძებნება ამ საქაღალდესა და მასში არსებულ საქაღალდეებში.",
"library_settings_description": "გარე ბიბლიოთეკების პარამეტრების მართვა",
"logging_settings": "ჟურნალი",
"map_settings": "რუკა",
"migration_job": "მიგრაცია",
"oauth_scope": "დიაპაზონი",
"oauth_settings": "OAuth",
"template_email_preview": "მინიატურა",
"transcoding_acceleration_vaapi": "VAAPI",
"transcoding_threads": "ნაკადები",
"transcoding_tone_mapping": "ტონების ასახვა"
},
"administration": "ადმინისტრაცია",
"advanced": "დამატებით",
"albums": "ალბომები",
"all": "ყველა",
"anti_clockwise": "საათის ისრის საწინააღმდეგო",
"archive": "არქივი",
"asset_hashing": "დაჰეშვა.…",
"asset_skipped": "გამოტოვებულია",
"asset_uploaded": "ატვირთულია",
"asset_uploading": "მიმდინარეობს ატვირთვა…",
"assets": "ობიექტები",
"back": "უკან",
"backward": "უკან გადასვლა",
"build": "აგება",
"camera": "კამერა",
"cancel": "გაუქმება",
"city": "ქალაქი",
"clear": "გასუფთავება",
"clockwise": "საათის ისრის მიმართულებით",
"close": "დახურვა",
"collapse": "აკეცვა",
"color": "ფერი",
"confirm": "დასტური",
"contain": "შეიცავს",
"context": "კონტექსტი",
"continue": "გაგრძელება",
"country": "ქვეყანა",
"cover": "ყდა",
"covers": "ყდები",
"create": "შექმნა",
"created": "შექმნილია",
"dark": "მუქი",
"day": "დღე",
"delete": "წაშლა",
"description": "აღწერა",
"details": "დეტალები",
"direction": "მიმართულება",
"disabled": "გათიშულია",
"discord": "Discord",
"discover": "აღმოჩენა",
"documentation": "დოკუმენტაცია",
"done": "მზადაა",
"download": "გადმოწერა",
"download_settings": "გადმოწერა",
"downloading": "მიმდინარეობს გადმოწერა",
"duplicates": "დუბლიკატები",
"duration": "ხანგრძლივობა",
"edit": "ჩასწორება",
"edited": "ჩასწორებულია",
"editor": "რედაქტორი",
"editor_crop_tool_h2_rotation": "ტრიალი",
"email": "ელფოსტა",
"enable": "ჩართვა",
"enabled": "ჩართულია",
"error": "შეცდომა",
"exif": "Exif",
"expired": "ვადაამოწურულია",
"explore": "დათვალიერება",
"explorer": "გამცილებელი",
"export": "გატანა",
"extension": "გაფართოება",
"external": "გარე",
"face_unassigned": "მიუნიჭებელი",
"favorite": "რჩეული",
"favorites": "რჩეულები",
"features": "თვისებები",
"filename": "ფაილის სახელი",
"filetype": "ფაილის ტიპი",
"folders": "საქაღალდეები",
"forward": "წინ",
"general": "ზოგადი",
"host": "ჰოსტი",
"hour": "საათი",
"image": "გამოსახულება",
"info": "ინფორმაცია",
"jobs": "დავალებები",
"keep": "შენარჩუნება",
"language": "ენა",
"latitude": "განედი",
"leave": "გასვლა",
"level": "დონე",
"library": "ბიბლიოთეკა",
"light": "ღია",
"list": "სია",
"loading": "ჩატვირთვა",
"login": "შესვლა",
"longitude": "გრძედი",
"look": "შეხედვა",
"make": "მწარმოებელი",
"map": "რუკა",
"matches": "დამთხვევები",
"memories": "მოგონებები",
"memory": "მეხსიერება",
"menu": "მენიუ",
"merge": "შერწყმა",
"minimize": "დაპატარავება"
}

View File

@@ -699,12 +699,18 @@
"purchase_button_remove_key": "Noņemt atslēgu",
"purchase_button_select": "Izvēlēties",
"purchase_individual_description_2": "Atbalstītāja statuss",
"purchase_input_suggestion": "Vai tev ir produkta atslēga? Ievadi atslēgu zemāk",
"purchase_license_subtitle": "Nopērc Immich licenci, lai atbalstītu turpmāku pakalpojuma attīstību",
"purchase_lifetime_description": "Pirkums uz mūžu",
"purchase_option_title": "IEGĀDES IESPĒJAS",
"purchase_panel_title": "Atbalstīt projektu",
"purchase_remove_product_key": "Noņemt produkta atslēgu",
"purchase_remove_server_product_key": "Noņemt servera produkta atslēgu",
"purchase_server_description_1": "Visam serverim",
"purchase_server_description_2": "Atbalstītāja statuss",
"purchase_server_title": "Serveris",
"purchase_settings_server_activated": "Servera produkta atslēgu pārvalda administrators",
"rating_clear": "Noņemt vērtējumu",
"reaction_options": "",
"read_changelog": "Lasīt izmaiņu sarakstu",
"recent": "",

View File

@@ -372,4 +372,4 @@
"yes": "Ya",
"you_dont_have_any_shared_links": "Anda tidak mempunyai apa-apa pautan yang dikongsi",
"zoom_image": "Zum Gambar"
}
}

View File

@@ -1374,7 +1374,7 @@
"welcome": "Velkommen",
"welcome_to_immich": "Velkommen til Immich",
"year": "År",
"years_ago": "{years, plural, one {# year} other {# years}} siden",
"years_ago": "{years, plural, one {# år} other {# år}} siden",
"yes": "Ja",
"you_dont_have_any_shared_links": "Du har ingen delte lenker",
"zoom_image": "Zoom Bilde"

View File

@@ -66,8 +66,13 @@
"forcing_refresh_library_files": "Geforceerd vernieuwen van alle bibliotheekbestanden",
"image_format": "Formaat",
"image_format_description": "WebP produceert kleinere bestanden dan JPEG, maar is langzamer om te verwerken.",
"image_fullsize_description": "Afbeelding op ware grootte met gestripte metadata, gebruikt bij inzoomen",
"image_fullsize_enabled": "Genereer afbeeldingen op ware grootte inschakelen",
"image_fullsize_enabled_description": "Genereer afbeelding op volledig formaat voor niet-webvriendelijke formaten. Als “Ingebed voorvertoning verkiezen” is ingeschakeld, worden ingesloten voorvertoningen direct gebruikt zonder conversie. Heeft geen invloed op webvriendelijke formaten zoals JPEG.",
"image_fullsize_quality_description": "Beeldkwaliteit op ware grootte van 1-100. Hoger is beter, maar genereert grotere bestanden.",
"image_fullsize_title": "Instellingen afbeelding op ware grootte",
"image_prefer_embedded_preview": "Ingebedde voorbeeldafbeelding gebruiken",
"image_prefer_embedded_preview_setting_description": "Ingebedde voorbeeldafbeelding van RAW bestanden gebruiken als invoer voor beeldverwerking wanneer beschikbaar. Dit kan preciezere kleuren produceren voor sommige afbeeldingen, maar de kwaliteit van het voorbeeld is afhankelijk van de camera en de afbeelding kan mogelijk meer compressie-artefacten hebben.",
"image_prefer_embedded_preview_setting_description": "Ingebedde voorbeeldafbeelding van RAW bestanden gebruiken als invoer voor beeldverwerking wanneer beschikbaar. Dit kan preciezere kleuren produceren voor sommige afbeeldingen, maar de kwaliteit van het voorbeeld is afhankelijk van de camera en de afbeelding kan mogelijk meer compressie-artefacten bevatten.",
"image_prefer_wide_gamut": "Voorkeur geven aan wide gamut",
"image_prefer_wide_gamut_setting_description": "Display P3 gebruiken voor voorbeeldafbeeldingen. Dit behoudt de levendigheid van afbeeldingen met brede kleurruimtes beter, maar afbeeldingen kunnen er anders uitzien op oude apparaten met een oude browserversie. sRGB-afbeeldingen blijven sRGB gebruiken om kleurverschuivingen te vermijden.",
"image_preview_description": "Middelgrote afbeelding met verwijderde metadata, gebruikt bij het bekijken van een enkele asset en voor machine learning",
@@ -859,6 +864,7 @@
"loop_videos": "Video's herhalen",
"loop_videos_description": "Inschakelen om video's automatisch te herhalen in de detailweergave.",
"main_branch_warning": "U gebruikt een ontwikkelingsversie. Wij raden u ten zeerste aan een releaseversie te gebruiken!",
"main_menu": "Hoofdmenu",
"make": "Merk",
"manage_shared_links": "Beheer gedeelde links",
"manage_sharing_with_partners": "Beheer delen met partners",

View File

@@ -1047,7 +1047,7 @@
"purchase_server_title": "Serwer",
"purchase_settings_server_activated": "Klucz produktu serwera jest zarządzany przez administratora",
"rating": "Ocena gwiazdkowa",
"rating_clear": "Wyczyść oceną",
"rating_clear": "Wyczyść ocenę",
"rating_count": "{count, plural, one {# gwiazdka} other {# gwiazdek}}",
"rating_description": "Wyświetl ocenę z EXIF w panelu informacji",
"reaction_options": "Opcje reakcji",
@@ -1077,8 +1077,10 @@
"remove_custom_date_range": "Usuń niestandardowy zakres dat",
"remove_deleted_assets": "Usuń Niedostępne Pliki",
"remove_from_album": "Usuń z albumu",
"remove_from_favorites": "Usuń z ulubionych",
"remove_from_favorites": "Usuń z ulubionych",
"remove_from_shared_link": "Usuń z udostępnionego linku",
"remove_memory": "Usuń pamięć",
"remove_photo_from_memory": "Usuń zdjęcia z tej pamięci",
"remove_url": "Usuń URL",
"remove_user": "Usuń użytkownika",
"removed_api_key": "Usunięto Klucz API: {name}",

View File

@@ -66,8 +66,13 @@
"forcing_refresh_library_files": "A forçar a atualização de todos os ficheiros da biblioteca",
"image_format": "Formato",
"image_format_description": "WebP produz ficheiros mais pequenos do que JPEG, mas é mais lento para codificar.",
"image_fullsize_description": "Imagem de tamanho inteiro sem meta dados, utilizada quando esta for ampliada",
"image_fullsize_enabled": "Ativar geração de imagem em tamanho inteiro",
"image_fullsize_enabled_description": "Gerar imagens de tamanho inteiro para formatos não compatíveis com a web. Quando a opção \"Preferir visualização incorporada\" está ativada, estas serão utilizadas diretamente sem serem convertidas. Não afeta formatos compatíveis com a web tais como JPEG.",
"image_fullsize_quality_description": "Qualidade da imagem de tamanho inteiro de 1 a 100. Valores mais altos são melhores, mas produzem ficheiros maiores.",
"image_fullsize_title": "Definições de imagem de tamanho inteiro",
"image_prefer_embedded_preview": "Preferir visualização incorporada",
"image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.",
"image_prefer_embedded_preview_setting_description": "Utilizar visualizações incorporadas em fotos RAW como entrada para processamento de imagem e quando disponível. Isto pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmara e a imagem pode ter mais artefatos de compressão.",
"image_prefer_wide_gamut": "Prefira ampla gama",
"image_prefer_wide_gamut_setting_description": "Utilizar Display P3 para miniaturas. Isso preserva melhor a vibrância das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.",
"image_preview_description": "Imagem de tamanho médio sem metadados, utilizada ao visualizar um único ficheiro e pela aprendizagem de máquina",
@@ -467,7 +472,7 @@
"check_all": "Verificar tudo",
"check_logs": "Verificar registos",
"choose_matching_people_to_merge": "Escolha pessoas correspondentes para unir",
"city": "Cidade",
"city": "Cidade/Localidade",
"clear": "Limpar",
"clear_all": "Limpar tudo",
"clear_all_recent_searches": "Limpar todas as pesquisas recentes",
@@ -859,6 +864,7 @@
"loop_videos": "Repetir vídeos",
"loop_videos_description": "Ativar para repetir os vídeos automaticamente durante a exibição.",
"main_branch_warning": "Está a utilizar uma versão de desenvolvimento, recomendamos vivamente que utilize uma versão estável!",
"main_menu": "Menu Principal",
"make": "Marca",
"manage_shared_links": "Gerir links partilhados",
"manage_sharing_with_partners": "Gerir partilha com parceiros",
@@ -1244,7 +1250,7 @@
"stacktrace": "Stacktrace",
"start": "Iniciar",
"start_date": "Data de início",
"state": "Estado",
"state": "Estado/Distrito",
"status": "Estado",
"stop_motion_photo": "Parar foto em movimento",
"stop_photo_sharing": "Deixar de partilhar as suas fotos?",

View File

@@ -66,8 +66,13 @@
"forcing_refresh_library_files": "Forçando a atualização de todos os arquivos da biblioteca",
"image_format": "Formato",
"image_format_description": "WebP produz arquivos menores que JPEG, mas é mais lento para codificar.",
"image_fullsize_description": "Imagem em tamanho real sem os metadados exibida quando der zoom",
"image_fullsize_enabled": "Ativar geração de imagem no tamanho real",
"image_fullsize_enabled_description": "Gerar imagens no tamanho real para os formatos de arquivos não compatíveis com a web. Quando \"Preferir visualização incorporada\" estiver ativado, essas serão utilizadas sem conversão. Não afeta arquivos já em formatos para web, como JPEG.",
"image_fullsize_quality_description": "Qualidade da imagem em tamanho real, de 1 a 100. Valores maiores tem melhor qualidade, mas gera arquivos maiores.",
"image_fullsize_title": "Configurações de imagem em tamanho real",
"image_prefer_embedded_preview": "Preferir visualização incorporada",
"image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como entrada para processamento de imagem, quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.",
"image_prefer_embedded_preview_setting_description": "Use visualizações incorporadas em fotos RAW como a entrada para processamento de imagem e quando disponível. Isso pode produzir cores mais precisas para algumas imagens, mas a qualidade da visualização depende da câmera e a imagem pode ter mais artefatos de compactação.",
"image_prefer_wide_gamut": "Prefira ampla gama",
"image_prefer_wide_gamut_setting_description": "Use o Display P3 para miniaturas. Isso preserva melhor a vibração das imagens com espaços de cores amplos, mas as imagens podem aparecer de maneira diferente em dispositivos antigos com uma versão antiga do navegador. As imagens sRGB são mantidas como sRGB para evitar mudanças de cores.",
"image_preview_description": "Imagem de tamanho médio sem os metadados, utilizado quando visualizando um único arquivo e também pelo aprendizado de máquina",
@@ -1174,7 +1179,7 @@
"server_version": "Versão do servidor",
"set": "Definir",
"set_as_album_cover": "Definir como capa do álbum",
"set_as_featured_photo": "Definir como foto principal",
"set_as_featured_photo": "Definir como foto em destaque",
"set_as_profile_picture": "Definir como foto de perfil",
"set_date_of_birth": "Definir data de nascimento",
"set_profile_picture": "Definir foto de perfil",
@@ -1187,14 +1192,14 @@
"shared_by_user": "Compartilhado por {user}",
"shared_by_you": "Compartilhado por você",
"shared_from_partner": "Fotos de {partner}",
"shared_link_options": "Opções do link compartilhado",
"shared_link_options": "Opções de link compartilhado",
"shared_links": "Links compartilhados",
"shared_links_description": "Compartilhar fotos e videos com um link",
"shared_photos_and_videos_count": "{assetCount, plural, one {# arquivo compartilhado.} other {# arquivos compartilhados.}}",
"shared_photos_and_videos_count": "{assetCount, plural, one {# Foto & vídeo compartilhado.} other {# Fotos & vídeos compartilhados.}}",
"shared_with_partner": "Compartilhado com {partner}",
"sharing": "Compartilhar",
"sharing": "Compartilhamento",
"sharing_enter_password": "Digite a senha para visualizar esta página.",
"sharing_sidebar_description": "Exibe o link Compartilhar na barra lateral",
"sharing_sidebar_description": "Exibe um link para Compartilhamento na barra lateral",
"shift_to_permanent_delete": "pressione ⇧ para excluir permanentemente o arquivo",
"show_album_options": "Exibir opções do álbum",
"show_albums": "Exibir álbuns",
@@ -1214,15 +1219,15 @@
"show_search_options": "Exibir opções de pesquisa",
"show_shared_links": "Mostrar links compartilhados",
"show_slideshow_transition": "Usar transições no modo de apresentação",
"show_supporter_badge": "Insígnia de Contribuidor",
"show_supporter_badge_description": "Mostrar a insígnia de contribuidor",
"show_supporter_badge": "Insígnia de apoiador",
"show_supporter_badge_description": "Mostrar uma insígnia de apoiador",
"shuffle": "Aleatório",
"sidebar": "Barra lateral",
"sidebar_display_description": "Exibir um link para visualizar na barra lateral",
"sidebar_display_description": "Exibir um link para a visualização na barra lateral",
"sign_out": "Sair",
"sign_up": "Registrar",
"size": "Tamanho",
"skip_to_content": "Pular para o conteúdo",
"skip_to_content": "Ir para o conteúdo",
"skip_to_folders": "Ir para pastas",
"skip_to_tags": "Ir para os marcadores",
"slideshow": "Apresentação",
@@ -1240,19 +1245,19 @@
"stack_duplicates": "Empilhar duplicados",
"stack_select_one_photo": "Selecione uma foto principal para a pilha",
"stack_selected_photos": "Empilhar fotos selecionadas",
"stacked_assets_count": "{count, plural, one {# arquivo empilhado} other {# arquivos empilhados}}",
"stacked_assets_count": "{count, plural, one {# Arquivo empilhado} other {# Arquivos empilhados}}",
"stacktrace": "Rastreamento de pilha",
"start": "Início",
"start_date": "Data inicial",
"state": "Estado",
"status": "Status",
"stop_motion_photo": "Parar foto em movimento",
"stop_photo_sharing": "Parar de partilhar as suas fotos?",
"stop_photo_sharing": "Parar de compartilhar suas fotos?",
"stop_photo_sharing_description": "{partner} não terá mais acesso às suas fotos.",
"stop_sharing_photos_with_user": "Parar de compartilhar as fotos com este usuário",
"stop_sharing_photos_with_user": "Parar de compartilhar suas fotos com este usuário",
"storage": "Espaço de armazenamento",
"storage_label": "Rótulo de armazenamento",
"storage_usage": "utilizado {used} de {available}",
"storage_usage": "Utilizado {used} de {available}",
"submit": "Enviar",
"suggestions": "Sugestões",
"sunrise_on_the_beach": "Nascer do sol na praia",
@@ -1264,11 +1269,11 @@
"tag": "Marcador",
"tag_assets": "Marcar arquivos",
"tag_created": "Marcador criado: {tag}",
"tag_feature_description": "Visualizar fotos e videos agrupados pelo tópico do marcador",
"tag_feature_description": "Navegando por fotos e videos agrupados pelo tópico lógico do marcador",
"tag_not_found_question": "Não consegue encontrar o marcador? <link>Crie uma novo aqui.</link>",
"tag_people": "Marcar pessoas",
"tag_updated": "Marcador foi atualizado: {tag}",
"tagged_assets": "{count, plural, one {# arquivo marcado} other {# arquivos marcados}}",
"tagged_assets": "{count, plural, one {# Arquivo marcado} other {# Arquivos marcados}}",
"tags": "Marcadores",
"template": "Modelo",
"theme": "Tema",
@@ -1276,14 +1281,14 @@
"theme_selection_description": "Defina automaticamente o tema como claro ou escuro com base na preferência do sistema do seu navegador",
"they_will_be_merged_together": "Eles serão mesclados",
"third_party_resources": "Recursos de terceiros",
"time_based_memories": "Memórias baseada no tempo",
"time_based_memories": "Memórias baseadas no tempo",
"timeline": "Linha do tempo",
"timezone": "Fuso horário",
"to_archive": "Arquivar",
"to_change_password": "Alterar senha",
"to_favorite": "Favorito",
"to_login": "Iniciar sessão",
"to_parent": "Voltar um nível acima",
"to_parent": "Voltar para nível acima",
"to_trash": "Mover para a lixeira",
"toggle_settings": "Alternar configurações",
"toggle_theme": "Alternar tema escuro",
@@ -1297,7 +1302,7 @@
"trashed_items_will_be_permanently_deleted_after": "Os itens da lixeira serão deletados permanentemente após {days, plural, one {# dia} other {# dias}}.",
"type": "Tipo",
"unarchive": "Desarquivar",
"unarchived_count": "{count, plural, one {# desarquivado} other {# desarquivados}}",
"unarchived_count": "{count, plural, one {# Desarquivado} other {# Desarquivados}}",
"unfavorite": "Remover favorito",
"unhide_person": "Exibir pessoa",
"unknown": "Desconhecido",
@@ -1312,19 +1317,19 @@
"unnamed_album_delete_confirmation": "Tem certeza que deseja excluir este álbum?",
"unnamed_share": "Compartilhamento sem nome",
"unsaved_change": "Alteração não salva",
"unselect_all": "Limpar seleção",
"unselect_all": "Desselecionar todos",
"unselect_all_duplicates": "Desselecionar todas as duplicatas",
"unstack": "Desempilhar",
"unstacked_assets_count": "{count, plural, one {# arquivo não empilhado} other {# arquivos não empilhados}}",
"unstacked_assets_count": "{count, plural, one {# Arquivo desempilhado} other {# Arquivos desempilhados}}",
"untracked_files": "Arquivos não monitorados",
"untracked_files_decription": "Estes arquivos não são monitorados pela aplicação. Podem ser resultados de falhas em uma movimentação, carregamentos interrompidos, ou deixados para trás por causa de um problema",
"up_next": "A seguir",
"updated_password": "Senha atualizada",
"upload": "Carregar",
"upload_concurrency": "Carregar simultâneo",
"upload_concurrency": "Envios simultâneos",
"upload_errors": "Envio concluído com {count, plural, one {# erro} other {# erros}}, atualize a página para ver os novos arquivos carregados.",
"upload_progress": "{remaining, number} processando - {processed, number}/{total, number} já processados",
"upload_skipped_duplicates": "{count, plural, one {# arquivo duplicado foi ignorado} other {# arquivos duplicados foram ignorados}}",
"upload_progress": "{remaining, number} restantes - {processed, number}/{total, number} já processados",
"upload_skipped_duplicates": "{count, plural, one {# Arquivo duplicado foi ignorado} other {# Arquivos duplicados foram ignorados}}",
"upload_status_duplicates": "Duplicados",
"upload_status_errors": "Erros",
"upload_status_uploaded": "Carregado",
@@ -1334,23 +1339,23 @@
"use_custom_date_range": "Usar intervalo de datas personalizado",
"user": "Usuário",
"user_id": "ID do usuário",
"user_liked": "{user} curtiu {type, select, photo {a foto} video {o vídeo} asset {o arquivo} other {isso}}",
"user_liked": "{user} curtiu {type, select, photo {esta foto} video {este vídeo} asset {este arquivo} other {isto}}",
"user_purchase_settings": "Comprar",
"user_purchase_settings_description": "Gerenciar sua compra",
"user_role_set": "Definir {user} como {role}",
"user_usage_detail": "Detalhes de uso do usuário",
"user_usage_stats": "Estatísticas de utilização de conta",
"user_usage_stats_description": "Ver estatísticas de utilização de conta",
"user_usage_stats": "Estatísticas de utilização da conta",
"user_usage_stats_description": "Ver estatísticas de utilização da conta",
"username": "Nome do usuário",
"users": "Usuários",
"utilities": "Utilitários",
"utilities": "Ferramentas",
"validate": "Validar",
"variables": "Variáveis",
"version": "Versão",
"version_announcement_closing": "De seu amigo, Alex",
"version_announcement_message": "Olá! Uma nova versão do Immich está disponível. Para evitar configurações incorretas, leia com calma a página de <link>notas da versão</link> e verifique se é necessário alterar alguma configuração, principalmente se você usa o WatchTower ou qualquer outro mecanismo que faça atualizações automáticas do Immich.",
"version_history": "Histórico de versões",
"version_history_item": "Instalado {version} em {date}",
"version_history_item": "Versão {version} instalada em {date}",
"video": "Vídeo",
"video_hover_setting": "Reproduzir miniatura do vídeo ao passar o mouse",
"video_hover_setting_description": "Reproduzir a miniatura do vídeo ao passar o mouse sobre o item. Mesmo quando desativado, a reprodução pode ser iniciada ao passar o mouse sobre o ícone de reprodução.",
@@ -1359,15 +1364,15 @@
"view": "Ver",
"view_album": "Ver álbum",
"view_all": "Ver tudo",
"view_all_users": "Ver todos usuários",
"view_all_users": "Ver todos os usuários",
"view_in_timeline": "Ver na linha do tempo",
"view_link": "Ver link",
"view_links": "Ver links",
"view_name": "Ver",
"view_next_asset": "Ver próximo arquivo",
"view_previous_asset": "Ver arquivo anterior",
"view_stack": "Exibir Pilha",
"visibility_changed": "A visibilidade de {count, plural, one {# pessoa foi alterada} other {# pessoas foram alteradas}}",
"view_stack": "Ver Pilha",
"visibility_changed": "A visibilidade {count, plural, one {# da pessoa foi alterada} other {# das pessoas foi alterada}}",
"waiting": "Aguardando",
"warning": "Aviso",
"week": "Semana",
@@ -1376,6 +1381,6 @@
"year": "Ano",
"years_ago": "{years, plural, one {# ano} other {# anos}} atrás",
"yes": "Sim",
"you_dont_have_any_shared_links": "Não há links compartilhados",
"you_dont_have_any_shared_links": "Você não possui links compartilhados",
"zoom_image": "Ampliar imagem"
}

View File

@@ -1358,4 +1358,4 @@
"yes": "Da",
"you_dont_have_any_shared_links": "Nu aveți linkuri partajate",
"zoom_image": "Măriți Imaginea"
}
}

View File

@@ -1,7 +1,7 @@
{
"about": "О продукте",
"account": "Учётная запись",
"account_settings": "Настройки аккаунта",
"account_settings": "Настройки учётной записи",
"acknowledge": "Подтвердить",
"action": "Действие",
"actions": "Действия",
@@ -33,7 +33,7 @@
"authentication_settings": "Настройки аутентификации",
"authentication_settings_description": "Управление паролями, OAuth и другими настройками аутентификации",
"authentication_settings_disable_all": "Вы уверены, что хотите отключить все методы входа? Вход будет полностью отключен.",
"authentication_settings_reenable": "Чтобы снова включить, используйте <link>Команда Сервера</link>.",
"authentication_settings_reenable": "Чтобы снова включить, используйте <link>Команду сервера</link>.",
"background_task_job": "Фоновые задачи",
"backup_database": "Резервное копирование базы данных",
"backup_database_enable_description": "Включить резервное копирование базы данных",
@@ -59,13 +59,18 @@
"external_library_created_at": "Внешняя библиотека (создана {date})",
"external_library_management": "Управление внешними библиотеками",
"face_detection": "Обнаружение лиц",
"face_detection_description": "Обнаруживает лица на медиа с помощью машинного обучения. Для видео учитывается только миниатюра. Обновить” — обработать все медиа. Сброс” — удалить все имеющиеся данные лиц и обработать заново. “Пропущенные” — добавить в очередь необработанные медиа. Обнаруженные лица будут помещены в очередь распознавания для привязки к существующим или новым людям.",
"facial_recognition_job_description": "Группирует распознанные лица по людям. Этот шаг выполняется после завершения обнаружения лиц. Сброс” - группирует все лица. “Пропущенные” - помещает в очередь лица, не привязанные к человеку.",
"face_detection_description": "Обнаруживает лица на медиа с использованием машинного обучения. Для видео анализируется только миниатюра. \"Обновить\" повторно обрабатывает все медиа. \"Сброс\" дополнительно удаляет все имеющиеся данные о лицах. \"Отсутствующие\" ставит в очередь медиа, которые ещё не были обработаны. Обнаруженные лица будут переданы в очередь для распознавания после завершения процесса их обнаружения, привязываясь к существующим или новым людям.",
"facial_recognition_job_description": "Группировка обнаруженных лиц по людям. Этот шаг выполняется после завершения обнаружения лиц. \"Сброс\" (пере)группирует все лица. \"Отсутствующие\" ставит в очередь лица, не привязанные к человеку.",
"failed_job_command": "Команда {command} не выполнена для задачи: {job}",
"force_delete_user_warning": "ПРЕДУПРЕЖДЕНИЕ: Это приведет к немедленному удалению пользователя и его ресурсов. Это действие невозможно отменить, и файлы не могут быть восстановлены.",
"forcing_refresh_library_files": "Принудительное обновление всех файлов библиотеки",
"image_format": "Формат",
"image_format_description": "WebP создает файлы меньшего размера, чем JPEG, но кодирует медленнее.",
"image_fullsize_description": "Полноразмерное изображение без метаданных, используется при увеличении",
"image_fullsize_enabled": "Включить создание полноразмерного изображения",
"image_fullsize_enabled_description": "Создавать полноразмерное изображение для форматов, не предназначенных для веба. Когда включен параметр «Предпочитать встроенное превью», встроенные превью используются напрямую без конверсии. Не влияет на веб-совместимые форматы, такие как JPEG.",
"image_fullsize_quality_description": "Качество полноразмерного изображения от 1 до 100. Чем выше значение, тем лучше качество, но больше размер файла.",
"image_fullsize_title": "Настройки полноразмерного изображения",
"image_prefer_embedded_preview": "Предпочитать встроенное превью",
"image_prefer_embedded_preview_setting_description": "Используйте встроенные превью в фотографиях RAW в качестве входных данных для обработки изображений, если они доступны. Это может обеспечить более точную цветопередачу для некоторых изображений, но качество предварительного просмотра зависит от камеры, и изображение может иметь больше артефактов сжатия.",
"image_prefer_wide_gamut": "Предпочитаю широкую гамму",
@@ -859,6 +864,7 @@
"loop_videos": "Циклическое воспроизведение",
"loop_videos_description": "Включить циклическое воспроизведение видео.",
"main_branch_warning": "Вы используете версию для разработки; мы настоятельно рекомендуем использовать релизную версию!",
"main_menu": "Главное меню",
"make": "Производитель",
"manage_shared_links": "Управление публичными ссылками",
"manage_sharing_with_partners": "Управление обменом информацией с партнерами. Эта функция позволяет вашему партнеру видеть ваши фотографии и видеозаписи, кроме тех, которые находятся в Архиве и Корзине",
@@ -886,7 +892,7 @@
"merged_people_count": "Объединено {count, plural, one {# человек} few {# человека} many {# человек} other {# человека}}",
"minimize": "Минимизировать",
"minute": "Минута",
"missing": "Пропущенные",
"missing": "Отсутствующие",
"model": "Модель",
"month": "Месяц",
"more": "Больше",
@@ -1374,7 +1380,7 @@
"welcome": "Добро пожаловать",
"welcome_to_immich": "Добро пожаловать в Immich",
"year": "Год",
"years_ago": "{years, plural, one {# год} few {# года} many {# лет} other {# года}} назад",
"years_ago": "{years, plural, one {# год} few {# года} many {# лет} other {# лет}} назад",
"yes": "Да",
"you_dont_have_any_shared_links": "У вас нет публичных ссылок",
"zoom_image": "Приблизить"

View File

@@ -66,8 +66,13 @@
"forcing_refresh_library_files": "Vsiljena osvežitev vseh datotek knjižnice",
"image_format": "Format",
"image_format_description": "WebP ustvari manjše datoteke kot JPEG, vendar je počasnejši za kodiranje.",
"image_fullsize_description": "Slika v polni velikosti brez metapodatkov, uporabljena pri povečavi",
"image_fullsize_enabled": "Omogoči ustvarjanje slik v polni velikosti",
"image_fullsize_enabled_description": "Ustvari sliko v polni velikosti za formate, ki niso prijazni spletu. Ko je omogočena možnost »Prednostno vdelani predogled«, se vdelani predogledi uporabljajo neposredno brez pretvorbe. Ne vpliva na spletu prijazne formate, kot je JPEG.",
"image_fullsize_quality_description": "Kakovost slike v polni velikosti od 1 do 100. Višja vrednost pomeni boljšo kakovost, vendar ustvarja večje datoteke.",
"image_fullsize_title": "Nastavitve slike v polni velikosti",
"image_prefer_embedded_preview": "Uporabi raje vdelan predogled",
"image_prefer_embedded_preview_setting_description": "Uporabite vdelane predoglede v fotografije RAW kot vhod za obdelavo slik, ko so na voljo. To lahko ustvari natančnejše barve za nekatere slike, vendar je kakovost predogleda odvisna od kamere in slika ima lahko več artefaktov stiskanja.",
"image_prefer_embedded_preview_setting_description": "Uporabi vdelane predoglede v fotografijah RAW kot vhod za obdelavo slik, kadar so na voljo. To lahko pri nekaterih slikah zagotovi natančnejše barve, vendar je kakovost predogleda odvisna od fotoaparata, slika pa lahko vsebuje več artefaktov stiskanja.",
"image_prefer_wide_gamut": "Uporabi raje širok razpon",
"image_prefer_wide_gamut_setting_description": "Uporabite P3 Display za sličice. To bolje ohranja živahnost slik s širokimi barvnimi prostori, vendar so lahko slike videti drugače na starih napravah s staro različico brskalnika. Slike sRGB se ohranijo kot sRGB, da se izognejo barvnim zamikom.",
"image_preview_description": "Slika srednje velikosti z odstranjenimi metapodatki, ki se uporablja pri ogledu posameznega sredstva in za strojno učenje",
@@ -859,6 +864,7 @@
"loop_videos": "Zanka videoposnetkov",
"loop_videos_description": "Omogočite samodejno ponavljanje videoposnetka v pregledovalniku podrobnosti.",
"main_branch_warning": "Uporabljate razvojno različico; močno priporočamo uporabo izdajne različice!",
"main_menu": "Glavni meni",
"make": "Izdelava",
"manage_shared_links": "Upravljanje povezav v skupni rabi",
"manage_sharing_with_partners": "Upravljajte skupno rabo s partnerji",

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "Принудно освежавање свих датотека библиотеке",
"image_format": "Формат",
"image_format_description": "WebP производи мање датотеке од ЈПЕГ, али се спорије кодира.",
"image_fullsize_description": "Слика у пуној величини са огољеним метаподацима, користи се када је увећана",
"image_fullsize_enabled": "Омогућите генерисање слике у пуној величини",
"image_fullsize_enabled_description": "Генеришите слику пуне величине за формате који нису прилагођени вебу. Када је „Преферирај уграђени преглед“ омогућен, уграђени прегледи се користе директно без конверзије. Не утиче на формате прилагођене вебу као што је JPEG.",
"image_fullsize_quality_description": "Квалитет слике у пуној величини од 1-100. Више је боље, али производи веће датотеке.",
"image_fullsize_title": "Подешавања слике у пуној величини",
"image_prefer_embedded_preview": "Преферирајте уграђени преглед",
"image_prefer_embedded_preview_setting_description": "Користите уграђене прегледе у RAW фотографије као улаз за обраду слике када су доступне. Ово може да произведе прецизније боје за неке слике, али квалитет прегледа зависи од камере и слика може имати више неправилности компресије.",
"image_prefer_wide_gamut": "Преферирајте широк спектар",
@@ -859,6 +864,7 @@
"loop_videos": "Понављајте видео записе",
"loop_videos_description": "Омогућите за аутоматско понављање видео записа у прегледнику детаља.",
"main_branch_warning": "Употребљавате развојну верзију; строго препоручујемо употребу издате верзије!",
"main_menu": "Главни мени",
"make": "Креирај",
"manage_shared_links": "Управљајте дељеним везама",
"manage_sharing_with_partners": "Управљајте дељењем са партнерима",

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "Prinudno osvežavanje svih datoteka biblioteke",
"image_format": "Format",
"image_format_description": "WebP proizvodi manje datoteke od JPEG, ali se sporije kodira.",
"image_fullsize_description": "Slika u punoj veličini sa ogoljenim metapodacima, koristi se kada je uvećana",
"image_fullsize_enabled": "Omogućite generisanje slike u punoj veličini",
"image_fullsize_enabled_description": "Generišite sliku pune veličine za formate koji nisu prilagođeni vebu. Kada je „Preferiraj ugrađeni pregled“ omogućen, ugrađeni pregledi se koriste direktno bez konverzije. Ne utiče na formate prilagođene vebu kao što je JPEG.",
"image_fullsize_quality_description": "Kvalitet slike u punoj veličini od 1-100. Više je bolje, ali proizvodi veće datoteke.",
"image_fullsize_title": "Podešavanja slike u punoj veličini",
"image_prefer_embedded_preview": "Preferirajte ugrađeni pregled",
"image_prefer_embedded_preview_setting_description": "Koristite ugrađene preglede u RAW fotografije kao ulaz za obradu slike kada su dostupne. Ovo može da proizvede preciznije boje za neke slike, ali kvalitet pregleda zavisi od kamere i slika može imati više nepravilnosti kompresije.",
"image_prefer_wide_gamut": "Preferirajte širok spektar",
@@ -859,6 +864,7 @@
"loop_videos": "Ponavljajte video zapise",
"loop_videos_description": "Omogućite za automatsko ponavljanje video zapisa u pregledniku detalja.",
"main_branch_warning": "Upotrebljavate razvojnu verziju; strogo preporučujemo upotrebu izdate verzije!",
"main_menu": "Glavni meni",
"make": "Kreiraj",
"manage_shared_links": "Upravljajte deljenim vezama",
"manage_sharing_with_partners": "Upravljajte deljenjem sa partnerima",

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "Tvingar uppdatering av alla biblioteksfiler",
"image_format": "Format",
"image_format_description": "WebP producerar mindre filer än JPEG, men kodas långsammare.",
"image_fullsize_description": "Fullstor bild med borttagen metadata, används vid inzoomning",
"image_fullsize_enabled": "Använd fullstor bildgenerering",
"image_fullsize_enabled_description": "Generera fullstor bild för icke webbvänliga format. När \"Använd inbäddade förhandsvisningar\" är aktiverat används inbäddad förhandsvisning utan konvertering. Påverkar inte webbvänliga format som JPEG.",
"image_fullsize_quality_description": "Bildkvalitet för fullstora bilder 1-100. Högre värde ger bättre kvalitet men större filer.",
"image_fullsize_title": "Inställningar för fullstora bilder",
"image_prefer_embedded_preview": "Föredra inbäddad förhandsgranskning",
"image_prefer_embedded_preview_setting_description": "Använd inbäddade förhandsvisningar i RAW-foton som indata till bildbehandling när det är tillgängligt. Detta kan ge mer exakta färger för vissa bilder, men kvaliteten på förhandsgranskningen är kameraberoende och bilden kan ha fler komprimeringsartefakter.",
"image_prefer_wide_gamut": "Föredrar brett spektrum",
@@ -859,6 +864,7 @@
"loop_videos": "Loopa videor",
"loop_videos_description": "Aktivera för att automatiskt loopa en video i detaljvisaren.",
"main_branch_warning": "Du använder en utvecklingsversion. Vi rekommenderar starkt att du använder en utgiven version!",
"main_menu": "Huvudmeny",
"make": "Tillverkare",
"manage_shared_links": "Hantera Delade länkar",
"manage_sharing_with_partners": "Hantera delning med partner",

View File

@@ -1339,4 +1339,4 @@
"yes": "ஆம்",
"you_dont_have_any_shared_links": "உங்களிடம் பகிரப்பட்ட இணைப்புகள் எதுவும் இல்லை",
"zoom_image": "பெரிதாக்க படம்"
}
}

View File

@@ -58,7 +58,7 @@
"exclusion_pattern_description": "మినహాయింపు నమూనాలు మీ లైబ్రరీని స్కాన్ చేస్తున్నప్పుడు ఫైల్‌లు మరియు ఫోల్డర్‌లను విస్మరించడానికి మిమ్మల్ని అనుమతిస్తాయి. మీరు దిగుమతి చేయకూడదనుకునే RAW ఫైల్‌లు వంటి ఫోల్డర్‌లను కలిగి ఉన్నట్లయితే ఇది ఉపయోగకరంగా ఉంటుంది.",
"external_library_created_at": "బాహ్య లైబ్రరీ ({date}న సృష్టించబడింది)",
"external_library_management": "బాహ్య లైబ్రరీ నిర్వహణ",
"face_detection": "ముఖ గుర్తింపు",
"face_detection": "ముఖ గమనింపు",
"face_detection_description": "మెషిన్ లెర్నింగ్ ఉపయోగించి ఆస్తులలో ముఖాలను గుర్తించండి. వీడియోల కోసం, సూక్ష్మచిత్రం మాత్రమే పరిగణించబడుతుంది. \"అన్నీ\" (పునః) అన్ని ఆస్తులను ప్రాసెస్ చేస్తుంది. ఇంకా ప్రాసెస్ చేయని ఆస్తులను \"మిస్సింగ్\" క్యూలు చేస్తుంది. గుర్తించబడిన ముఖాలు ఇప్పటికే ఉన్న లేదా కొత్త వ్యక్తులతో సమూహపరచడం పూర్తయిన తర్వాత ముఖ గుర్తింపు కోసం క్యూలో ఉంచబడతాయి.",
"facial_recognition_job_description": "సమూహం వ్యక్తుల ముఖాలను గుర్తించింది. ఫేస్ డిటెక్షన్ పూర్తయిన తర్వాత ఈ దశ అమలవుతుంది. \"అన్ని\" (పునః) అన్ని ముఖాలను క్లస్టర్‌లు చేస్తుంది. \"తప్పిపోయిన\" వ్యక్తిని కేటాయించని ముఖాలను క్యూలో ఉంచుతుంది.",
"failed_job_command": "ఉద్యోగం కోసం కమాండ్ {command} విఫలమైంది: {job}",
@@ -67,7 +67,7 @@
"image_format": "ఫార్మాట్",
"image_format_description": "WebP JPEG కంటే చిన్న ఫైల్‌లను ఉత్పత్తి చేస్తుంది, కానీ ఎన్‌కోడ్ చేయడం నెమ్మదిగా ఉంటుంది.",
"image_prefer_embedded_preview": "పొందుపరిచిన పరిదృశ్యానికి ప్రాధాన్యత ఇవ్వండి",
"image_prefer_embedded_preview_setting_description": "అందుబాటులో ఉన్నప్పుడు ఇమేజ్ ప్రాసెసింగ్‌కు ఇన్‌పుట్‌గా RAW ఫోటోలలో ఎంబెడెడ్ ప్రివ్యూలను ఉపయోగించండి. ఇది కొన్ని చిత్రాలకు మరింత ఖచ్చితమైన రంగులను ఉత్పత్తి చేయగలదు, అయితే ప్రివ్యూ నాణ్యత కెమెరాపై ఆధారపడి ఉంటుంది మరియు చిత్రం మరిన్ని కుదింపు కళాఖండాలను కలిగి ఉండవచ్చు.",
"image_prefer_embedded_preview_setting_description": "అందుబాటులో ఉన్నప్పుడు మరియు ఇమేజ్ ప్రాసెసింగ్‌కు ఇన్‌పుట్‌గా RAW ఫోటోలలో ఎంబెడెడ్ ప్రివ్యూలను ఉపయోగించండి. ఇది కొన్ని చిత్రాలకు మరింత ఖచ్చితమైన రంగులను ఉత్పత్తి చేయగలదు, అయితే ప్రివ్యూ నాణ్యత కెమెరాపై ఆధారపడి ఉంటుంది మరియు చిత్రం మరిన్ని కుదింపు కళాఖండాలను కలిగి ఉండవచ్చు.",
"image_prefer_wide_gamut": "విస్తృత స్వరసప్తకానికి ప్రాధాన్యత ఇవ్వండి",
"image_prefer_wide_gamut_setting_description": "థంబ్‌నెయిల్‌ల కోసం డిస్‌ప్లే P3ని ఉపయోగించండి. ఇది విస్తృత రంగుల ఖాళీలతో చిత్రాల వైబ్రెన్స్‌ను మెరుగ్గా భద్రపరుస్తుంది, అయితే పాత బ్రౌజర్ వెర్షన్‌తో పాత పరికరాల్లో చిత్రాలు విభిన్నంగా కనిపించవచ్చు. రంగు మార్పులను నివారించడానికి sRGB చిత్రాలు sRGB వలె ఉంచబడతాయి.",
"image_preview_description": "ఒకే ఆస్తిని వీక్షించేటప్పుడు మరియు యంత్ర అభ్యాసం కోసం మెటాడేటా లేని మధ్యస్థ-పరిమాణ చిత్రం ఉపయోగించబడుతుంది",
@@ -113,7 +113,7 @@
"machine_learning_enabled": "మెషిన్ లెర్నింగ్ ప్రారంభించండి",
"machine_learning_enabled_description": "డిజేబుల్ చేయబడితే, దిగువ సెట్టింగ్‌లతో సంబంధం లేకుండా అన్ని ML ఫీచర్‌లు నిలిపివేయబడతాయి.",
"machine_learning_facial_recognition": "ముఖ గుర్తింపు",
"machine_learning_facial_recognition_description": "చిత్రాలలో ముఖాలను గుర్తించండి, గుర్తించండి మరియు సమూహపరచండి",
"machine_learning_facial_recognition_description": "చిత్రాలలో ముఖాలను కనుగొనండి, గుర్తించండి మరియు సమూహపరచండి",
"machine_learning_facial_recognition_model": "ముఖ గుర్తింపు మోడల్",
"machine_learning_facial_recognition_model_description": "నమూనాలు పరిమాణం యొక్క అవరోహణ క్రమంలో జాబితా చేయబడ్డాయి. పెద్ద మోడల్‌లు నెమ్మదిగా ఉంటాయి మరియు ఎక్కువ మెమరీని ఉపయోగిస్తాయి, కానీ మంచి ఫలితాలను ఇస్తాయి. మీరు మోడల్‌ను మార్చిన తర్వాత అన్ని చిత్రాల కోసం తప్పనిసరిగా ఫేస్ డిటెక్షన్ జాబ్‌ని మళ్లీ అమలు చేయాలని గుర్తుంచుకోండి.",
"machine_learning_facial_recognition_setting": "ముఖ గుర్తింపును ప్రారంభించండి",
@@ -395,7 +395,7 @@
"allow_public_user_to_download": "పబ్లిక్ వినియోగదారుడు డౌన్‌లోడ్ చేసేందుకు అనుమతించండి",
"allow_public_user_to_upload": "పబ్లిక్ వినియోగదారుడు అప్‌లోడ్ చేసేందుకు అనుమతించండి",
"alt_text_qr_code": "క్యూఆర్ కోడ్ చిత్రం",
"anti_clockwise": "ఎడమవైపు తిరిగే దిశ",
"anti_clockwise": "అపసవ్య-దిశ",
"api_key": "API కీ",
"api_key_description": "ఈ విలువ ఒక్కసారి మాత్రమే చూపబడుతుంది. విండోను మూసివేసే ముందు దయచేసి దీనిని ఖచ్చితంగా కాపీ చేసి ఎక్కడైనా భద్రపరచండి.",
"api_key_empty": "మీ API కీ పేరు ఖాళీగా ఉండకూడదు",
@@ -473,7 +473,7 @@
"clear_all_recent_searches": "ఇటీవల చేసిన అన్ని శోధనలను ఖాళీ చేయి",
"clear_message": "సందేశాన్ని ఖాళీ చేయి",
"clear_value": "విలువను ఖాళీ చేయి",
"clockwise": "సయదిశగా",
"clockwise": "సవ్యదిశ",
"close": "మూసివేయి",
"collapse": "సంకుచితం చేయి",
"collapse_all": "అన్నీ సంకుచితం చేయి",

View File

@@ -41,6 +41,7 @@
"backup_settings": "ตั้งค่าการสำรองข้อมูล",
"backup_settings_description": "จัดการการตั้งค่าการสำรองฐานข้อมูล",
"check_all": "ตรวจสอบทั้งหมด",
"cleanup": "ทำความสะอาด",
"cleared_jobs": "เคลียร์งานสำหรับ: {job}",
"config_set_by_file": "การตั้งค่าคอนฟิกกำลังถูกกำหนดโดยไฟล์คอนฟิก",
"confirm_delete_library": "คุณแน่ใจว่าอยากลบคลังภาพ {library} หรือไม่?",
@@ -65,8 +66,12 @@
"forcing_refresh_library_files": "บังคับรีเฟรชไฟล์ทั้งหมด",
"image_format": "Format",
"image_format_description": "WebP จะให้ไฟล์ที่เล็กกว่า JPEG แต่ใช้เวลาแปลงไฟล์นานกว่า",
"image_fullsize_description": "รูปภาพขนาดเต็มที่ถูกถอดข้อมูล metadata ออก ใช้ในขณะทำการขยายรูปภาพดู",
"image_fullsize_enabled": "เปิดใช้งานการสร้างรูปภาพขนาดเต็ม",
"image_fullsize_quality_description": "คุณภาพรูปภาพขนาดเต็มจาก 1-100 ค่ายิ่งสูงคุณภาพยิ่งสูง แต่แลกมาด้วยขนาดไฟล์ที่ใหญ่ขึ้น",
"image_fullsize_title": "ตั้งค่ารูปภาพขนาดเต็ม",
"image_prefer_embedded_preview": "ใช้พรีวิวแบบฝังตัว",
"image_prefer_embedded_preview_setting_description": "ใช้พรีวิวฝังตัวในรูปภาพ RAW ในการวิเคราะห์รูปภาพถ้ามี แต่คุณภาพรูปภาพขึ้นอยู่กับกล้อง และอาจจะมีสิ่งตกค้างจากการย่อขนาดไฟล์",
"image_prefer_embedded_preview_setting_description": "ใช้การแสดงภาพแบบฝังตัวในรูปภาพ RAW ในการวิเคราะห์รูปภาพหากสามารถใช้ได้ สิ่งนี้จะช่วยให้รูปภาพมีสีสันที่ถูกต้องมากยิ่งขึ้น แต่อย่างไรก็ตาม คุณภาพรูปภาพขึ้นอยู่กับกล้องถ่ายรูป และอาจจะเกิดร่องรอย ลาย ที่ไม่พึงประสงค์บนรูปภาพ จากการย่อขนาดไฟล์",
"image_prefer_wide_gamut": "ใช้ช่วงสีกว้าง",
"image_prefer_wide_gamut_setting_description": "ใช้ Display P3 สำหรับภาพตัวอย่าง (thumbnails) เพื่อรักษาความสดใสของภาพที่มีช่วงสีที่กว้างขึ้น อย่างไรก็ตาม ภาพอาจแสดงผลแตกต่างกันบนอุปกรณ์เก่าที่ใช้เว็บเบราว์เซอร์เวอร์ชันเก่า สำหรับภาพที่อยู่ใน sRGB จะยังคงใช้ sRGB ต่อไปเพื่อหลีกเลี่ยงการเปลี่ยนแปลงของสี",
"image_preview_description": "ภาพขนาดปานกลางที่ถูกลบข้อมูลเมตา ใช้สำหรับการดูแอสเซ็ตเดี่ยวและสำหรับการเรียนรู้ของเครื่อง (Machine Learning)",
@@ -131,7 +136,7 @@
"machine_learning_smart_search_description": "ค้นหาภาพโดยใช้ความหมายจากการใช้ CLIP",
"machine_learning_smart_search_enabled": "เปิดใช้งานการค้นหาอัจฉริยะ",
"machine_learning_smart_search_enabled_description": "หากปิดใช้งาน ภาพจะไม่ถูกใช้สําหรับการค้นหาอัจฉริยะ",
"machine_learning_url_description": "URL ของเซิร์ฟเวอร์ machine learning",
"machine_learning_url_description": "URL ของเซิร์ฟเวอร์ machine learning กรณีมี URL มากกว่าหนึ่ง URL จะทำการทดลองส่งข้อมูลเรียงไปทีละอันตามลำดับจนกว่าจะพบ URL ที่ตอบสนอง และจะเลิกส่งข้อมูลชั่วคราวในส่วนของ URL ที่ไม่ตอบสนอง",
"manage_concurrency": "จัดการการทำงานพร้อมกัน",
"manage_log_settings": "จัดการการตั้งค่าจดบันทึก",
"map_dark_style": "แบบมืด",
@@ -147,6 +152,8 @@
"map_settings": "การตั้งค่าแผนที่และ GPS",
"map_settings_description": "จัดการการตั้งค่าแผนที่",
"map_style_description": "URL ไปยังธีมแผนที่ style.json",
"memory_cleanup_job": "ล้างข้อมูลในหน่วยความจำ (memory)",
"memory_generate_job": "การสร้างความทรงจำ",
"metadata_extraction_job": "ดึงข้อมูล metadata",
"metadata_extraction_job_description": "ดึงข้อมูล metadata จากสื่อ เช่น GPS และความคมชัด",
"metadata_faces_import_setting": "เปิดการนำเข้าข้อมูลใบหน้า",
@@ -219,7 +226,7 @@
"reset_settings_to_default": "ตั้งค่าการตั้งค่าเป็นค่าเริ่มต้น",
"reset_settings_to_recent_saved": "ตั้งค่าการตั้งค่าเป็นค่าล่าสุด",
"scanning_library": "กำลังสแกนคลัง",
"search_jobs": "ค้นหางาน",
"search_jobs": "ค้นหางาน",
"send_welcome_email": "ส่งอีเมลต้อนรับ",
"server_external_domain_settings": "โดเมนภายนอก",
"server_external_domain_settings_description": "โดเมนสำหรับลิงก์แชร์สาธารณะ แบบมี http(s)://",
@@ -240,7 +247,7 @@
"storage_template_hash_verification_enabled_description": "เปิดใช้งานการตรวจสอบ hash ห้ามปิดใช้งานเว้นแต่คุณจะเข้าใจผลกระทบ",
"storage_template_migration": "การย้ายเทมเพลตที่เก็บข้อมูล",
"storage_template_migration_description": "ใช้<link>{template}</link>ปัจจุบันกับสื่อที่อัปโหลดก่อนหน้านี้",
"storage_template_migration_info": "การเปลี่ยนแปลงเทมเพลตจะมีผลกับแอสเซ็ตใหม่เท่านั้น หากต้องการนำเทมเพลตไปใช้กับ Asset ที่อัปโหลดก่อนหน้านี้ ให้รัน <link>{job}</link>.",
"storage_template_migration_info": "เทมเพลตของการจัดเก็บข้อมูลจะเปลี่ยนตัวอักษรเป็นตัวพิมพ์เล็กทั้งหมด การเปลี่ยนแปลงเทมเพลตจะมีผลกับแอสเซ็ตใหม่เท่านั้น หากต้องการนำเทมเพลตไปใช้กับ Asset ที่อัปโหลดก่อนหน้านี้ ให้รัน <link>{job}</link>.",
"storage_template_migration_job": "เทมเพลตการ Migration ข้อมูล",
"storage_template_more_details": "สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับฟีเจอร์นี้ โปรดดูที่ <template-link>Storage Template</template-link> และ <implications-link>ผลกระทบ</implications-link>",
"storage_template_onboarding_description": "เมื่อเปิดใช้งาน ฟีเจอร์นี้จะจัดระเบียบไฟล์โดยอัตโนมัติตามเทมเพลตที่ผู้ใช้กำหนด เนื่องจากปัญหาด้านความเสถียร ฟีเจอร์นี้จึงถูกปิดใช้งานเป็นค่าเริ่มต้น สำหรับข้อมูลเพิ่มเติม โปรดดูที่ <link>เอกสารประกอบ</link>",
@@ -313,7 +320,7 @@
"transcoding_reference_frames_description": "จำนวนเฟรมที่จะอ้างอิงเมื่อบีบอัดเฟรมที่กำหนด ค่าที่สูงขึ้นจะช่วยเพิ่มประสิทธิภาพในการบีบอัด แต่จะทำให้การเข้ารหัสช้าลง ค่า 0 จะตั้งค่านี้โดยอัตโนมัติ",
"transcoding_required_description": "เฉพาะวิดีโอที่ไม่อยู่ในรูปแบบที่ยอมรับเท่านั้น",
"transcoding_settings": "การตั้งค่าการแปลงไฟล์วิดีโอ",
"transcoding_settings_description": "จัดการข้อมูลความคมชัดและแบบไฟล์วิดีโอ",
"transcoding_settings_description": "จัดการว่าวีดีโอไหนจะถูกแปลงการเข้ารหัส (Transcode) และวิธีการประมวลผลไฟล์ดังกล่าว",
"transcoding_target_resolution": "เป้าหมายความคมชัด",
"transcoding_target_resolution_description": "ความคมชัดที่สูงกว่าจะเก็บรายละเอียดดีกว่าแต่ใช้เวลาแปลงไฟล์นานกว่า ขนาดไฟล์ใหญ่กว่า และลดการตอบสนองของแอป",
"transcoding_temporal_aq": "AQ ชั่วคราว",
@@ -353,6 +360,7 @@
"version_check_implications": "การตรวจสอบเวอร์ชันใหม่จะต้องติดต่อกับ github.com เป็นระยะ",
"version_check_settings": "ตรวจสอบรุ่น",
"version_check_settings_description": "เปิด/ปิดการแจ้งเตือนรุ่นใหม่",
"video_conversion_job": "เข้ารหัสวีดีโอ (transcode)",
"video_conversion_job_description": "แปลงไฟล์วิดีโอเพึ่อรองรับบราวเซอร์และเครื่องเล่นอื่น ๆ มากขึ้น"
},
"admin_email": "อีเมลผู้ดูแลระบบ",
@@ -369,7 +377,7 @@
"album_delete_confirmation_description": "หากแชร์อัลบั้มนี้ ผู้ใช้รายอื่นจะไม่สามารถเข้าถึงได้อีก",
"album_info_updated": "อัปเดทข้อมูลอัลบั้มแล้ว",
"album_leave": "ออกจากอัลบั้ม ?",
"album_leave_confirmation": "คุณต้องการออกจากอัลบั้ม {album} ใช่หรือไม่",
"album_leave_confirmation": "คุณต้องการออกจากอัลบั้ม {album} ใช่หรือไม่?",
"album_name": "ชื่ออัลบั้ม",
"album_options": "ตัวเลือกอัลบั้ม",
"album_remove_user": "ลบผู้ใช้ ?",
@@ -390,6 +398,7 @@
"allow_edits": "อนุญาตให้แก้ไขได้",
"allow_public_user_to_download": "อนุญาตให้ผู้ใช้สาธารณะดาวน์โหลดได้",
"allow_public_user_to_upload": "อนุญาตให้ผู้ใช้สาธารณะอัปโหลดได้",
"alt_text_qr_code": "รูปภาพ QR code",
"anti_clockwise": "ทวนเข็มนาฬิกา",
"api_key": "API key",
"api_key_description": "ค่านี้จะแสดงเพียงครั้งเดียว โปรดคัดลอกก่อนปิดหน้าต่าง",
@@ -444,7 +453,7 @@
"cancel": "ยกเลิก",
"cancel_search": "ยกเลิกการค้นหา",
"cannot_merge_people": "ไม่สามารถรวมกลุ่มคนได้",
"cannot_undo_this_action": "ไม่สามารถย้อนกลับได้",
"cannot_undo_this_action": "การกระทำนี้ไม่สามารถย้อนกลับได้!",
"cannot_update_the_description": "ไม่สามารถอัพเดทรายละเอียดได้",
"change_date": "เปลี่ยนวันที่",
"change_expiration_time": "เปลี่ยนเวลาหมดอายุ",
@@ -476,6 +485,7 @@
"comments_are_disabled": "ความคิดเห็นถูกปิดใช้งาน",
"confirm": "ยืนยัน",
"confirm_admin_password": "ยืนยันรหัสผ่านผู้ดูแลระบบ",
"confirm_delete_face": "คุณแน่ใจว่าต้องการลบใบหน้า{name}ออกหรือไม่?",
"confirm_delete_shared_link": "คุณต้องการที่จะลบลิงก์ที่แชร์ใช่หรือไม่ ?",
"confirm_keep_this_delete_others": "จะลบทั้งหมดในรายการ และยกเว้นสื่อนี้หรือไม่ คุณแน่ใจใช่ไหมที่ต้องการดำเนินการต่อ?",
"confirm_password": "ยืนยันรหัสผ่าน",
@@ -528,13 +538,14 @@
"delete_album": "ลบอัลบั้ม",
"delete_api_key_prompt": "คุณต้องการลบ API คีย์ นี้ใช่ไหม ?",
"delete_duplicates_confirmation": "คุณแน่ใจที่ต้องการลบรายการซ้ำอย่างถาวรใช่ไหม ?",
"delete_face": "ลบใบหน้า",
"delete_key": "ลบกุญแจ",
"delete_library": "ลบคลังภาพ",
"delete_link": "ลบลิงก์",
"delete_others": "ลบผู้อื่น",
"delete_shared_link": "ลบลิงก์ที่แชร์",
"delete_tag": "ลบแท็ก",
"delete_tag_confirmation_prompt": "คุณต้องการลบแท็ก {tagName} ใช่หรือไม่",
"delete_tag_confirmation_prompt": "คุณแน่ใจว่าต้องการลบแท็ก {tagName} ใช่หรือไม่?",
"delete_user": "ลบผู้ใช้",
"deleted_shared_link": "ลบลิงก์ที่แชร์แล้ว",
"deletes_missing_assets": "ลบสื่อที่หายไปออกจากดิสถ์",
@@ -543,7 +554,7 @@
"direction": "เส้นทาง",
"disabled": "ปิดการใช้งาน",
"disallow_edits": "ไม่อนุญาตให้แก้ไข",
"discord": "Discord",
"discord": "ดิสคอร์ด",
"discover": "ค้นพบ",
"dismiss_all_errors": "ปฏิเสธข้อผิดพลาดทั้งหมด",
"dismiss_error": "ปฏิเสธข้อผิดพลาด",
@@ -595,6 +606,7 @@
"enabled": "เปิดใช้งาน",
"end_date": "วันสิ้นสุด",
"error": "เกิดข้อผิดพลาด",
"error_delete_face": "เกิดเออเรอร์ ไม่สามารถลบใบหน้าออกได้",
"error_loading_image": "เกิดข้อผิดพลาดระหว่างโหลดภาพ",
"error_title": "เกิดข้อผิดพลาด",
"errors": {
@@ -613,7 +625,7 @@
"error_adding_users_to_album": "เกิดข้อผิดพลาดในการเพิ่มผู้ใช้ไปยังอัลบั้ม",
"error_deleting_shared_user": "เกิดข้อผิดพลาดในการลบผู้ใช้ที่แชร์",
"error_downloading": "ไม่สามารถดาวน์โหลด {filename} ได้",
"error_hiding_buy_button": "Error hiding buy button",
"error_hiding_buy_button": "เกิดข้อผิดพลาด ไม่สามารถซ่อนปุ่มซื้อได้",
"error_removing_assets_from_album": "เกิดข้อผิดพลาดในการลบสื่อจากอัลบั้ม",
"error_selecting_all_assets": "เกิดข้อผิดพลาดในการเลือกสื่อทั้งหมด",
"exclusion_pattern_already_exists": "ข้อยกเว้นนี้มีอยู่แล้ว",
@@ -626,7 +638,7 @@
"failed_to_load_asset": "ไม่สามารถโหลดสื่อได้",
"failed_to_load_assets": "ไม่สามารถโหลดสื่อได้",
"failed_to_load_people": "ไม่สามารถโหลดบุคคลได้",
"failed_to_remove_product_key": "Failed to remove product key",
"failed_to_remove_product_key": "ไม่สามารถลบ product key ได้",
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
"import_path_already_exists": "พาธนำเข้านี้มีอยู่แล้ว",
@@ -760,8 +772,10 @@
"go_to_folder": "ไปที่โฟล์เดอร์",
"go_to_search": "กลับไปยังการค้นหา",
"group_albums_by": "จัดกลุ่มอัลบั้มตาม",
"group_country": "จัดเรียงกลุ่มตามประเทศ",
"group_no": "ไม่จัดกลุ่ม",
"group_owner": "จัดกลุ่มโดยเจ้าของ",
"group_places_by": "จัดเรียงกลุ่มของสถานที่ด้วยการ...",
"group_year": "จัดกลุ่มตามปี",
"has_quota": "เหลือพื้นที่",
"hi_user": "สวัสดีคุณ {name} {email}",
@@ -842,6 +856,7 @@
"loop_videos": "วนวิดีโอ",
"loop_videos_description": "เปิดเพื่อให้วิดีโอวนลูปในที่ดูรายละเอียด",
"main_branch_warning": "คุณกำลังใช้เวอร์ชันการพัฒนา เราขอแนะนำอย่างยิ่งให้ใช้เวอร์ชันเสถียร !",
"main_menu": "เมนูหลัก",
"make": "สร้าง",
"manage_shared_links": "จัดการลิงก์ที่แชร์",
"manage_sharing_with_partners": "จัดการการแชร์กับคู่หู",
@@ -969,6 +984,7 @@
"permanently_deleted_asset": "ลบสื่อถาวรแล้ว",
"permanently_deleted_assets_count": "ลบ {count, plural, one {# asset} other {# assets}} เรียบร้อยแล้ว",
"person": "บุคคล",
"person_birthdate": "เกิดวัน{date}",
"photo_shared_all_users": "ดูเหมือนว่าคุณได้แชร์รูปภาพของคุณกับผู้ใช้ทั้งหมด หรือคุณไม่มีผู้ใช้ใดที่จะแชร์ด้วย",
"photos": "รูปภาพ",
"photos_and_videos": "รูปภาพ และ วิดีโอ",
@@ -1058,12 +1074,16 @@
"remove_from_album": "ลบออกจากอัลบั้ม",
"remove_from_favorites": "เอาออกจากรายการโปรด",
"remove_from_shared_link": "ลบออกจากลิงก์ที่แชร์",
"remove_memory": "ลบความทรงจำ",
"remove_photo_from_memory": "ลบรูปออกจากความทรงจำนี้",
"remove_url": "ลบ URL",
"remove_user": "ลบผู้ใช้",
"removed_api_key": "API คีย์ของ: {name} ถูกลบแล้ว",
"removed_from_archive": "ลบจากเก็บถาวรแล้ว",
"removed_from_favorites": "ลบจากรายการโปรดแล้ว",
"removed_from_favorites_count": "{count, plural, other {ถูกลบ#}} จากรายการโปรดแล้ว",
"removed_memory": "ความทรงจำที่ถูกลบ",
"removed_photo_from_memory": "รูปที่ถูกลบออกจากความทรงจำ",
"removed_tagged_assets": "ลบแท็กจาก {count, plural, one {# สื่อ} other {# สื่อ}}",
"rename": "เปลี่ยนชื่อ",
"repair": "ซ่อมแซม",
@@ -1071,6 +1091,7 @@
"replace_with_upload": "อัปโหลดทับรูปหรือวิดีโอนี้",
"require_password": "ต้องการรหัสผ่าน",
"require_user_to_change_password_on_first_login": "จำเป็นต้องเปลี่ยนรหัสผ่าน ในการเข้าสู่ระบบครั้งแรก",
"rescan": "สแกนใหม่",
"reset": "รีเซ็ต",
"reset_password": "ตั้งค่ารหัสผ่านใหม่",
"reset_people_visibility": "ปรับการมองเห็นใหม่",
@@ -1099,6 +1120,8 @@
"search": "ค้นหา",
"search_albums": "ค้นหาอัลบั้ม",
"search_by_context": "ค้นหาตามบริบท",
"search_by_description": "ค้นหาด้วยคำอธิบาย",
"search_by_description_example": "วันปีนเขาในซาปา",
"search_by_filename": "ค้นหาชื่อไฟล์ชื่อ หรือ ชนิดไฟล์",
"search_by_filename_example": "ตัวอย่าง. IMG_1234.JPG หรือ PNG",
"search_camera_make": "ค้นหายี่ห้อกล้อง",
@@ -1112,6 +1135,7 @@
"search_options": "ตัวเลือกการค้นหา",
"search_people": "ค้นหาผู้คน",
"search_places": "ค้นหาสถานที่",
"search_rating": "ค้นหาตามเรตติ้ง...",
"search_settings": "ตั้งค่าการค้นหา",
"search_state": "ค้นหาตามรัฐ",
"search_tags": "ค้นหาแท็ก",
@@ -1121,6 +1145,7 @@
"searching_locales": "ค้นหาตามภูมิภาค",
"second": "วินาที",
"see_all_people": "ดูบุคคลทั้งหมด",
"select": "เลือก",
"select_album_cover": "เลือกภาพปกอัลบั้ม",
"select_all": "เลือกทั้งหมด",
"select_all_duplicates": "เลือกรายการที่ซ้ำทั้งหมด",
@@ -1158,6 +1183,7 @@
"shared_from_partner": "รูปจาก {partner}",
"shared_link_options": "ตั้งค่าลิงก์ที่แชร์",
"shared_links": "ลิงก์ที่แชร์",
"shared_links_description": "แบ่งปันรูปและวีดีโอด้วยลิ้งค์",
"shared_with_partner": "แชร์กับ {partner}",
"sharing": "การแชร์",
"sharing_enter_password": "โปรดป้อนรหัสผ่าน สำหรับเปิดดูหน้านี้",
@@ -1179,6 +1205,7 @@
"show_person_options": "แสดงตัวเลือกของตัวบุคคล",
"show_progress_bar": "แสดงความคืบหน้า แถบ",
"show_search_options": "แสดงตัวเลือกการค้นหา",
"show_shared_links": "แสดงลิ้งค์ที่ถูกแบ่งปัน",
"show_slideshow_transition": "แสดงสไลค์โชว์",
"show_supporter_badge": "เครื่องหมายผู้สนับสนุน",
"show_supporter_badge_description": "แสดงเครื่องหมายผู้สนับสนุน",
@@ -1203,17 +1230,21 @@
"sort_title": "ไตเติ้ล",
"source": "แหล่ง",
"stack": "ซ้อน",
"stack_selected_photos": "",
"stack_duplicates": "นำสิ่งที่ซ้ำมาซ้อนอยู่ด้วยกัน",
"stack_select_one_photo": "เลือกรูปหลักหนึ่งรูปสำหรับรูปที่ซ้อนกันนี้",
"stack_selected_photos": "ซ้อนรูปที่ถูกเลือก",
"stacktrace": "",
"start": "เริ่มต้น",
"start_date": "วันที่เริ่ม",
"state": "รัฐ",
"status": "สถานะ",
"stop_motion_photo": "ภาพวัตถุเคลื่อนไหว",
"stop_photo_sharing": "หยุดแชร์รูปภาพ?",
"stop_photo_sharing_description": "{partner}จะไม่สามารถเข้าถึงรูปของคุณได้อีก",
"stop_sharing_photos_with_user": "หยุดการแชร์รูปภาพของคุณกับผู้ใช้นี้",
"storage": "พื้นที่จัดเก็บ",
"storage_label": "เนื้อที่จัดเก็บ",
"storage_usage": "ใช้ไป {used} จาก {available} ",
"storage_usage": "ใช้ไป {used} จาก {available}",
"submit": "ส่ง",
"suggestions": "ข้อเสนอแนะ",
"sunrise_on_the_beach": "พระอาทิตย์ขึ้นบนชายหาด",
@@ -1224,22 +1255,28 @@
"sync": "ซิงค์",
"tag": "แท็ก",
"tag_created": "สร้างแท็ก: {tag}",
"tag_not_found_question": "ไม่สามารถหาแท็กได้ใช่หรือไม่?<link>สร้างแท็กใหม่</link>",
"tag_people": "แท็กผู้คน",
"tag_updated": "แท็กที่ถูกอัพเดต: {tag}",
"tags": "แท็ก",
"template": "เท็มเพลต",
"theme": "ธีม",
"theme_selection": "การเลือกธีม",
"theme_selection_description": "ตั้งค่าธีมให้สว่างหรือมืดโดยอัตโนมัติ อิงจากค่าของเบราว์เซอร์ของคุณ",
"they_will_be_merged_together": "จะถูกรวมเข้าด้วยกัน",
"third_party_resources": "ทรัพยากรบุคคลที่สาม",
"time_based_memories": "ความทรงจําตามเวลา",
"timeline": "Timeline",
"timeline": "ทามไลน์",
"timezone": "เขตเวลา",
"to_archive": "จัดเก็บถาวร",
"to_change_password": "Change password",
"to_change_password": "เปลี่ยนรหัสผ่าน",
"to_favorite": "รายการโปรด",
"to_login": "เข้าสู่ระบบ",
"to_parent": "ไปยังบนสุด",
"to_trash": "ถังขยะ",
"toggle_settings": "สลับการตั้งค่า",
"toggle_theme": "สลับธีม",
"total": "ทั้งหมด",
"total_usage": "การใช้งานรวม",
"trash": "ถังขยะ",
"trash_all": "ทิ้งทั้งหมด",

View File

@@ -132,7 +132,7 @@
"machine_learning_smart_search_description": "Fotoğrafları CLIP kullanarak semantik olarak ara",
"machine_learning_smart_search_enabled": "Akıllı aramayı etkinleştir",
"machine_learning_smart_search_enabled_description": "Eğer devre dışı bırakılırsa fotoğraflar akıllı arama için işlenmeyecek.",
"machine_learning_url_description": "Makine öğrenimi sunucusunun URL'si. Birden fazla URL sağlanırsa, ilkinden sonuna doğru, biri başarılı bir şekilde yanıt verene kadar her sunucu tek tek denenir.",
"machine_learning_url_description": "Makine öğrenimi sunucusunun URLsi. Birden fazla URL sağlanırsa, her sunucu sırayla tek tek denenir ve biri başarılı yanıt verene kadar devam edilir. Yanıt vermeyen sunucular, çevrimiçi duruma gelene kadar geçici olarak yok sayılır.",
"manage_concurrency": "Aynı anda çalışmayı yönet",
"manage_log_settings": "Günlük ayarlarını yönet",
"map_dark_style": "Koyu mod",

View File

@@ -66,18 +66,23 @@
"forcing_refresh_library_files": "Примусове оновлення всіх файлів бібліотеки",
"image_format": "Формат",
"image_format_description": "Формат WebP виробляє меньші файлів, ніж JPEG, але його кодування вимагає більше часу.",
"image_prefer_embedded_preview": "Надати перевагу вбудованому перегляду",
"image_prefer_embedded_preview_setting_description": "Використовуйте вбудовані попередні перегляди у RAW фотографіях як вхідні дані для обробки зображень, коли це можливо. Це може забезпечити більш точні кольори для деяких зображень, але якість попереднього перегляду залежить від камери та зображення можуть мати більше артефактів стиснення.",
"image_fullsize_description": "Повнорозмірне зображення з видаленими метаданими, які використовуються під час збільшення",
"image_fullsize_enabled": "Увімкнути створення повнорозмірного зображення",
"image_fullsize_enabled_description": "Генерувати зображення повного розміру для форматів, не призначених для вебу. Якщо увімкнено \"Надавати перевагу вбудованому прев’ю\", вбудовані прев’ю використовуються без конвертації. Не впливає на веб-дружні формати, такі як JPEG.",
"image_fullsize_quality_description": "Якість повнорозмірного зображення від 1 до 100. Чим вище значення, тим краще якість, але більше розмір файлу.",
"image_fullsize_title": "Налаштування повнорозмірного зображення",
"image_prefer_embedded_preview": "Надавати перевагу вбудованому прев’ю",
"image_prefer_embedded_preview_setting_description": "Використовувати вбудовані прев’ю в RAW-фотографіях як вхідні дані для обробки зображень, якщо вони доступні. Це може забезпечити точніші кольори для деяких зображень, але якість прев’ю залежить від камери і зображення може містити більше артефактів стиснення.",
"image_prefer_wide_gamut": "Віддають перевагу широкій гамі",
"image_prefer_wide_gamut_setting_description": "Для мініатюр використовуйте дисплей P3. Це краще зберігає яскравість зображень з широким колірним простором, але на старих пристроях зі старою версією браузера зображення можуть виглядати інакше. sRGB-зображення зберігаються у форматі sRGB, щоб уникнути зсуву кольорів.",
"image_preview_description": "Зображення середнього розміру з видаленими метаданими, яке використовується при перегляді одного об'єкта та для машинного навчання",
"image_preview_quality_description": "Якість попереднього перегляду від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми. Встановлення низького значення може вплинути на якість машинного навчання.",
"image_preview_title": "Налаштування попереднього перегляду",
"image_preview_quality_description": "Якість прев’ю від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми. Встановлення низького значення може вплинути на якість машинного навчання.",
"image_preview_title": "Налаштування прев’ю",
"image_quality": "Якість",
"image_resolution": "Роздільність",
"image_resolution_description": "Вища роздільність може зберігати більше деталей, але займає більше часу для кодування, має більші розміри файлів і може зменшити швидкість роботи програми.",
"image_settings": "Налаштування зображення",
"image_settings_description": "Керуйте якістю та роздільною здатністю згенерованих зображень",
"image_settings_description": "Керувати якістю та роздільною здатністю згенерованих зображень",
"image_thumbnail_description": "Маленька мініатюра із видаленими метаданими, що використовується для перегляду груп фотографій, наприклад, на основній лінії часу",
"image_thumbnail_quality_description": "Якість мініатюри від 1 до 100. Вища оцінка означає кращу якість, але створює більші файли та може зменшити швидкість роботи програми.",
"image_thumbnail_title": "Налаштування мініатюр",
@@ -256,7 +261,7 @@
"template_email_available_tags": "Ви можете використовувати наступні змінні у вашому шаблоні: {tags}",
"template_email_if_empty": "Якщо шаблон порожній, буде використано стандартний ел. лист.",
"template_email_invite_album": "Шаблон запрошення до альбому",
"template_email_preview": опередній перегляд",
"template_email_preview": рев’ю",
"template_email_settings": "Шаблони ел. листів",
"template_email_settings_description": "Керувати шаблонами сповіщень ел. пошти",
"template_email_update_album": "Оновити шаблон альбому",
@@ -859,6 +864,7 @@
"loop_videos": "Циклічні відео",
"loop_videos_description": "Увімкнути циклічне відтворення відео.",
"main_branch_warning": "Ви використовуєте версію для розробників; ми настійно рекомендуємо використовувати релізну версію!",
"main_menu": "Головне меню",
"make": "Виробник",
"manage_shared_links": "Керування спільними посиланнями",
"manage_sharing_with_partners": "Керуйте спільним використанням з партнерами",

View File

@@ -1317,4 +1317,4 @@
"yes": "Có",
"you_dont_have_any_shared_links": "Bạn không có liên kết chia sẻ nào",
"zoom_image": "Thu phóng ảnh"
}
}

View File

@@ -2,12 +2,12 @@
"about": "關於",
"account": "帳號",
"account_settings": "帳號設定",
"acknowledge": "明白",
"acknowledge": "了解",
"action": "操作",
"actions": "作",
"actions": "進行動作",
"active": "處理中",
"activity": "動態",
"activity_changed": "動態{enabled, select, true {啟} other {停用}}",
"activity_changed": "動態{enabled, select, true {啟} other {關閉}}",
"add": "加入",
"add_a_description": "加入文字說明",
"add_a_location": "新增地點",
@@ -23,8 +23,8 @@
"add_to": "加入到…",
"add_to_album": "加入到相簿",
"add_to_shared_album": "加到共享相簿",
"add_url": "新增URL",
"added_to_archive": "已新增至封存",
"add_url": "建立連結",
"added_to_archive": "至封存",
"added_to_favorites": "加入收藏",
"added_to_favorites_count": "將 {count, number} 個項目加入收藏",
"admin": {
@@ -33,7 +33,7 @@
"authentication_settings": "驗證設定",
"authentication_settings_description": "管理密碼、OAuth 與其他驗證設定",
"authentication_settings_disable_all": "確定要停用所有登入方式嗎?這樣會完全無法登入。",
"authentication_settings_reenable": "如需重新啟用,請使用 <link>伺服器指令</link>。",
"authentication_settings_reenable": "如需重新啟用,請使用 <link> 伺服器指令 </link> 。",
"background_task_job": "背景執行",
"backup_database": "備份資料庫",
"backup_database_enable_description": "啟用資料庫備份",
@@ -485,7 +485,7 @@
"comments_are_disabled": "留言已停用",
"confirm": "確認",
"confirm_admin_password": "確認管理者密碼",
"confirm_delete_face": "您確定要從資產中刪除 {name} 的臉嗎?",
"confirm_delete_face": "您確定要從項目中刪除 {name} 的臉嗎?",
"confirm_delete_shared_link": "確定刪除連結嗎?",
"confirm_keep_this_delete_others": "所有的其他堆疊項目將被刪除。確定繼續嗎?",
"confirm_password": "確認密碼",
@@ -606,7 +606,7 @@
"enabled": "己啟用",
"end_date": "結束日期",
"error": "錯誤",
"error_delete_face": "從資產中刪除臉時發生錯誤",
"error_delete_face": "從項目中刪除臉時發生錯誤",
"error_loading_image": "載入圖片時出錯",
"error_title": "錯誤 - 出問題了",
"errors": {
@@ -618,7 +618,7 @@
"cant_change_metadata_assets_count": "無法更改 {count, plural, other {# 個檔案}}的詳細資料",
"cant_get_faces": "無法取得臉孔",
"cant_get_number_of_comments": "無法取得留言數量",
"cant_search_people": "無法搜尋人",
"cant_search_people": "未搜尋到人物",
"cant_search_places": "無法搜尋地點",
"cleared_jobs": "已清除的作業:{job}",
"error_adding_assets_to_album": "將檔案加入相簿時出錯",
@@ -1374,4 +1374,4 @@
"yes": "是",
"you_dont_have_any_shared_links": "您沒有任何共享連結",
"zoom_image": "縮放圖片"
}
}

View File

@@ -66,6 +66,11 @@
"forcing_refresh_library_files": "强制刷新所有图库文件",
"image_format": "格式",
"image_format_description": "WebP 文件体积较 JPEG 文件更小,但编码速度较慢。",
"image_fullsize_description": "去除元数据的全尺寸图像,放大时使用",
"image_fullsize_enabled": "启用全尺寸图像生成",
"image_fullsize_enabled_description": "生成非网络友好格式的全尺寸图像。启用 “首选嵌入式预览 ”后,将直接使用嵌入式预览而无需转换。不影响 JPEG 等网络友好格式。",
"image_fullsize_quality_description": "全尺寸图像质量从 1 到 100。越高越好但生成的文件较大。",
"image_fullsize_title": "全尺寸图像设置",
"image_prefer_embedded_preview": "嵌入式预览",
"image_prefer_embedded_preview_setting_description": "优先使用 RAW 照片的嵌入式预览作为图像处理的输入。可以提升某些影像的颜色准确度,但嵌入式预览的质量取决于相机,图像可能压缩失真更严重。",
"image_prefer_wide_gamut": "广色域",
@@ -859,6 +864,7 @@
"loop_videos": "循环视频",
"loop_videos_description": "启用在详细信息中自动循环播放视频。",
"main_branch_warning": "您当前使用的是开发版我们强烈建议您使用正式发行版release版",
"main_menu": "主菜单",
"make": "品牌",
"manage_shared_links": "管理共享链接",
"manage_sharing_with_partners": "管理与同伴的共享",

View File

@@ -61,6 +61,7 @@ custom_lint:
# refactor to make the providers and services testable
- lib/providers/backup/{backup,manual_upload}.provider.dart # uses only PMProgressHandler
- lib/services/{background,backup}.service.dart # uses only PMProgressHandler
- test/**.dart
- import_rule_isar:
message: isar must only be used in entities and repositories
restrict: package:isar
@@ -150,7 +151,6 @@ dart_code_metrics:
- avoid-unnecessary-continue
- avoid-unnecessary-nullable-return-type: false
- binary-expression-operand-order
- move-variable-outside-iteration
- pattern-fields-ordering
- prefer-abstract-final-static-class
- prefer-commenting-future-delayed

View File

@@ -6,6 +6,7 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@@ -124,4 +125,4 @@
<data android:scheme="geo" />
</intent>
</queries>
</manifest>
</manifest>

View File

@@ -1,25 +1,40 @@
package app.alextran.immich
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.provider.Settings
import android.util.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest
import java.io.FileInputStream
import kotlinx.coroutines.*
/**
* Android plugin for Dart `BackgroundService`
*
* Receives messages/method calls from the foreground Dart side to manage
* the background service, e.g. start (enqueue), stop (cancel)
* Android plugin for Dart `BackgroundService` and file trash operations
*/
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
private var methodChannel: MethodChannel? = null
private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null
private var pendingResult: Result? = null
private val PERMISSION_REQUEST_CODE = 1001
private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
@@ -29,6 +44,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = ctx
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
methodChannel?.setMethodCallHandler(this)
// Add file trash channel
fileTrashChannel = MethodChannel(messenger, "file_trash")
fileTrashChannel?.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -38,11 +57,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private fun onDetachedFromEngine() {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
fileTrashChannel?.setMethodCallHandler(null)
fileTrashChannel = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
override fun onMethodCall(call: MethodCall, result: Result) {
val ctx = context!!
when (call.method) {
// Existing BackgroundService methods
"enable" -> {
val args = call.arguments<ArrayList<*>>()!!
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
@@ -114,10 +136,180 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
}
}
// File Trash methods moved from MainActivity
"moveToTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = moveToTrash(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"restoreFromTrash" -> {
val fileName = call.argument<String>("fileName")
if (fileName != null) {
if (hasManageStoragePermission()) {
val success = untrashImage(fileName)
result.success(success)
} else {
result.error("PERMISSION_DENIED", "Storage permission required", null)
}
} else {
result.error("INVALID_NAME", "The file name is not specified.", null)
}
}
"requestManageStoragePermission" -> {
if (!hasManageStoragePermission()) {
requestManageStoragePermission(result)
} else {
Log.e("Manage storage permission", "Permission already granted")
result.success(true)
}
}
else -> result.notImplemented()
}
}
// File Trash methods moved from MainActivity
private fun hasManageStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
true
}
}
private fun requestManageStoragePermission(result: Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
pendingResult = result // Store the result callback
val activity = activityBinding?.activity ?: return
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
} else {
result.success(true)
}
}
private fun moveToTrash(fileName: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getFileUri(fileName)
Log.e("FILE_URI", uri.toString())
return uri?.let { moveToTrash(it) } ?: false
}
private fun moveToTrash(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error moving to trash", e)
false
}
}
private fun getFileUri(fileName: String): Uri? {
val contentResolver = context?.contentResolver ?: return null
val contentUri = MediaStore.Files.getContentUri("external")
val projection = arrayOf(MediaStore.Images.Media._ID)
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(fileName)
var fileUri: Uri? = null
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
fileUri = ContentUris.withAppendedId(contentUri, id)
}
}
return fileUri
}
private fun untrashImage(name: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getTrashedFileUri(contentResolver, name)
Log.e("FILE_URI", uri.toString())
return uri?.let { untrashImage(it) } ?: false
}
private fun untrashImage(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error restoring file", e)
false
}
}
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?")
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
}
contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
return ContentUris.withAppendedId(contentUri, id)
}
}
return null
}
// ActivityAware implementation
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityBinding = binding
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activityBinding?.removeActivityResultListener(this)
activityBinding = null
}
// ActivityResultListener implementation
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) {
val granted = hasManageStoragePermission()
pendingResult?.success(granted)
pendingResult = null
return true
}
return false
}
}
private const val TAG = "BackgroundServicePlugin"
private const val BUFFER_SIZE = 2 * 1024 * 1024;
private const val BUFFER_SIZE = 2 * 1024 * 1024

View File

@@ -2,14 +2,12 @@ package app.alextran.immich
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import android.os.Bundle
import android.content.Intent
import androidx.annotation.NonNull
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(BackgroundServicePlugin())
// No need to set up method channel here as it's now handled in the plugin
}
}

View File

@@ -17,10 +17,14 @@
"advanced_settings_proxy_headers_title": "Proxy Headers",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates",
"advanced_settings_enable_alternate_media_filter_title": "[EXPERIMENTAL] Use alternate device album sync filter",
"advanced_settings_enable_alternate_media_filter_subtitle": "Use this option to filter media during sync based on alternate criteria. Only try this if you have issues with the app detecting all albums.",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_tile_title": "Advanced",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"album_info_card_backup_album_excluded": "EXCLUDED",
"album_info_card_backup_album_included": "INCLUDED",
"albums": "Albums",

View File

@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 200;
CURRENT_PROJECT_VERSION = 201;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 200;
CURRENT_PROJECT_VERSION = 201;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 200;
CURRENT_PROJECT_VERSION = 201;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 200;
CURRENT_PROJECT_VERSION = 201;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 200;
CURRENT_PROJECT_VERSION = 201;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 200;
CURRENT_PROJECT_VERSION = 201;
CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.131.0</string>
<string>1.131.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict>
</array>
<key>CFBundleVersion</key>
<string>200</string>
<string>201</string>
<key>FLTEnableImpeller</key>
<true/>
<key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -4,3 +4,6 @@ const double downloadFailed = -2;
// Number of log entries to retain on app start
const int kLogTruncateLimit = 250;
const int kBatchHashFileLimit = 128;
const int kBatchHashSizeLimit = 1024 * 1024 * 1024; // 1GB

View File

@@ -0,0 +1,12 @@
import 'dart:async';
import 'package:immich_mobile/domain/interfaces/db.interface.dart';
import 'package:immich_mobile/domain/models/device_asset.model.dart';
abstract interface class IDeviceAssetRepository implements IDatabaseRepository {
Future<bool> updateAll(List<DeviceAsset> assetHash);
Future<List<DeviceAsset>> getByIds(List<String> localIds);
Future<void> deleteIds(List<String> ids);
}

View File

@@ -0,0 +1,44 @@
import 'dart:typed_data';
class DeviceAsset {
final String assetId;
final Uint8List hash;
final DateTime modifiedTime;
const DeviceAsset({
required this.assetId,
required this.hash,
required this.modifiedTime,
});
@override
bool operator ==(covariant DeviceAsset other) {
if (identical(this, other)) return true;
return other.assetId == assetId &&
other.hash == hash &&
other.modifiedTime == modifiedTime;
}
@override
int get hashCode {
return assetId.hashCode ^ hash.hashCode ^ modifiedTime.hashCode;
}
@override
String toString() {
return 'DeviceAsset(assetId: $assetId, hash: $hash, modifiedTime: $modifiedTime)';
}
DeviceAsset copyWith({
String? assetId,
Uint8List? hash,
DateTime? modifiedTime,
}) {
return DeviceAsset(
assetId: assetId ?? this.assetId,
hash: hash ?? this.hash,
modifiedTime: modifiedTime ?? this.modifiedTime,
);
}
}

View File

@@ -65,7 +65,10 @@ enum StoreKey<T> {
// Video settings
loadOriginalVideo<bool>._(136),
;
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000);
const StoreKey._(this.id);
final int id;

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
as entity;
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
@@ -358,7 +359,7 @@ class Asset {
// take most values from newer asset
// keep vales that can never be set by the asset not in DB
if (a.isRemote) {
return a._copyWith(
return a.copyWith(
id: id,
localId: localId,
width: a.width ?? width,
@@ -366,7 +367,7 @@ class Asset {
exifInfo: a.exifInfo?.copyWith(assetId: id) ?? exifInfo,
);
} else if (isRemote) {
return _copyWith(
return copyWith(
localId: localId ?? a.localId,
width: width ?? a.width,
height: height ?? a.height,
@@ -374,7 +375,7 @@ class Asset {
);
} else {
// TODO: Revisit this and remove all bool field assignments
return a._copyWith(
return a.copyWith(
id: id,
remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId,
@@ -394,7 +395,7 @@ class Asset {
// fill in potentially missing values, i.e. merge assets
if (a.isRemote) {
// values from remote take precedence
return _copyWith(
return copyWith(
remoteId: a.remoteId,
width: a.width,
height: a.height,
@@ -416,7 +417,7 @@ class Asset {
);
} else {
// add only missing values (and set isLocal to true)
return _copyWith(
return copyWith(
localId: localId ?? a.localId,
width: width ?? a.width,
height: height ?? a.height,
@@ -427,7 +428,7 @@ class Asset {
}
}
Asset _copyWith({
Asset copyWith({
Id? id,
String? checksum,
String? remoteId,
@@ -488,6 +489,9 @@ class Asset {
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
static int compareByLocalId(Asset a, Asset b) =>
compareToNullable(a.localId, b.localId);
static int compareByChecksum(Asset a, Asset b) =>
a.checksum.compareTo(b.checksum);

View File

@@ -0,0 +1,36 @@
import 'dart:typed_data';
import 'package:immich_mobile/domain/models/device_asset.model.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
part 'device_asset.entity.g.dart';
@Collection(inheritance: false)
class DeviceAssetEntity {
Id get id => fastHash(assetId);
@Index(replace: true, unique: true, type: IndexType.hash)
final String assetId;
@Index(unique: false, type: IndexType.hash)
final List<byte> hash;
final DateTime modifiedTime;
const DeviceAssetEntity({
required this.assetId,
required this.hash,
required this.modifiedTime,
});
DeviceAsset toModel() => DeviceAsset(
assetId: assetId,
hash: Uint8List.fromList(hash),
modifiedTime: modifiedTime,
);
static DeviceAssetEntity fromDto(DeviceAsset dto) => DeviceAssetEntity(
assetId: dto.assetId,
hash: dto.hash,
modifiedTime: dto.modifiedTime,
);
}

View File

@@ -0,0 +1,895 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'device_asset.entity.dart';
// **************************************************************************
// IsarCollectionGenerator
// **************************************************************************
// coverage:ignore-file
// ignore_for_file: duplicate_ignore, non_constant_identifier_names, constant_identifier_names, invalid_use_of_protected_member, unnecessary_cast, prefer_const_constructors, lines_longer_than_80_chars, require_trailing_commas, inference_failure_on_function_invocation, unnecessary_parenthesis, unnecessary_raw_strings, unnecessary_null_checks, join_return_with_assignment, prefer_final_locals, avoid_js_rounded_ints, avoid_positional_boolean_parameters, always_specify_types
extension GetDeviceAssetEntityCollection on Isar {
IsarCollection<DeviceAssetEntity> get deviceAssetEntitys => this.collection();
}
const DeviceAssetEntitySchema = CollectionSchema(
name: r'DeviceAssetEntity',
id: 6967030785073446271,
properties: {
r'assetId': PropertySchema(
id: 0,
name: r'assetId',
type: IsarType.string,
),
r'hash': PropertySchema(
id: 1,
name: r'hash',
type: IsarType.byteList,
),
r'modifiedTime': PropertySchema(
id: 2,
name: r'modifiedTime',
type: IsarType.dateTime,
)
},
estimateSize: _deviceAssetEntityEstimateSize,
serialize: _deviceAssetEntitySerialize,
deserialize: _deviceAssetEntityDeserialize,
deserializeProp: _deviceAssetEntityDeserializeProp,
idName: r'id',
indexes: {
r'assetId': IndexSchema(
id: 174362542210192109,
name: r'assetId',
unique: true,
replace: true,
properties: [
IndexPropertySchema(
name: r'assetId',
type: IndexType.hash,
caseSensitive: true,
)
],
),
r'hash': IndexSchema(
id: -7973251393006690288,
name: r'hash',
unique: false,
replace: false,
properties: [
IndexPropertySchema(
name: r'hash',
type: IndexType.hash,
caseSensitive: false,
)
],
)
},
links: {},
embeddedSchemas: {},
getId: _deviceAssetEntityGetId,
getLinks: _deviceAssetEntityGetLinks,
attach: _deviceAssetEntityAttach,
version: '3.1.8',
);
int _deviceAssetEntityEstimateSize(
DeviceAssetEntity object,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
var bytesCount = offsets.last;
bytesCount += 3 + object.assetId.length * 3;
bytesCount += 3 + object.hash.length;
return bytesCount;
}
void _deviceAssetEntitySerialize(
DeviceAssetEntity object,
IsarWriter writer,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
writer.writeString(offsets[0], object.assetId);
writer.writeByteList(offsets[1], object.hash);
writer.writeDateTime(offsets[2], object.modifiedTime);
}
DeviceAssetEntity _deviceAssetEntityDeserialize(
Id id,
IsarReader reader,
List<int> offsets,
Map<Type, List<int>> allOffsets,
) {
final object = DeviceAssetEntity(
assetId: reader.readString(offsets[0]),
hash: reader.readByteList(offsets[1]) ?? [],
modifiedTime: reader.readDateTime(offsets[2]),
);
return object;
}
P _deviceAssetEntityDeserializeProp<P>(
IsarReader reader,
int propertyId,
int offset,
Map<Type, List<int>> allOffsets,
) {
switch (propertyId) {
case 0:
return (reader.readString(offset)) as P;
case 1:
return (reader.readByteList(offset) ?? []) as P;
case 2:
return (reader.readDateTime(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
}
Id _deviceAssetEntityGetId(DeviceAssetEntity object) {
return object.id;
}
List<IsarLinkBase<dynamic>> _deviceAssetEntityGetLinks(
DeviceAssetEntity object) {
return [];
}
void _deviceAssetEntityAttach(
IsarCollection<dynamic> col, Id id, DeviceAssetEntity object) {}
extension DeviceAssetEntityByIndex on IsarCollection<DeviceAssetEntity> {
Future<DeviceAssetEntity?> getByAssetId(String assetId) {
return getByIndex(r'assetId', [assetId]);
}
DeviceAssetEntity? getByAssetIdSync(String assetId) {
return getByIndexSync(r'assetId', [assetId]);
}
Future<bool> deleteByAssetId(String assetId) {
return deleteByIndex(r'assetId', [assetId]);
}
bool deleteByAssetIdSync(String assetId) {
return deleteByIndexSync(r'assetId', [assetId]);
}
Future<List<DeviceAssetEntity?>> getAllByAssetId(List<String> assetIdValues) {
final values = assetIdValues.map((e) => [e]).toList();
return getAllByIndex(r'assetId', values);
}
List<DeviceAssetEntity?> getAllByAssetIdSync(List<String> assetIdValues) {
final values = assetIdValues.map((e) => [e]).toList();
return getAllByIndexSync(r'assetId', values);
}
Future<int> deleteAllByAssetId(List<String> assetIdValues) {
final values = assetIdValues.map((e) => [e]).toList();
return deleteAllByIndex(r'assetId', values);
}
int deleteAllByAssetIdSync(List<String> assetIdValues) {
final values = assetIdValues.map((e) => [e]).toList();
return deleteAllByIndexSync(r'assetId', values);
}
Future<Id> putByAssetId(DeviceAssetEntity object) {
return putByIndex(r'assetId', object);
}
Id putByAssetIdSync(DeviceAssetEntity object, {bool saveLinks = true}) {
return putByIndexSync(r'assetId', object, saveLinks: saveLinks);
}
Future<List<Id>> putAllByAssetId(List<DeviceAssetEntity> objects) {
return putAllByIndex(r'assetId', objects);
}
List<Id> putAllByAssetIdSync(List<DeviceAssetEntity> objects,
{bool saveLinks = true}) {
return putAllByIndexSync(r'assetId', objects, saveLinks: saveLinks);
}
}
extension DeviceAssetEntityQueryWhereSort
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QWhere> {
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhere> anyId() {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(const IdWhereClause.any());
});
}
}
extension DeviceAssetEntityQueryWhere
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QWhereClause> {
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
idEqualTo(Id id) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: id,
upper: id,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
idNotEqualTo(Id id) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: false),
)
.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: false),
);
} else {
return query
.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: false),
)
.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: false),
);
}
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
idGreaterThan(Id id, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.greaterThan(lower: id, includeLower: include),
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
idLessThan(Id id, {bool include = false}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(
IdWhereClause.lessThan(upper: id, includeUpper: include),
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
idBetween(
Id lowerId,
Id upperId, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IdWhereClause.between(
lower: lowerId,
includeLower: includeLower,
upper: upperId,
includeUpper: includeUpper,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
assetIdEqualTo(String assetId) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'assetId',
value: [assetId],
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
assetIdNotEqualTo(String assetId) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'assetId',
lower: [],
upper: [assetId],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'assetId',
lower: [assetId],
includeLower: false,
upper: [],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'assetId',
lower: [assetId],
includeLower: false,
upper: [],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'assetId',
lower: [],
upper: [assetId],
includeUpper: false,
));
}
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
hashEqualTo(List<int> hash) {
return QueryBuilder.apply(this, (query) {
return query.addWhereClause(IndexWhereClause.equalTo(
indexName: r'hash',
value: [hash],
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterWhereClause>
hashNotEqualTo(List<int> hash) {
return QueryBuilder.apply(this, (query) {
if (query.whereSort == Sort.asc) {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [],
upper: [hash],
includeUpper: false,
))
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [hash],
includeLower: false,
upper: [],
));
} else {
return query
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [hash],
includeLower: false,
upper: [],
))
.addWhereClause(IndexWhereClause.between(
indexName: r'hash',
lower: [],
upper: [hash],
includeUpper: false,
));
}
});
}
}
extension DeviceAssetEntityQueryFilter
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QFilterCondition> {
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdEqualTo(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'assetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdGreaterThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'assetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdLessThan(
String value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'assetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdBetween(
String lower,
String upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'assetId',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'assetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'assetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdContains(String value, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'assetId',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdMatches(String pattern, {bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'assetId',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'assetId',
value: '',
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
assetIdIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'assetId',
value: '',
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashElementEqualTo(int value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'hash',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashElementGreaterThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'hash',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashElementLessThan(
int value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'hash',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashElementBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'hash',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashLengthEqualTo(int length) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
length,
true,
length,
true,
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
0,
true,
0,
true,
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
0,
false,
999999,
true,
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashLengthLessThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
0,
true,
length,
include,
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashLengthGreaterThan(
int length, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
length,
include,
999999,
true,
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
hashLengthBetween(
int lower,
int upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.listLength(
r'hash',
lower,
includeLower,
upper,
includeUpper,
);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
idEqualTo(Id value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'id',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
idGreaterThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
idLessThan(
Id value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'id',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
idBetween(
Id lower,
Id upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'id',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
modifiedTimeEqualTo(DateTime value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'modifiedTime',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
modifiedTimeGreaterThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'modifiedTime',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
modifiedTimeLessThan(
DateTime value, {
bool include = false,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'modifiedTime',
value: value,
));
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterFilterCondition>
modifiedTimeBetween(
DateTime lower,
DateTime upper, {
bool includeLower = true,
bool includeUpper = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'modifiedTime',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
));
});
}
}
extension DeviceAssetEntityQueryObject
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QFilterCondition> {}
extension DeviceAssetEntityQueryLinks
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QFilterCondition> {}
extension DeviceAssetEntityQuerySortBy
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QSortBy> {
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
sortByAssetId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'assetId', Sort.asc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
sortByAssetIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'assetId', Sort.desc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
sortByModifiedTime() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'modifiedTime', Sort.asc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
sortByModifiedTimeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'modifiedTime', Sort.desc);
});
}
}
extension DeviceAssetEntityQuerySortThenBy
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QSortThenBy> {
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
thenByAssetId() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'assetId', Sort.asc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
thenByAssetIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'assetId', Sort.desc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy> thenById() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.asc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
thenByIdDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'id', Sort.desc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
thenByModifiedTime() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'modifiedTime', Sort.asc);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QAfterSortBy>
thenByModifiedTimeDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'modifiedTime', Sort.desc);
});
}
}
extension DeviceAssetEntityQueryWhereDistinct
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct> {
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct>
distinctByAssetId({bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'assetId', caseSensitive: caseSensitive);
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct>
distinctByHash() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'hash');
});
}
QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QDistinct>
distinctByModifiedTime() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'modifiedTime');
});
}
}
extension DeviceAssetEntityQueryProperty
on QueryBuilder<DeviceAssetEntity, DeviceAssetEntity, QQueryProperty> {
QueryBuilder<DeviceAssetEntity, int, QQueryOperations> idProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'id');
});
}
QueryBuilder<DeviceAssetEntity, String, QQueryOperations> assetIdProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'assetId');
});
}
QueryBuilder<DeviceAssetEntity, List<int>, QQueryOperations> hashProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'hash');
});
}
QueryBuilder<DeviceAssetEntity, DateTime, QQueryOperations>
modifiedTimeProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'modifiedTime');
});
}
}

View File

@@ -0,0 +1,37 @@
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/models/device_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:isar/isar.dart';
class IsarDeviceAssetRepository extends IsarDatabaseRepository
implements IDeviceAssetRepository {
final Isar _db;
const IsarDeviceAssetRepository(this._db) : super(_db);
@override
Future<void> deleteIds(List<String> ids) {
return transaction(() async {
await _db.deviceAssetEntitys.deleteAllByAssetId(ids.toList());
});
}
@override
Future<List<DeviceAsset>> getByIds(List<String> localIds) {
return _db.deviceAssetEntitys
.where()
.anyOf(localIds, (query, id) => query.assetIdEqualTo(id))
.findAll()
.then((value) => value.map((e) => e.toModel()).toList());
}
@override
Future<bool> updateAll(List<DeviceAsset> assetHash) {
return transaction(() async {
await _db.deviceAssetEntitys
.putAll(assetHash.map(DeviceAssetEntity.fromDto).toList());
return true;
});
}
}

View File

@@ -9,7 +9,9 @@ import 'package:isar/isar.dart';
class IsarStoreRepository extends IsarDatabaseRepository
implements IStoreRepository {
final Isar _db;
const IsarStoreRepository(super.db) : _db = db;
final validStoreKeys = StoreKey.values.map((e) => e.id).toSet();
IsarStoreRepository(super.db) : _db = db;
@override
Future<bool> deleteAll() async {
@@ -21,9 +23,14 @@ class IsarStoreRepository extends IsarDatabaseRepository
@override
Stream<StoreUpdateEvent> watchAll() {
return _db.storeValues.where().watch(fireImmediately: true).asyncExpand(
(entities) =>
Stream.fromFutures(entities.map((e) async => _toUpdateEvent(e))),
return _db.storeValues
.filter()
.anyOf(validStoreKeys, (query, id) => query.idEqualTo(id))
.watch(fireImmediately: true)
.asyncExpand(
(entities) => Stream.fromFutures(
entities.map((e) async => _toUpdateEvent(e)),
),
);
}

View File

@@ -1,6 +1,5 @@
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/device_asset.entity.dart';
import 'package:immich_mobile/interfaces/database.interface.dart';
abstract interface class IAssetRepository implements IDatabaseRepository {
@@ -50,10 +49,6 @@ abstract interface class IAssetRepository implements IDatabaseRepository {
int limit = 100,
});
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids);
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets);
Future<void> upsertDuplicatedAssets(Iterable<String> duplicatedAssets);
Future<List<String>> getAllDuplicatedAssetIds();

View File

@@ -0,0 +1,5 @@
abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(String fileName);
Future<bool> restoreFromTrash(String fileName);
Future<bool> requestManageStoragePermission();
}

View File

@@ -12,8 +12,8 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
@@ -63,6 +63,10 @@ class GalleryViewerPage extends HookConsumerWidget {
final loadAsset = renderList.loadAsset;
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
// This key is to prevent the video player from being re-initialized during
// hero animation or device rotation.
final videoPlayerKey = useMemoized(() => GlobalKey());
Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
return;
@@ -225,8 +229,6 @@ class GalleryViewerPage extends HookConsumerWidget {
}
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
// This key is to prevent the video player from being re-initialized during the hero animation
final key = GlobalKey();
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
@@ -241,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget {
width: context.width,
height: context.height,
child: NativeVideoViewerPage(
key: key,
key: videoPlayerKey,
asset: asset,
image: Image(
key: ValueKey(asset),

View File

@@ -0,0 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/infrastructure/repositories/device_asset.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final deviceAssetRepositoryProvider = Provider<IDeviceAssetRepository>(
(ref) => IsarDeviceAssetRepository(ref.watch(isarProvider)),
);

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/user.service.dart';
@@ -36,10 +37,13 @@ final currentUserProvider =
class TimelineUserIdsProvider extends StateNotifier<List<String>> {
TimelineUserIdsProvider(this._timelineService) : super([]) {
final listEquality = const ListEquality();
_timelineService.getTimelineUserIds().then((users) => state = users);
streamSub = _timelineService
.watchTimelineUserIds()
.listen((users) => state = users);
streamSub = _timelineService.watchTimelineUserIds().listen((users) {
if (!listEquality.equals(state, users)) {
state = users;
}
});
}
late final StreamSubscription<List<String>> streamSub;

View File

@@ -23,6 +23,7 @@ enum PendingAction {
assetDelete,
assetUploaded,
assetHidden,
assetTrash,
}
class PendingChange {
@@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
socket.on('on_upload_success', _handleOnUploadSuccess);
socket.on('on_config_update', _handleOnConfigUpdate);
socket.on('on_asset_delete', _handleOnAssetDelete);
socket.on('on_asset_trash', _handleServerUpdates);
socket.on('on_asset_trash', _handleOnAssetTrash);
socket.on('on_asset_restore', _handleServerUpdates);
socket.on('on_asset_update', _handleServerUpdates);
socket.on('on_asset_stack_update', _handleServerUpdates);
@@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
_debounce.run(handlePendingChanges);
}
Future<void> _handlePendingTrashes() async {
final trashChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetTrash)
.toList();
if (trashChanges.isNotEmpty) {
List<String> remoteIds = trashChanges
.expand((a) => (a.value as List).map((e) => e.toString()))
.toList();
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
await _ref.read(assetProvider.notifier).getAllAsset();
state = state.copyWith(
pendingChanges: state.pendingChanges
.whereNot((c) => trashChanges.contains(c))
.toList(),
);
}
}
Future<void> _handlePendingDeletes() async {
final deleteChanges = state.pendingChanges
.where((c) => c.action == PendingAction.assetDelete)
@@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
await _handlePendingUploaded();
await _handlePendingDeletes();
await _handlingPendingHidden();
await _handlePendingTrashes();
}
void _handleOnConfigUpdate(dynamic _) {
@@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
void _handleOnAssetDelete(dynamic data) =>
addPendingChange(PendingAction.assetDelete, data);
void _handleOnAssetTrash(dynamic data) {
addPendingChange(PendingAction.assetTrash, data);
}
void _handleOnAssetHidden(dynamic data) =>
addPendingChange(PendingAction.assetHidden, data);

View File

@@ -8,16 +8,23 @@ 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());
final albumMediaRepositoryProvider =
Provider((ref) => const AlbumMediaRepository());
class AlbumMediaRepository implements IAlbumMediaRepository {
const AlbumMediaRepository();
bool get useCustomFilter =>
Store.get(StoreKey.photoManagerCustomFilter, false);
@override
Future<List<Album>> getAll() async {
final filter = useCustomFilter
? CustomFilter.sql(where: '${CustomColumns.base.width} > 0')
: FilterOptionGroup(containsPathModified: true);
final List<AssetPathEntity> assetPathEntities =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
await PhotoManager.getAssetPathList(hasAll: true, filterOption: filter);
return assetPathEntities.map(_toAlbum).toList();
}
@@ -47,18 +54,18 @@ class AlbumMediaRepository implements IAlbumMediaRepository {
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),
containsPathModified: true,
updateTimeCond: modifiedFrom == null && modifiedUntil == null
? null
: DateTimeCond(
min: modifiedFrom ?? DateTime.utc(-271820),
max: modifiedUntil ?? DateTime.utc(275760),
),
orders: orderByModificationDate
? [const OrderOption(type: OrderOptionType.updateDate)]
: [],
),
);

View File

@@ -1,12 +1,7 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/device_asset.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart';
@@ -158,19 +153,6 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
return _getMatchesImpl(query, fastHash(ownerId), assets, limit);
}
@override
Future<List<DeviceAsset?>> getDeviceAssetsById(List<Object> ids) =>
Platform.isAndroid
? db.androidDeviceAssets.getAll(ids.cast())
: db.iOSDeviceAssets.getAllById(ids.cast());
@override
Future<void> upsertDeviceAssets(List<DeviceAsset> deviceAssets) => txn(
() => Platform.isAndroid
? db.androidDeviceAssets.putAll(deviceAssets.cast())
: db.iOSDeviceAssets.putAll(deviceAssets.cast()),
);
@override
Future<Asset> update(Asset asset) async {
await txn(() => asset.put(db));

View File

@@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/utils/local_files_manager.dart';
final localFilesManagerRepositoryProvider =
Provider((ref) => LocalFilesManagerRepository());
class LocalFilesManagerRepository implements ILocalFilesManager {
@override
Future<bool> moveToTrash(String fileName) async {
return await LocalFilesManager.moveToTrash(fileName);
}
@override
Future<bool> restoreFromTrash(String fileName) async {
return await LocalFilesManager.restoreFromTrash(fileName);
}
@override
Future<bool> requestManageStoragePermission() async {
return await LocalFilesManager.requestManageStoragePermission();
}
}

View File

@@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
0,
),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
@@ -84,6 +85,11 @@ enum AppSettingsEnum<T> {
enableHapticFeedback<bool>(StoreKey.enableHapticFeedback, null, true),
syncAlbums<bool>(StoreKey.syncAlbums, null, false),
autoEndpointSwitching<bool>(StoreKey.autoEndpointSwitching, null, false),
photoManagerCustomFilter<bool>(
StoreKey.photoManagerCustomFilter,
null,
false,
),
;
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -256,7 +256,7 @@ class AssetService {
for (var element in assets) {
element.fileCreatedAt = DateTime.parse(updatedDt);
element.exifInfo ??= element.exifInfo
element.exifInfo = element.exifInfo
?.copyWith(dateTimeOriginal: DateTime.parse(updatedDt));
}
@@ -283,7 +283,7 @@ class AssetService {
);
for (var element in assets) {
element.exifInfo ??= element.exifInfo?.copyWith(
element.exifInfo = element.exifInfo?.copyWith(
latitude: location.latitude,
longitude: location.longitude,
);

View File

@@ -1,172 +1,205 @@
// ignore_for_file: avoid-unsafe-collection-methods
import 'dart:convert';
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/interfaces/asset.interface.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
import 'package:immich_mobile/domain/models/device_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/device_asset.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/device_asset.provider.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:logging/logging.dart';
class HashService {
HashService(
this._assetRepository,
this._backgroundService,
this._albumMediaRepository,
);
final IAssetRepository _assetRepository;
final BackgroundService _backgroundService;
final IAlbumMediaRepository _albumMediaRepository;
final _log = Logger('HashService');
HashService({
required IDeviceAssetRepository deviceAssetRepository,
required BackgroundService backgroundService,
this.batchSizeLimit = kBatchHashSizeLimit,
this.batchFileLimit = kBatchHashFileLimit,
}) : _deviceAssetRepository = deviceAssetRepository,
_backgroundService = backgroundService;
/// Returns all assets that were successfully hashed
Future<List<Asset>> getHashedAssets(
Album album, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
Set<String>? excludedAssets,
}) async {
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.localId!)).toList();
return _hashAssets(filtered);
}
final IDeviceAssetRepository _deviceAssetRepository;
final BackgroundService _backgroundService;
final int batchSizeLimit;
final int batchFileLimit;
final _log = Logger('HashService');
/// 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<Asset> assets) async {
const int batchFileCount = 128;
const int batchDataSize = 1024 * 1024 * 1024; // 1GB
/// [DeviceAsset] by local id. Only missing entries are newly hashed and added to the DB table.
Future<List<Asset>> hashAssets(List<Asset> assets) async {
assets.sort(Asset.compareByLocalId);
final ids = assets
.map(Platform.isAndroid ? (a) => a.localId!.toInt() : (a) => a.localId!)
.toList();
final List<DeviceAsset?> hashes =
await _assetRepository.getDeviceAssetsById(ids);
final List<DeviceAsset> toAdd = [];
final List<String> toHash = [];
// Get and sort DB entries - guaranteed to be a subset of assets
final hashesInDB = await _deviceAssetRepository.getByIds(
assets.map((a) => a.localId!).toList(),
);
hashesInDB.sort((a, b) => a.assetId.compareTo(b.assetId));
int bytes = 0;
int dbIndex = 0;
int bytesProcessed = 0;
final hashedAssets = <Asset>[];
final toBeHashed = <_AssetPath>[];
final toBeDeleted = <String>[];
for (int i = 0; i < assets.length; i++) {
if (hashes[i] != null) {
for (int assetIndex = 0; assetIndex < assets.length; assetIndex++) {
final asset = assets[assetIndex];
DeviceAsset? matchingDbEntry;
if (dbIndex < hashesInDB.length) {
final deviceAsset = hashesInDB[dbIndex];
if (deviceAsset.assetId == asset.localId) {
matchingDbEntry = deviceAsset;
dbIndex++;
}
}
if (matchingDbEntry != null &&
matchingDbEntry.hash.isNotEmpty &&
matchingDbEntry.modifiedTime.isAtSameMomentAs(asset.fileModifiedAt)) {
// Reuse the existing hash
hashedAssets.add(
asset.copyWith(checksum: base64.encode(matchingDbEntry.hash)),
);
continue;
}
File? file;
try {
file = await assets[i].local!.originFile;
} catch (error, stackTrace) {
_log.warning(
"Error getting file to hash for asset ${assets[i].localId}, name: ${assets[i].fileName}, created on: ${assets[i].fileCreatedAt}, skipping",
error,
stackTrace,
);
}
final file = await _tryGetAssetFile(asset);
if (file == null) {
final fileName = assets[i].fileName;
_log.warning(
"Failed to get file for asset ${assets[i].localId}, name: $fileName, created on: ${assets[i].fileCreatedAt}, skipping",
);
// Can't access file, delete any DB entry
if (matchingDbEntry != null) {
toBeDeleted.add(matchingDbEntry.assetId);
}
continue;
}
bytes += await file.length();
toHash.add(file.path);
final deviceAsset = Platform.isAndroid
? AndroidDeviceAsset(id: ids[i] as int, hash: const [])
: IOSDeviceAsset(id: ids[i] as String, hash: const []);
toAdd.add(deviceAsset);
hashes[i] = deviceAsset;
if (toHash.length == batchFileCount || bytes >= batchDataSize) {
await _processBatch(toHash, toAdd);
toAdd.clear();
toHash.clear();
bytes = 0;
bytesProcessed += await file.length();
toBeHashed.add(_AssetPath(asset: asset, path: file.path));
if (_shouldProcessBatch(toBeHashed.length, bytesProcessed)) {
hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted));
toBeHashed.clear();
toBeDeleted.clear();
bytesProcessed = 0;
}
}
if (toHash.isNotEmpty) {
await _processBatch(toHash, toAdd);
assert(dbIndex == hashesInDB.length, "All hashes should've been processed");
// Process any remaining files
if (toBeHashed.isNotEmpty) {
hashedAssets.addAll(await _processBatch(toBeHashed, toBeDeleted));
}
return _getHashedAssets(assets, hashes);
// Clean up deleted references
if (toBeDeleted.isNotEmpty) {
await _deviceAssetRepository.deleteIds(toBeDeleted);
}
return hashedAssets;
}
/// Processes a batch of files and saves any successfully hashed
/// values to the DB table.
Future<void> _processBatch(
final List<String> toHash,
final List<DeviceAsset> toAdd,
bool _shouldProcessBatch(int assetCount, int bytesProcessed) =>
assetCount >= batchFileLimit || bytesProcessed >= batchSizeLimit;
Future<File?> _tryGetAssetFile(Asset asset) async {
try {
final file = await asset.local!.originFile;
if (file == null) {
_log.warning(
"Failed to get file for asset ${asset.localId ?? '<N/A>'}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping",
);
return null;
}
return file;
} catch (error, stackTrace) {
_log.warning(
"Error getting file to hash for asset ${asset.localId ?? '<N/A>'}, name: ${asset.fileName}, created on: ${asset.fileCreatedAt}, skipping",
error,
stackTrace,
);
return null;
}
}
/// Processes a batch of files and returns a list of successfully hashed assets after saving
/// them in [DeviceAssetToHash] for future retrieval
Future<List<Asset>> _processBatch(
List<_AssetPath> toBeHashed,
List<String> toBeDeleted,
) async {
final hashes = await _hashFiles(toHash);
bool anyNull = false;
for (int j = 0; j < hashes.length; j++) {
if (hashes[j]?.length == 20) {
toAdd[j].hash = hashes[j]!;
_log.info("Hashing ${toBeHashed.length} files");
final hashes = await _hashFiles(toBeHashed.map((e) => e.path).toList());
assert(
hashes.length == toBeHashed.length,
"Number of Hashes returned from platform should be the same as the input",
);
final hashedAssets = <Asset>[];
final toBeAdded = <DeviceAsset>[];
for (final (index, hash) in hashes.indexed) {
final asset = toBeHashed.elementAtOrNull(index)?.asset;
if (asset != null && hash?.length == 20) {
hashedAssets.add(asset.copyWith(checksum: base64.encode(hash!)));
toBeAdded.add(
DeviceAsset(
assetId: asset.localId!,
hash: hash,
modifiedTime: asset.fileModifiedAt,
),
);
} else {
_log.warning("Failed to hash file ${toHash[j]}, skipping");
anyNull = true;
_log.warning("Failed to hash file ${asset?.localId ?? '<null>'}");
if (asset != null) {
toBeDeleted.add(asset.localId!);
}
}
}
final validHashes = anyNull
? toAdd.where((e) => e.hash.length == 20).toList(growable: false)
: toAdd;
await _assetRepository
.transaction(() => _assetRepository.upsertDeviceAssets(validHashes));
_log.fine("Hashed ${validHashes.length}/${toHash.length} assets");
// Update the DB for future retrieval
await _deviceAssetRepository.transaction(() async {
await _deviceAssetRepository.updateAll(toBeAdded);
await _deviceAssetRepository.deleteIds(toBeDeleted);
});
_log.fine("Hashed ${hashedAssets.length}/${toBeHashed.length} assets");
return hashedAssets;
}
/// Hashes the given files and returns a list of the same length
/// files that could not be hashed have a `null` value
/// Hashes the given files and returns a list of the same length.
/// Files that could not be hashed will have a `null` value
Future<List<Uint8List?>> _hashFiles(List<String> paths) async {
final List<Uint8List?>? hashes =
await _backgroundService.digestFiles(paths);
if (hashes == null) {
throw Exception("Hashing ${paths.length} files failed");
}
return hashes;
}
/// 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) {
assets[i].byteHash = hashes[i]!.hash;
result.add(assets[i]);
try {
final hashes = await _backgroundService.digestFiles(paths);
if (hashes != null) {
return hashes;
}
_log.severe("Hashing ${paths.length} files failed");
} catch (e, s) {
_log.severe("Error occurred while hashing assets", e, s);
}
return result;
return List.filled(paths.length, null);
}
}
class _AssetPath {
final Asset asset;
final String path;
const _AssetPath({required this.asset, required this.path});
_AssetPath copyWith({Asset? asset, String? path}) {
return _AssetPath(asset: asset ?? this.asset, path: path ?? this.path);
}
}
final hashServiceProvider = Provider(
(ref) => HashService(
ref.watch(assetRepositoryProvider),
ref.watch(backgroundServiceProvider),
ref.watch(albumMediaRepositoryProvider),
deviceAssetRepository: ref.watch(deviceAssetRepositoryProvider),
backgroundService: ref.watch(backgroundServiceProvider),
),
);

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -16,6 +17,8 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/etag.interface.dart';
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/interfaces/partner.interface.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
@@ -25,6 +28,8 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/repositories/asset.repository.dart';
import 'package:immich_mobile/repositories/etag.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/repositories/partner.repository.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/services/entity.service.dart';
@@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
ref.watch(userRepositoryProvider),
ref.watch(userServiceProvider),
ref.watch(etagRepositoryProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(localFilesManagerRepositoryProvider),
ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider),
),
@@ -69,6 +76,8 @@ class SyncService {
final IUserApiRepository _userApiRepository;
final AsyncMutex _lock = AsyncMutex();
final Logger _log = Logger('SyncService');
final AppSettingsService _appSettingsService;
final ILocalFilesManager _localFilesManager;
SyncService(
this._hashService,
@@ -82,6 +91,8 @@ class SyncService {
this._userRepository,
this._userService,
this._eTagRepository,
this._appSettingsService,
this._localFilesManager,
this._partnerApiRepository,
this._userApiRepository,
);
@@ -238,8 +249,19 @@ class SyncService {
return null;
}
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
final List<Asset> localAssets = await _assetRepository.getAllLocal();
final List<Asset> matchedAssets = localAssets
.where((asset) => idsToDelete.contains(asset.remoteId))
.toList();
for (var asset in matchedAssets) {
_localFilesManager.moveToTrash(asset.fileName);
}
}
/// Deletes remote-only assets, updates merged assets to be local-only
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
return _assetRepository.transaction(() async {
await _assetRepository.deleteAllByRemoteId(
idsToDelete,
@@ -249,6 +271,12 @@ class SyncService {
idsToDelete,
state: AssetState.merged,
);
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
await _moveToTrashMatchedAssets(idsToDelete);
}
if (merged.isEmpty) return;
for (final Asset asset in merged) {
asset.remoteId = null;
@@ -577,15 +605,18 @@ class SyncService {
Set<String>? excludedAssets,
bool forceRefresh = false,
]) async {
_log.info("Syncing a local album to DB: ${deviceAlbum.name}");
if (!forceRefresh && !await _hasAlbumChangeOnDevice(deviceAlbum, dbAlbum)) {
_log.fine(
_log.info(
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
);
return false;
}
_log.info("Local album ${deviceAlbum.name} has changed. Syncing...");
if (!forceRefresh &&
excludedAssets == null &&
await _syncDeviceAlbumFast(deviceAlbum, dbAlbum)) {
_log.info("Fast synced local album ${deviceAlbum.name} to DB");
return true;
}
// general case, e.g. some assets have been deleted or there are excluded albums on iOS
@@ -598,7 +629,7 @@ class SyncService {
assert(inDb.isSorted(Asset.compareByChecksum), "inDb not sorted!");
final int assetCountOnDevice =
await _albumMediaRepository.getAssetCount(deviceAlbum.localId!);
final List<Asset> onDevice = await _hashService.getHashedAssets(
final List<Asset> onDevice = await _getHashedAssets(
deviceAlbum,
excludedAssets: excludedAssets,
);
@@ -611,7 +642,7 @@ class SyncService {
dbAlbum.name == deviceAlbum.name &&
dbAlbum.modifiedAt.isAtSameMomentAs(deviceAlbum.modifiedAt)) {
// changes only affeted excluded albums
_log.fine(
_log.info(
"Only excluded assets in local album ${deviceAlbum.name} changed. Stopping sync.",
);
if (assetCountOnDevice !=
@@ -626,11 +657,11 @@ class SyncService {
}
return false;
}
_log.fine(
_log.info(
"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(
_log.info(
"Linking assets to add with existing from db. ${existingInDb.length} existing, ${updated.length} to update",
);
deleteCandidates.addAll(toDelete);
@@ -667,6 +698,9 @@ class SyncService {
/// returns `true` if successful, else `false`
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
_log.info(
"Local album ${deviceAlbum.name} has not changed. Skipping sync.",
);
return false;
}
final int totalOnDevice =
@@ -676,15 +710,21 @@ class SyncService {
?.assetCount ??
0;
if (totalOnDevice <= lastKnownTotal) {
_log.info(
"Local album ${deviceAlbum.name} totalOnDevice is less than lastKnownTotal. Skipping sync.",
);
return false;
}
final List<Asset> newAssets = await _hashService.getHashedAssets(
final List<Asset> newAssets = await _getHashedAssets(
deviceAlbum,
modifiedFrom: dbAlbum.modifiedAt.add(const Duration(seconds: 1)),
modifiedUntil: deviceAlbum.modifiedAt,
);
if (totalOnDevice != lastKnownTotal + newAssets.length) {
_log.info(
"Local album ${deviceAlbum.name} totalOnDevice is not equal to lastKnownTotal + newAssets.length. Skipping sync.",
);
return false;
}
dbAlbum.modifiedAt = deviceAlbum.modifiedAt;
@@ -719,8 +759,8 @@ class SyncService {
List<Asset> existing, [
Set<String>? excludedAssets,
]) async {
_log.info("Syncing a new local album to DB: ${album.name}");
final assets = await _hashService.getHashedAssets(
_log.info("Adding a new local album to DB: ${album.name}");
final assets = await _getHashedAssets(
album,
excludedAssets: excludedAssets,
);
@@ -778,15 +818,33 @@ class SyncService {
return (existing, toUpsert);
}
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
for (var asset in assetsList) {
if (asset.isTrashed) {
_localFilesManager.moveToTrash(asset.fileName);
} else {
_localFilesManager.restoreFromTrash(asset.fileName);
}
}
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) return;
if (Platform.isAndroid &&
_appSettingsService.getSetting<bool>(
AppSettingsEnum.manageLocalMediaAndroid,
)) {
_toggleTrashStatusForAssets(assets);
}
final exifInfos = assets.map((e) => e.exifInfo).nonNulls.toList();
try {
await _assetRepository.transaction(() async {
await _assetRepository.updateAll(assets);
for (final Asset added in assets) {
added.exifInfo ??= added.exifInfo?.copyWith(assetId: added.id);
added.exifInfo = added.exifInfo?.copyWith(assetId: added.id);
}
await _exifInfoRepository.updateAll(exifInfos);
});
@@ -824,6 +882,28 @@ class SyncService {
}
}
/// Returns all assets that were successfully hashed
Future<List<Asset>> _getHashedAssets(
Album album, {
int start = 0,
int end = 0x7fffffffffffffff,
DateTime? modifiedFrom,
DateTime? modifiedUntil,
Set<String>? excludedAssets,
}) async {
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.localId!)).toList();
return _hashService.hashAssets(filtered);
}
List<Asset> _removeDuplicates(List<Asset> assets) {
final int before = assets.length;
assets.sort(Asset.compareByOwnerChecksumCreatedModified);

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/log.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
@@ -39,9 +40,10 @@ abstract final class Bootstrap {
ETagSchema,
if (Platform.isAndroid) AndroidDeviceAssetSchema,
if (Platform.isIOS) IOSDeviceAssetSchema,
DeviceAssetEntitySchema,
],
directory: dir.path,
maxSizeMiB: 1024,
maxSizeMiB: 2048,
inspector: kDebugMode,
);
}

View File

@@ -75,3 +75,17 @@ bool diffSortedListsSync<T>(
}
return diff;
}
int compareToNullable<T extends Comparable>(T? a, T? b) {
if (a == null && b == null) {
return 0;
}
if (a == null) {
return 1;
}
if (b == null) {
return -1;
}
return a.compareTo(b);
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class LocalFilesManager {
static const MethodChannel _channel = MethodChannel('file_trash');
static Future<bool> moveToTrash(String fileName) async {
try {
final bool success =
await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error moving to trash: ${e.message}');
return false;
}
}
static Future<bool> restoreFromTrash(String fileName) async {
try {
final bool success = await _channel
.invokeMethod('restoreFromTrash', {'fileName': fileName});
return success;
} on PlatformException catch (e) {
debugPrint('Error restoring file: ${e.message}');
return false;
}
}
static Future<bool> requestManageStoragePermission() async {
try {
final bool success =
await _channel.invokeMethod('requestManageStoragePermission');
return success;
} on PlatformException catch (e) {
debugPrint('Error requesting permission: ${e.message}');
return false;
}
}
}

View File

@@ -1,40 +1,51 @@
import 'dart:async';
// ignore_for_file: avoid-unsafe-collection-methods
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/ios_device_asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/device_asset.entity.dart';
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart';
import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
const int targetVersion = 9;
const int targetVersion = 10;
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);
final int version = Store.get(StoreKey.version, targetVersion);
if (version < 9) {
await Store.put(StoreKey.version, version);
await Store.put(StoreKey.version, targetVersion);
final value = await db.storeValues.get(StoreKey.currentUser.id);
if (value != null) {
final id = value.intValue;
if (id == null) {
return;
if (id != null) {
await db.writeTxn(() async {
final user = await db.users.get(id);
await db.storeValues
.put(StoreValue(StoreKey.currentUser.id, strValue: user?.id));
});
}
await db.writeTxn(() async {
final user = await db.users.get(id);
await db.storeValues
.put(StoreValue(StoreKey.currentUser.id, strValue: user?.id));
});
}
// Do not clear other entities
return;
}
if (version < targetVersion) {
_migrateTo(db, targetVersion);
if (version < 10) {
await Store.put(StoreKey.version, targetVersion);
await _migrateDeviceAsset(db);
}
final shouldTruncate = version < 8 && version < targetVersion;
if (shouldTruncate) {
await _migrateTo(db, targetVersion);
}
}
@@ -49,3 +60,59 @@ Future<void> _migrateTo(Isar db, int version) async {
});
await Store.put(StoreKey.version, version);
}
Future<void> _migrateDeviceAsset(Isar db) async {
final ids = Platform.isAndroid
? (await db.androidDeviceAssets.where().findAll())
.map((a) => _DeviceAsset(assetId: a.id.toString(), hash: a.hash))
.toList()
: (await db.iOSDeviceAssets.where().findAll())
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
.toList();
final localAssets = (await db.assets
.where()
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
.findAll())
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
.toList();
debugPrint("Device Asset Ids length - ${ids.length}");
debugPrint("Local Asset Ids length - ${localAssets.length}");
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
final List<DeviceAssetEntity> toAdd = [];
await diffSortedLists(
ids,
localAssets,
compare: (a, b) => a.assetId.compareTo(b.assetId),
both: (deviceAsset, asset) {
toAdd.add(
DeviceAssetEntity(
assetId: deviceAsset.assetId,
hash: deviceAsset.hash!,
modifiedTime: asset.dateTime!,
),
);
return false;
},
onlyFirst: (deviceAsset) {
debugPrint(
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
);
},
onlySecond: (asset) {
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}');
},
);
debugPrint("Total number of device assets migrated - ${toAdd.length}");
await db.writeTxn(() async {
await db.deviceAssetEntitys.putAll(toAdd);
});
}
class _DeviceAsset {
final String assetId;
final List<int>? hash;
final DateTime? dateTime;
const _DeviceAsset({required this.assetId, this.hash, this.dateTime});
}

View File

@@ -1,5 +1,6 @@
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:punycode/punycode.dart';
String sanitizeUrl(String url) {
// Add schema if none is set
@@ -11,13 +12,80 @@ String sanitizeUrl(String url) {
}
String? getServerUrl() {
final serverUrl = Store.tryGet(StoreKey.serverEndpoint);
final serverUrl = punycodeDecodeUrl(Store.tryGet(StoreKey.serverEndpoint));
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null) {
return null;
}
return serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}";
return Uri.decodeFull(
serverUri.hasPort
? "${serverUri.scheme}://${serverUri.host}:${serverUri.port}"
: "${serverUri.scheme}://${serverUri.host}",
);
}
/// Converts a Unicode URL to its ASCII-compatible encoding (Punycode).
///
/// This is especially useful for internationalized domain names (IDNs),
/// where parts of the URL (typically the host) contain non-ASCII characters.
///
/// Example:
/// ```dart
/// final encodedUrl = punycodeEncodeUrl('https://bücher.de');
/// print(encodedUrl); // Outputs: https://xn--bcher-kva.de
/// ```
///
/// Notes:
/// - If the input URL is invalid, an empty string is returned.
/// - Only the host part of the URL is converted to Punycode; the scheme,
/// path, and port remain unchanged.
///
String punycodeEncodeUrl(String serverUrl) {
final serverUri = Uri.tryParse(serverUrl);
if (serverUri == null || serverUri.host.isEmpty) return '';
final encodedHost = Uri.decodeComponent(serverUri.host).split('.').map(
(segment) {
// If segment is already ASCII, then return as it is.
if (segment.runes.every((c) => c < 0x80)) return segment;
return 'xn--${punycodeEncode(segment)}';
},
).join('.');
return serverUri.replace(host: encodedHost).toString();
}
/// Decodes an ASCII-compatible (Punycode) URL back to its original Unicode representation.
///
/// This method is useful for converting internationalized domain names (IDNs)
/// that were previously encoded with Punycode back to their human-readable Unicode form.
///
/// Example:
/// ```dart
/// final decodedUrl = punycodeDecodeUrl('https://xn--bcher-kva.de');
/// print(decodedUrl); // Outputs: https://bücher.de
/// ```
///
/// Notes:
/// - If the input URL is invalid the method returns `null`.
/// - Only the host part of the URL is decoded. The scheme and port (if any) are preserved.
/// - The method assumes that the input URL only contains: scheme, host, port (optional).
/// - Query parameters, fragments, and user info are not handled (by design, as per constraints).
///
String? punycodeDecodeUrl(String? serverUrl) {
final serverUri = serverUrl != null ? Uri.tryParse(serverUrl) : null;
if (serverUri == null || serverUri.host.isEmpty) return null;
final decodedHost = serverUri.host.split('.').map(
(segment) {
if (segment.toLowerCase().startsWith('xn--')) {
return punycodeDecode(segment.substring(4));
}
// If segment is not punycode encoded, then return as it is.
return segment;
},
).join('.');
return Uri.decodeFull(serverUri.replace(host: decodedHost).toString());
}

View File

@@ -8,25 +8,25 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:fluttertoast/fluttertoast.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/collection_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
@@ -107,6 +107,8 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
final Set<Asset> _draggedAssets =
HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
ScrollPhysics? _scrollPhysics;
Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets);
}
@@ -265,6 +267,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
),
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
physics: _scrollPhysics,
itemScrollController: _itemScrollController,
scrollOffsetController: _scrollOffsetController,
itemCount: widget.renderList.elements.length +
@@ -439,6 +442,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
void _setDragStartIndex(AssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorAssetIndex = index.rowIndex;
_dragAnchorSectionIndex = index.sectionIndex;
_dragging = true;
@@ -446,6 +450,12 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();
@@ -546,15 +556,16 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
if (didPop) {
return;
} else {
if (widget.preselectedAssets == null) {
Navigator.of(context).canPop() ? Navigator.of(context).pop() : null;
}
if (_selectedAssets.length != widget.preselectedAssets!.length &&
!widget.preselectedAssets!.containsAll(_selectedAssets)) {
{
_deselectAll();
return;
}
/// `preselectedAssets` is only present when opening the asset grid from the
/// "add to album" button.
///
/// `_selectedAssets` includes `preselectedAssets` on initialization.
if (_selectedAssets.length >
(widget.preselectedAssets?.length ?? 0)) {
/// `_deselectAll` only deselects the selected assets,
/// doesn't affect the preselected ones.
_deselectAll();
return;
} else {
Navigator.of(context).canPop() ? Navigator.of(context).pop() : null;
}

View File

@@ -34,17 +34,24 @@ class DescriptionInput extends HookConsumerWidget {
final owner = ref.watch(currentUserProvider);
final hasError = useState(false);
final assetWithExif = ref.watch(assetDetailProvider(asset));
final hasDescription = useState(false);
final isOwner = fastHash(owner?.id ?? '') == asset.ownerId;
useEffect(
() {
assetService
.getDescription(asset)
.then((value) => controller.text = value);
assetService.getDescription(asset).then((value) {
controller.text = value;
hasDescription.value = value.isNotEmpty;
});
return null;
},
[assetWithExif.value],
);
if (!isOwner && !hasDescription.value) {
return const SizedBox.shrink();
}
submitDescription(String description) async {
hasError.value = false;
try {
@@ -82,7 +89,7 @@ class DescriptionInput extends HookConsumerWidget {
}
return TextField(
enabled: fastHash(owner?.id ?? '') == asset.ownerId,
enabled: isOwner,
focusNode: focusNode,
onTap: () => isFocus.value = true,
onChanged: (value) {

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@@ -7,18 +8,18 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
@@ -82,7 +83,8 @@ class LoginForm extends HookConsumerWidget {
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final serverUrl = sanitizeUrl(serverEndpointController.text);
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
// Guard empty URL
if (serverUrl.isEmpty) {

View File

@@ -1,11 +1,13 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
@@ -25,10 +27,14 @@ class AdvancedSettings extends HookConsumerWidget {
final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid =
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert =
useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
final useAlternatePMFilter =
useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter);
final logLevel = Level.LEVELS[levelId.value].name;
@@ -38,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
);
Future<bool> checkAndroidVersion() async {
if (Platform.isAndroid) {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 30;
}
return false;
}
final advancedSettings = [
SettingsSwitchListTile(
enabled: true,
@@ -45,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
FutureBuilder<bool>(
future: checkAndroidVersion(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref
.read(localFilesManagerRepositoryProvider)
.requestManageStoragePermission();
manageLocalMediaAndroid.value = result;
}
},
);
} else {
return const SizedBox.shrink();
}
},
),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId,
@@ -68,6 +107,12 @@ class AdvancedSettings extends HookConsumerWidget {
),
const CustomeProxyHeaderSettings(),
SslClientCertSettings(isLoggedIn: ref.read(currentUserProvider) != null),
SettingsSwitchListTile(
valueNotifier: useAlternatePMFilter,
title: "advanced_settings_enable_alternate_media_filter_title".tr(),
subtitle:
"advanced_settings_enable_alternate_media_filter_subtitle".tr(),
),
];
return SettingsSubPageScaffold(settings: advancedSettings);

View File

@@ -36,7 +36,7 @@ class UserAdminCreateDto {
String password;
/// Minimum value: 1
/// Minimum value: 0
int? quotaSizeInBytes;
///

View File

@@ -45,7 +45,7 @@ class UserAdminUpdateDto {
///
String? password;
/// Minimum value: 1
/// Minimum value: 0
int? quotaSizeInBytes;
///

View File

@@ -463,7 +463,7 @@ packages:
source: hosted
version: "2.1.4"
file:
dependency: transitive
dependency: "direct dev"
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4

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