Compare commits

...

75 Commits

Author SHA1 Message Date
mertalev
2c7e6071f5 split up search page 2025-03-05 19:06:04 -05:00
Alex
c110c9b00e chore(mobile): post release task (#16623) 2025-03-05 14:54:56 -06:00
Yaros
b241a80339 feat(mobile): Navigate back on memories (#16545)
* Navigate back on memories

* Fixes crash on navigating back
2025-03-05 14:42:43 -06:00
github-actions
31dd15ce8a chore: version v1.129.0 2025-03-05 19:47:50 +00:00
Alex
6108587c8b fix(web): show tags timeline (#16617)
* fix(web): show tags timeline

* fix(web): show tags timeline
2025-03-05 13:36:56 -06:00
Alex
3e50f668d9 feat(mobile): add catalan i18n (#16616)
* feat(mobile): Add Catalan

* refactor

* fix: load correct file

* chore: remove unused language files
2025-03-05 11:47:31 -06:00
Daniel Dietzler
9b82617e22 docs: 60k stars! (#16618)
60k stars! 
2025-03-05 11:40:45 -06:00
Alex
76cb32d8d0 chore(mobile): translations update (#16615)
chore(mobile): translation update
2025-03-05 16:33:41 +00:00
Yaros
e8f3348833 fix(mobile): Fixed zh-Hans not persisting (#16608)
Fixed zh-Hans not persisting
2025-03-05 09:56:00 -06:00
Zack Pollard
9922c8de59 fix: storage template failure after re-upload and previous fail (#16611)
fix: storage template breaks when files are re-uploaded after a move failure
2025-03-05 15:00:37 +00:00
shenlong
3f4bbab4eb fix: isar crash on resume from app detach (#16599)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-03-05 08:28:40 -06:00
Jason Rasmussen
2da9e3152b refactor: download service (#16600) 2025-03-05 08:38:23 -05:00
Min Idzelis
56b85f7479 fix(web): fix lost scrollpos on deep link to timeline asset, scrub stop (#16305)
* Work in progress - super quick asset store->state

* bugfix: deep linking to timeline, on scrub stop

* format, remove stale

* disable test, todo: fix test

* remove unused import

* Fix merge

* lint

* lint

* lint

* Default to non-wasm layout

* lint

* intobs fix

* fix rejected promise

* Review comments, static import wasm

* Back to dynamic

* try top-level-await

* back to the first solution, with more finesse

* comment out wasm for now

* back out the wasm/thumbhash/thumbnail changes

* lint

* Fully remove wasm

* lockfile

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-03-04 20:34:53 -06:00
waclaw66
8b43066632 fix(mobile): .well-known usage (#16577)
fix: .well-known

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-03-04 20:25:57 -06:00
bo0tzz
20acdcd884 chore: run docker workflow on non-main PRs (#16582) 2025-03-05 02:15:17 +00:00
Jonathan Jogenfors
22d348beca feat(server): e2e for missing jobs (#15910)
* feat: test face detection

* Add duplicate and smart search fixes and tests

* do e2e instead

* Remove ML e2e jobs
2025-03-04 20:44:31 -05:00
shenlong
3b0af1c8a9 fix(mobile): do not pause audio on app start (#16596)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-03-04 16:00:01 -06:00
Mert
61c8237a4d fix(ml): set face detection threshold correctly in locust (#13419)
* set minScore correctly

* cleanup

* remove outdated tag score
2025-03-04 20:52:07 +00:00
Jason Rasmussen
d740f0283a chore: no more immortal PRs (#16595) 2025-03-04 15:06:41 -05:00
Jonathan Jogenfors
4ada28ac99 fix(server): check updateLibraryIndex for zero (#16585)
* fix(server): check updateLibraryIndex for zero

* Update web/src/routes/admin/library-management/+page.svelte

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-03-04 20:00:10 +00:00
Jason Rasmussen
63c01b78e2 refactor: test utils (#16588) 2025-03-04 16:15:41 +00:00
renovate[bot]
1423cfd53c chore(deps): update ghcr.io/immich-app/base-server-dev docker tag to v20250304 (#16580)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 15:39:34 +00:00
Snowknight26
867eec86f5 fix(web): Update menu titles to be more consistent (#16558) 2025-03-04 12:55:54 +00:00
Alex
86e8effd8e fix(mobile): incorrect memories with timezone (#16562) 2025-03-04 12:54:54 +00:00
Jonathan Jogenfors
49d393216a fix(server): fix import path truthiness check (#16570) 2025-03-04 12:54:12 +00:00
renovate[bot]
75c9f63757 chore(deps): update typescript-projects (#16573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 12:53:28 +00:00
Kofi
63984890df docs: clean up environment variables formatting & grammar (#16555)
docs: clean up environment variables formatting & grammar - Just going through the docs and noticed some inconsistent capitalization and minor grammar issues. Fixed them up while having my Monday coffee :) Nothing major, but makes the docs a bit more polished.
2025-03-04 05:00:27 +00:00
Jason Rasmussen
1356468c38 fix: reset/regenerate memories (#16548)
fix: reset memories
2025-03-03 23:48:05 -05:00
renovate[bot]
c23c53bf6f fix(deps): update machine-learning (#16560) 2025-03-04 01:42:35 +00:00
renovate[bot]
0dcfc43461 chore(deps): update node (#16538)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 14:31:34 -05:00
Jason Rasmussen
d1fd0076cc refactor: migration tag repository to kysely (#16398) 2025-03-03 18:41:19 +00:00
Zack Pollard
ff19502035 feat: qr code for new shared link (#16543) 2025-03-03 13:40:41 -05:00
renovate[bot]
6ef069b537 chore(deps): update github-actions (#16539)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 18:39:15 +00:00
Matthew Momjian
a03e999bde fix(docs): info on preloading ML models (#16452)
info on preload
2025-03-03 18:39:02 +00:00
aviv926
ad1ba4be5f docs: better facial recognition cluster guide (#14911)
* Better Facial Recognition Clusters

* Add information about the guide

* Update docs/docs/features/facial-recognition.md

Co-authored-by: Felix Bühler <Stunkymonkey@users.noreply.github.com>

* PR Feedback

---------

Co-authored-by: Felix Bühler <Stunkymonkey@users.noreply.github.com>
2025-03-03 18:33:32 +00:00
Alessandro Baroni
f89e74181b fix(web): delete action closes asset viewer in asset view (#15469)
fixes #14647
2025-03-03 18:24:37 +00:00
Eli Gao
e2c34f17ba feat(cli): watch paths for auto uploading daemon (#14923)
* feat(cli): watch paths for auto uploading daemon

* chore: update package-lock

* test(cli): Batcher util calss

* feat(cli): expose batcher params from startWatch()

* test(cli): startWatch() for `--watch`

* refactor(cli): more reliable watcher

* feat(cli): disable progress bar on --no-progress or --watch

* fix(cli): extensions match when upload with watch

* feat(cli): basic logs without progress on upload

* feat(cli): hide progress in uploadFiles()

* refactor(cli): use promise-based setTimeout() instead of hand crafted sleep()

* refactor(cli): unexport UPLOAD_WATCH consts

* refactor(cli): rename fsWatchListener() to onFile()

* test(cli): prefix dot to mocked getSupportedMediaTypes()

* test(cli): add tests for ignored patterns/ unsupported exts

* refactor(cli): minor changes for code reviews

* feat(cli): disable onFile logs when progress bar is enabled
2025-03-03 13:05:32 -05:00
Zack Pollard
23b1256592 ci: weblate checks should always run, should skip on en.json (#16544) 2025-03-03 17:12:26 +00:00
Yaros
7bbc1d9f68 feat(web): Video memories on web (#16500)
* Video memories on web

* switched mixed up strings
2025-03-03 09:54:26 -06:00
renovate[bot]
8b24c31d20 fix(deps): update typescript-projects (#16540)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 09:38:24 -06:00
shenlong
7f61ac6983 chore(mobile): fix store.put type def (#16517)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-03-03 09:11:13 -06:00
shenlong
4db8f0c666 refactor(mobile): move timeline methods to timeline repo (#16526)
* refactor: move timeline calls to timeline repo

* refactor: review changes

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-03-03 09:10:09 -06:00
renovate[bot]
3d6a6f77a8 chore(deps): update dependency eslint-plugin-svelte to v3 (#16532)
* chore(deps): update dependency eslint-plugin-svelte to v3

* chore: linting

* chore: rebase

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Zack Pollard <zackpollard@ymail.com>
2025-03-03 14:24:26 +00:00
Mert
5698f446f7 refactor(server): link live photos as part of metadata extraction instead of queueing job (#16390)
* link live photos helper instead of job

* update test

* queue storage template migration

* queue in onDone

* remove link live photos job
2025-03-03 09:19:36 -05:00
renovate[bot]
eb74fafb00 chore(deps): update dependency globals to v16 (#16534)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 14:11:44 +00:00
Zack Pollard
24da25dbbf ci: don't check weblate lock on chore/translations and add success job (#16533) 2025-03-03 13:22:33 +01:00
renovate[bot]
9b842d4cca chore(deps): update tensorchord/pgvecto-rs:pg14-v0.2.0 docker digest to 739cdd6 (#16530)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 12:13:38 +00:00
renovate[bot]
a99bd94717 fix(deps): update dependency ua-parser-js to v2 (#14301)
* fix(deps): update dependency ua-parser-js to v2

* fix: breaking changes from ua-parsed-js major update

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Zack Pollard <zackpollard@ymail.com>
2025-03-03 12:01:40 +00:00
renovate[bot]
4b568dcbb3 chore(deps): update dependency black to v25 (#16033)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 11:57:46 +00:00
renovate[bot]
12ab56c885 chore(deps): update prom/prometheus docker digest to 6927e09 (#16529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 11:52:22 +00:00
renovate[bot]
eed6465b41 chore(deps): update grafana/grafana docker tag to v11.5.2 (#16301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 11:51:44 +00:00
renovate[bot]
5f6c16080b chore(deps): update docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0 docker digest to 739cdd6 (#16528)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 11:51:13 +00:00
Alex
a2aab1f373 fix: don't use public keyword in migration query (#16514)
Co-authored-by: Zack Pollard <zack@futo.org>
2025-03-03 11:40:14 +00:00
bo0tzz
8e076ecfe4 feat: weblate checks workflow (#16251) 2025-03-03 11:39:53 +00:00
Zack Pollard
fe702ba6d7 feat: partner sync (#16424)
feat: partner CUD sync
2025-03-03 11:05:30 +00:00
Jonathan Jogenfors
869839f642 feat(server): library cleanup from ui (#16226)
* feat(server,web): scan all libraries from frontend

* feat(server,web): scan all libraries from frontend

* Add button text
2025-03-02 21:29:02 -06:00
Justin Cichra
8885e3105e chore: reword backup_manual_in_progress (#16513)
fix(i18n): reword backup_manual_in_progress

Split "sometime" into "some time".
2025-03-03 03:27:20 +00:00
bo0tzz
6e51c4ec71 chore: add extra note to no-dupes checkbox (#16499) 2025-03-02 21:02:36 -06:00
knechtandreas
6bf2e8dbcb feat: add album keyboard shortcuts (#16442)
* 15712: Added keyboard shortcuts for opening add to album modal and highlighting/selecting an album to add to.

* 15712: Re-factored logic from template code into script. Extracted new album button into separate cmponent.

* 15712: Document new keyboard shortucts now that they work everywhere.

* 15712: Extract some constants/helper functions.

* 15712: Missing comma.

* 15712: Pulled logic out into separate unit testable class.

* 15712: Added a unit test.

* 15712: Move the modal back up to keep the github PR happy.

* 15712: PR feedback - renamed typescript files and switch to class bind directive.

* 15712:Move selection modal into correct package.

* 15712: Better naming of module and files.
2025-03-02 13:15:00 +00:00
Yaros
366f23774a fix(web): Default to context search on web (#16485)
Default to context search on web
2025-03-02 13:06:15 +00:00
Yaros
fd5e931617 fix(mobile): Updated formatting of server address in networking (#16483)
* Updated formatting of server address in networking

* fallback for undefined endpoint
2025-03-02 06:58:05 -06:00
shenlong
d8d87bb565 chore(mobile): rename log enum to lowercase (#16476)
* chore(mobile): rename log enum to lowercase

* chore(mobile): do not abbreviate

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-03-02 06:30:48 -06:00
Lukas Jost
6cc1978b2d fix(web): Open huggingface.co link on settings page in new tab (#16470)
fix(web): Open huggingface on settings page in new tab
2025-03-01 23:02:56 +00:00
luzpaz
506d2d0f81 fix(web): fix typos (#16466)
Found via codespell
2025-03-01 16:51:50 -06:00
Yaros
f13d13b2ea fix(web): Fixed people list overflowing in advanced search (#16457)
* Fixed people list overflowing in search

* styling: better fix

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-03-01 20:34:57 +00:00
Thomas Laroche
2510684bf7 fix(web): unable to download live photo as anonymous user (#16455) 2025-03-01 14:07:19 -06:00
luzpaz
c8eef5ad4d fix(mobile): fix typos (#16456)
Found via codespell
2025-03-01 20:06:47 +00:00
bo0tzz
0cb3dc6211 chore: add 'not duplicate' checkbox to issue template (#16462) 2025-03-01 14:05:36 -06:00
Alex
f11080cc2d chore(mobile): post release task (#16437) 2025-02-28 21:09:09 -06:00
Matthew Momjian
efcf773ea0 feat(server): Shortened asset ID in storage template (#16433)
* Update storage-template.service.ts

* Update supported-variables-panel.svelte

* docs example

* Update storage-template-settings.svelte
2025-02-28 16:04:34 -05:00
github-actions
dc143046e3 chore: version v1.128.0 2025-02-28 18:54:08 +00:00
Jason Rasmussen
e684062569 fix: memories off by one (#16434) 2025-02-28 12:51:28 -06:00
Desmond Cox
5c0538e52c fix(server): stringify error log parameter to ensure correct overload (#16422)
* fix(server): stringify error log parameter to ensure correct overload

The intended error(message, stack, context) overload is only selected if context is a string.

* formatter
2025-02-28 11:50:00 -06:00
Jason Rasmussen
84cf0d1670 fix: duplicate memories (#16432) 2025-02-28 17:49:29 +00:00
Jonathan Jogenfors
bfcde05b1c chore(server): trash e2e cleanup (#16423) 2025-02-28 12:45:30 -05:00
275 changed files with 7851 additions and 4658 deletions

View File

@@ -11,7 +11,7 @@ body:
- type: checkboxes - type: checkboxes
attributes: attributes:
label: I have searched the existing feature requests to make sure this is not a duplicate request. label: I have searched the existing feature requests, both open and closed, to make sure this is not a duplicate request.
options: options:
- label: "Yes" - label: "Yes"
required: true required: true

View File

@@ -1,6 +1,13 @@
name: Report an issue with Immich name: Report an issue with Immich
description: Report an issue with Immich description: Report an issue with Immich
body: body:
- type: checkboxes
attributes:
label: I have searched the existing issues, both open and closed, to make sure this is not a duplicate report.
options:
- label: "Yes"
required: true
- type: markdown - type: markdown
attributes: attributes:
value: | value: |

View File

@@ -56,10 +56,10 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3.4.0 uses: docker/setup-qemu-action@v3.5.0
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.9.0 uses: docker/setup-buildx-action@v3.10.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -88,7 +88,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push image - name: Build and push image
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.15.0
with: with:
file: cli/Dockerfile file: cli/Dockerfile
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64

View File

@@ -5,7 +5,6 @@ on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main]
release: release:
types: [published] types: [published]
@@ -141,7 +140,7 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3.9.0 uses: docker/setup-buildx-action@v3.10.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -171,7 +170,7 @@ jobs:
- name: Build and push image - name: Build and push image
id: build id: build
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}
@@ -334,7 +333,7 @@ jobs:
- name: Build and push image - name: Build and push image
id: build id: build
uses: docker/build-push-action@v6.13.0 uses: docker/build-push-action@v6.15.0
with: with:
context: ${{ env.context }} context: ${{ env.context }}
file: ${{ env.file }} file: ${{ env.file }}

View File

@@ -457,7 +457,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
services: services:
postgres: postgres:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
env: env:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres POSTGRES_USER: postgres

50
.github/workflows/weblate-lock.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Weblate checks
on:
pull_request:
branches: [main]
jobs:
pre-job:
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.found_paths.outputs.i18n == 'true' && github.head_ref != 'chore/translations'}}
steps:
- name: Checkout code
uses: actions/checkout@v4
- id: found_paths
uses: dorny/paths-filter@v3
with:
filters: |
i18n:
- 'i18n/!(en)**\.json'
enforce-lock:
name: Check Weblate Lock
runs-on: ubuntu-latest
if: ${{ needs.pre-job.outputs.should_run == 'true' }}
steps:
- name: Check weblate lock
run: |
if [[ "false" = $(curl https://hosted.weblate.org/api/components/immich/immich/lock/ | jq .locked) ]]; then
exit 1
fi
- name: Find Pull Request
uses: juliangruber/find-pull-request-action@v1
id: find-pr
with:
branch: chore/translations
- name: Fail if existing weblate PR
if: ${{ steps.find-pr.outputs.number }}
run: exit 1
success-check-lock:
name: Weblate Lock Check Success
needs: [ enforce-lock ]
runs-on: ubuntu-latest
if: always()
steps:
- name: Any jobs failed?
if: ${{ contains(needs.*.result, 'failure') }}
run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"

View File

@@ -1,4 +1,4 @@
FROM node:22.13.1-alpine3.20@sha256:c52e20859a92b3eccbd3a36c5e1a90adc20617d8d421d65e8a622e87b5dac963 AS core FROM node:22.14.0-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS core
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

616
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.51", "version": "2.2.53",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",
@@ -19,8 +19,9 @@
"@types/byte-size": "^8.1.0", "@types/byte-size": "^8.1.0",
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.13.4", "@types/node": "^22.13.5",
"@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0", "@typescript-eslint/parser": "^8.15.0",
"@vitest/coverage-v8": "^3.0.0", "@vitest/coverage-v8": "^3.0.0",
@@ -31,7 +32,7 @@
"eslint-config-prettier": "^10.0.0", "eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^56.0.1", "eslint-plugin-unicorn": "^56.0.1",
"globals": "^15.9.0", "globals": "^16.0.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0", "prettier-plugin-organize-imports": "^4.0.0",
@@ -62,9 +63,11 @@
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"dependencies": { "dependencies": {
"chokidar": "^4.0.3",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fastq": "^1.17.1", "fastq": "^1.17.1",
"lodash-es": "^4.17.21" "lodash-es": "^4.17.21",
"micromatch": "^4.0.8"
}, },
"volta": { "volta": {
"node": "22.14.0" "node": "22.14.0"

View File

@@ -1,12 +1,13 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as os from 'node:os'; import * as os from 'node:os';
import * as path from 'node:path'; import * as path from 'node:path';
import { describe, expect, it, vi } from 'vitest'; import { setTimeout as sleep } from 'node:timers/promises';
import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { Action, checkBulkUpload, defaults, Reason } from '@immich/sdk'; import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock'; import createFetchMock from 'vitest-fetch-mock';
import { checkForDuplicates, getAlbumName, uploadFiles, UploadOptionsDto } from './asset'; import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
vi.mock('@immich/sdk'); vi.mock('@immich/sdk');
@@ -199,3 +200,112 @@ describe('checkForDuplicates', () => {
}); });
}); });
}); });
describe('startWatch', () => {
let testFolder: string;
let checkBulkUploadMocked: MockedFunction<typeof checkBulkUpload>;
beforeEach(async () => {
vi.restoreAllMocks();
vi.mocked(getSupportedMediaTypes).mockResolvedValue({
image: ['.jpg'],
sidecar: ['.xmp'],
video: ['.mp4'],
});
testFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-startWatch-'));
checkBulkUploadMocked = vi.mocked(checkBulkUpload);
checkBulkUploadMocked.mockResolvedValue({
results: [],
});
});
it('should start watching a directory and upload new files', async () => {
const testFilePath = path.join(testFolder, 'test.jpg');
await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 });
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: [
expect.objectContaining({
id: testFilePath,
}),
],
},
});
});
it('should filter out unsupported files', async () => {
const testFilePath = path.join(testFolder, 'test.jpg');
const unsupportedFilePath = path.join(testFolder, 'test.txt');
await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 });
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(unsupportedFilePath, 'testtxt');
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
});
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: unsupportedFilePath,
}),
]),
},
});
});
it('should filger out ignored patterns', async () => {
const testFilePath = path.join(testFolder, 'test.jpg');
const ignoredPattern = 'ignored';
const ignoredFolder = path.join(testFolder, ignoredPattern);
await fs.promises.mkdir(ignoredFolder, { recursive: true });
const ignoredFilePath = path.join(ignoredFolder, 'ignored.jpg');
await startWatch([testFolder], { concurrency: 1, ignore: ignoredPattern }, { batchSize: 1, debounceTimeMs: 10 });
await sleep(100); // to debounce the watcher from considering the test file as a existing file
await fs.promises.writeFile(testFilePath, 'testjpg');
await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg');
await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000);
expect(checkBulkUpload).toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: testFilePath,
}),
]),
},
});
expect(checkBulkUpload).not.toHaveBeenCalledWith({
assetBulkUploadCheckDto: {
assets: expect.arrayContaining([
expect.objectContaining({
id: ignoredFilePath,
}),
]),
},
});
});
afterEach(async () => {
await fs.promises.rm(testFolder, { recursive: true, force: true });
});
});

View File

@@ -12,13 +12,18 @@ import {
getSupportedMediaTypes, getSupportedMediaTypes,
} from '@immich/sdk'; } from '@immich/sdk';
import byteSize from 'byte-size'; import byteSize from 'byte-size';
import { Matcher, watch as watchFs } from 'chokidar';
import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { MultiBar, Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es'; import { chunk } from 'lodash-es';
import micromatch from 'micromatch';
import { Stats, createReadStream } from 'node:fs'; import { Stats, createReadStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises'; import { stat, unlink } from 'node:fs/promises';
import path, { basename } from 'node:path'; import path, { basename } from 'node:path';
import { Queue } from 'src/queue'; import { Queue } from 'src/queue';
import { BaseOptions, authenticate, crawl, sha1 } from 'src/utils'; import { BaseOptions, Batcher, authenticate, crawl, sha1 } from 'src/utils';
const UPLOAD_WATCH_BATCH_SIZE = 100;
const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000;
const s = (count: number) => (count === 1 ? '' : 's'); const s = (count: number) => (count === 1 ? '' : 's');
@@ -36,6 +41,8 @@ export interface UploadOptionsDto {
albumName?: string; albumName?: string;
includeHidden?: boolean; includeHidden?: boolean;
concurrency: number; concurrency: number;
progress?: boolean;
watch?: boolean;
} }
class UploadFile extends File { class UploadFile extends File {
@@ -55,19 +62,94 @@ class UploadFile extends File {
} }
} }
const uploadBatch = async (files: string[], options: UploadOptionsDto) => {
const { newFiles, duplicates } = await checkForDuplicates(files, options);
const newAssets = await uploadFiles(newFiles, options);
await updateAlbums([...newAssets, ...duplicates], options);
await deleteFiles(newFiles, options);
};
export const startWatch = async (
paths: string[],
options: UploadOptionsDto,
{
batchSize = UPLOAD_WATCH_BATCH_SIZE,
debounceTimeMs = UPLOAD_WATCH_DEBOUNCE_TIME_MS,
}: { batchSize?: number; debounceTimeMs?: number } = {},
) => {
const watcherIgnored: Matcher[] = [];
const { image, video } = await getSupportedMediaTypes();
const extensions = new Set([...image, ...video]);
if (options.ignore) {
watcherIgnored.push((path) => micromatch.contains(path, `**/${options.ignore}`));
}
const pathsBatcher = new Batcher<string>({
batchSize,
debounceTimeMs,
onBatch: async (paths: string[]) => {
const uniquePaths = [...new Set(paths)];
await uploadBatch(uniquePaths, options);
},
});
const onFile = async (path: string, stats?: Stats) => {
if (stats?.isDirectory()) {
return;
}
const ext = '.' + path.split('.').pop()?.toLowerCase();
if (!ext || !extensions.has(ext)) {
return;
}
if (!options.progress) {
// logging when progress is disabled as it can cause issues with the progress bar rendering
console.log(`Change detected: ${path}`);
}
pathsBatcher.add(path);
};
const fsWatcher = watchFs(paths, {
ignoreInitial: true,
ignored: watcherIgnored,
alwaysStat: true,
awaitWriteFinish: true,
depth: options.recursive ? undefined : 1,
persistent: true,
})
.on('add', onFile)
.on('change', onFile)
.on('error', (error) => console.error(`Watcher error: ${error}`));
process.on('SIGINT', async () => {
console.log('Exiting...');
await fsWatcher.close();
process.exit();
});
};
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => { export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
await authenticate(baseOptions); await authenticate(baseOptions);
const scanFiles = await scan(paths, options); const scanFiles = await scan(paths, options);
if (scanFiles.length === 0) { if (scanFiles.length === 0) {
console.log('No files found, exiting'); if (options.watch) {
return; console.log('No files found initially.');
} else {
console.log('No files found, exiting');
return;
}
} }
const { newFiles, duplicates } = await checkForDuplicates(scanFiles, options); if (options.watch) {
const newAssets = await uploadFiles(newFiles, options); console.log('Watching for changes...');
await updateAlbums([...newAssets, ...duplicates], options); await startWatch(paths, options);
await deleteFiles(newFiles, options); // watcher does not handle the initial scan
// as the scan() is a more efficient quick start with batched results
}
await uploadBatch(scanFiles, options);
}; };
const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => { const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
@@ -85,19 +167,25 @@ const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
return files; return files;
}; };
export const checkForDuplicates = async (files: string[], { concurrency, skipHash }: UploadOptionsDto) => { export const checkForDuplicates = async (files: string[], { concurrency, skipHash, progress }: UploadOptionsDto) => {
if (skipHash) { if (skipHash) {
console.log('Skipping hash check, assuming all files are new'); console.log('Skipping hash check, assuming all files are new');
return { newFiles: files, duplicates: [] }; return { newFiles: files, duplicates: [] };
} }
const multiBar = new MultiBar( let multiBar: MultiBar | undefined;
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
const hashProgressBar = multiBar.create(files.length, 0, { message: 'Hashing files ' }); if (progress) {
const checkProgressBar = multiBar.create(files.length, 0, { message: 'Checking for duplicates' }); multiBar = new MultiBar(
{ format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
Presets.shades_classic,
);
} else {
console.log(`Received ${files.length} files, hashing...`);
}
const hashProgressBar = multiBar?.create(files.length, 0, { message: 'Hashing files ' });
const checkProgressBar = multiBar?.create(files.length, 0, { message: 'Checking for duplicates' });
const newFiles: string[] = []; const newFiles: string[] = [];
const duplicates: Asset[] = []; const duplicates: Asset[] = [];
@@ -117,7 +205,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
} }
} }
checkProgressBar.increment(assets.length); checkProgressBar?.increment(assets.length);
}, },
{ concurrency, retry: 3 }, { concurrency, retry: 3 },
); );
@@ -137,7 +225,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
void checkBulkUploadQueue.push(batch); void checkBulkUploadQueue.push(batch);
} }
hashProgressBar.increment(); hashProgressBar?.increment();
return results; return results;
}, },
{ concurrency, retry: 3 }, { concurrency, retry: 3 },
@@ -155,7 +243,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
await checkBulkUploadQueue.drained(); await checkBulkUploadQueue.drained();
multiBar.stop(); multiBar?.stop();
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`); console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
@@ -171,7 +259,10 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas
return { newFiles, duplicates }; return { newFiles, duplicates };
}; };
export const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptionsDto): Promise<Asset[]> => { export const uploadFiles = async (
files: string[],
{ dryRun, concurrency, progress }: UploadOptionsDto,
): Promise<Asset[]> => {
if (files.length === 0) { if (files.length === 0) {
console.log('All assets were already uploaded, nothing to do.'); console.log('All assets were already uploaded, nothing to do.');
return []; return [];
@@ -191,12 +282,20 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo
return files.map((filepath) => ({ id: '', filepath })); return files.map((filepath) => ({ id: '', filepath }));
} }
const uploadProgress = new SingleBar( let uploadProgress: SingleBar | undefined;
{ format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}' },
Presets.shades_classic, if (progress) {
); uploadProgress = new SingleBar(
uploadProgress.start(totalSize, 0); {
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}',
},
Presets.shades_classic,
);
} else {
console.log(`Uploading ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`);
}
uploadProgress?.start(totalSize, 0);
uploadProgress?.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
let duplicateCount = 0; let duplicateCount = 0;
let duplicateSize = 0; let duplicateSize = 0;
@@ -222,7 +321,7 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo
successSize += stats.size ?? 0; successSize += stats.size ?? 0;
} }
uploadProgress.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) }); uploadProgress?.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) });
return response; return response;
}, },
@@ -235,7 +334,7 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo
await queue.drained(); await queue.drained();
uploadProgress.stop(); uploadProgress?.stop();
console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`); console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`);
if (duplicateCount > 0) { if (duplicateCount > 0) {

View File

@@ -69,6 +69,13 @@ program
.default(4), .default(4),
) )
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.addOption(new Option('--no-progress', 'Hide progress bars').env('IMMICH_PROGRESS_BAR').default(true))
.addOption(
new Option('--watch', 'Watch for changes and upload automatically')
.env('IMMICH_WATCH_CHANGES')
.default(false)
.implies({ progress: false }),
)
.argument('[paths...]', 'One or more paths to assets to be uploaded') .argument('[paths...]', 'One or more paths to assets to be uploaded')
.action((paths, options) => upload(paths, program.opts(), options)); .action((paths, options) => upload(paths, program.opts(), options));

View File

@@ -1,6 +1,7 @@
import mockfs from 'mock-fs'; import mockfs from 'mock-fs';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { CrawlOptions, crawl } from 'src/utils'; import { Batcher, CrawlOptions, crawl } from 'src/utils';
import { Mock } from 'vitest';
interface Test { interface Test {
test: string; test: string;
@@ -303,3 +304,38 @@ describe('crawl', () => {
} }
}); });
}); });
describe('Batcher', () => {
let batcher: Batcher;
let onBatch: Mock;
beforeEach(() => {
onBatch = vi.fn();
batcher = new Batcher({ batchSize: 2, onBatch });
});
it('should trigger onBatch() when a batch limit is reached', async () => {
batcher.add('a');
batcher.add('b');
batcher.add('c');
expect(onBatch).toHaveBeenCalledOnce();
expect(onBatch).toHaveBeenCalledWith(['a', 'b']);
});
it('should trigger onBatch() when flush() is called', async () => {
batcher.add('a');
batcher.flush();
expect(onBatch).toHaveBeenCalledOnce();
expect(onBatch).toHaveBeenCalledWith(['a']);
});
it('should trigger onBatch() when debounce time reached', async () => {
vi.useFakeTimers();
batcher = new Batcher({ batchSize: 2, debounceTimeMs: 100, onBatch });
batcher.add('a');
expect(onBatch).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(onBatch).toHaveBeenCalledOnce();
expect(onBatch).toHaveBeenCalledWith(['a']);
vi.useRealTimers();
});
});

View File

@@ -172,3 +172,64 @@ export const sha1 = (filepath: string) => {
rs.on('end', () => resolve(hash.digest('hex'))); rs.on('end', () => resolve(hash.digest('hex')));
}); });
}; };
/**
* Batches items and calls onBatch to process them
* when the batch size is reached or the debounce time has passed.
*/
export class Batcher<T = unknown> {
private items: T[] = [];
private readonly batchSize: number;
private readonly debounceTimeMs?: number;
private readonly onBatch: (items: T[]) => void;
private debounceTimer?: NodeJS.Timeout;
constructor({
batchSize,
debounceTimeMs,
onBatch,
}: {
batchSize: number;
debounceTimeMs?: number;
onBatch: (items: T[]) => Promise<void>;
}) {
this.batchSize = batchSize;
this.debounceTimeMs = debounceTimeMs;
this.onBatch = onBatch;
}
private setDebounceTimer() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.debounceTimeMs) {
this.debounceTimer = setTimeout(() => this.flush(), this.debounceTimeMs);
}
}
private clearDebounceTimer() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = undefined;
}
}
add(item: T) {
this.items.push(item);
this.setDebounceTimer();
if (this.items.length >= this.batchSize) {
this.flush();
}
}
flush() {
this.clearDebounceTimer();
if (this.items.length === 0) {
return;
}
this.onBatch(this.items);
this.items = [];
}
}

View File

@@ -122,7 +122,7 @@ services:
database: database:
container_name: immich_postgres container_name: immich_postgres
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
env_file: env_file:
- .env - .env
environment: environment:

View File

@@ -63,7 +63,7 @@ services:
database: database:
container_name: immich_postgres container_name: immich_postgres
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
env_file: env_file:
- .env - .env
environment: environment:
@@ -100,7 +100,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:5888c188cf09e3f7eebc97369c3b2ce713e844cdbd88ccf36f5047c958aea120 image: prom/prometheus@sha256:6927e0919a144aa7616fd0137d4816816d42f6b816de3af269ab065250859a62
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
@@ -112,7 +112,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:11.5.1-ubuntu@sha256:9a4ab78cec1a2ec7d1ca5dfd5aacec6412706a1bc9e971fc7184e2f6696a63f5 image: grafana/grafana:11.5.2-ubuntu@sha256:8b5858c447e06fd7a89006b562ba7bba7c4d5813600c7982374c41852adefaeb
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

@@ -56,7 +56,7 @@ services:
database: database:
container_name: immich_postgres container_name: immich_postgres
image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
environment: environment:
POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME} POSTGRES_USER: ${DB_USERNAME}

View File

@@ -69,6 +69,8 @@ Navigating to Administration > Settings > Machine Learning Settings > Facial Rec
:::tip :::tip
It's better to only tweak the parameters here than to set them to something very different unless you're ready to test a variety of options. If you do need to set a parameter to a strict setting, relaxing other settings can be a good option to compensate, and vice versa. It's better to only tweak the parameters here than to set them to something very different unless you're ready to test a variety of options. If you do need to set a parameter to a strict setting, relaxing other settings can be a good option to compensate, and vice versa.
You can learn how the tune the result in this [Guide](/docs/guides/better-facial-clusters)
::: :::
### Facial recognition model ### Facial recognition model

View File

@@ -68,7 +68,7 @@ In rare cases, the library watcher can hang, preventing Immich from starting up.
### Nightly job ### Nightly job
There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. There is an automatic scan job that is scheduled to run once a day. This job also cleans up any libraries stuck in deletion. It is possible to trigger the cleanup by clicking "Scan all libraries" in the library managment page.
## Usage ## Usage

View File

@@ -0,0 +1,72 @@
# Better Facial Recognition Clusters
## Purpose
This guide explains how to optimize facial recognition in systems with large image libraries. By following these steps, you'll achieve better clustering of faces, reducing the need for manual merging.
---
## Important Notes
- **Best Suited For:** Large image libraries after importing a significant number of images.
- **Warning:** This method deletes all previously assigned names.
- **Tip:** **Always take a [backup](/docs/administration/backup-and-restore#database) before proceeding!**
---
## Step-by-Step Instructions
### Objective
To enhance face clustering and ensure the model effectively identifies faces using qualitative initial data.
---
### Steps
#### 1. Adjust Machine Learning Settings
Navigate to:
**Admin → Administration → Settings → Machine Learning Settings**
Make the following changes:
- **Maximum recognition distance (Optional):**
Lower this value, e.g., to **0.4**, if the library contains people with similar facial features.
- **Minimum recognized faces:**
Set this to a **high value** (e.g., 20 For libraries with a large amount of assets (~100K+), and 10 for libraries with medium amount of assets (~40K+)).
> A high value ensures clusters only include faces that appear at least 20/`value` times in the library, improving the initial clustering process.
---
#### 2. Run Reset Jobs
Go to:
**Admin → Administration → Settings → Jobs**
Perform the following:
1. **FACIAL RECOGNITION → Reset**
> These reset jobs rebuild the recognition model based on the new settings.
---
#### 3. Refine Recognition with Lower Thresholds
Once the reset jobs are complete, refine the recognition as follows:
- **Step 1:**
Return to **Minimum recognized faces** in Machine Learning Settings and lower the value to **10** (In medium libraries we will lower the value from 10 to 5).
> Run the job: **FACIAL RECOGNITION → MISSING Mode**
- **Step 2:**
Lower the value again to **3**.
> Run the job: **FACIAL RECOGNITION → MISSING Mode**
:::tip try different values
For certain libraries with a larger or smaller amount of assets, other settings will be better or worse. It is recommended to try different values **before assigning names** and see which settings work best for your library.
:::
---

View File

@@ -31,6 +31,10 @@ SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9'; SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9';
``` ```
```sql title="Find by partial ID"
SELECT * FROM "assets" WHERE "id"::text LIKE '%ab431d3a%';
```
:::note :::note
You can calculate the checksum for a particular file by using the command `sha1sum <filename>`. You can calculate the checksum for a particular file by using the command `sha1sum <filename>`.
::: :::

View File

@@ -11,7 +11,7 @@ Just restarting the containers does not replace the environment within the conta
In order to recreate the container using docker compose, run `docker compose up -d`. In order to recreate the container using docker compose, run `docker compose up -d`.
In most cases docker will recognize that the `.env` file has changed and recreate the affected containers. In most cases docker will recognize that the `.env` file has changed and recreate the affected containers.
If this should not work, try running `docker compose up -d --force-recreate`. If this does not work, try running `docker compose up -d --force-recreate`.
::: :::
@@ -20,8 +20,8 @@ If this should not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers | | Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-------: | :----------------------- | | :----------------- | :------------------------------ | :-------: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `release` | server, machine learning | | `IMMICH_VERSION` | Image tags | `release` | server, machine learning |
| `UPLOAD_LOCATION` | Host Path for uploads | | server | | `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host Path for Postgres database | | database | | `DB_DATA_LOCATION` | Host path for Postgres database | | database |
:::tip :::tip
These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly. These environment variables are used by the `docker-compose.yml` file and do **NOT** affect the containers directly.
@@ -33,15 +33,15 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | | :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices | | `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | | `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log Level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | | `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media Location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `./upload`<sup>\*3</sup> | server | api, microservices | | `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `./upload`<sup>\*3</sup> | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | | `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | | `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Amount of cores available to the immich server | auto-detected cpu core count | server | | | `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api | | `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | | `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | | `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api | | `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices | | `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
@@ -50,7 +50,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
\*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead. \*2: This path is where the Immich code looks for the files, which is internal to the docker container. Setting it to a path on your host will certainly break things, you should use the `UPLOAD_LOCATION` variable instead.
\*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`. \*3: With the default `WORKDIR` of `/usr/src/app`, this path will resolve to `/usr/src/app/upload`.
It only need to be set if the Immich deployment method is changing. It only needs to be set if the Immich deployment method is changing.
## Workers ## Workers
@@ -75,12 +75,12 @@ Information on the current workers can be found [here](/docs/administration/jobs
| Variable | Description | Default | Containers | | Variable | Description | Default | Containers |
| :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :----------------------------- | | :---------------------------------- | :----------------------------------------------------------------------- | :----------: | :----------------------------- |
| `DB_URL` | Database URL | | server | | `DB_URL` | Database URL | | server |
| `DB_HOSTNAME` | Database Host | `database` | server | | `DB_HOSTNAME` | Database host | `database` | server |
| `DB_PORT` | Database Port | `5432` | server | | `DB_PORT` | Database port | `5432` | server |
| `DB_USERNAME` | Database User | `postgres` | server, database<sup>\*1</sup> | | `DB_USERNAME` | Database user | `postgres` | server, database<sup>\*1</sup> |
| `DB_PASSWORD` | Database Password | `postgres` | server, database<sup>\*1</sup> | | `DB_PASSWORD` | Database password | `postgres` | server, database<sup>\*1</sup> |
| `DB_DATABASE_NAME` | Database Name | `immich` | server, database<sup>\*1</sup> | | `DB_DATABASE_NAME` | Database name | `immich` | server, database<sup>\*1</sup> |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database Vector Extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server | | `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`pgvector`, `pgvecto.rs`]) | `pgvecto.rs` | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server | | `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`. \*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.
@@ -103,18 +103,18 @@ When `DB_URL` is defined, the `DB_HOSTNAME`, `DB_PORT`, `DB_USERNAME`, `DB_PASSW
| Variable | Description | Default | Containers | | Variable | Description | Default | Containers |
| :--------------- | :------------- | :-----: | :--------- | | :--------------- | :------------- | :-----: | :--------- |
| `REDIS_URL` | Redis URL | | server | | `REDIS_URL` | Redis URL | | server |
| `REDIS_SOCKET` | Redis Socket | | server | | `REDIS_SOCKET` | Redis socket | | server |
| `REDIS_HOSTNAME` | Redis Host | `redis` | server | | `REDIS_HOSTNAME` | Redis host | `redis` | server |
| `REDIS_PORT` | Redis Port | `6379` | server | | `REDIS_PORT` | Redis port | `6379` | server |
| `REDIS_USERNAME` | Redis Username | | server | | `REDIS_USERNAME` | Redis username | | server |
| `REDIS_PASSWORD` | Redis Password | | server | | `REDIS_PASSWORD` | Redis password | | server |
| `REDIS_DBINDEX` | Redis DB Index | `0` | server | | `REDIS_DBINDEX` | Redis DB index | `0` | server |
:::info :::info
All `REDIS_` variables must be provided to all Immich workers, including `api` and `microservices`. All `REDIS_` variables must be provided to all Immich workers, including `api` and `microservices`.
`REDIS_URL` must start with `ioredis://` and then include a `base64` encoded JSON string for the configuration. `REDIS_URL` must start with `ioredis://` and then include a `base64` encoded JSON string for the configuration.
More info can be found in the upstream [ioredis] documentation. More information can be found in the upstream [ioredis] documentation.
When `REDIS_URL` or `REDIS_SOCKET` are defined, the `REDIS_HOSTNAME`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`, and `REDIS_DBINDEX` variables are ignored. When `REDIS_URL` or `REDIS_SOCKET` are defined, the `REDIS_HOSTNAME`, `REDIS_PORT`, `REDIS_USERNAME`, `REDIS_PASSWORD`, and `REDIS_DBINDEX` variables are ignored.
::: :::
@@ -181,7 +181,11 @@ Redis (Sentinel) URL example JSON before encoding:
:::info :::info
Other machine learning parameters can be tuned from the admin UI. While the `textual` model is the only one required for smart search, some users may experience slow first searches
due to backups triggering loading of the other models into memory, which blocks other requests until completed.
To avoid this, you can preload the other models (`visual`, `recognition`, and `detection`) if you have enough RAM to do so.
Additional machine learning parameters can be tuned from the admin UI.
::: :::
@@ -212,7 +216,7 @@ the `_FILE` variable should be set to the path of a file containing the variable
details on how to use Docker Secrets in the Postgres image. details on how to use Docker Secrets in the Postgres image.
\*2: See [this comment][docker-secrets-example] for an example of how \*2: See [this comment][docker-secrets-example] for an example of how
to use use a Docker secret for the password in the Redis container. to use a Docker secret for the password in the Redis container.
[tz-list]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List [tz-list]: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List
[docker-secrets-example]: https://github.com/docker-library/redis/issues/46#issuecomment-335326234 [docker-secrets-example]: https://github.com/docker-library/redis/issues/46#issuecomment-335326234

12
docs/package-lock.json generated
View File

@@ -14070,9 +14070,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.2", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==", "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -15734,9 +15734,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.5.1", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.1.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-hPpFQvHwL3Qv5AdRvBFMhnKo4tYxp0ReXiPn2bxkiohEX6mBeBwEpBSQTkD458RaaDKQMYSp4hX4UtfUTA5wDw==", "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {

View File

@@ -242,6 +242,13 @@ const roadmap: Item[] = [
]; ];
const milestones: Item[] = [ const milestones: Item[] = [
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({ withRelease({
icon: mdiLinkEdit, icon: mdiLinkEdit,
iconColor: 'crimson', iconColor: 'crimson',

View File

@@ -1,4 +1,12 @@
[ [
{
"label": "v1.129.0",
"url": "https://v1.129.0.archive.immich.app"
},
{
"label": "v1.128.0",
"url": "https://v1.128.0.archive.immich.app"
},
{ {
"label": "v1.127.0", "label": "v1.127.0",
"url": "https://v1.127.0.archive.immich.app" "url": "https://v1.127.0.archive.immich.app"

View File

@@ -37,7 +37,7 @@ services:
image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8 image: redis:6.2-alpine@sha256:148bb5411c184abd288d9aaed139c98123eeb8824c5d3fce03cf721db58066d8
database: database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0 image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
command: -c fsync=off -c shared_preload_libraries=vectors.so command: -c fsync=off -c shared_preload_libraries=vectors.so
environment: environment:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres

754
e2e/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.127.0", "version": "1.129.0",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.13.4", "@types/node": "^22.13.5",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -38,7 +38,7 @@
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^56.0.1", "eslint-plugin-unicorn": "^56.0.1",
"exiftool-vendored": "^28.3.1", "exiftool-vendored": "^28.3.1",
"globals": "^15.9.0", "globals": "^16.0.0",
"jose": "^5.6.3", "jose": "^5.6.3",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"oidc-provider": "^8.5.1", "oidc-provider": "^8.5.1",

View File

@@ -4,7 +4,6 @@ import {
AssetResponseDto, AssetResponseDto,
AssetTypeEnum, AssetTypeEnum,
getAssetInfo, getAssetInfo,
getConfig,
getMyUser, getMyUser,
LoginResponseDto, LoginResponseDto,
SharedLinkType, SharedLinkType,
@@ -45,8 +44,6 @@ const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-sp
const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`; const ratingAssetFilepath = `${testAssetDir}/metadata/rating/mongolels.jpg`;
const facesAssetFilepath = `${testAssetDir}/metadata/faces/portrait.jpg`; const facesAssetFilepath = `${testAssetDir}/metadata/faces/portrait.jpg`;
const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) });
const readTags = async (bytes: Buffer, filename: string) => { const readTags = async (bytes: Buffer, filename: string) => {
const filepath = join(tempDir, filename); const filepath = join(tempDir, filename);
await writeFile(filepath, bytes); await writeFile(filepath, bytes);
@@ -228,7 +225,7 @@ describe('/asset', () => {
}); });
it('should get the asset faces', async () => { it('should get the asset faces', async () => {
const config = await getSystemConfig(admin.accessToken); const config = await utils.getSystemConfig(admin.accessToken);
config.metadata.faces.import = true; config.metadata.faces.import = true;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) }); await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });

View File

@@ -1,8 +1,9 @@
import { JobCommand, JobName, LoginResponseDto } from '@immich/sdk'; import { JobCommand, JobName, LoginResponseDto, updateConfig } from '@immich/sdk';
import { cpSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { basename } from 'node:path'; import { basename } from 'node:path';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils'; import { app, asBearerAuth, testAssetDir, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { afterEach, beforeAll, describe, expect, it } from 'vitest';
@@ -20,6 +21,33 @@ describe('/jobs', () => {
command: JobCommand.Resume, command: JobCommand.Resume,
force: false, force: false,
}); });
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.FaceDetection, {
command: JobCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.SmartSearch, {
command: JobCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.DuplicateDetection, {
command: JobCommand.Resume,
force: false,
});
const config = await utils.getSystemConfig(admin.accessToken);
config.machineLearning.duplicateDetection.enabled = false;
config.machineLearning.enabled = false;
config.metadata.faces.import = false;
config.machineLearning.clip.enabled = false;
await updateConfig({ systemConfigDto: config }, { headers: asBearerAuth(admin.accessToken) });
}); });
it('should require authentication', async () => { it('should require authentication', async () => {
@@ -29,14 +57,7 @@ describe('/jobs', () => {
}); });
it('should queue metadata extraction for missing assets', async () => { it('should queue metadata extraction for missing assets', async () => {
const path1 = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`; const path = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
const path2 = `${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`;
await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path1), filename: basename(path1) },
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, { await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Pause, command: JobCommand.Pause,
@@ -44,7 +65,7 @@ describe('/jobs', () => {
}); });
const { id } = await utils.createAsset(admin.accessToken, { const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path2), filename: basename(path2) }, assetData: { bytes: await readFile(path), filename: basename(path) },
}); });
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction'); await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
@@ -82,5 +103,123 @@ describe('/jobs', () => {
expect(asset.exifInfo?.make).toBe('NIKON CORPORATION'); expect(asset.exifInfo?.make).toBe('NIKON CORPORATION');
} }
}); });
it('should not re-extract metadata for existing assets', async () => {
const path = `${testAssetDir}/temp/metadata/asset.jpg`;
cpSync(`${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`, path);
const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
{
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo?.model).toBe('NIKON D700');
}
cpSync(`${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`, path);
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
{
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo?.model).toBe('NIKON D700');
}
rmSync(path);
});
it('should queue thumbnail extraction for assets missing thumbs', async () => {
const path = `${testAssetDir}/albums/nature/tanners_ridge.jpg`;
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Pause,
force: false,
});
const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
expect(assetBefore.thumbhash).toBeNull();
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Empty,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
expect(assetAfter.thumbhash).not.toBeNull();
});
it('should not reload existing thumbnail when running thumb job for missing assets', async () => {
const path = `${testAssetDir}/temp/thumbs/asset1.jpg`;
cpSync(`${testAssetDir}/albums/nature/tanners_ridge.jpg`, path);
const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path), filename: basename(path) },
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
const assetBefore = await utils.getAssetInfo(admin.accessToken, id);
cpSync(`${testAssetDir}/albums/nature/notocactus_minimus.jpg`, path);
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Resume,
force: false,
});
// This runs the missing thumbnail job
await utils.jobCommand(admin.accessToken, JobName.ThumbnailGeneration, {
command: JobCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, JobName.MetadataExtraction);
await utils.waitForQueueFinish(admin.accessToken, JobName.ThumbnailGeneration);
const assetAfter = await utils.getAssetInfo(admin.accessToken, id);
// Asset 1 thumbnail should be untouched since its thumb should not have been reloaded, even though the file was changed
expect(assetAfter.thumbhash).toEqual(assetBefore.thumbhash);
rmSync(path);
});
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { LoginResponseDto, getAssetInfo, getAssetStatistics, scanLibrary } from '@immich/sdk'; import { LoginResponseDto, getAssetInfo, getAssetStatistics } from '@immich/sdk';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
@@ -6,8 +6,6 @@ import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'sr
import request from 'supertest'; import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string) => scanLibrary({ id }, { headers: asBearerAuth(accessToken) });
describe('/trash', () => { describe('/trash', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let ws: Socket; let ws: Socket;
@@ -81,8 +79,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1); expect(assets.items.length).toBe(1);
@@ -90,8 +87,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -116,8 +112,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.items.length).toBe(1); expect(assets.items.length).toBe(1);
@@ -125,8 +120,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id); const assetBefore = await utils.getAssetInfo(admin.accessToken, asset.id);
expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true }); expect(assetBefore).toMatchObject({ isTrashed: true, isOffline: true });
@@ -180,8 +174,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1); expect(assets.count).toBe(1);
@@ -189,9 +182,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library');
const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const before = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); expect(before).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
@@ -201,6 +192,8 @@ describe('/trash', () => {
const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) }); const after = await getAssetInfo({ id: assetId }, { headers: asBearerAuth(admin.accessToken) });
expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true })); expect(after).toStrictEqual(expect.objectContaining({ id: assetId, isOffline: true }));
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
}); });
}); });
@@ -238,7 +231,7 @@ describe('/trash', () => {
utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`); utils.createImageFile(`${testAssetDir}/temp/offline/offline.png`);
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id }); const { assets } = await utils.searchAssets(admin.accessToken, { libraryId: library.id });
@@ -247,7 +240,7 @@ describe('/trash', () => {
await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] }); await utils.updateLibrary(admin.accessToken, library.id, { exclusionPatterns: ['**/offline/**'] });
await scan(admin.accessToken, library.id); await utils.scan(admin.accessToken, library.id);
await utils.waitForQueueFinish(admin.accessToken, 'library'); await utils.waitForQueueFinish(admin.accessToken, 'library');
const before = await utils.getAssetInfo(admin.accessToken, assetId); const before = await utils.getAssetInfo(admin.accessToken, assetId);
@@ -261,6 +254,8 @@ describe('/trash', () => {
const after = await utils.getAssetInfo(admin.accessToken, assetId); const after = await utils.getAssetInfo(admin.accessToken, assetId);
expect(after.isTrashed).toBe(true); expect(after.isTrashed).toBe(true);
utils.removeImageFile(`${testAssetDir}/temp/offline/offline.png`);
}); });
}); });
}); });

View File

@@ -28,6 +28,7 @@ import {
deleteAssets, deleteAssets,
getAllJobsStatus, getAllJobsStatus,
getAssetInfo, getAssetInfo,
getConfig,
getConfigDefaults, getConfigDefaults,
login, login,
scanLibrary, scanLibrary,
@@ -121,6 +122,7 @@ const execPromise = promisify(exec);
const onEvent = ({ event, id }: { event: EventType; id: string }) => { const onEvent = ({ event, id }: { event: EventType; id: string }) => {
// console.log(`Received event: ${event} [id=${id}]`); // console.log(`Received event: ${event} [id=${id}]`);
const set = events[event]; const set = events[event];
set.add(id); set.add(id);
const idCallback = idCallbacks[id]; const idCallback = idCallbacks[id];
@@ -415,6 +417,8 @@ export const utils = {
rmSync(path, { recursive: true }); rmSync(path, { recursive: true });
}, },
getSystemConfig: (accessToken: string) => getConfig({ headers: asBearerAuth(accessToken) }),
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) => checkExistingAssets: (accessToken: string, checkExistingAssetsDto: CheckExistingAssetsDto) =>

View File

@@ -96,7 +96,7 @@
"library_scanning_enable_description": "Enable periodic library scanning", "library_scanning_enable_description": "Enable periodic library scanning",
"library_settings": "External Library", "library_settings": "External Library",
"library_settings_description": "Manage external library settings", "library_settings_description": "Manage external library settings",
"library_tasks_description": "Perform library tasks", "library_tasks_description": "Scan external libraries for new and/or changed assets",
"library_watching_enable_description": "Watch external libraries for file changes", "library_watching_enable_description": "Watch external libraries for file changes",
"library_watching_settings": "Library watching (EXPERIMENTAL)", "library_watching_settings": "Library watching (EXPERIMENTAL)",
"library_watching_settings_description": "Automatically watch for changed files", "library_watching_settings_description": "Automatically watch for changed files",
@@ -336,6 +336,7 @@
"untracked_files": "Untracked Files", "untracked_files": "Untracked Files",
"untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug", "untracked_files_description": "These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug",
"user_cleanup_job": "User cleanup", "user_cleanup_job": "User cleanup",
"cleanup": "Cleanup",
"user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.", "user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.",
"user_delete_delay_settings": "Delete delay", "user_delete_delay_settings": "Delete delay",
"user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.", "user_delete_delay_settings_description": "Number of days after removal to permanently delete a user's account and assets. The user deletion job runs at midnight to check for users that are ready for deletion. Changes to this setting will be evaluated at the next execution.",
@@ -393,6 +394,7 @@
"allow_edits": "Allow edits", "allow_edits": "Allow edits",
"allow_public_user_to_download": "Allow public user to download", "allow_public_user_to_download": "Allow public user to download",
"allow_public_user_to_upload": "Allow public user to upload", "allow_public_user_to_upload": "Allow public user to upload",
"alt_text_qr_code": "QR code image",
"anti_clockwise": "Anti-clockwise", "anti_clockwise": "Anti-clockwise",
"api_key": "API Key", "api_key": "API Key",
"api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.", "api_key_description": "This value will only be shown once. Please be sure to copy it before closing the window.",
@@ -889,6 +891,7 @@
"month": "Month", "month": "Month",
"more": "More", "more": "More",
"moved_to_trash": "Moved to trash", "moved_to_trash": "Moved to trash",
"mute_memories": "Mute Memories",
"my_albums": "My albums", "my_albums": "My albums",
"name": "Name", "name": "Name",
"name_or_nickname": "Name or nickname", "name_or_nickname": "Name or nickname",
@@ -1114,6 +1117,7 @@
"say_something": "Say something", "say_something": "Say something",
"scan_all_libraries": "Scan All Libraries", "scan_all_libraries": "Scan All Libraries",
"scan_library": "Scan", "scan_library": "Scan",
"rescan": "Rescan",
"scan_settings": "Scan Settings", "scan_settings": "Scan Settings",
"scanning_for_album": "Scanning for album...", "scanning_for_album": "Scanning for album...",
"search": "Search", "search": "Search",
@@ -1302,6 +1306,7 @@
"unnamed_album": "Unnamed Album", "unnamed_album": "Unnamed Album",
"unnamed_album_delete_confirmation": "Are you sure you want to delete this album?", "unnamed_album_delete_confirmation": "Are you sure you want to delete this album?",
"unnamed_share": "Unnamed Share", "unnamed_share": "Unnamed Share",
"unmute_memories": "Unmute Memories",
"unsaved_change": "Unsaved change", "unsaved_change": "Unsaved change",
"unselect_all": "Unselect all", "unselect_all": "Unselect all",
"unselect_all_duplicates": "Unselect all duplicates", "unselect_all_duplicates": "Unselect all duplicates",
@@ -1352,6 +1357,7 @@
"view_all": "View All", "view_all": "View All",
"view_all_users": "View all users", "view_all_users": "View all users",
"view_in_timeline": "View in timeline", "view_in_timeline": "View in timeline",
"view_link": "View link",
"view_links": "View links", "view_links": "View links",
"view_name": "View", "view_name": "View",
"view_next_asset": "View next asset", "view_next_asset": "View next asset",

View File

@@ -66,8 +66,8 @@ download:
locale_code: es-MX locale_code: es-MX
- file: mobile/assets/i18n/sv-FI.json - file: mobile/assets/i18n/sv-FI.json
locale_code: sv-FI locale_code: sv-FI
- file: mobile/assets/i18n/ca-CA.json - file: mobile/assets/i18n/ca.json
locale_code: ca-CA locale_code: ca
- file: mobile/assets/i18n/hu-HU.json - file: mobile/assets/i18n/hu-HU.json
locale_code: hu-HU locale_code: hu-HU
- file: mobile/assets/i18n/lv-LV.json - file: mobile/assets/i18n/lv-LV.json

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:14b4620f59a90f163dfa6bd252b68743f9a41d494a9fde935f9d7669d98094bb AS builder-cpu FROM python:3.11-bookworm@sha256:68a8863d0625f42d47e0684f33ca02f19d6094ef859a8af237aaf645195ed477 AS builder-cpu
FROM builder-cpu AS builder-openvino FROM builder-cpu AS builder-openvino
@@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv
COPY poetry.lock pyproject.toml ./ COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
FROM python:3.11-slim-bookworm@sha256:42420f737ba91d509fc60d5ed65ed0492678a90c561e1fa08786ae8ba8b52eda AS prod-cpu FROM python:3.11-slim-bookworm@sha256:614c8691ab74150465ec9123378cd4dde7a6e57be9e558c3108df40664667a4c AS prod-cpu
FROM prod-cpu AS prod-openvino FROM prod-cpu AS prod-openvino

View File

@@ -20,9 +20,8 @@ class FaceRecognizer(InferenceModel):
depends = [(ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)] depends = [(ModelType.DETECTION, ModelTask.FACIAL_RECOGNITION)]
identity = (ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION) identity = (ModelType.RECOGNITION, ModelTask.FACIAL_RECOGNITION)
def __init__(self, model_name: str, min_score: float = 0.7, **model_kwargs: Any) -> None: def __init__(self, model_name: str, **model_kwargs: Any) -> None:
super().__init__(model_name, **model_kwargs) super().__init__(model_name, **model_kwargs)
self.min_score = model_kwargs.pop("minScore", min_score)
max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None max_batch_size = settings.max_batch_size.facial_recognition if settings.max_batch_size else None
self.batch_size = max_batch_size if max_batch_size else self._batch_size_default self.batch_size = max_batch_size if max_batch_size else self._batch_size_default

View File

@@ -324,7 +324,7 @@ class TestAnnSession:
session.run(None, input_feed) session.run(None, input_feed)
ann_session.return_value.execute.assert_called_once_with(123, [input1, input2]) ann_session.return_value.execute.assert_called_once_with(123, [input1, input2])
np_spy.call_count == 2 assert np_spy.call_count == 2
np_spy.assert_has_calls([mock.call(input1), mock.call(input2)]) np_spy.assert_has_calls([mock.call(input1), mock.call(input2)])
@@ -457,11 +457,14 @@ class TestCLIP:
class TestFaceRecognition: class TestFaceRecognition:
def test_set_min_score(self, mocker: MockerFixture) -> None: def test_set_min_score(self, snapshot_download: mock.Mock, ort_session: mock.Mock, path: mock.Mock) -> None:
mocker.patch.object(FaceRecognizer, "load") path.return_value.__truediv__.return_value.__truediv__.return_value.suffix = ".onnx"
face_recognizer = FaceRecognizer("buffalo_s", cache_dir="test_cache", min_score=0.5)
assert face_recognizer.min_score == 0.5 face_detector = FaceDetector("buffalo_s", min_score=0.5, cache_dir="test_cache")
face_detector.load()
assert face_detector.min_score == 0.5
assert face_detector.model.det_thresh == 0.5
def test_detection(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None: def test_detection(self, cv_image: cv2.Mat, mocker: MockerFixture) -> None:
mocker.patch.object(FaceDetector, "load") mocker.patch.object(FaceDetector, "load")

View File

@@ -14,12 +14,6 @@ byte_image = BytesIO()
def _(parser: ArgumentParser) -> None: def _(parser: ArgumentParser) -> None:
parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai") parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai")
parser.add_argument("--face-model", type=str, default="buffalo_l") parser.add_argument("--face-model", type=str, default="buffalo_l")
parser.add_argument(
"--tag-min-score",
type=int,
default=0.0,
help="Returns all tags at or above this score. The default returns all tags.",
)
parser.add_argument( parser.add_argument(
"--face-min-score", "--face-min-score",
type=int, type=int,
@@ -74,10 +68,10 @@ class RecognitionFormDataLoadTest(InferenceLoadTest):
"facial-recognition": { "facial-recognition": {
"recognition": { "recognition": {
"modelName": self.environment.parsed_options.face_model, "modelName": self.environment.parsed_options.face_model,
"options": {"minScore": self.environment.parsed_options.face_min_score},
}, },
"detection": { "detection": {
"modelName": self.environment.parsed_options.face_model, "modelName": self.environment.parsed_options.face_model,
"options": {"minScore": self.environment.parsed_options.face_min_score},
}, },
} }
} }

View File

@@ -75,33 +75,33 @@ trio = ["trio (>=0.23)"]
[[package]] [[package]]
name = "black" name = "black"
version = "24.10.0" version = "25.1.0"
description = "The uncompromising code formatter." description = "The uncompromising code formatter."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-25.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:759e7ec1e050a15f89b770cefbf91ebee8917aac5c20483bc2d80a6c3a04df32"},
{file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, {file = "black-25.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e519ecf93120f34243e6b0054db49c00a35f84f195d5bce7e9f5cfc578fc2da"},
{file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, {file = "black-25.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:055e59b198df7ac0b7efca5ad7ff2516bca343276c466be72eb04a3bcc1f82d7"},
{file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, {file = "black-25.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:db8ea9917d6f8fc62abd90d944920d95e73c83a5ee3383493e35d271aca872e9"},
{file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, {file = "black-25.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a39337598244de4bae26475f77dda852ea00a93bd4c728e09eacd827ec929df0"},
{file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, {file = "black-25.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:96c1c7cd856bba8e20094e36e0f948718dc688dba4a9d78c3adde52b9e6c2299"},
{file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, {file = "black-25.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce2e264d59c91e52d8000d507eb20a9aca4a778731a08cfff7e5ac4a4bb7096"},
{file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, {file = "black-25.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:172b1dbff09f86ce6f4eb8edf9dede08b1fce58ba194c87d7a4f1a5aa2f5b3c2"},
{file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, {file = "black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b"},
{file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, {file = "black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc"},
{file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, {file = "black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f"},
{file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, {file = "black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba"},
{file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, {file = "black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f"},
{file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, {file = "black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3"},
{file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, {file = "black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171"},
{file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, {file = "black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18"},
{file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, {file = "black-25.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1ee0a0c330f7b5130ce0caed9936a904793576ef4d2b98c40835d6a65afa6a0"},
{file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, {file = "black-25.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f3df5f1bf91d36002b0a75389ca8663510cf0531cca8aa5c1ef695b46d98655f"},
{file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, {file = "black-25.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9e6827d563a2c820772b32ce8a42828dc6790f095f441beef18f96aa6f8294e"},
{file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, {file = "black-25.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:bacabb307dca5ebaf9c118d2d2f6903da0d62c9faa82bd21a33eecc319559355"},
{file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, {file = "black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717"},
{file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, {file = "black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666"},
] ]
[package.dependencies] [package.dependencies]
@@ -1331,13 +1331,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "huggingface-hub" name = "huggingface-hub"
version = "0.28.1" version = "0.29.1"
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
optional = false optional = false
python-versions = ">=3.8.0" python-versions = ">=3.8.0"
files = [ files = [
{file = "huggingface_hub-0.28.1-py3-none-any.whl", hash = "sha256:aa6b9a3ffdae939b72c464dbb0d7f99f56e649b55c3d52406f49e0a5a620c0a7"}, {file = "huggingface_hub-0.29.1-py3-none-any.whl", hash = "sha256:352f69caf16566c7b6de84b54a822f6238e17ddd8ae3da4f8f2272aea5b198d5"},
{file = "huggingface_hub-0.28.1.tar.gz", hash = "sha256:893471090c98e3b6efbdfdacafe4052b20b84d59866fb6f54c33d9af18c303ae"}, {file = "huggingface_hub-0.29.1.tar.gz", hash = "sha256:9524eae42077b8ff4fc459ceb7a514eca1c1232b775276b009709fe2a084f250"},
] ]
[package.dependencies] [package.dependencies]
@@ -1625,23 +1625,23 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]] [[package]]
name = "locust" name = "locust"
version = "2.32.9" version = "2.33.0"
description = "Developer-friendly load testing framework" description = "Developer-friendly load testing framework"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "locust-2.32.9-py3-none-any.whl", hash = "sha256:d9447c26d2bbaec5a0ace7cadefa1a31820ed392234257b309965a43d5e8d26f"}, {file = "locust-2.33.0-py3-none-any.whl", hash = "sha256:77fcc5cc35cceee5e12d99f5bb23bc441d145bdef6967c2e93d6e4d93451553e"},
{file = "locust-2.32.9.tar.gz", hash = "sha256:4c297afa5cdc3de15dfa79279576e5f33c1d69dd70006b51d079dcbd212201cc"}, {file = "locust-2.33.0.tar.gz", hash = "sha256:ba291b7ab2349cc2db540adb8888bc93feb89ea4e4e10d80b935e5065091e8e9"},
] ]
[package.dependencies] [package.dependencies]
ConfigArgParse = ">=1.5.5" configargparse = ">=1.5.5"
flask = ">=2.0.0" flask = ">=2.0.0"
Flask-Cors = ">=3.0.10" flask-cors = ">=3.0.10"
Flask-Login = ">=0.6.3" flask-login = ">=0.6.3"
gevent = [ gevent = [
{version = ">=22.10.2", markers = "python_full_version <= \"3.12.0\""}, {version = ">=22.10.2", markers = "python_version <= \"3.12\""},
{version = ">=24.10.1", markers = "python_full_version > \"3.13.0\""}, {version = ">=24.10.1", markers = "python_version > \"3.13\""},
] ]
geventhttpclient = ">=2.3.1" geventhttpclient = ">=2.3.1"
msgpack = ">=1.0.0" msgpack = ">=1.0.0"
@@ -1649,13 +1649,13 @@ psutil = ">=5.9.1"
pywin32 = {version = "*", markers = "sys_platform == \"win32\""} pywin32 = {version = "*", markers = "sys_platform == \"win32\""}
pyzmq = ">=25.0.0" pyzmq = ">=25.0.0"
requests = [ requests = [
{version = ">=2.26.0", markers = "python_full_version <= \"3.11.0\""}, {version = ">=2.26.0", markers = "python_version <= \"3.11\""},
{version = ">=2.32.2", markers = "python_full_version > \"3.11.0\""}, {version = ">=2.32.2", markers = "python_version > \"3.11\""},
] ]
setuptools = ">=70.0.0" setuptools = ">=70.0.0"
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
typing_extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""} typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.11\""}
Werkzeug = ">=2.0.0" werkzeug = ">=2.0.0"
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
@@ -2628,13 +2628,13 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0"
[[package]] [[package]]
name = "pydantic-settings" name = "pydantic-settings"
version = "2.7.1" version = "2.8.1"
description = "Settings management using Pydantic" description = "Settings management using Pydantic"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pydantic_settings-2.7.1-py3-none-any.whl", hash = "sha256:590be9e6e24d06db33a4262829edef682500ef008565a969c73d39d5f8bfb3fd"}, {file = "pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c"},
{file = "pydantic_settings-2.7.1.tar.gz", hash = "sha256:10c9caad35e64bfb3c2fbf70a078c0e25cc92499782e5200747f942a065dec93"}, {file = "pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585"},
] ]
[package.dependencies] [package.dependencies]
@@ -3047,29 +3047,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.9.6" version = "0.9.9"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.9.6-py3-none-linux_armv6l.whl", hash = "sha256:2f218f356dd2d995839f1941322ff021c72a492c470f0b26a34f844c29cdf5ba"}, {file = "ruff-0.9.9-py3-none-linux_armv6l.whl", hash = "sha256:628abb5ea10345e53dff55b167595a159d3e174d6720bf19761f5e467e68d367"},
{file = "ruff-0.9.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b908ff4df65dad7b251c9968a2e4560836d8f5487c2f0cc238321ed951ea0504"}, {file = "ruff-0.9.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cd1428e834b35d7493354723543b28cc11dc14d1ce19b685f6e68e07c05ec7"},
{file = "ruff-0.9.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b109c0ad2ececf42e75fa99dc4043ff72a357436bb171900714a9ea581ddef83"}, {file = "ruff-0.9.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5ee162652869120ad260670706f3cd36cd3f32b0c651f02b6da142652c54941d"},
{file = "ruff-0.9.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1de4367cca3dac99bcbd15c161404e849bb0bfd543664db39232648dc00112dc"}, {file = "ruff-0.9.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3aa0f6b75082c9be1ec5a1db78c6d4b02e2375c3068438241dc19c7c306cc61a"},
{file = "ruff-0.9.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac3ee4d7c2c92ddfdaedf0bf31b2b176fa7aa8950efc454628d477394d35638b"}, {file = "ruff-0.9.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:584cc66e89fb5f80f84b05133dd677a17cdd86901d6479712c96597a3f28e7fe"},
{file = "ruff-0.9.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dc1edd1775270e6aa2386119aea692039781429f0be1e0949ea5884e011aa8e"}, {file = "ruff-0.9.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf3369325761a35aba75cd5c55ba1b5eb17d772f12ab168fbfac54be85cf18c"},
{file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4a091729086dffa4bd070aa5dab7e39cc6b9d62eb2bef8f3d91172d30d599666"}, {file = "ruff-0.9.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3403a53a32a90ce929aa2f758542aca9234befa133e29f4933dcef28a24317be"},
{file = "ruff-0.9.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1bbc6808bf7b15796cef0815e1dfb796fbd383e7dbd4334709642649625e7c5"}, {file = "ruff-0.9.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:18454e7fa4e4d72cffe28a37cf6a73cb2594f81ec9f4eca31a0aaa9ccdfb1590"},
{file = "ruff-0.9.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:589d1d9f25b5754ff230dce914a174a7c951a85a4e9270613a2b74231fdac2f5"}, {file = "ruff-0.9.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fadfe2c88724c9617339f62319ed40dcdadadf2888d5afb88bf3adee7b35bfb"},
{file = "ruff-0.9.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc61dd5131742e21103fbbdcad683a8813be0e3c204472d520d9a5021ca8b217"}, {file = "ruff-0.9.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6df104d08c442a1aabcfd254279b8cc1e2cbf41a605aa3e26610ba1ec4acf0b0"},
{file = "ruff-0.9.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5e2d9126161d0357e5c8f30b0bd6168d2c3872372f14481136d13de9937f79b6"}, {file = "ruff-0.9.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:d7c62939daf5b2a15af48abbd23bea1efdd38c312d6e7c4cedf5a24e03207e17"},
{file = "ruff-0.9.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:68660eab1a8e65babb5229a1f97b46e3120923757a68b5413d8561f8a85d4897"}, {file = "ruff-0.9.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9494ba82a37a4b81b6a798076e4a3251c13243fc37967e998efe4cce58c8a8d1"},
{file = "ruff-0.9.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c4cae6c4cc7b9b4017c71114115db0445b00a16de3bcde0946273e8392856f08"}, {file = "ruff-0.9.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4efd7a96ed6d36ef011ae798bf794c5501a514be369296c672dab7921087fa57"},
{file = "ruff-0.9.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19f505b643228b417c1111a2a536424ddde0db4ef9023b9e04a46ed8a1cb4656"}, {file = "ruff-0.9.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ab90a7944c5a1296f3ecb08d1cbf8c2da34c7e68114b1271a431a3ad30cb660e"},
{file = "ruff-0.9.6-py3-none-win32.whl", hash = "sha256:194d8402bceef1b31164909540a597e0d913c0e4952015a5b40e28c146121b5d"}, {file = "ruff-0.9.9-py3-none-win32.whl", hash = "sha256:6b4c376d929c25ecd6d87e182a230fa4377b8e5125a4ff52d506ee8c087153c1"},
{file = "ruff-0.9.6-py3-none-win_amd64.whl", hash = "sha256:03482d5c09d90d4ee3f40d97578423698ad895c87314c4de39ed2af945633caa"}, {file = "ruff-0.9.9-py3-none-win_amd64.whl", hash = "sha256:837982ea24091d4c1700ddb2f63b7070e5baec508e43b01de013dc7eff974ff1"},
{file = "ruff-0.9.6-py3-none-win_arm64.whl", hash = "sha256:0e2bb706a2be7ddfea4a4af918562fdc1bcb16df255e5fa595bbd800ce322a5a"}, {file = "ruff-0.9.9-py3-none-win_arm64.whl", hash = "sha256:3ac78f127517209fe6d96ab00f3ba97cafe38718b23b1db3e96d8b2d39e37ddf"},
{file = "ruff-0.9.6.tar.gz", hash = "sha256:81761592f72b620ec8fa1068a6fd00e98a5ebee342a3642efd84454f3031dca9"}, {file = "ruff-0.9.9.tar.gz", hash = "sha256:0062ed13f22173e85f8f7056f9a24016e692efeea8704d1a5e8011b8aa850933"},
] ]
[[package]] [[package]]

View File

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

View File

@@ -70,6 +70,7 @@ custom_lint:
- lib/infrastructure/repositories/{store,db,log}.repository.dart - lib/infrastructure/repositories/{store,db,log}.repository.dart
- lib/providers/infrastructure/db.provider.dart - lib/providers/infrastructure/db.provider.dart
# acceptable exceptions for the time being (until Isar is fully replaced) # acceptable exceptions for the time being (until Isar is fully replaced)
- lib/providers/app_life_cycle.provider.dart
- integration_test/test_utils/general_helper.dart - integration_test/test_utils/general_helper.dart
- lib/main.dart - lib/main.dart
- lib/pages/album/album_asset_selection.page.dart - lib/pages/album/album_asset_selection.page.dart

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 185, "android.injected.version.code" => 187,
"android.injected.version.name" => "1.127.0", "android.injected.version.name" => "1.129.0",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -1,24 +1,35 @@
{ {
"action_common_cancel": "Cancel·la", "action_common_back": "Enrere",
"action_common_update": "Actualitza", "action_common_cancel": "Cancel·lar",
"add_to_album_bottom_sheet_added": "S'ha afegit a {album}", "action_common_clear": "Buida",
"add_to_album_bottom_sheet_already_exists": "Ja es troba en {album}", "action_common_confirm": "Confirmar",
"action_common_save": "Desa",
"action_common_select": "Selecciona",
"action_common_update": "Actualitzar",
"add_a_name": "Afegeix un nom",
"add_endpoint": "afegir endpoint",
"add_to_album_bottom_sheet_added": "Added to {album}",
"add_to_album_bottom_sheet_already_exists": "Already in {album}",
"advanced_settings_log_level_title": "Log level: {}", "advanced_settings_log_level_title": "Log level: {}",
"advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.", "advanced_settings_prefer_remote_subtitle": "Some devices are painfully slow to load thumbnails from assets on the device. Activate this setting to load remote images instead.",
"advanced_settings_prefer_remote_title": "Prefereix imatges remotes", "advanced_settings_prefer_remote_title": "Prefereix imatges remotes",
"advanced_settings_proxy_headers_subtitle": "Definiu les capçaleres de proxy que Immich per enviar amb cada sol·licitud de xarxa",
"advanced_settings_proxy_headers_title": "Capçaleres de proxy",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.", "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_self_signed_ssl_title": "Allow self-signed SSL certificates",
"advanced_settings_tile_subtitle": "Configuració avançada", "advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_tile_title": "Avançat", "advanced_settings_tile_title": "Avançat",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting", "advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Resolució de problemes", "advanced_settings_troubleshooting_title": "Resolució de problemes",
"album_info_card_backup_album_excluded": "Exclosos", "album_info_card_backup_album_excluded": "Exclosos",
"album_info_card_backup_album_included": "Inclosos", "album_info_card_backup_album_included": "Inclosos",
"album_thumbnail_card_item": "1 element", "albums": "Àlbums",
"album_thumbnail_card_items": "{} elements", "album_thumbnail_card_item": "1 item",
"album_thumbnail_card_shared": " · Compartit", "album_thumbnail_card_items": "{} items",
"album_thumbnail_card_shared": " · Shared",
"album_thumbnail_owned": "Owned", "album_thumbnail_owned": "Owned",
"album_thumbnail_shared_by": "Compartit per {}", "album_thumbnail_shared_by": "Compartit per {}",
"album_viewer_appbar_delete_confirm": "Confirmes que vols suprimir aquest àlbum del teu compte?",
"album_viewer_appbar_share_delete": "Esborra l'àlbum", "album_viewer_appbar_share_delete": "Esborra l'àlbum",
"album_viewer_appbar_share_err_delete": "Error al esborrar l'àlbum", "album_viewer_appbar_share_err_delete": "Error al esborrar l'àlbum",
"album_viewer_appbar_share_err_leave": "Error al sortir de l'àlbum", "album_viewer_appbar_share_err_leave": "Error al sortir de l'àlbum",
@@ -28,25 +39,39 @@
"album_viewer_appbar_share_remove": "Treu de l'àlbum", "album_viewer_appbar_share_remove": "Treu de l'àlbum",
"album_viewer_appbar_share_to": "Share To", "album_viewer_appbar_share_to": "Share To",
"album_viewer_page_share_add_users": "Afegeix usuaris", "album_viewer_page_share_add_users": "Afegeix usuaris",
"all": "Tot",
"all_people_page_title": "Persones", "all_people_page_title": "Persones",
"all_videos_page_title": "Vídeos", "all_videos_page_title": "Vídeos",
"app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_content": "Are you sure you want to sign out?",
"app_bar_signout_dialog_ok": "Yes", "app_bar_signout_dialog_ok": "Yes",
"app_bar_signout_dialog_title": "Sign out", "app_bar_signout_dialog_title": "Sign out",
"archived": "Arxivat",
"archive_page_no_archived_assets": "No s'ha trobat res arxivat", "archive_page_no_archived_assets": "No s'ha trobat res arxivat",
"archive_page_title": "Arxiu({})", "archive_page_title": "Arxiu({})",
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping", "asset_action_delete_err_read_only": "No es poden esborrar el fitxer(s) de només lectura, ometent",
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping", "asset_action_share_err_offline": "No s'ha pogut obtenir el fitxer(s) sense connexió, ometent",
"asset_list_group_by_sub_title": "Group by", "asset_list_group_by_sub_title": "Agrupar per",
"asset_list_layout_settings_dynamic_layout_title": "Dynamic layout", "asset_list_layout_settings_dynamic_layout_title": "Dynamic layout",
"asset_list_layout_settings_group_automatically": "Automàtic", "asset_list_layout_settings_group_automatically": "Automàtic",
"asset_list_layout_settings_group_by": "Group assets by", "asset_list_layout_settings_group_by": "Group assets by",
"asset_list_layout_settings_group_by_month": "Month", "asset_list_layout_settings_group_by_month": "Month",
"asset_list_layout_settings_group_by_month_day": "Month + day", "asset_list_layout_settings_group_by_month_day": "Month + day",
"asset_list_layout_sub_title": "Layout", "asset_list_layout_sub_title": "Disseny",
"asset_list_settings_subtitle": "Photo grid layout settings", "asset_list_settings_subtitle": "Photo grid layout settings",
"asset_list_settings_title": "Photo Grid", "asset_list_settings_title": "Photo Grid",
"asset_viewer_settings_title": "Asset Viewer", "asset_restored_successfully": "Element recuperat correctament",
"assets_deleted_permanently": "{} element(s) esborrats permanentment",
"assets_deleted_permanently_from_server": "{} element(s) esborrats permanentment del servidor d'Immich",
"assets_removed_permanently_from_device": "{} element(s) esborrat permanentment del dispositiu",
"assets_restored_successfully": "{} element(s) recuperats correctament",
"assets_trashed": "{} element(s) enviat a la paperera",
"assets_trashed_from_server": "{} element(s) enviat a la paperera del servidor d'Immich",
"asset_viewer_settings_subtitle": "Gestiona la configuració del visualitzador de la galeria",
"asset_viewer_settings_title": "Visor d'arxius",
"automatic_endpoint_switching_subtitle": "Connecteu-vos localment a través de la Wi-Fi designada quan estigui disponible i utilitzeu connexions alternatives en altres llocs",
"automatic_endpoint_switching_title": "Canvi automàtic d'URL",
"background_location_permission": "Permís d'ubicació en segon pla",
"background_location_permission_content": "Per canviar de xarxa quan s'executa en segon pla, Immich ha de *sempre* tenir accés a la ubicació precisa perquè l'aplicació pugui llegir el nom de la xarxa Wi-Fi",
"backup_album_selection_page_albums_device": "Àlbums al dispositiu ({})", "backup_album_selection_page_albums_device": "Àlbums al dispositiu ({})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude", "backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.", "backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",
@@ -111,6 +136,8 @@
"backup_manual_in_progress": "Upload already in progress. Try after sometime", "backup_manual_in_progress": "Upload already in progress. Try after sometime",
"backup_manual_success": "Success", "backup_manual_success": "Success",
"backup_manual_title": "Upload status", "backup_manual_title": "Upload status",
"backup_options_page_title": "Opcions de còpia de seguretat",
"backup_setting_subtitle": "Gestiona la configuració de càrrega en segon pla i en primer pla",
"cache_settings_album_thumbnails": "Library page thumbnails ({} assets)", "cache_settings_album_thumbnails": "Library page thumbnails ({} assets)",
"cache_settings_clear_cache_button": "Clear cache", "cache_settings_clear_cache_button": "Clear cache",
"cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.", "cache_settings_clear_cache_button_title": "Clears the app's cache. This will significantly impact the app's performance until the cache has rebuilt.",
@@ -129,73 +156,136 @@
"cache_settings_tile_subtitle": "Control the local storage behaviour", "cache_settings_tile_subtitle": "Control the local storage behaviour",
"cache_settings_tile_title": "Local Storage", "cache_settings_tile_title": "Local Storage",
"cache_settings_title": "Configuració de la memòria cau", "cache_settings_title": "Configuració de la memòria cau",
"cancel": "Cancel·la",
"canceled": "Cancel·lat",
"change_display_order": "Canvia l'ordre de visualització",
"change_password_form_confirm_password": "Confirma la contrasenya", "change_password_form_confirm_password": "Confirma la contrasenya",
"change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.", "change_password_form_description": "Hi {name},\n\nThis is either the first time you are signing into the system or a request has been made to change your password. Please enter the new password below.",
"change_password_form_new_password": "New Password", "change_password_form_new_password": "New Password",
"change_password_form_password_mismatch": "Passwords do not match", "change_password_form_password_mismatch": "Passwords do not match",
"change_password_form_reenter_new_password": "Re-enter New Password", "change_password_form_reenter_new_password": "Re-enter New Password",
"check_corrupt_asset_backup": "Comprovar les còpies de seguretat corruptes",
"check_corrupt_asset_backup_button": "Realitzar comprovació",
"check_corrupt_asset_backup_description": "Executeu aquesta comprovació només mitjançant Wi-Fi i un cop s'hagi fet una còpia de seguretat de tots els actius. El procediment pot trigar uns minuts.",
"client_cert_dialog_msg_confirm": "OK",
"client_cert_enter_password": "Introdueix la contrasenya",
"client_cert_import": "Importar",
"client_cert_import_success_msg": "S'ha importat el certificat del client",
"client_cert_invalid_msg": "Fitxer de certificat no vàlid o contrasenya incorrecta",
"client_cert_remove": "Eliminar",
"client_cert_remove_msg": "S'ha eliminat el certificat del client",
"client_cert_subtitle": "Només admet el format PKCS12 (.p12, .pfx). La importació/eliminació de certificats només està disponible abans d'iniciar sessió",
"client_cert_title": "Certificat de client SSL",
"common_add_to_album": "Add to album", "common_add_to_album": "Add to album",
"common_change_password": "Change Password", "common_change_password": "Change Password",
"common_create_new_album": "Crea un àlbum nou", "common_create_new_album": "Crea un àlbum nou",
"common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.", "common_server_error": "Please check your network connection, make sure the server is reachable and app/server versions are compatible.",
"common_shared": "Compartit", "common_shared": "Compartit",
"completed": "Completat",
"contextual_search": "Sortida del sol a la platja",
"control_bottom_app_bar_add_to_album": "Add to album", "control_bottom_app_bar_add_to_album": "Add to album",
"control_bottom_app_bar_album_info": "{} elements", "control_bottom_app_bar_album_info": "{} elements",
"control_bottom_app_bar_album_info_shared": "{} elements - Compartits", "control_bottom_app_bar_album_info_shared": "{} elements - Compartits",
"control_bottom_app_bar_archive": "Arxiu", "control_bottom_app_bar_archive": "Arxiu",
"control_bottom_app_bar_create_new_album": "Crea un àlbum nou", "control_bottom_app_bar_create_new_album": "Crea un àlbum nou",
"control_bottom_app_bar_delete": "Esborra", "control_bottom_app_bar_delete": "Esborra",
"control_bottom_app_bar_delete_from_immich": "Delete from Immich", "control_bottom_app_bar_delete_from_immich": "Suprimeix del Immich",
"control_bottom_app_bar_delete_from_local": "Delete from device", "control_bottom_app_bar_delete_from_local": "Suprimeix del dispositiu",
"control_bottom_app_bar_download": "Descarrega",
"control_bottom_app_bar_edit": "Edita",
"control_bottom_app_bar_edit_location": "Edit Location", "control_bottom_app_bar_edit_location": "Edit Location",
"control_bottom_app_bar_edit_time": "Edit Date & Time", "control_bottom_app_bar_edit_time": "Edit Date & Time",
"control_bottom_app_bar_favorite": "Preferit", "control_bottom_app_bar_favorite": "Preferit",
"control_bottom_app_bar_share": "Share", "control_bottom_app_bar_share": "Share",
"control_bottom_app_bar_share_to": "Share To", "control_bottom_app_bar_share_to": "Share To",
"control_bottom_app_bar_stack": "Stack", "control_bottom_app_bar_stack": "Stack",
"control_bottom_app_bar_trash_from_immich": "Move to Trash", "control_bottom_app_bar_trash_from_immich": "Mou a paperera",
"control_bottom_app_bar_unarchive": "Desarxiva", "control_bottom_app_bar_unarchive": "Desarxiva",
"control_bottom_app_bar_unfavorite": "Unfavorite", "control_bottom_app_bar_unfavorite": "Unfavorite",
"control_bottom_app_bar_upload": "Upload", "control_bottom_app_bar_upload": "Upload",
"create_album": "Crear àlbum",
"create_album_page_untitled": "Untitled", "create_album_page_untitled": "Untitled",
"create_new": "CREAR NOU",
"create_shared_album_page_create": "Create", "create_shared_album_page_create": "Create",
"create_shared_album_page_share": "Comparteix", "create_shared_album_page_share": "Comparteix",
"create_shared_album_page_share_add_assets": "AFEGEIX ELEMENTS", "create_shared_album_page_share_add_assets": "AFEGEIX ELEMENTS",
"create_shared_album_page_share_select_photos": "Escull fotografies", "create_shared_album_page_share_select_photos": "Escull fotografies",
"crop": "Retalla",
"curated_location_page_title": "Localitzacions", "curated_location_page_title": "Localitzacions",
"curated_object_page_title": "Coses", "curated_object_page_title": "Coses",
"current_server_address": "Current server address",
"daily_title_text_date": "E, MMM dd", "daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy", "daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a", "date_format": "E, LLL d, y • h:mm a",
"delete_dialog_alert": "These items will be permanently deleted from Immich and from your device", "delete_dialog_alert": "These items will be permanently deleted from Immich and from your device",
"delete_dialog_alert_local": "These items will be permanently removed from your device but still be available on the Immich server", "delete_dialog_alert_local": "Aquests elements s'eliminaran permanentment del vostre dispositiu, però encara estaran disponibles al servidor Immich",
"delete_dialog_alert_local_non_backed_up": "Some of the items aren't backed up to Immich and will be permanently removed from your device", "delete_dialog_alert_local_non_backed_up": "Alguns dels elements no tenen còpia de seguretat a Immich i s'eliminaran permanentment del dispositiu",
"delete_dialog_alert_remote": "These items will be permanently deleted from the Immich server", "delete_dialog_alert_remote": "Aquests elements s'eliminaran permanentment del servidor Immich",
"delete_dialog_cancel": "Cancel·la", "delete_dialog_cancel": "Cancel·la",
"delete_dialog_ok": "Esborra", "delete_dialog_ok": "Esborra",
"delete_dialog_ok_force": "Delete Anyway", "delete_dialog_ok_force": "Suprimeix de totes maneres",
"delete_dialog_title": "Esborra permanentment", "delete_dialog_title": "Esborra permanentment",
"delete_local_dialog_ok_backed_up_only": "Delete Backed Up Only", "delete_local_dialog_ok_backed_up_only": "Esborrar només les que tinguin còpia de seguretat",
"delete_local_dialog_ok_force": "Delete Anyway", "delete_local_dialog_ok_force": "Suprimeix de totes maneres",
"delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?", "delete_shared_link_dialog_content": "Are you sure you want to delete this shared link?",
"delete_shared_link_dialog_title": "Delete Shared Link", "delete_shared_link_dialog_title": "Delete Shared Link",
"description_input_hint_text": "Afegeix descripció...", "description_input_hint_text": "Afegeix descripció...",
"description_input_submit_error": "Error updating description, check the log for more details", "description_input_submit_error": "Error updating description, check the log for more details",
"edit_date_time_dialog_date_time": "Date and Time", "description_search": "Jornada de senderisme a Sapa",
"edit_date_time_dialog_timezone": "Timezone", "download_canceled": "Descàrrega cancel·lada",
"edit_location_dialog_title": "Location", "download_complete": "Descàrrega completada",
"download_enqueue": "Descàrrega en cua",
"download_error": "Error de descàrrega",
"download_failed": "Descàrrega ha fallat",
"download_filename": "arxiu: {}",
"download_finished": "Descàrrega acabada",
"downloading": "Descarregant...",
"downloading_media": "Descàrrega multimèdia",
"download_notfound": "No s'ha trobat la descàrrega",
"download_paused": "Descàrrega pausada",
"download_started": "Descàrrega ha començat",
"download_sucess": "Descarregat amb èxit",
"download_sucess_android": "El multimedia s'ha descarregat a DCIM/Immich",
"download_waiting_to_retry": "Esperant per tornar-ho a intentar",
"edit_date_time_dialog_date_time": "Data i Hora",
"edit_date_time_dialog_search_timezone": "Cerca zona horària...",
"edit_date_time_dialog_timezone": "Zona horària",
"edit_image_title": "Editar",
"edit_location_dialog_title": "Ubicació",
"end_date": "Data final",
"enqueued": "En cua",
"enter_wifi_name": "Introdueix el nom de WiFi",
"error_change_sort_album": "No s'ha pogut canviar l'ordre d'ordenació dels àlbums",
"error_saving_image": "Error: {}",
"exif_bottom_sheet_description": "Afegeix descripció", "exif_bottom_sheet_description": "Afegeix descripció",
"exif_bottom_sheet_details": "DETALLS", "exif_bottom_sheet_details": "DETALLS",
"exif_bottom_sheet_location": "UBICACIÓ", "exif_bottom_sheet_location": "UBICACIÓ",
"exif_bottom_sheet_location_add": "Add a location", "exif_bottom_sheet_location_add": "Add a location",
"exif_bottom_sheet_people": "PEOPLE", "exif_bottom_sheet_people": "PERSONES",
"exif_bottom_sheet_person_add_person": "Add name", "exif_bottom_sheet_person_add_person": "Afegir nom",
"experimental_settings_new_asset_list_subtitle": "Work in progress", "experimental_settings_new_asset_list_subtitle": "Work in progress",
"experimental_settings_new_asset_list_title": "Enable experimental photo grid", "experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!", "experimental_settings_subtitle": "Use at your own risk!",
"experimental_settings_title": "Experimental", "experimental_settings_title": "Experimental",
"external_network": "Xarxa externa",
"external_network_sheet_info": "Quan no estigui a la xarxa WiFi preferida, l'aplicació es connectarà al servidor mitjançant el primer dels URL següents a què pot arribar, començant de dalt a baix.",
"failed": "Fallat",
"favorites": "Favorits",
"favorites_page_no_favorites": "No s'han trobat preferits", "favorites_page_no_favorites": "No s'han trobat preferits",
"favorites_page_title": "Favorites", "favorites_page_title": "Favorites",
"filename_search": "Nom o extensió del fitxer",
"filter": "Filtrar",
"get_wifiname_error": "No s'ha pogut obtenir el nom de la Wi-Fi. Assegureu-vos que heu concedit els permisos necessaris i que esteu connectat a una xarxa Wi-Fi",
"grant_permission": "Grant permission",
"haptic_feedback_switch": "Activa la resposta hàptica",
"haptic_feedback_title": "Resposta Hàptica",
"header_settings_add_header_tip": "Afegeix Capçalera",
"header_settings_field_validator_msg": "El valor no pot estar buit",
"header_settings_header_name_input": "Nom de la capçalera",
"header_settings_header_value_input": "Valor de la capçalera",
"header_settings_page_title": "Capçaleres de proxy",
"headers_settings_tile_subtitle": "Definiu les capçaleres de proxy que l'aplicació hauria d'enviar amb cada sol·licitud de xarxa",
"headers_settings_tile_title": "Custom proxy headers",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.", "home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping", "home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_success": "Added {added} assets to album {album}.", "home_page_add_to_album_success": "Added {added} assets to album {album}.",
@@ -204,16 +294,22 @@
"home_page_archive_err_partner": "Can not archive partner assets, skipping", "home_page_archive_err_partner": "Can not archive partner assets, skipping",
"home_page_building_timeline": "Building the timeline", "home_page_building_timeline": "Building the timeline",
"home_page_delete_err_partner": "Can not delete partner assets, skipping", "home_page_delete_err_partner": "Can not delete partner assets, skipping",
"home_page_delete_remote_err_local": "Local assets in delete remote selection, skipping", "home_page_delete_remote_err_local": "Elements locals a la selecció d'eliminació remota, ometent",
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping", "home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
"home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping", "home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping",
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).", "home_page_first_time_notice": "If this is your first time using the app, please make sure to choose a backup album(s) so that the timeline can populate photos and videos in the album(s).",
"home_page_share_err_local": "Can not share local assets via link, skipping", "home_page_share_err_local": "Can not share local assets via link, skipping",
"home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping", "home_page_upload_err_limit": "Can only upload a maximum of 30 assets at a time, skipping",
"ignore_icloud_photos": "Ignora fotos d'iCloud",
"ignore_icloud_photos_description": "Les fotos emmagatzemades a iCloud no es penjaran al servidor Immich",
"image_saved_successfully": "Imatge desada",
"image_viewer_page_state_provider_download_error": "Download Error", "image_viewer_page_state_provider_download_error": "Download Error",
"image_viewer_page_state_provider_download_started": "Download Started", "image_viewer_page_state_provider_download_started": "Descàrrega començada",
"image_viewer_page_state_provider_download_success": "Download Success", "image_viewer_page_state_provider_download_success": "Download Success",
"image_viewer_page_state_provider_share_error": "Share Error", "image_viewer_page_state_provider_share_error": "Share Error",
"invalid_date": "Data invàlida",
"invalid_date_format": "Format de data invàlid",
"library": "Llibreria",
"library_page_albums": "Àlbums", "library_page_albums": "Àlbums",
"library_page_archive": "Arxiu", "library_page_archive": "Arxiu",
"library_page_device_albums": "Àlbums al Dispositiu", "library_page_device_albums": "Àlbums al Dispositiu",
@@ -226,13 +322,17 @@
"library_page_sort_most_oldest_photo": "Oldest photo", "library_page_sort_most_oldest_photo": "Oldest photo",
"library_page_sort_most_recent_photo": "Most recent photo", "library_page_sort_most_recent_photo": "Most recent photo",
"library_page_sort_title": "Album title", "library_page_sort_title": "Album title",
"location_picker_choose_on_map": "Choose on map", "local_network": "Xarxa local",
"location_picker_latitude": "Latitude", "local_network_sheet_info": "L'aplicació es connectarà al servidor mitjançant aquest URL quan utilitzeu la xarxa Wi-Fi especificada",
"location_picker_latitude_error": "Enter a valid latitude", "location_permission": "Permís d'ubicació",
"location_permission_content": "Per utilitzar la funció de canvi automàtic, Immich necessita un permís de ubicació precisa perquè pugui llegir el nom de la xarxa WiFi actual",
"location_picker_choose_on_map": "Escollir en el mapa",
"location_picker_latitude": "Latitud",
"location_picker_latitude_error": "Introdueix una latitud vàlida",
"location_picker_latitude_hint": "Enter your latitude here", "location_picker_latitude_hint": "Enter your latitude here",
"location_picker_longitude": "Longitude", "location_picker_longitude": "Longitud",
"location_picker_longitude_error": "Enter a valid longitude", "location_picker_longitude_error": "Introdueix una longitud vàlida",
"location_picker_longitude_hint": "Enter your longitude here", "location_picker_longitude_hint": "Introdueix aquí la longitud",
"login_disabled": "Login has been disabled", "login_disabled": "Login has been disabled",
"login_form_api_exception": "API exception. Please check the server URL and try again.", "login_form_api_exception": "API exception. Please check the server URL and try again.",
"login_form_back_button_text": "Back", "login_form_back_button_text": "Back",
@@ -258,12 +358,12 @@
"login_form_server_error": "Could not connect to server.", "login_form_server_error": "Could not connect to server.",
"login_password_changed_error": "There was an error updating your password", "login_password_changed_error": "There was an error updating your password",
"login_password_changed_success": "Password updated successfully", "login_password_changed_success": "Password updated successfully",
"map_assets_in_bound": "{} photo", "map_assets_in_bound": "{} foto",
"map_assets_in_bounds": "{} photos", "map_assets_in_bounds": "{} fotos",
"map_cannot_get_user_location": "Cannot get user's location", "map_cannot_get_user_location": "Cannot get user's location",
"map_location_dialog_cancel": "Cancel", "map_location_dialog_cancel": "Cancel",
"map_location_dialog_yes": "Yes", "map_location_dialog_yes": "Yes",
"map_location_picker_page_use_location": "Use this location", "map_location_picker_page_use_location": "Utilitzar aquesta ubicació",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?", "map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_location_service_disabled_title": "Location Service disabled", "map_location_service_disabled_title": "Location Service disabled",
"map_no_assets_in_bounds": "No photos in this area", "map_no_assets_in_bounds": "No photos in this area",
@@ -271,32 +371,44 @@
"map_no_location_permission_title": "Location Permission denied", "map_no_location_permission_title": "Location Permission denied",
"map_settings_dark_mode": "Dark mode", "map_settings_dark_mode": "Dark mode",
"map_settings_date_range_option_all": "All", "map_settings_date_range_option_all": "All",
"map_settings_date_range_option_day": "Past 24 hours", "map_settings_date_range_option_day": "Últimes 24 hores",
"map_settings_date_range_option_days": "Past {} days", "map_settings_date_range_option_days": "Darrers {} dies",
"map_settings_date_range_option_year": "Past year", "map_settings_date_range_option_year": "Any passat",
"map_settings_date_range_option_years": "Past {} years", "map_settings_date_range_option_years": "Darrers {} anys",
"map_settings_dialog_cancel": "Cancel", "map_settings_dialog_cancel": "Cancel",
"map_settings_dialog_save": "Save", "map_settings_dialog_save": "Save",
"map_settings_dialog_title": "Map Settings", "map_settings_dialog_title": "Map Settings",
"map_settings_include_show_archived": "Include Archived", "map_settings_include_show_archived": "Include Archived",
"map_settings_include_show_partners": "Incloure companys",
"map_settings_only_relative_range": "Date range", "map_settings_only_relative_range": "Date range",
"map_settings_only_show_favorites": "Show Favorite Only", "map_settings_only_show_favorites": "Show Favorite Only",
"map_settings_theme_settings": "Map Theme", "map_settings_theme_settings": "Tema del Mapa",
"map_zoom_to_see_photos": "Zoom out to see photos", "map_zoom_to_see_photos": "Zoom out to see photos",
"memories_all_caught_up": "All caught up", "memories_all_caught_up": "Posat al dia",
"memories_check_back_tomorrow": "Check back tomorrow for more memories", "memories_check_back_tomorrow": "Torna demà per veure més records",
"memories_start_over": "Start Over", "memories_start_over": "Torna a començar",
"memories_swipe_to_close": "Swipe up to close", "memories_swipe_to_close": "Llisca per tancar",
"memories_year_ago": "Fa un any",
"memories_years_ago": "Fa {} anys",
"monthly_title_text_date_format": "MMMM y", "monthly_title_text_date_format": "MMMM y",
"motion_photos_page_title": "Motion Photos", "motion_photos_page_title": "Motion Photos",
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping", "multiselect_grid_edit_date_time_err_read_only": "No es pot canviar la data del fitxer(s) de només lectura, ometent",
"multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping", "multiselect_grid_edit_gps_err_read_only": "No es pot canviar la localització de fitxers de només lectura. Saltant.",
"my_albums": "Els meus àlbums",
"networking_settings": "Xarxes",
"networking_subtitle": "Gestiona la configuració del endpoint del servidor",
"no_assets_to_show": "No hi ha elements per mostrar",
"no_name": "Sense nom",
"notification_permission_dialog_cancel": "Cancel·la", "notification_permission_dialog_cancel": "Cancel·la",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.", "notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_dialog_settings": "Configuració", "notification_permission_dialog_settings": "Configuració",
"notification_permission_list_tile_content": "Grant permission to enable notifications.", "notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Activa les notificacions", "notification_permission_list_tile_enable_button": "Activa les notificacions",
"notification_permission_list_tile_title": "Notification Permission", "notification_permission_list_tile_title": "Notification Permission",
"not_selected": "No seleccionat",
"on_this_device": "En aquest dispositiu",
"partner_list_user_photos": "fotos de {user}",
"partner_list_view_all": "Veure tot",
"partner_page_add_partner": "Afegeix company", "partner_page_add_partner": "Afegeix company",
"partner_page_empty_message": "Your photos are not yet shared with any partner.", "partner_page_empty_message": "Your photos are not yet shared with any partner.",
"partner_page_no_more_users": "No more users to add", "partner_page_no_more_users": "No more users to add",
@@ -306,6 +418,9 @@
"partner_page_stop_sharing_content": "{} will no longer be able to access your photos.", "partner_page_stop_sharing_content": "{} will no longer be able to access your photos.",
"partner_page_stop_sharing_title": "Stop sharing your photos?", "partner_page_stop_sharing_title": "Stop sharing your photos?",
"partner_page_title": "Company", "partner_page_title": "Company",
"partners": "Companys",
"paused": "Pausat",
"people": "Persones",
"permission_onboarding_back": "Back", "permission_onboarding_back": "Back",
"permission_onboarding_continue_anyway": "Continue anyway", "permission_onboarding_continue_anyway": "Continue anyway",
"permission_onboarding_get_started": "Get started", "permission_onboarding_get_started": "Get started",
@@ -316,7 +431,9 @@
"permission_onboarding_permission_granted": "Permission granted! You are all set.", "permission_onboarding_permission_granted": "Permission granted! You are all set.",
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.", "permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
"permission_onboarding_request": "Immich requires permission to view your photos and videos.", "permission_onboarding_request": "Immich requires permission to view your photos and videos.",
"preferences_settings_title": "Preferences", "places": "Llocs",
"preferences_settings_subtitle": "Gestiona les preferències de l'aplicació",
"preferences_settings_title": "Preferències",
"profile_drawer_app_logs": "Logs", "profile_drawer_app_logs": "Logs",
"profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.", "profile_drawer_client_out_of_date_major": "Mobile App is out of date. Please update to the latest major version.",
"profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.", "profile_drawer_client_out_of_date_minor": "Mobile App is out of date. Please update to the latest minor version.",
@@ -328,9 +445,44 @@
"profile_drawer_settings": "Settings", "profile_drawer_settings": "Settings",
"profile_drawer_sign_out": "Tanca la sessió", "profile_drawer_sign_out": "Tanca la sessió",
"profile_drawer_trash": "Trash", "profile_drawer_trash": "Trash",
"recently_added": "Afegit recentment",
"recently_added_page_title": "Recently Added", "recently_added_page_title": "Recently Added",
"scaffold_body_error_occurred": "Error occurred", "save": "Desa",
"save_to_gallery": "Desa a galeria",
"scaffold_body_error_occurred": "S'ha produït un error",
"search_albums": "Cerca àlbums",
"search_bar_hint": "Search your photos", "search_bar_hint": "Search your photos",
"search_filter_apply": "Aplicar filtre",
"search_filter_camera": "Càmera",
"search_filter_camera_make": "Marca",
"search_filter_camera_model": "Model",
"search_filter_camera_title": "Selecciona el tipus de càmera",
"search_filter_contextual": "Cerca per contexte",
"search_filter_date": "Data",
"search_filter_date_interval": "{start} a {end}",
"search_filter_date_title": "Selecciona un rang de dates",
"search_filter_description": "Cerca per descripció",
"search_filter_display_option_archive": "Arxivat",
"search_filter_display_option_favorite": "Favorit",
"search_filter_display_option_not_in_album": "No en àlbum",
"search_filter_display_options": "Opcions de Visualització",
"search_filter_display_options_title": "Opcions de visualització",
"search_filter_filename": "Cerca pel nom del fitxer",
"search_filter_location": "Ubicació",
"search_filter_location_city": "Ciutat",
"search_filter_location_country": "País",
"search_filter_location_state": "Estat",
"search_filter_location_title": "Selecciona l'ubicació",
"search_filter_media_type": "Tipus de multimèdia",
"search_filter_media_type_all": "Tot",
"search_filter_media_type_image": "Imatge",
"search_filter_media_type_title": "Selecciona tipus de multimèdia",
"search_filter_media_type_video": "Vídeo",
"search_filter_people": "Persones",
"search_filter_people_hint": "Filtra persones",
"search_filter_people_title": "Selecciona persones",
"search_no_more_result": "No més resultats",
"search_no_result": "No s'han trobat resultats, proveu un terme de cerca o una combinació diferents",
"search_page_categories": "Categories", "search_page_categories": "Categories",
"search_page_favorites": "Preferides", "search_page_favorites": "Preferides",
"search_page_motion_photos": "Fotografies animades", "search_page_motion_photos": "Fotografies animades",
@@ -347,6 +499,7 @@
"search_page_places": "Llocs", "search_page_places": "Llocs",
"search_page_recently_added": "Afegit recentment", "search_page_recently_added": "Afegit recentment",
"search_page_screenshots": "Captures de pantalla", "search_page_screenshots": "Captures de pantalla",
"search_page_search_photos_videos": "Cerca les teves fotos i vídeos",
"search_page_selfies": "Autofotos", "search_page_selfies": "Autofotos",
"search_page_things": "Coses", "search_page_things": "Coses",
"search_page_videos": "Videos", "search_page_videos": "Videos",
@@ -359,6 +512,7 @@
"select_additional_user_for_sharing_page_suggestions": "Suggeriments", "select_additional_user_for_sharing_page_suggestions": "Suggeriments",
"select_user_for_sharing_page_err_album": "Error al crear l'àlbum", "select_user_for_sharing_page_err_album": "Error al crear l'àlbum",
"select_user_for_sharing_page_share_suggestions": "Suggestions", "select_user_for_sharing_page_share_suggestions": "Suggestions",
"server_endpoint": "Endpoint de Servidor",
"server_info_box_app_version": "Versió de l'aplicació", "server_info_box_app_version": "Versió de l'aplicació",
"server_info_box_latest_release": "Latest Version", "server_info_box_latest_release": "Latest Version",
"server_info_box_server_url": "Server URL", "server_info_box_server_url": "Server URL",
@@ -368,6 +522,10 @@
"setting_image_viewer_original_title": "Load original image", "setting_image_viewer_original_title": "Load original image",
"setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.", "setting_image_viewer_preview_subtitle": "Enable to load a medium-resolution image. Disable to either directly load the original or only use the thumbnail.",
"setting_image_viewer_preview_title": "Load preview image", "setting_image_viewer_preview_title": "Load preview image",
"setting_image_viewer_title": "Imatges",
"setting_languages_apply": "Aplicar",
"setting_languages_subtitle": "Canvia el llenguatge de l'aplicació",
"setting_languages_title": "Idiomes",
"setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}", "setting_notifications_notify_failures_grace_period": "Notify background backup failures: {}",
"setting_notifications_notify_hours": "{} hours", "setting_notifications_notify_hours": "{} hours",
"setting_notifications_notify_immediately": "immediately", "setting_notifications_notify_immediately": "immediately",
@@ -382,9 +540,15 @@
"setting_notifications_total_progress_title": "Show background backup total progress", "setting_notifications_total_progress_title": "Show background backup total progress",
"setting_pages_app_bar_settings": "Settings", "setting_pages_app_bar_settings": "Settings",
"settings_require_restart": "Please restart Immich to apply this setting", "settings_require_restart": "Please restart Immich to apply this setting",
"setting_video_viewer_looping_subtitle": "Habilita per reproduir automàticament un vídeo al visualitzador de detalls.",
"setting_video_viewer_looping_title": "Bucle",
"setting_video_viewer_original_video_subtitle": "Quan reproduïu un vídeo des del servidor, reproduïu l'original encara que hi hagi una transcodificació disponible. Pot conduir a l'amortització. Els vídeos disponibles localment es reprodueixen en qualitat original independentment d'aquesta configuració.",
"setting_video_viewer_original_video_title": "Força el vídeo original",
"setting_video_viewer_title": "Vídeos",
"share_add": "Afegeix", "share_add": "Afegeix",
"share_add_photos": "Afegeix fotografies", "share_add_photos": "Afegeix fotografies",
"share_add_title": "Afegeix un títol", "share_add_title": "Afegeix un títol",
"share_assets_selected": "{} seleccionats",
"share_create_album": "Crea un àlbum", "share_create_album": "Crea un àlbum",
"shared_album_activities_input_disable": "Comment is disabled", "shared_album_activities_input_disable": "Comment is disabled",
"shared_album_activities_input_hint": "Say something", "shared_album_activities_input_hint": "Say something",
@@ -398,6 +562,7 @@
"shared_album_section_people_owner_label": "Owner", "shared_album_section_people_owner_label": "Owner",
"shared_album_section_people_title": "PEOPLE", "shared_album_section_people_title": "PEOPLE",
"share_dialog_preparing": "Preparing...", "share_dialog_preparing": "Preparing...",
"shared_intent_upload_button_progress_text": "{} / {} Pujat",
"shared_link_app_bar_title": "Shared Links", "shared_link_app_bar_title": "Shared Links",
"shared_link_clipboard_copied_massage": "Copied to clipboard", "shared_link_clipboard_copied_massage": "Copied to clipboard",
"shared_link_clipboard_text": "Link: {}\nPassword: {}", "shared_link_clipboard_text": "Link: {}\nPassword: {}",
@@ -412,13 +577,15 @@
"shared_link_edit_description": "Description", "shared_link_edit_description": "Description",
"shared_link_edit_description_hint": "Enter the share description", "shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_expire_after": "Expire after", "shared_link_edit_expire_after": "Expire after",
"shared_link_edit_expire_after_option_day": "1 day", "shared_link_edit_expire_after_option_day": "1 dia",
"shared_link_edit_expire_after_option_days": "{} days", "shared_link_edit_expire_after_option_days": "{} dies",
"shared_link_edit_expire_after_option_hour": "1 hour", "shared_link_edit_expire_after_option_hour": "1 hora",
"shared_link_edit_expire_after_option_hours": "{} hours", "shared_link_edit_expire_after_option_hours": "{} hores",
"shared_link_edit_expire_after_option_minute": "1 minute", "shared_link_edit_expire_after_option_minute": "1 minut",
"shared_link_edit_expire_after_option_minutes": "{} minutes", "shared_link_edit_expire_after_option_minutes": "{} minuts",
"shared_link_edit_expire_after_option_months": "{} mesos",
"shared_link_edit_expire_after_option_never": "Never", "shared_link_edit_expire_after_option_never": "Never",
"shared_link_edit_expire_after_option_year": "any {}",
"shared_link_edit_password": "Password", "shared_link_edit_password": "Password",
"shared_link_edit_password_hint": "Enter the share password", "shared_link_edit_password_hint": "Enter the share password",
"shared_link_edit_show_meta": "Show metadata", "shared_link_edit_show_meta": "Show metadata",
@@ -426,65 +593,89 @@
"shared_link_empty": "You don't have any shared links", "shared_link_empty": "You don't have any shared links",
"shared_link_error_server_url_fetch": "Cannot fetch the server url", "shared_link_error_server_url_fetch": "Cannot fetch the server url",
"shared_link_expired": "Expired", "shared_link_expired": "Expired",
"shared_link_expires_day": "Expires in {} day", "shared_link_expires_day": "Caduca d'aquí a {} dia",
"shared_link_expires_days": "Expires in {} days", "shared_link_expires_days": "Caduca d'aquí a {} dies",
"shared_link_expires_hour": "Expires in {} hour", "shared_link_expires_hour": "Caduca d'aquí a {} hora",
"shared_link_expires_hours": "Expires in {} hours", "shared_link_expires_hours": "Caduca d'aquí a {} hores",
"shared_link_expires_minute": "Expires in {} minute", "shared_link_expires_minute": "Caduca d'aquí a {} minut",
"shared_link_expires_minutes": "Expires in {} minutes", "shared_link_expires_minutes": "Expires in {} minutes",
"shared_link_expires_never": "Expires ∞", "shared_link_expires_never": "Expires ∞",
"shared_link_expires_second": "Expires in {} second", "shared_link_expires_second": "Caduca d'aquí a {} segon",
"shared_link_expires_seconds": "Expires in {} seconds", "shared_link_expires_seconds": "Expires in {} seconds",
"shared_link_info_chip_download": "Baixa", "shared_link_individual_shared": "Individual compartit",
"shared_link_info_chip_download": "Download",
"shared_link_info_chip_metadata": "EXIF", "shared_link_info_chip_metadata": "EXIF",
"shared_link_info_chip_upload": "Puja", "shared_link_info_chip_upload": "Puja",
"shared_link_manage_links": "Manage Shared links", "shared_link_manage_links": "Manage Shared links",
"share_done": "Fet", "shared_link_public_album": "Àlbum públic",
"shared_links": "Enllaços compartits",
"share_done": "Done",
"shared_with_me": "Compartit amb mi",
"share_invite": "Convida a l'àlbum", "share_invite": "Convida a l'àlbum",
"sharing_page_album": "Àlbums compartits", "sharing_page_album": "Shared albums",
"sharing_page_description": "Create shared albums to share photos and videos with people in your network.", "sharing_page_description": "Create shared albums to share photos and videos with people in your network.",
"sharing_page_empty_list": "EMPTY LIST", "sharing_page_empty_list": "EMPTY LIST",
"sharing_silver_appbar_create_shared_album": "Crea àlbum compartit", "sharing_silver_appbar_create_shared_album": "Crea àlbum compartit",
"sharing_silver_appbar_shared_links": "Shared links", "sharing_silver_appbar_shared_links": "Shared links",
"sharing_silver_appbar_share_partner": "Comparteix amb un company", "sharing_silver_appbar_share_partner": "Comparteix amb un company",
"tab_controller_nav_library": "Bibilioteca", "start_date": "Data inicial",
"tab_controller_nav_photos": "Fotos", "sync": "Sincronitzar",
"sync_albums": "Sincronitzar àlbums",
"sync_albums_manual_subtitle": "Sincronitza tots els vídeos i fotos penjats amb els àlbums de còpia de seguretat seleccionats",
"sync_upload_album_setting_subtitle": "Creeu i pugeu les seves fotos i vídeos als àlbums seleccionats a Immich",
"tab_controller_nav_library": "Library",
"tab_controller_nav_photos": "Fotografies",
"tab_controller_nav_search": "Cerca", "tab_controller_nav_search": "Cerca",
"tab_controller_nav_sharing": "Compartint", "tab_controller_nav_sharing": "Compartint",
"theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles", "theme_setting_asset_list_storage_indicator_title": "Show storage indicator on asset tiles",
"theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})", "theme_setting_asset_list_tiles_per_row_title": "Number of assets per row ({})",
"theme_setting_dark_mode_switch": "Modes fosc", "theme_setting_colorful_interface_subtitle": "Apliqueu color primari a les superfícies de fons.",
"theme_setting_colorful_interface_title": "Interfície colorida",
"theme_setting_dark_mode_switch": "Dark mode",
"theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer", "theme_setting_image_viewer_quality_subtitle": "Adjust the quality of the detail image viewer",
"theme_setting_image_viewer_quality_title": "Image viewer quality", "theme_setting_image_viewer_quality_title": "Image viewer quality",
"theme_setting_primary_color_subtitle": "Trieu un color per a les accions i els accents principals.",
"theme_setting_primary_color_title": "Color primari",
"theme_setting_system_primary_color_title": "Utilitza color de sistema",
"theme_setting_system_theme_switch": "Automatic (Follow system setting)", "theme_setting_system_theme_switch": "Automatic (Follow system setting)",
"theme_setting_theme_subtitle": "Choose the app's theme setting", "theme_setting_theme_subtitle": "Choose the app's theme setting",
"theme_setting_theme_title": "Tema", "theme_setting_theme_title": "Theme",
"theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load", "theme_setting_three_stage_loading_subtitle": "Three-stage loading might increase the loading performance but causes significantly higher network load",
"theme_setting_three_stage_loading_title": "Enable three-stage loading", "theme_setting_three_stage_loading_title": "Enable three-stage loading",
"translated_text_options": "Options", "translated_text_options": "Options",
"trash_page_delete": "Elimina", "trash": "Paperera",
"trash_page_delete_all": "Elimina-ho tot", "trash_emptied": "Paperera buidada",
"trash_page_empty_trash_btn": "Buida la paperera", "trash_page_delete": "Delete",
"trash_page_delete_all": "Delete All",
"trash_page_empty_trash_btn": "Empty trash",
"trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich", "trash_page_empty_trash_dialog_content": "Do you want to empty your trashed assets? These items will be permanently removed from Immich",
"trash_page_empty_trash_dialog_ok": "Ok", "trash_page_empty_trash_dialog_ok": "Ok",
"trash_page_info": "Trashed items will be permanently deleted after {} days", "trash_page_info": "Trashed items will be permanently deleted after {} days",
"trash_page_no_assets": "No trashed assets", "trash_page_no_assets": "No trashed assets",
"trash_page_restore": "Recupera", "trash_page_restore": "Restore",
"trash_page_restore_all": "Recupera-ho tot", "trash_page_restore_all": "Restore All",
"trash_page_select_assets_btn": "Select assets", "trash_page_select_assets_btn": "Select assets",
"trash_page_select_btn": "Select", "trash_page_select_btn": "Select",
"trash_page_title": "Trash ({})", "trash_page_title": "Trash ({})",
"upload_dialog_cancel": "Cancel·la", "upload": "Puja",
"upload_dialog_cancel": "Cancel",
"upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?", "upload_dialog_info": "Do you want to backup the selected Asset(s) to the server?",
"upload_dialog_ok": "Upload", "upload_dialog_ok": "Upload",
"upload_dialog_title": "Upload Asset", "upload_dialog_title": "Upload Asset",
"uploading": "Pujant",
"upload_to_immich": "Puja a Immich ({})",
"use_current_connection": "utilitzar la connexió actual",
"validate_endpoint_error": "Per favor introdueix un URL vàlid",
"version_announcement_overlay_ack": "Acknowledge", "version_announcement_overlay_ack": "Acknowledge",
"version_announcement_overlay_release_notes": "release notes", "version_announcement_overlay_release_notes": "release notes",
"version_announcement_overlay_text_1": "Hi friend, there is a new release of", "version_announcement_overlay_text_1": "Hi friend, there is a new release of",
"version_announcement_overlay_text_2": "please take your time to visit the ", "version_announcement_overlay_text_2": "please take your time to visit the ",
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.", "version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89", "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
"videos": "Vídeos",
"viewer_remove_from_stack": "Remove from Stack", "viewer_remove_from_stack": "Remove from Stack",
"viewer_stack_use_as_main_asset": "Use as Main Asset", "viewer_stack_use_as_main_asset": "Use as Main Asset",
"viewer_unstack": "Un-Stack" "viewer_unstack": "Un-Stack",
} "wifi_name": "Nom WiFi",
"your_wifi_name": "El teu nom WiFi"
}

View File

@@ -133,7 +133,7 @@
"backup_info_card_assets": "assets", "backup_info_card_assets": "assets",
"backup_manual_cancelled": "Cancelled", "backup_manual_cancelled": "Cancelled",
"backup_manual_failed": "Failed", "backup_manual_failed": "Failed",
"backup_manual_in_progress": "Upload already in progress. Try after sometime", "backup_manual_in_progress": "Upload already in progress. Try after some time",
"backup_manual_success": "Success", "backup_manual_success": "Success",
"backup_manual_title": "Upload status", "backup_manual_title": "Upload status",
"backup_options_page_title": "Backup options", "backup_options_page_title": "Backup options",

View File

@@ -7,10 +7,10 @@
"action_common_select": "Вибрати", "action_common_select": "Вибрати",
"action_common_update": "Оновити", "action_common_update": "Оновити",
"add_a_name": "Додати ім'я", "add_a_name": "Додати ім'я",
"add_endpoint": "Add endpoint", "add_endpoint": "Додати кінцеву точку",
"add_to_album_bottom_sheet_added": "Додано до {album}", "add_to_album_bottom_sheet_added": "Додано до {album}",
"add_to_album_bottom_sheet_already_exists": "Вже є в {album}", "add_to_album_bottom_sheet_already_exists": "Вже є в {album}",
"advanced_settings_log_level_title": "Log level: {}", "advanced_settings_log_level_title": "Рівень логування: {}",
"advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.", "advanced_settings_prefer_remote_subtitle": "Деякі пристрої вельми повільно завантажують мініатюри із елементів на пристрої. Активуйте для завантаження віддалених мініатюр натомість.",
"advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням", "advanced_settings_prefer_remote_title": "Перевага віддаленим зображенням",
"advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.", "advanced_settings_proxy_headers_subtitle": "Визначте заголовки проксі-сервера, які Immich має надсилати з кожним мережевим запитом.",
@@ -66,12 +66,12 @@
"assets_restored_successfully": "{} елемент(и) успішно відновлено", "assets_restored_successfully": "{} елемент(и) успішно відновлено",
"assets_trashed": "{} елемент(и) поміщено до кошика", "assets_trashed": "{} елемент(и) поміщено до кошика",
"assets_trashed_from_server": "{} елемент(и) поміщено до кошика на сервері Immich", "assets_trashed_from_server": "{} елемент(и) поміщено до кошика на сервері Immich",
"asset_viewer_settings_subtitle": "Manage your gallery viewer settings", "asset_viewer_settings_subtitle": "Керуйте налаштуваннями переглядача галереї",
"asset_viewer_settings_title": "Переглядач зображень", "asset_viewer_settings_title": "Переглядач зображень",
"automatic_endpoint_switching_subtitle": "Connect locally over designated Wi-Fi when available and use alternative connections elsewhere", "automatic_endpoint_switching_subtitle": "Підключатися локально через зазначену Wi-Fi мережу, коли це можливо, і використовувати альтернативні з'єднання в інших випадках",
"automatic_endpoint_switching_title": "Automatic URL switching", "automatic_endpoint_switching_title": "Автоматичне перемикання URL",
"background_location_permission": "Background location permission", "background_location_permission": "Дозвіл до місцезнаходження у фоні",
"background_location_permission_content": "In order to switch networks when running in the background, Immich must *always* have precise location access so the app can read the Wi-Fi network's name", "background_location_permission_content": "Щоб перемикати мережі у фоновому режимі, Immich має *завжди* мати доступ до точної геолокації, щоб зчитувати назву Wi-Fi мережі",
"backup_album_selection_page_albums_device": "Альбоми на пристрої ({})", "backup_album_selection_page_albums_device": "Альбоми на пристрої ({})",
"backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити", "backup_album_selection_page_albums_tap": "Торкніться, щоб включити,\nторкніться двічі, щоб виключити",
"backup_album_selection_page_assets_scatter": "Елементи можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.", "backup_album_selection_page_assets_scatter": "Елементи можуть належати до кількох альбомів водночас. Таким чином, альбоми можуть бути включені або вилучені під час резервного копіювання.",
@@ -119,7 +119,7 @@
"backup_controller_page_remainder_sub": "Решта знімків та відео для резервного копіювання з вибраних", "backup_controller_page_remainder_sub": "Решта знімків та відео для резервного копіювання з вибраних",
"backup_controller_page_select": "Вибрати", "backup_controller_page_select": "Вибрати",
"backup_controller_page_server_storage": "Сховище сервера", "backup_controller_page_server_storage": "Сховище сервера",
"backup_controller_page_start_backup": "Почати Резервне Копіювання", "backup_controller_page_start_backup": "Почати резервне копіювання",
"backup_controller_page_status_off": "Автоматичне резервне копіювання в активному режимі вимкнено", "backup_controller_page_status_off": "Автоматичне резервне копіювання в активному режимі вимкнено",
"backup_controller_page_status_on": "Автоматичне резервне копіювання в активному режимі ввімкнено", "backup_controller_page_status_on": "Автоматичне резервне копіювання в активному режимі ввімкнено",
"backup_controller_page_storage_format": "{} із {} спожито", "backup_controller_page_storage_format": "{} із {} спожито",
@@ -137,7 +137,7 @@
"backup_manual_success": "Успіх", "backup_manual_success": "Успіх",
"backup_manual_title": "Стан завантаження", "backup_manual_title": "Стан завантаження",
"backup_options_page_title": "Резервне копіювання", "backup_options_page_title": "Резервне копіювання",
"backup_setting_subtitle": "Manage background and foreground upload settings", "backup_setting_subtitle": "Управління налаштуваннями завантаження у фоновому та активному режимі",
"cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)", "cache_settings_album_thumbnails": "Мініатюри сторінок бібліотеки ({} елементи)",
"cache_settings_clear_cache_button": "Очистити кеш", "cache_settings_clear_cache_button": "Очистити кеш",
"cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.", "cache_settings_clear_cache_button_title": "Очищає кеш програми. Це суттєво знизить продуктивність програми, доки кеш не буде перебудовано.",
@@ -156,17 +156,17 @@
"cache_settings_tile_subtitle": "Керування поведінкою локального сховища", "cache_settings_tile_subtitle": "Керування поведінкою локального сховища",
"cache_settings_tile_title": "Локальне сховище", "cache_settings_tile_title": "Локальне сховище",
"cache_settings_title": "Налаштування кешування", "cache_settings_title": "Налаштування кешування",
"cancel": "Cancel", "cancel": "Скасувати",
"canceled": "Canceled", "canceled": "Скасовано",
"change_display_order": "Change display order", "change_display_order": "Змінити порядок відображення",
"change_password_form_confirm_password": "Підтвердити пароль", "change_password_form_confirm_password": "Підтвердити пароль",
"change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.", "change_password_form_description": "Привіт {name},\n\nВи або або вперше входите у систему, або було зроблено запит на зміну вашого пароля. \nВведіть ваш новий пароль.",
"change_password_form_new_password": "Новий пароль", "change_password_form_new_password": "Новий пароль",
"change_password_form_password_mismatch": "Паролі не співпадають", "change_password_form_password_mismatch": "Паролі не співпадають",
"change_password_form_reenter_new_password": "Повторіть новий пароль", "change_password_form_reenter_new_password": "Повторіть новий пароль",
"check_corrupt_asset_backup": "Check for corrupt asset backups", "check_corrupt_asset_backup": "Перевірити на пошкоджені резервні копії активів",
"check_corrupt_asset_backup_button": "Perform check", "check_corrupt_asset_backup_button": "Виконати перевірку",
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.", "check_corrupt_asset_backup_description": "Запустіть цю перевірку лише через Wi-Fi та після того, як всі активи будуть завантажені на сервер. Процес може зайняти кілька хвилин.",
"client_cert_dialog_msg_confirm": "OK", "client_cert_dialog_msg_confirm": "OK",
"client_cert_enter_password": "Введіть пароль", "client_cert_enter_password": "Введіть пароль",
"client_cert_import": "Імпорт", "client_cert_import": "Імпорт",
@@ -181,7 +181,7 @@
"common_create_new_album": "Створити новий альбом", "common_create_new_album": "Створити новий альбом",
"common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.", "common_server_error": "Будь ласка, перевірте з'єднання, переконайтеся, що сервер доступний і версія програми/сервера сумісна.",
"common_shared": "Спільні", "common_shared": "Спільні",
"completed": "Completed", "completed": "Завершено",
"contextual_search": "Схід сонця на пляжі", "contextual_search": "Схід сонця на пляжі",
"control_bottom_app_bar_add_to_album": "Додати у альбом", "control_bottom_app_bar_add_to_album": "Додати у альбом",
"control_bottom_app_bar_album_info": "{} елементи", "control_bottom_app_bar_album_info": "{} елементи",
@@ -199,7 +199,7 @@
"control_bottom_app_bar_share": "Поділитися", "control_bottom_app_bar_share": "Поділитися",
"control_bottom_app_bar_share_to": "Поділитися", "control_bottom_app_bar_share_to": "Поділитися",
"control_bottom_app_bar_stack": "Стек", "control_bottom_app_bar_stack": "Стек",
"control_bottom_app_bar_trash_from_immich": "Перемістити до кошика", "control_bottom_app_bar_trash_from_immich": "До кошика",
"control_bottom_app_bar_unarchive": "Розархівувати", "control_bottom_app_bar_unarchive": "Розархівувати",
"control_bottom_app_bar_unfavorite": "Видалити з улюблених", "control_bottom_app_bar_unfavorite": "Видалити з улюблених",
"control_bottom_app_bar_upload": "Завантажити", "control_bottom_app_bar_upload": "Завантажити",
@@ -213,7 +213,7 @@
"crop": "Кадрувати", "crop": "Кадрувати",
"curated_location_page_title": "Місця", "curated_location_page_title": "Місця",
"curated_object_page_title": "Речі", "curated_object_page_title": "Речі",
"current_server_address": "Current server address", "current_server_address": "Поточна адреса сервера",
"daily_title_text_date": "E, MMM dd", "daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy", "daily_title_text_date_year": "E, MMM dd, yyyy",
"date_format": "E, LLL d, y • h:mm a", "date_format": "E, LLL d, y • h:mm a",
@@ -231,7 +231,7 @@
"delete_shared_link_dialog_title": "Видалити спільне посилання", "delete_shared_link_dialog_title": "Видалити спільне посилання",
"description_input_hint_text": "Додати опис...", "description_input_hint_text": "Додати опис...",
"description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць", "description_input_submit_error": "Помилка оновлення опису, перевірте логи для подробиць",
"description_search": "Hiking day in Sapa", "description_search": "День походу в Сапі",
"download_canceled": "Завантаження скасовано", "download_canceled": "Завантаження скасовано",
"download_complete": "Завантаження закінчено", "download_complete": "Завантаження закінчено",
"download_enqueue": "Завантаження поставлено в чергу", "download_enqueue": "Завантаження поставлено в чергу",
@@ -248,14 +248,14 @@
"download_sucess_android": "Медіафайли завантажено в DCIM/Immich", "download_sucess_android": "Медіафайли завантажено в DCIM/Immich",
"download_waiting_to_retry": "Очікування повторної спроби", "download_waiting_to_retry": "Очікування повторної спроби",
"edit_date_time_dialog_date_time": "Дата і час", "edit_date_time_dialog_date_time": "Дата і час",
"edit_date_time_dialog_search_timezone": "Search timezone...", "edit_date_time_dialog_search_timezone": "Пошук часової зони...",
"edit_date_time_dialog_timezone": "Часовий пояс", "edit_date_time_dialog_timezone": "Часовий пояс",
"edit_image_title": "Редагувати", "edit_image_title": "Редагувати",
"edit_location_dialog_title": "Місцезнаходження", "edit_location_dialog_title": "Місцезнаходження",
"end_date": "End date", "end_date": "Дата завершення",
"enqueued": "Enqueued", "enqueued": "У черзі",
"enter_wifi_name": "Enter WiFi name", "enter_wifi_name": "Введіть назву WiFi",
"error_change_sort_album": "Failed to change album sort order", "error_change_sort_album": "Не вдалося змінити порядок сортування альбому",
"error_saving_image": "Помилка: {}", "error_saving_image": "Помилка: {}",
"exif_bottom_sheet_description": "Додати опис...", "exif_bottom_sheet_description": "Додати опис...",
"exif_bottom_sheet_details": "ПОДРОБИЦІ", "exif_bottom_sheet_details": "ПОДРОБИЦІ",
@@ -267,16 +267,16 @@
"experimental_settings_new_asset_list_title": "Експериментальний макет знімків", "experimental_settings_new_asset_list_title": "Експериментальний макет знімків",
"experimental_settings_subtitle": "На власний ризик!", "experimental_settings_subtitle": "На власний ризик!",
"experimental_settings_title": "Експериментальні", "experimental_settings_title": "Експериментальні",
"external_network": "External network", "external_network": "Зовнішня мережа",
"external_network_sheet_info": "When not on the preferred WiFi network, the app will connect to the server through the first of the below URLs it can reach, starting from top to bottom", "external_network_sheet_info": "Коли ви не підключені до переважної мережі WiFi, додаток підключатиметься до сервера через першу з наведених нижче URL-адрес, яку він зможе досягти, починаючи зверху вниз",
"failed": "Failed", "failed": "Не вдалося",
"favorites": "Вибране", "favorites": "Вибране",
"favorites_page_no_favorites": "Немає улюблених елементів", "favorites_page_no_favorites": "Немає улюблених елементів",
"favorites_page_title": "Улюблені", "favorites_page_title": "Улюблені",
"filename_search": "Ім'я або розширення файлу", "filename_search": "Ім'я або розширення файлу",
"filter": "Фільтр", "filter": "Фільтр",
"get_wifiname_error": "Could not get Wi-Fi name. Make sure you have granted the necessary permissions and are connected to a Wi-Fi network", "get_wifiname_error": "Не вдалося отримати назву Wi-Fi. Переконайтеся, що ви надали необхідні дозволи та підключені до Wi-Fi мережі",
"grant_permission": "Grant permission", "grant_permission": "Надати дозвіл",
"haptic_feedback_switch": "Увімкнути тактильну віддачу", "haptic_feedback_switch": "Увімкнути тактильну віддачу",
"haptic_feedback_title": "Тактильна віддача", "haptic_feedback_title": "Тактильна віддача",
"header_settings_add_header_tip": "Додати заголовок", "header_settings_add_header_tip": "Додати заголовок",
@@ -322,10 +322,10 @@
"library_page_sort_most_oldest_photo": "Найдавніші фото", "library_page_sort_most_oldest_photo": "Найдавніші фото",
"library_page_sort_most_recent_photo": "Найновіші фото", "library_page_sort_most_recent_photo": "Найновіші фото",
"library_page_sort_title": "Назва альбому", "library_page_sort_title": "Назва альбому",
"local_network": "Local network", "local_network": "Локальна мережа",
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network", "local_network_sheet_info": "Додаток підключатиметься до сервера через цей URL, коли використовується вказана Wi-Fi мережа",
"location_permission": "Location permission", "location_permission": "Дозвіл до місцезнаходження",
"location_permission_content": "In order to use the auto-switching feature, Immich needs precise location permission so it can read the current WiFi network's name", "location_permission_content": "Щоб перемикати мережі у фоновому режимі, Immich має *завжди* мати доступ до точної геолокації, щоб зчитувати назву Wi-Fi мережі",
"location_picker_choose_on_map": "Обрати на мапі", "location_picker_choose_on_map": "Обрати на мапі",
"location_picker_latitude": "Широта", "location_picker_latitude": "Широта",
"location_picker_latitude_error": "Вкажіть дійсну широту", "location_picker_latitude_error": "Вкажіть дійсну широту",
@@ -395,8 +395,8 @@
"multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено", "multiselect_grid_edit_date_time_err_read_only": "Неможливо редагувати дату елементів лише для читання, пропущено",
"multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено", "multiselect_grid_edit_gps_err_read_only": "Неможливо редагувати місцезнаходження елементів лише для читання, пропущено",
"my_albums": "Мої альбоми", "my_albums": "Мої альбоми",
"networking_settings": "Networking", "networking_settings": "Мережеві налаштування",
"networking_subtitle": "Manage the server endpoint settings", "networking_subtitle": "Керування налаштуваннями кінцевої точки сервера",
"no_assets_to_show": "Елементи відсутні", "no_assets_to_show": "Елементи відсутні",
"no_name": "Без імені", "no_name": "Без імені",
"notification_permission_dialog_cancel": "Скасувати", "notification_permission_dialog_cancel": "Скасувати",
@@ -405,7 +405,7 @@
"notification_permission_list_tile_content": "Надати дозвіл для сповіщень.", "notification_permission_list_tile_content": "Надати дозвіл для сповіщень.",
"notification_permission_list_tile_enable_button": "Увімкнути Сповіщення", "notification_permission_list_tile_enable_button": "Увімкнути Сповіщення",
"notification_permission_list_tile_title": "Дозвіл на Сповіщення", "notification_permission_list_tile_title": "Дозвіл на Сповіщення",
"not_selected": "Not selected", "not_selected": "Не вибрано",
"on_this_device": "На цьому пристрої", "on_this_device": "На цьому пристрої",
"partner_list_user_photos": "Фотографії {user}", "partner_list_user_photos": "Фотографії {user}",
"partner_list_view_all": "Переглянути усі", "partner_list_view_all": "Переглянути усі",
@@ -419,7 +419,7 @@
"partner_page_stop_sharing_title": "Припинити надання ваших знімків?", "partner_page_stop_sharing_title": "Припинити надання ваших знімків?",
"partner_page_title": "Партнер", "partner_page_title": "Партнер",
"partners": "\nПартнери", "partners": "\nПартнери",
"paused": "Paused", "paused": "Призупинено",
"people": "Люди", "people": "Люди",
"permission_onboarding_back": "Назад", "permission_onboarding_back": "Назад",
"permission_onboarding_continue_anyway": "Все одно продовжити", "permission_onboarding_continue_anyway": "Все одно продовжити",
@@ -432,7 +432,7 @@
"permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях", "permission_onboarding_permission_limited": "Обмежений доступ. Аби дозволити Immich резервне копіювання та керування вашою галереєю, надайте доступ до знімків та відео у Налаштуваннях",
"permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.", "permission_onboarding_request": "Immich потребує доступу до ваших знімків та відео.",
"places": "Місця", "places": "Місця",
"preferences_settings_subtitle": "Manage the app's preferences", "preferences_settings_subtitle": "Керування налаштуваннями додатку",
"preferences_settings_title": "Параметри", "preferences_settings_title": "Параметри",
"profile_drawer_app_logs": "Журнал", "profile_drawer_app_logs": "Журнал",
"profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.", "profile_drawer_client_out_of_date_major": "Мобільний додаток застарів. Будь ласка, оновіть до останньої мажорної версії.",
@@ -447,7 +447,7 @@
"profile_drawer_trash": "Кошик", "profile_drawer_trash": "Кошик",
"recently_added": "Нещодавно додані", "recently_added": "Нещодавно додані",
"recently_added_page_title": "Нещодавні", "recently_added_page_title": "Нещодавні",
"save": "Save", "save": "Зберегти",
"save_to_gallery": "Зберегти в галерею", "save_to_gallery": "Зберегти в галерею",
"scaffold_body_error_occurred": "Виникла помилка", "scaffold_body_error_occurred": "Виникла помилка",
"search_albums": "Пошук альбому", "search_albums": "Пошук альбому",
@@ -457,17 +457,17 @@
"search_filter_camera_make": "Виробник", "search_filter_camera_make": "Виробник",
"search_filter_camera_model": "Модель", "search_filter_camera_model": "Модель",
"search_filter_camera_title": "Виберіть тип камери", "search_filter_camera_title": "Виберіть тип камери",
"search_filter_contextual": "Search by context", "search_filter_contextual": "Пошук за контекстом",
"search_filter_date": "Дата", "search_filter_date": "Дата",
"search_filter_date_interval": "{start} до {end}", "search_filter_date_interval": "{start} до {end}",
"search_filter_date_title": "Виберіть діапазон дат", "search_filter_date_title": "Виберіть діапазон дат",
"search_filter_description": "Search by description", "search_filter_description": "Пошук за описом",
"search_filter_display_option_archive": "Архів", "search_filter_display_option_archive": "Архів",
"search_filter_display_option_favorite": "Улюблені", "search_filter_display_option_favorite": "Улюблені",
"search_filter_display_option_not_in_album": "Не в альбомі", "search_filter_display_option_not_in_album": "Не в альбомі",
"search_filter_display_options": "Параметри відображення", "search_filter_display_options": "Параметри відображення",
"search_filter_display_options_title": "Параметри відображення", "search_filter_display_options_title": "Параметри відображення",
"search_filter_filename": "Search by file name", "search_filter_filename": "Пошук за назвою файлу",
"search_filter_location": "Місцезнаходження", "search_filter_location": "Місцезнаходження",
"search_filter_location_city": "Місто", "search_filter_location_city": "Місто",
"search_filter_location_country": "Країна", "search_filter_location_country": "Країна",
@@ -479,10 +479,10 @@
"search_filter_media_type_title": "Виберіть тип носія", "search_filter_media_type_title": "Виберіть тип носія",
"search_filter_media_type_video": "Відео", "search_filter_media_type_video": "Відео",
"search_filter_people": "Люди", "search_filter_people": "Люди",
"search_filter_people_hint": "Filter people", "search_filter_people_hint": "Фільтрувати за людьми",
"search_filter_people_title": "Виберіть людей", "search_filter_people_title": "Виберіть людей",
"search_no_more_result": "No more results", "search_no_more_result": "Більше результатів немає",
"search_no_result": "No results found, try a different search term or combination", "search_no_result": "Результатів не знайдено, спробуйте інший запит або комбінацію",
"search_page_categories": "Категорії", "search_page_categories": "Категорії",
"search_page_favorites": "Улюблені", "search_page_favorites": "Улюблені",
"search_page_motion_photos": "Рухомі знімки", "search_page_motion_photos": "Рухомі знімки",
@@ -499,7 +499,7 @@
"search_page_places": "Місця", "search_page_places": "Місця",
"search_page_recently_added": "Нещодавно додані", "search_page_recently_added": "Нещодавно додані",
"search_page_screenshots": "Знімки екрану", "search_page_screenshots": "Знімки екрану",
"search_page_search_photos_videos": "Search for your photos and videos", "search_page_search_photos_videos": "Шукайте ваші фото та відео",
"search_page_selfies": "Селфі", "search_page_selfies": "Селфі",
"search_page_things": "Речі", "search_page_things": "Речі",
"search_page_videos": "Відео", "search_page_videos": "Відео",
@@ -512,7 +512,7 @@
"select_additional_user_for_sharing_page_suggestions": "Пропозиції", "select_additional_user_for_sharing_page_suggestions": "Пропозиції",
"select_user_for_sharing_page_err_album": "Не вдалося створити альбом", "select_user_for_sharing_page_err_album": "Не вдалося створити альбом",
"select_user_for_sharing_page_share_suggestions": "Пропозиції", "select_user_for_sharing_page_share_suggestions": "Пропозиції",
"server_endpoint": "Server Endpoint", "server_endpoint": "Кінцева точка сервера",
"server_info_box_app_version": "Версія додатка", "server_info_box_app_version": "Версія додатка",
"server_info_box_latest_release": "Остання версія", "server_info_box_latest_release": "Остання версія",
"server_info_box_server_url": "URL сервера", "server_info_box_server_url": "URL сервера",
@@ -524,7 +524,7 @@
"setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду", "setting_image_viewer_preview_title": "Завантажувати зображення попереднього перегляду",
"setting_image_viewer_title": "Зображення", "setting_image_viewer_title": "Зображення",
"setting_languages_apply": "Застосувати", "setting_languages_apply": "Застосувати",
"setting_languages_subtitle": "Change the app's language", "setting_languages_subtitle": "Змінити мову додатку",
"setting_languages_title": "Мова", "setting_languages_title": "Мова",
"setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}", "setting_notifications_notify_failures_grace_period": "Повідомити про помилки фонового резервного копіювання: {}",
"setting_notifications_notify_hours": "{} годин", "setting_notifications_notify_hours": "{} годин",
@@ -542,8 +542,8 @@
"settings_require_restart": "Перезавантажте програму для застосування цього налаштування", "settings_require_restart": "Перезавантажте програму для застосування цього налаштування",
"setting_video_viewer_looping_subtitle": "Увімкнути циклічне відтворення відео", "setting_video_viewer_looping_subtitle": "Увімкнути циклічне відтворення відео",
"setting_video_viewer_looping_title": "Циклічне відтворення", "setting_video_viewer_looping_title": "Циклічне відтворення",
"setting_video_viewer_original_video_subtitle": "When streaming a video from the server, play the original even when a transcode is available. May lead to buffering. Videos available locally are played in original quality regardless of this setting.", "setting_video_viewer_original_video_subtitle": "При трансляції відео з сервера відтворювати оригінал, навіть якщо доступна транскодування. Може призвести до буферизації. Відео, доступні локально, відтворюються в оригінальній якості, незважаючи на це налаштування.",
"setting_video_viewer_original_video_title": "Force original video", "setting_video_viewer_original_video_title": "Примусово відтворювати оригінальне відео",
"setting_video_viewer_title": "Відео", "setting_video_viewer_title": "Відео",
"share_add": "Додати", "share_add": "Додати",
"share_add_photos": "Додати знімки", "share_add_photos": "Додати знімки",
@@ -562,7 +562,7 @@
"shared_album_section_people_owner_label": "Власник", "shared_album_section_people_owner_label": "Власник",
"shared_album_section_people_title": "ЛЮДИ", "shared_album_section_people_title": "ЛЮДИ",
"share_dialog_preparing": "Підготовка...", "share_dialog_preparing": "Підготовка...",
"shared_intent_upload_button_progress_text": "{} / {} Uploaded", "shared_intent_upload_button_progress_text": "{} / {} Завантажено",
"shared_link_app_bar_title": "Спільні посилання", "shared_link_app_bar_title": "Спільні посилання",
"shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну", "shared_link_clipboard_copied_massage": "Скопійовано в буфер обміну",
"shared_link_clipboard_text": "Посилання: {}\nПароль: {}", "shared_link_clipboard_text": "Посилання: {}\nПароль: {}",
@@ -618,7 +618,7 @@
"sharing_silver_appbar_create_shared_album": "Створити спільний альбом", "sharing_silver_appbar_create_shared_album": "Створити спільний альбом",
"sharing_silver_appbar_shared_links": "Спільні посилання", "sharing_silver_appbar_shared_links": "Спільні посилання",
"sharing_silver_appbar_share_partner": "Поділитися з партнером", "sharing_silver_appbar_share_partner": "Поділитися з партнером",
"start_date": "Start date", "start_date": "Дата початку",
"sync": "Синхронізувати", "sync": "Синхронізувати",
"sync_albums": "Синхронізувати альбоми", "sync_albums": "Синхронізувати альбоми",
"sync_albums_manual_subtitle": "Синхронізувати всі завантажені фото та відео у вибрані альбоми для резервного копіювання", "sync_albums_manual_subtitle": "Синхронізувати всі завантажені фото та відео у вибрані альбоми для резервного копіювання",
@@ -657,15 +657,15 @@
"trash_page_select_assets_btn": "Вибрані елементи", "trash_page_select_assets_btn": "Вибрані елементи",
"trash_page_select_btn": "Вибрати", "trash_page_select_btn": "Вибрати",
"trash_page_title": "Кошик ({})", "trash_page_title": "Кошик ({})",
"upload": "Upload", "upload": "Завантажити",
"upload_dialog_cancel": "Скасувати", "upload_dialog_cancel": "Скасувати",
"upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?", "upload_dialog_info": "Бажаєте створити резервну копію вибраних елементів на сервері?",
"upload_dialog_ok": "Завантажити", "upload_dialog_ok": "Завантажити",
"upload_dialog_title": "Завантажити Елементи", "upload_dialog_title": "Завантажити Елементи",
"uploading": "Uploading", "uploading": "Завантаження",
"upload_to_immich": "Upload to Immich ({})", "upload_to_immich": "Завантажити в Immich ({})",
"use_current_connection": "use current connection", "use_current_connection": "використовувати поточне підключення",
"validate_endpoint_error": "Please enter a valid URL", "validate_endpoint_error": "Будь ласка, введіть дійсну URL-адресу",
"version_announcement_overlay_ack": "Прийняти", "version_announcement_overlay_ack": "Прийняти",
"version_announcement_overlay_release_notes": "примітки до випуску", "version_announcement_overlay_release_notes": "примітки до випуску",
"version_announcement_overlay_text_1": "Вітаємо, є новий випуск ", "version_announcement_overlay_text_1": "Вітаємо, є новий випуск ",
@@ -676,6 +676,6 @@
"viewer_remove_from_stack": "Видалити зі стеку", "viewer_remove_from_stack": "Видалити зі стеку",
"viewer_stack_use_as_main_asset": "Використовувати як основний елементи", "viewer_stack_use_as_main_asset": "Використовувати як основний елементи",
"viewer_unstack": "Розібрати стек", "viewer_unstack": "Розібрати стек",
"wifi_name": "WiFi Name", "wifi_name": "Назва WiFi",
"your_wifi_name": "Your WiFi name" "your_wifi_name": "Ваша назва WiFi"
} }

View File

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

View File

@@ -18,13 +18,6 @@ import UIKit
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
} }
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .default)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
print("Failed to set audio session category. Error: \(error)")
}
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
BackgroundServicePlugin.registerBackgroundProcessing() BackgroundServicePlugin.registerBackgroundProcessing()

View File

@@ -160,7 +160,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
} }
} }
// Called by the flutter code when enabled so that we can turn on the backround services // Called by the flutter code when enabled so that we can turn on the background services
// and save the callback information to communicate on this method channel // and save the callback information to communicate on this method channel
public func handleBackgroundEnable(call: FlutterMethodCall, result: FlutterResult) { public func handleBackgroundEnable(call: FlutterMethodCall, result: FlutterResult) {
@@ -249,7 +249,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
result(true) result(true)
} }
// Returns the number of currently scheduled background processes to Flutter, striclty // Returns the number of currently scheduled background processes to Flutter, strictly
// for debugging // for debugging
func handleNumberOfProcesses(call: FlutterMethodCall, result: @escaping FlutterResult) { func handleNumberOfProcesses(call: FlutterMethodCall, result: @escaping FlutterResult) {
BGTaskScheduler.shared.getPendingTaskRequests { requests in BGTaskScheduler.shared.getPendingTaskRequests { requests in
@@ -355,7 +355,7 @@ class BackgroundServicePlugin: NSObject, FlutterPlugin {
let isExpensive = wifiMonitor.currentPath.isExpensive let isExpensive = wifiMonitor.currentPath.isExpensive
if (isExpensive) { if (isExpensive) {
// The network is expensive and we have required Wi-Fi // The network is expensive and we have required Wi-Fi
// Therfore, we will simply complete the task without // Therefore, we will simply complete the task without
// running it // running it
task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
return return

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.127.0</string> <string>1.129.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>195</string> <string>197</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.127.0" version_number: "1.129.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -5,7 +5,7 @@ const Map<String, Locale> locales = {
'English (en_US)': Locale('en', 'US'), 'English (en_US)': Locale('en', 'US'),
// Additional locales // Additional locales
'Arabic (ar_JO)': Locale('ar', 'JO'), 'Arabic (ar_JO)': Locale('ar', 'JO'),
'Catalan (ca_CA)': Locale('ca', 'CA'), 'Catalan (ca)': Locale('ca'),
'Chinese (zh_CN)': Locale('zh', 'CN'), 'Chinese (zh_CN)': Locale('zh', 'CN'),
'Chinese Simplified (zh_Hans)': Locale('zh', 'Hans'), 'Chinese Simplified (zh_Hans)': Locale('zh', 'Hans'),
'Chinese TW (zh_TW)': Locale('zh', 'TW'), 'Chinese TW (zh_TW)': Locale('zh', 'TW'),

View File

@@ -1,19 +1,15 @@
// ignore_for_file: constant_identifier_names
import 'package:logging/logging.dart';
/// Log levels according to dart logging [Level] /// Log levels according to dart logging [Level]
enum LogLevel { enum LogLevel {
ALL, all,
FINEST, finest,
FINER, finer,
FINE, fine,
CONFIG, config,
INFO, info,
WARNING, warning,
SEVERE, severe,
SHOUT, shout,
OFF, off,
} }
class LogMessage { class LogMessage {

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/constants/constants.dart'; import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/interfaces/log.interface.dart'; import 'package:immich_mobile/domain/interfaces/log.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart';
@@ -39,29 +40,29 @@ class LogService {
} }
static Future<LogService> init({ static Future<LogService> init({
required ILogRepository logRepo, required ILogRepository logRepository,
required IStoreRepository storeRepo, required IStoreRepository storeRepository,
bool shouldBuffer = true, bool shouldBuffer = true,
}) async { }) async {
if (_instance != null) { if (_instance != null) {
return _instance!; return _instance!;
} }
_instance = await create( _instance = await create(
logRepo: logRepo, logRepository: logRepository,
storeRepo: storeRepo, storeRepository: storeRepository,
shouldBuffer: shouldBuffer, shouldBuffer: shouldBuffer,
); );
return _instance!; return _instance!;
} }
static Future<LogService> create({ static Future<LogService> create({
required ILogRepository logRepo, required ILogRepository logRepository,
required IStoreRepository storeRepo, required IStoreRepository storeRepository,
bool shouldBuffer = true, bool shouldBuffer = true,
}) async { }) async {
final instance = LogService._(logRepo, storeRepo, shouldBuffer); final instance = LogService._(logRepository, storeRepository, shouldBuffer);
// Truncate logs to 250 // Truncate logs to 250
await logRepo.truncate(limit: kLogTruncateLimit); await logRepository.truncate(limit: kLogTruncateLimit);
// Get log level from store // Get log level from store
final level = await instance._storeRepository.tryGet(StoreKey.logLevel); final level = await instance._storeRepository.tryGet(StoreKey.logLevel);
if (level != null) { if (level != null) {
@@ -91,12 +92,13 @@ class LogService {
} }
/// Flush pending log messages to persistent storage /// Flush pending log messages to persistent storage
Future<void> flush() async { void flush() {
if (_flushTimer == null) { if (_flushTimer == null) {
return; return;
} }
_flushTimer!.cancel(); _flushTimer!.cancel();
await _flushBufferToDatabase(); // TODO: Rename enable this after moving to sqlite - #16504
// await _flushBufferToDatabase();
} }
Future<void> dispose() { Future<void> dispose() {
@@ -106,6 +108,10 @@ class LogService {
} }
void _writeLogToDatabase(LogRecord r) { void _writeLogToDatabase(LogRecord r) {
if (kDebugMode) {
debugPrint('[${r.level.name}] [${r.time}] ${r.message}');
}
final record = LogMessage( final record = LogMessage(
message: r.message, message: r.message,
level: r.level.toLogLevel(), level: r.level.toLogLevel(),
@@ -145,7 +151,7 @@ class LoggerUnInitializedException implements Exception {
extension LevelDomainToInfraExtension on Level { extension LevelDomainToInfraExtension on Level {
LogLevel toLogLevel() => LogLevel toLogLevel() =>
LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ?? LogLevel.values.elementAtOrNull(Level.LEVELS.indexOf(this)) ??
LogLevel.INFO; LogLevel.info;
} }
extension on LogLevel { extension on LogLevel {

View File

@@ -75,7 +75,7 @@ class StoreService {
} }
/// Asynchronously stores the value in the DB and synchronously in the cache /// Asynchronously stores the value in the DB and synchronously in the cache
Future<void> put<T>(StoreKey<T> key, T value) async { Future<void> put<U extends StoreKey<T>, T>(U key, T value) async {
if (_cache[key.id] == value) return; if (_cache[key.id] == value) return;
await _storeRepository.insert(key, value); await _storeRepository.insert(key, value);
_cache[key.id] = value; _cache[key.id] = value;

View File

@@ -5,29 +5,24 @@ part 'log.entity.g.dart';
@Collection(inheritance: false) @Collection(inheritance: false)
class LoggerMessage { class LoggerMessage {
Id id = Isar.autoIncrement; final Id id = Isar.autoIncrement;
String message; final String message;
String? details; final String? details;
@Enumerated(EnumType.ordinal) @Enumerated(EnumType.ordinal)
LogLevel level = LogLevel.INFO; final LogLevel level;
DateTime createdAt; final DateTime createdAt;
String? context1; final String? context1;
String? context2; final String? context2;
LoggerMessage({ const LoggerMessage({
required this.message, required this.message,
required this.details, required this.details,
required this.level, this.level = LogLevel.info,
required this.createdAt, required this.createdAt,
required this.context1, required this.context1,
required this.context2, required this.context2,
}); });
@override
String toString() {
return 'LoggerMessage(message: $message, level: $level, createdAt: $createdAt)';
}
LogMessage toDto() { LogMessage toDto() {
return LogMessage( return LogMessage(
message: message, message: message,

View File

@@ -117,10 +117,9 @@ LoggerMessage _loggerMessageDeserialize(
createdAt: reader.readDateTime(offsets[2]), createdAt: reader.readDateTime(offsets[2]),
details: reader.readStringOrNull(offsets[3]), details: reader.readStringOrNull(offsets[3]),
level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ?? level: _LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offsets[4])] ??
LogLevel.ALL, LogLevel.info,
message: reader.readString(offsets[5]), message: reader.readString(offsets[5]),
); );
object.id = id;
return object; return object;
} }
@@ -141,7 +140,7 @@ P _loggerMessageDeserializeProp<P>(
return (reader.readStringOrNull(offset)) as P; return (reader.readStringOrNull(offset)) as P;
case 4: case 4:
return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ?? return (_LoggerMessagelevelValueEnumMap[reader.readByteOrNull(offset)] ??
LogLevel.ALL) as P; LogLevel.info) as P;
case 5: case 5:
return (reader.readString(offset)) as P; return (reader.readString(offset)) as P;
default: default:
@@ -150,28 +149,28 @@ P _loggerMessageDeserializeProp<P>(
} }
const _LoggerMessagelevelEnumValueMap = { const _LoggerMessagelevelEnumValueMap = {
'ALL': 0, 'all': 0,
'FINEST': 1, 'finest': 1,
'FINER': 2, 'finer': 2,
'FINE': 3, 'fine': 3,
'CONFIG': 4, 'config': 4,
'INFO': 5, 'info': 5,
'WARNING': 6, 'warning': 6,
'SEVERE': 7, 'severe': 7,
'SHOUT': 8, 'shout': 8,
'OFF': 9, 'off': 9,
}; };
const _LoggerMessagelevelValueEnumMap = { const _LoggerMessagelevelValueEnumMap = {
0: LogLevel.ALL, 0: LogLevel.all,
1: LogLevel.FINEST, 1: LogLevel.finest,
2: LogLevel.FINER, 2: LogLevel.finer,
3: LogLevel.FINE, 3: LogLevel.fine,
4: LogLevel.CONFIG, 4: LogLevel.config,
5: LogLevel.INFO, 5: LogLevel.info,
6: LogLevel.WARNING, 6: LogLevel.warning,
7: LogLevel.SEVERE, 7: LogLevel.severe,
8: LogLevel.SHOUT, 8: LogLevel.shout,
9: LogLevel.OFF, 9: LogLevel.off,
}; };
Id _loggerMessageGetId(LoggerMessage object) { Id _loggerMessageGetId(LoggerMessage object) {
@@ -183,9 +182,7 @@ List<IsarLinkBase<dynamic>> _loggerMessageGetLinks(LoggerMessage object) {
} }
void _loggerMessageAttach( void _loggerMessageAttach(
IsarCollection<dynamic> col, Id id, LoggerMessage object) { IsarCollection<dynamic> col, Id id, LoggerMessage object) {}
object.id = id;
}
extension LoggerMessageQueryWhereSort extension LoggerMessageQueryWhereSort
on QueryBuilder<LoggerMessage, LoggerMessage, QWhere> { on QueryBuilder<LoggerMessage, LoggerMessage, QWhere> {

View File

@@ -5,8 +5,9 @@ part 'store.entity.g.dart';
/// Internal class for `Store`, do not use elsewhere. /// Internal class for `Store`, do not use elsewhere.
@Collection(inheritance: false) @Collection(inheritance: false)
class StoreValue { class StoreValue {
const StoreValue(this.id, {this.intValue, this.strValue});
final Id id; final Id id;
final int? intValue; final int? intValue;
final String? strValue; final String? strValue;
const StoreValue(this.id, {this.intValue, this.strValue});
} }

View File

@@ -3,6 +3,10 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
abstract class ITimelineRepository { abstract class ITimelineRepository {
Future<List<int>> getTimelineUserIds(int id);
Stream<List<int>> watchTimelineUsers(int id);
Stream<RenderList> watchArchiveTimeline(int userId); Stream<RenderList> watchArchiveTimeline(int userId);
Stream<RenderList> watchFavoriteTimeline(int userId); Stream<RenderList> watchFavoriteTimeline(int userId);
Stream<RenderList> watchTrashTimeline(int userId); Stream<RenderList> watchTrashTimeline(int userId);

View File

@@ -22,10 +22,6 @@ abstract interface class IUserRepository implements IDatabaseRepository {
Future<User> me(); Future<User> me();
Future<void> clearTable(); Future<void> clearTable();
Future<List<int>> getTimelineUserIds(int id);
Stream<List<int>> watchTimelineUsers(int id);
} }
enum UserSort { id } enum UserSort { id }

View File

@@ -7,7 +7,7 @@ mixin ErrorLoggerMixin {
abstract final Logger logger; abstract final Logger logger;
/// Returns an AsyncValue<T> if the future is successfully executed /// Returns an AsyncValue<T> if the future is successfully executed
/// Else, logs the error to the overrided logger and returns an AsyncError<> /// Else, logs the error to the overridden logger and returns an AsyncError<>
AsyncFuture<T> guardError<T>( AsyncFuture<T> guardError<T>(
Future<T> Function() fn, { Future<T> Function() fn, {
required String errorMessage, required String errorMessage,

View File

@@ -5,10 +5,11 @@ import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/person_api.interface.dart'; import 'package:immich_mobile/interfaces/person_api.interface.dart';
class SearchLocationFilter { class SearchLocationFilter {
String? country; final String? country;
String? state; final String? state;
String? city; final String? city;
SearchLocationFilter({
const SearchLocationFilter({
this.country, this.country,
this.state, this.state,
this.city, this.city,
@@ -65,9 +66,10 @@ class SearchLocationFilter {
} }
class SearchCameraFilter { class SearchCameraFilter {
String? make; final String? make;
String? model; final String? model;
SearchCameraFilter({
const SearchCameraFilter({
this.make, this.make,
this.model, this.model,
}); });
@@ -116,9 +118,10 @@ class SearchCameraFilter {
} }
class SearchDateFilter { class SearchDateFilter {
DateTime? takenBefore; final DateTime? takenBefore;
DateTime? takenAfter; final DateTime? takenAfter;
SearchDateFilter({
const SearchDateFilter({
this.takenBefore, this.takenBefore,
this.takenAfter, this.takenAfter,
}); });
@@ -172,10 +175,11 @@ class SearchDateFilter {
} }
class SearchDisplayFilters { class SearchDisplayFilters {
bool isNotInAlbum = false; final bool isNotInAlbum;
bool isArchive = false; final bool isArchive;
bool isFavorite = false; final bool isFavorite;
SearchDisplayFilters({
const SearchDisplayFilters({
required this.isNotInAlbum, required this.isNotInAlbum,
required this.isArchive, required this.isArchive,
required this.isFavorite, required this.isFavorite,
@@ -233,19 +237,19 @@ class SearchDisplayFilters {
} }
class SearchFilter { class SearchFilter {
String? context; final String? context;
String? filename; final String? filename;
String? description; final String? description;
Set<Person> people; final Set<Person> people;
SearchLocationFilter location; final SearchLocationFilter location;
SearchCameraFilter camera; final SearchCameraFilter camera;
SearchDateFilter date; final SearchDateFilter date;
SearchDisplayFilters display; final SearchDisplayFilters display;
// Enum // Enum
AssetType mediaType; final AssetType mediaType;
SearchFilter({ const SearchFilter({
this.context, this.context,
this.filename, this.filename,
this.description, this.description,

View File

@@ -6,7 +6,7 @@ class SearchResult {
final List<Asset> assets; final List<Asset> assets;
final int? nextPage; final int? nextPage;
SearchResult({ const SearchResult({
required this.assets, required this.assets,
this.nextPage, this.nextPage,
}); });

View File

@@ -41,16 +41,16 @@ class AppLogPage extends HookConsumerWidget {
} }
Widget buildLeadingIcon(LogLevel level) => switch (level) { Widget buildLeadingIcon(LogLevel level) => switch (level) {
LogLevel.INFO => colorStatusIndicator(context.primaryColor), LogLevel.info => colorStatusIndicator(context.primaryColor),
LogLevel.SEVERE => colorStatusIndicator(Colors.redAccent), LogLevel.severe => colorStatusIndicator(Colors.redAccent),
LogLevel.WARNING => colorStatusIndicator(Colors.orangeAccent), LogLevel.warning => colorStatusIndicator(Colors.orangeAccent),
_ => colorStatusIndicator(Colors.grey), _ => colorStatusIndicator(Colors.grey),
}; };
Color getTileColor(LogLevel level) => switch (level) { Color getTileColor(LogLevel level) => switch (level) {
LogLevel.INFO => Colors.transparent, LogLevel.info => Colors.transparent,
LogLevel.SEVERE => Colors.redAccent.withOpacity(0.25), LogLevel.severe => Colors.redAccent.withOpacity(0.25),
LogLevel.WARNING => Colors.orangeAccent.withOpacity(0.25), LogLevel.warning => Colors.orangeAccent.withOpacity(0.25),
_ => context.primaryColor.withOpacity(0.1), _ => context.primaryColor.withOpacity(0.1),
}; };

View File

@@ -1,5 +1,6 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -61,6 +62,37 @@ class MemoryPage extends HookConsumerWidget {
); );
} }
void toPreviousMemory() {
if (currentMemoryIndex.value > 0) {
// Move to the previous memory page
memoryPageController.previousPage(
duration: const Duration(milliseconds: 500),
curve: Curves.easeIn,
);
// Wait for the next frame to ensure the page is built
SchedulerBinding.instance.addPostFrameCallback((_) {
final previousIndex = currentMemoryIndex.value - 1;
final previousMemoryController =
memoryAssetPageControllers[previousIndex];
// Ensure the controller is attached
if (previousMemoryController.hasClients) {
previousMemoryController
.jumpToPage(memories[previousIndex].assets.length - 1);
} else {
// Wait for the next frame until it is attached
SchedulerBinding.instance.addPostFrameCallback((_) {
if (previousMemoryController.hasClients) {
previousMemoryController
.jumpToPage(memories[previousIndex].assets.length - 1);
}
});
}
});
}
}
toNextAsset(int currentAssetIndex) { toNextAsset(int currentAssetIndex) {
if (currentAssetIndex + 1 < currentMemory.value.assets.length) { if (currentAssetIndex + 1 < currentMemory.value.assets.length) {
// Go to the next asset // Go to the next asset
@@ -77,6 +109,22 @@ class MemoryPage extends HookConsumerWidget {
} }
} }
toPreviousAsset(int currentAssetIndex) {
if (currentAssetIndex > 0) {
// Go to the previous asset
PageController controller =
memoryAssetPageControllers[currentMemoryIndex.value];
controller.previousPage(
curve: Curves.easeInOut,
duration: const Duration(milliseconds: 500),
);
} else {
// Go to the previous memory since we are at the end of our assets
toPreviousMemory();
}
}
updateProgressText() { updateProgressText() {
assetProgress.value = assetProgress.value =
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}"; "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
@@ -141,17 +189,17 @@ class MemoryPage extends HookConsumerWidget {
currentAssetPage.value = otherIndex; currentAssetPage.value = otherIndex;
updateProgressText(); updateProgressText();
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// And then precache the next asset
await precacheAsset(otherIndex + 1);
final asset = currentMemory.value.assets[otherIndex]; final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset; currentAsset.value = asset;
ref.read(currentAssetProvider.notifier).set(asset); ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) { if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset(); ref.read(videoPlaybackValueProvider.notifier).reset();
} }
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// And then precache the next asset
await precacheAsset(otherIndex + 1);
} }
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called /* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
@@ -248,19 +296,42 @@ class MemoryPage extends HookConsumerWidget {
itemCount: memories[mIndex].assets.length, itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index]; final asset = memories[mIndex].assets[index];
return GestureDetector( return Stack(
behavior: HitTestBehavior.translucent, children: [
onTap: () { Container(
toNextAsset(index); color: Colors.black,
}, child: MemoryCard(
child: Container( asset: asset,
color: Colors.black, title: memories[mIndex].title,
child: MemoryCard( showTitle: index == 0,
asset: asset, ),
title: memories[mIndex].title,
showTitle: index == 0,
), ),
), Positioned.fill(
child: Row(
children: [
// Left side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toPreviousAsset(index);
},
),
),
// Right side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toNextAsset(index);
},
),
),
],
),
),
],
); );
}, },
), ),

View File

@@ -6,520 +6,49 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart'; import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/interfaces/person_api.interface.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/search/search_body.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart'; import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart'; import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart'; import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
@RoutePage() @RoutePage()
class SearchPage extends HookConsumerWidget { class SearchPage extends HookConsumerWidget {
const SearchPage({super.key, this.prefilter});
final SearchFilter? prefilter; final SearchFilter? prefilter;
const SearchPage({super.key, this.prefilter});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final textSearchType = useState<TextSearchType>(TextSearchType.context); final textSearchType = useState<TextSearchType>(TextSearchType.context);
final searchHintText = useState<String>('contextual_search'.tr()); final searchHintText = useState<String>('contextual_search'.tr());
final textSearchController = useTextEditingController(); final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>(
SearchFilter(
people: prefilter?.people ?? {},
location: prefilter?.location ?? SearchLocationFilter(),
camera: prefilter?.camera ?? SearchCameraFilter(),
date: prefilter?.date ?? SearchDateFilter(),
display: prefilter?.display ??
SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: prefilter?.mediaType ?? AssetType.other,
),
);
final previousFilter = useState<SearchFilter?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(
message,
style: context.textTheme.labelLarge,
),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
search() async {
if (filter.value.isEmpty) {
return;
}
if (prefilter == null && filter.value == previousFilter.value) {
return;
}
isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref
.watch(paginatedSearchProvider.notifier)
.search(filter.value);
if (!hasResult) {
context.showSnackBar(
searchInfoSnackBar('search_no_result'.tr()),
);
}
previousFilter.value = filter.value;
isSearching.value = false;
}
loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref
.watch(paginatedSearchProvider.notifier)
.search(filter.value);
if (!hasResult) {
context.showSnackBar(
searchInfoSnackBar('search_no_more_result'.tr()),
);
}
isSearching.value = false;
}
searchPrefilter() {
if (prefilter != null) {
Future.delayed(
Duration.zero,
() {
search();
if (prefilter!.location.city != null) {
locationCurrentFilterWidget.value = Text(
prefilter!.location.city!,
style: context.textTheme.labelLarge,
);
}
},
);
}
}
useEffect(
() {
Future.microtask(
() => ref.invalidate(paginatedSearchProvider),
);
searchPrefilter();
return null;
},
[],
);
showPeoplePicker() {
handleOnSelect(Set<Person> value) {
filter.value = filter.value.copyWith(
people: value,
);
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
people: {},
);
peopleCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.tr(),
expanded: true,
onSearch: search,
onClear: handleClear,
child: PeoplePicker(
onSelect: handleOnSelect,
filter: filter.value.people,
),
),
),
);
}
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(
country: value['country'],
city: value['city'],
state: value['state'],
),
);
final locationText = <String>[];
if (value['country'] != null) {
locationText.add(value['country']!);
}
if (value['state'] != null) {
locationText.add(value['state']!);
}
if (value['city'] != null) {
locationText.add(value['city']!);
}
locationCurrentFilterWidget.value = Text(
locationText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(),
);
locationCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.tr(),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
padding: EdgeInsets.only(
bottom: context.viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LocationPicker(
onSelected: handleOnSelect,
filter: filter.value.location,
),
),
),
),
),
);
}
showCameraPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(
make: value['make'],
model: value['model'],
),
);
cameraCurrentFilterWidget.value = Text(
'${value['make'] ?? ''} ${value['model'] ?? ''}',
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(),
);
cameraCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.tr(),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(
onSelect: handleOnSelect,
filter: filter.value.camera,
),
),
),
);
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
),
helpText: 'search_filter_date_title'.tr(),
cancelText: 'action_common_cancel'.tr(),
confirmText: 'action_common_select'.tr(),
saveText: 'action_common_save'.tr(),
errorFormatText: 'invalid_date_format'.tr(),
errorInvalidText: 'invalid_date'.tr(),
fieldStartHintText: 'start_date'.tr(),
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
);
if (date == null) {
filter.value = filter.value.copyWith(
date: SearchDateFilter(),
);
dateRangeCurrentFilterWidget.value = null;
search();
return;
}
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(
const Duration(
hours: 23,
minutes: 59,
seconds: 59,
),
),
),
);
// If date range is less than 24 hours, set the end date to the end of the day
if (date.end.difference(date.start).inHours < 24) {
dateRangeCurrentFilterWidget.value = Text(
DateFormat.yMMMd().format(date.start.toLocal()),
style: context.textTheme.labelLarge,
);
} else {
dateRangeCurrentFilterWidget.value = Text(
'search_filter_date_interval'.tr(
namedArgs: {
"start": DateFormat.yMMMd().format(date.start.toLocal()),
"end": DateFormat.yMMMd().format(date.end.toLocal()),
},
),
style: context.textTheme.labelLarge,
);
}
search();
}
// MEDIA PICKER
showMediaTypePicker() {
handleOnSelected(AssetType assetType) {
filter.value = filter.value.copyWith(
mediaType: assetType,
);
mediaTypeCurrentFilterWidget.value = Text(
assetType == AssetType.image
? 'search_filter_media_type_image'.tr()
: assetType == AssetType.video
? 'search_filter_media_type_video'.tr()
: 'search_filter_media_type_all'.tr(),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
mediaType: AssetType.other,
);
mediaTypeCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.tr(),
onSearch: search,
onClear: handleClear,
child: MediaTypePicker(
onSelect: handleOnSelected,
filter: filter.value.mediaType,
),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
final filterText = <String>[];
value.forEach((key, value) {
switch (key) {
case DisplayOption.notInAlbum:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isNotInAlbum: value,
),
);
if (value) {
filterText
.add('search_filter_display_option_not_in_album'.tr());
}
break;
case DisplayOption.archive:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isArchive: value,
),
);
if (value) {
filterText.add('search_filter_display_option_archive'.tr());
}
break;
case DisplayOption.favorite:
filter.value = filter.value.copyWith(
display: filter.value.display.copyWith(
isFavorite: value,
),
);
if (value) {
filterText.add('search_filter_display_option_favorite'.tr());
}
break;
}
});
if (filterText.isEmpty) {
displayOptionCurrentFilterWidget.value = null;
return;
}
displayOptionCurrentFilterWidget.value = Text(
filterText.join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
);
displayOptionCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_display_options_title'.tr(),
onSearch: search,
onClear: handleClear,
child: DisplayOptionPicker(
onSelect: handleOnSelect,
filter: filter.value.display,
),
),
);
}
handleTextSubmitted(String value) { handleTextSubmitted(String value) {
switch (textSearchType.value) { final filter = ref.read(searchFiltersProvider);
case TextSearchType.context: ref.read(searchFiltersProvider.notifier).value =
filter.value = filter.value.copyWith( switch (textSearchType.value) {
TextSearchType.context => filter.copyWith(
filename: '', filename: '',
context: value, context: value,
description: '', description: '',
); ),
TextSearchType.filename => filter.copyWith(
break;
case TextSearchType.filename:
filter.value = filter.value.copyWith(
filename: value, filename: value,
context: '', context: '',
description: '', description: '',
); ),
TextSearchType.description => filter.copyWith(
break;
case TextSearchType.description:
filter.value = filter.value.copyWith(
filename: '', filename: '',
context: '', context: '',
description: value, description: value,
); ),
break; };
} ref.read(searchFiltersProvider.notifier).search();
search();
}
IconData getSearchPrefixIcon() {
switch (textSearchType.value) {
case TextSearchType.context:
return Icons.image_search_rounded;
case TextSearchType.filename:
return Icons.abc_rounded;
case TextSearchType.description:
return Icons.text_snippet_outlined;
default:
return Icons.search_rounded;
}
} }
return Scaffold( return Scaffold(
@@ -530,16 +59,14 @@ class SearchPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.only(right: 16.0), padding: const EdgeInsets.only(right: 16.0),
child: MenuAnchor( child: MenuAnchor(
style: MenuStyle( style: const MenuStyle(
elevation: const WidgetStatePropertyAll(1), elevation: WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all( shape: WidgetStatePropertyAll(
RoundedRectangleBorder( RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.all(Radius.circular(24)),
), ),
), ),
padding: const WidgetStatePropertyAll( padding: WidgetStatePropertyAll(EdgeInsets.all(4)),
EdgeInsets.all(4),
),
), ),
builder: ( builder: (
BuildContext context, BuildContext context,
@@ -625,13 +152,13 @@ class SearchPage extends HookConsumerWidget {
), ),
), ),
], ],
title: Container( title: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(
color: context.colorScheme.onSurface.withAlpha(0), color: context.colorScheme.onSurface.withAlpha(0),
width: 0, width: 0,
), ),
borderRadius: BorderRadius.circular(24), borderRadius: const BorderRadius.all(Radius.circular(24)),
gradient: LinearGradient( gradient: LinearGradient(
colors: [ colors: [
context.colorScheme.primary.withOpacity(0.075), context.colorScheme.primary.withOpacity(0.075),
@@ -652,7 +179,7 @@ class SearchPage extends HookConsumerWidget {
prefixIcon: prefilter != null prefixIcon: prefilter != null
? null ? null
: Icon( : Icon(
getSearchPrefixIcon(), getSearchPrefixIcon(textSearchType.value),
color: context.colorScheme.primary, color: context.colorScheme.primary,
), ),
hintText: searchHintText.value, hintText: searchHintText.value,
@@ -660,25 +187,20 @@ class SearchPage extends HookConsumerWidget {
color: context.themeData.colorScheme.onSurfaceSecondary, color: context.themeData.colorScheme.onSurfaceSecondary,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(25), borderRadius: const BorderRadius.all(Radius.circular(25)),
borderSide: BorderSide( borderSide: BorderSide(color: context.colorScheme.surfaceDim),
color: context.colorScheme.surfaceDim,
),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25), borderRadius: const BorderRadius.all(Radius.circular(25)),
borderSide: BorderSide( borderSide:
color: context.colorScheme.surfaceContainer, BorderSide(color: context.colorScheme.surfaceContainer),
),
), ),
disabledBorder: OutlineInputBorder( disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25), borderRadius: const BorderRadius.all(Radius.circular(25)),
borderSide: BorderSide( borderSide: BorderSide(color: context.colorScheme.surfaceDim),
color: context.colorScheme.surfaceDim,
),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(25), borderRadius: const BorderRadius.all(Radius.circular(25)),
borderSide: BorderSide( borderSide: BorderSide(
color: context.colorScheme.primary.withAlpha(100), color: context.colorScheme.primary.withAlpha(100),
), ),
@@ -690,72 +212,35 @@ class SearchPage extends HookConsumerWidget {
), ),
), ),
), ),
body: Column( body: const SearchBody(),
children: [
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: SizedBox(
height: 50,
child: ListView(
key: const Key('search_filter_chip_list'),
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SearchFilterChip(
icon: Icons.people_alt_rounded,
onTap: showPeoplePicker,
label: 'search_filter_people'.tr(),
currentFilter: peopleCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.location_pin,
onTap: showLocationPicker,
label: 'search_filter_location'.tr(),
currentFilter: locationCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.camera_alt_rounded,
onTap: showCameraPicker,
label: 'search_filter_camera'.tr(),
currentFilter: cameraCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.date_range_rounded,
onTap: showDatePicker,
label: 'search_filter_date'.tr(),
currentFilter: dateRangeCurrentFilterWidget.value,
),
SearchFilterChip(
key: const Key('media_type_chip'),
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.tr(),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'search_filter_display_options'.tr(),
currentFilter: displayOptionCurrentFilterWidget.value,
),
],
),
),
),
if (isSearching.value)
const Expanded(
child: Center(child: CircularProgressIndicator.adaptive()),
)
else
SearchResultGrid(
onScrollEnd: loadMoreSearchResult,
isSearching: isSearching.value,
),
],
),
); );
} }
SnackBar searchInfoSnackBar(
String message,
TextStyle? textStyle,
Color closeIconColor,
) {
return SnackBar(
content: Text(message, style: textStyle),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: closeIconColor,
);
}
IconData getSearchPrefixIcon(TextSearchType textSearchType) {
switch (textSearchType) {
case TextSearchType.context:
return Icons.image_search_rounded;
case TextSearchType.filename:
return Icons.abc_rounded;
case TextSearchType.description:
return Icons.text_snippet_outlined;
default:
return Icons.search_rounded;
}
}
} }
class SearchResultGrid extends StatelessWidget { class SearchResultGrid extends StatelessWidget {
@@ -798,12 +283,15 @@ class SearchResultGrid extends StatelessWidget {
editEnabled: true, editEnabled: true,
favoriteEnabled: true, favoriteEnabled: true,
stackEnabled: false, stackEnabled: false,
emptyIndicator: Padding( emptyIndicator: isSearching
padding: const EdgeInsets.symmetric(horizontal: 16.0), ? const Padding(
child: !isSearching padding: EdgeInsets.symmetric(horizontal: 16.0),
? const SearchEmptyContent() child: SizedBox.shrink(),
: const SizedBox.shrink(), )
), : const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: SearchEmptyContent(),
),
), ),
), ),
), ),
@@ -822,14 +310,19 @@ class SearchEmptyContent extends StatelessWidget {
shrinkWrap: false, shrinkWrap: false,
children: [ children: [
const SizedBox(height: 40), const SizedBox(height: 40),
Center( context.isDarkTheme
child: Image.asset( ? const Center(
context.isDarkTheme child: Image(
? 'assets/polaroid-dark.png' image: AssetImage('assets/polaroid-dark.png'),
: 'assets/polaroid-light.png', height: 125,
height: 125, ),
), )
), : const Center(
child: Image(
image: AssetImage('assets/polaroid-light.png'),
height: 125,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
Center( Center(
child: Text( child: Text(
@@ -850,9 +343,9 @@ class QuickLinkList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), borderRadius: const BorderRadius.all(Radius.circular(20)),
border: Border.all( border: Border.all(
color: context.colorScheme.outline.withAlpha(10), color: context.colorScheme.outline.withAlpha(10),
width: 1, width: 1,
@@ -912,21 +405,34 @@ class QuickLink extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final borderRadius = BorderRadius.only( final shape = switch ((isTop, isBottom)) {
topLeft: Radius.circular(isTop ? 20 : 0), (true, false) => const RoundedRectangleBorder(
topRight: Radius.circular(isTop ? 20 : 0), borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isBottom ? 20 : 0), topLeft: Radius.circular(20),
bottomRight: Radius.circular(isBottom ? 20 : 0), topRight: Radius.circular(20),
); ),
),
(false, true) => const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
(true, true) => const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
(false, false) =>
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
};
return ListTile( return ListTile(
shape: RoundedRectangleBorder( shape: shape,
borderRadius: borderRadius, leading: Icon(icon, size: 26),
),
leading: Icon(
icon,
size: 26,
),
title: Text( title: Text(
title, title,
style: context.textTheme.titleSmall?.copyWith( style: context.textTheme.titleSmall?.copyWith(

View File

@@ -0,0 +1,306 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/search/show_camera_picker.dart';
import 'package:immich_mobile/pages/search/show_date_picker.dart';
import 'package:immich_mobile/pages/search/show_display_option_picker.dart';
import 'package:immich_mobile/pages/search/show_location_picker.dart';
import 'package:immich_mobile/pages/search/show_media_type_picker.dart';
import 'package:immich_mobile/pages/search/show_people_picker.dart';
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
class SearchBody extends HookConsumerWidget {
const SearchBody({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isSearching = ref.watch(isSearchingProvider);
loadMoreSearchResult() async {
final filter = ref.read(searchFiltersProvider);
final hasResult =
await ref.read(paginatedSearchProvider.notifier).search(filter);
if (!hasResult) {
context.showSnackBar(
searchInfoSnackBar(
'search_no_more_result'.tr(),
context.textTheme.labelLarge,
context.colorScheme.onSurface,
),
);
}
}
const pickers = Padding(
padding: EdgeInsets.only(top: 12.0),
child: SizedBox(
height: 50,
child: ListView.custom(
key: Key('search_filter_chip_list'),
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: EdgeInsets.symmetric(horizontal: 16),
childrenDelegate: SliverChildListDelegate.fixed(
[
ShowPeoplePicker(),
ShowLocationPicker(),
ShowCameraPicker(),
ShowDatePicker(),
ShowMediaTypePicker(),
ShowDisplayOptionsPicker(),
],
addAutomaticKeepAlives: true,
addRepaintBoundaries: true,
addSemanticIndexes: true,
),
),
),
);
// TODO: extend render list without discarding the existing result grid
return isSearching
? const Column(
children: [
pickers,
Expanded(
child: Center(child: CircularProgressIndicator.adaptive()),
),
],
)
: Column(
children: [
pickers,
SearchResultGrid(onScrollEnd: loadMoreSearchResult),
],
);
}
SnackBar searchInfoSnackBar(
String message,
TextStyle? textStyle,
Color closeIconColor,
) {
return SnackBar(
content: Text(message, style: textStyle),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: closeIconColor,
);
}
IconData getSearchPrefixIcon(TextSearchType textSearchType) {
switch (textSearchType) {
case TextSearchType.context:
return Icons.image_search_rounded;
case TextSearchType.filename:
return Icons.abc_rounded;
case TextSearchType.description:
return Icons.text_snippet_outlined;
default:
return Icons.search_rounded;
}
}
}
class SearchResultGrid extends StatelessWidget {
final VoidCallback onScrollEnd;
const SearchResultGrid({
super.key,
required this.onScrollEnd,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification = notification.context
?.findAncestorWidgetOfExactType<
DraggableScrollableSheet>() !=
null;
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
if (metrics.pixels >= metrics.maxScrollExtent &&
isVerticalScroll &&
!isBottomSheetNotification) {
onScrollEnd();
}
return true;
},
child: MultiselectGrid(
renderListProvider: paginatedSearchRenderListProvider,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
emptyIndicator: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: SearchEmptyContent(),
),
),
),
),
);
}
}
class SearchEmptyContent extends StatelessWidget {
const SearchEmptyContent({super.key});
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (_) => true,
child: ListView(
shrinkWrap: false,
children: [
const SizedBox(height: 40),
context.isDarkTheme
? const Center(
child: Image(
image: AssetImage('assets/polaroid-dark.png'),
height: 125,
),
)
: const Center(
child: Image(
image: AssetImage('assets/polaroid-light.png'),
height: 125,
),
),
const SizedBox(height: 16),
Center(
child: Text(
'search_page_search_photos_videos'.tr(),
style: context.textTheme.labelLarge,
),
),
const SizedBox(height: 32),
const QuickLinkList(),
],
),
);
}
}
class QuickLinkList extends StatelessWidget {
const QuickLinkList({super.key});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
border: Border.all(
color: context.colorScheme.outline.withAlpha(10),
width: 1,
),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
context.colorScheme.primary.withAlpha(20),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
QuickLink(
title: 'recently_added'.tr(),
icon: const Icon(Icons.schedule_outlined, size: 26),
isTop: true,
onTap: () => context.pushRoute(const RecentlyAddedRoute()),
),
QuickLink(
title: 'videos'.tr(),
icon: const Icon(Icons.play_circle_outline_rounded, size: 26),
onTap: () => context.pushRoute(const AllVideosRoute()),
),
QuickLink(
title: 'favorites'.tr(),
icon: const Icon(Icons.favorite_border_rounded, size: 26),
isBottom: true,
onTap: () => context.pushRoute(const FavoritesRoute()),
),
],
),
);
}
}
class QuickLink extends StatelessWidget {
final String title;
final Icon icon;
final VoidCallback onTap;
final bool isTop;
final bool isBottom;
const QuickLink({
super.key,
required this.title,
required this.icon,
required this.onTap,
this.isTop = false,
this.isBottom = false,
});
@override
Widget build(BuildContext context) {
final shape = switch ((isTop, isBottom)) {
(true, false) => const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
(false, true) => const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
(true, true) => const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
),
(false, false) =>
const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
};
return ListTile(
shape: shape,
leading: icon,
title: Text(
title,
style:
context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: onTap,
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
class ShowCameraPicker extends ConsumerWidget {
const ShowCameraPicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(searchFiltersProvider);
showCameraPicker() {
handleOnSelect(Map<String, String?> value) {
ref.read(searchFiltersProvider.notifier).camera = SearchCameraFilter(
make: value['make'],
model: value['model'],
);
}
handleClear() {
ref.read(searchFiltersProvider.notifier).value = filter.copyWith(
camera: const SearchCameraFilter(),
);
ref.read(searchFiltersProvider.notifier).search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.tr(),
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(
onSelect: handleOnSelect,
filter: filter.camera,
),
),
),
);
}
return SearchFilterChip(
icon: Icons.camera_alt_rounded,
onTap: showCameraPicker,
label: 'search_filter_camera'.tr(),
currentFilter: Text(
'${filter.camera.make ?? ''} ${filter.camera.model ?? ''}',
style: context.textTheme.labelLarge,
),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
class ShowDatePicker extends ConsumerWidget {
const ShowDatePicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final date =
ref.watch(searchFiltersProvider.select((filters) => filters.date));
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final dateRange = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: date.takenAfter ?? lastDate,
end: date.takenBefore ?? lastDate,
),
helpText: 'search_filter_date_title'.tr(),
cancelText: 'action_common_cancel'.tr(),
confirmText: 'action_common_select'.tr(),
saveText: 'action_common_save'.tr(),
errorFormatText: 'invalid_date_format'.tr(),
errorInvalidText: 'invalid_date'.tr(),
fieldStartHintText: 'start_date'.tr(),
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
);
if (dateRange == null) {
ref.read(searchFiltersProvider.notifier).date =
const SearchDateFilter();
return;
}
ref.read(searchFiltersProvider.notifier).date = SearchDateFilter(
takenAfter: dateRange.start,
takenBefore: dateRange.end.add(
const Duration(
hours: 23,
minutes: 59,
seconds: 59,
),
),
);
ref.read(searchFiltersProvider.notifier).search();
}
return SearchFilterChip(
icon: Icons.date_range_rounded,
onTap: showDatePicker,
label: 'search_filter_date'.tr(),
currentFilter: Text(
getFormattedText(date.takenAfter, date.takenBefore),
style: context.textTheme.labelLarge,
),
);
}
String getFormattedText(DateTime? start, DateTime? end) {
if (start == null || end == null) {
return '';
}
if (end.difference(start).inHours < 24) {
return DateFormat.yMMMd().format(start.toLocal());
}
return 'search_filter_date_interval'.tr(
namedArgs: {
"start": DateFormat.yMMMd().format(start.toLocal()),
"end": DateFormat.yMMMd().format(end.toLocal()),
},
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
class ShowDisplayOptionsPicker extends ConsumerWidget {
const ShowDisplayOptionsPicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final display =
ref.watch(searchFiltersProvider.select((filters) => filters.display));
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
value.forEach((key, value) {
ref.read(searchFiltersProvider.notifier).display = switch (key) {
DisplayOption.notInAlbum => display.copyWith(isNotInAlbum: value),
DisplayOption.archive => display.copyWith(isArchive: value),
DisplayOption.favorite => display.copyWith(isFavorite: value),
};
});
}
handleClear() {
ref.read(searchFiltersProvider.notifier).display =
const SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
);
ref.read(searchFiltersProvider.notifier).search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_display_options_title'.tr(),
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
onClear: handleClear,
child: DisplayOptionPicker(
onSelect: handleOnSelect,
filter: display,
),
),
);
}
return SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'search_filter_display_options'.tr(),
currentFilter: Text(
getFormattedText(display),
style: context.textTheme.labelLarge,
),
);
}
getFormattedText(SearchDisplayFilters display) {
return [
if (display.isNotInAlbum)
'search_filter_display_option_not_in_album'.tr(),
if (display.isArchive) 'search_filter_display_option_archive'.tr(),
if (display.isFavorite) 'search_filter_display_option_favorite'.tr(),
].join(', ');
}
}

View File

@@ -0,0 +1,82 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
class ShowLocationPicker extends ConsumerWidget {
const ShowLocationPicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final location =
ref.watch(searchFiltersProvider.select((filters) => filters.location));
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
ref.read(searchFiltersProvider.notifier).location =
SearchLocationFilter(
country: value['country'],
city: value['city'],
state: value['state'],
);
}
handleClear() {
ref.read(searchFiltersProvider.notifier).location =
const SearchLocationFilter();
ref.read(searchFiltersProvider.notifier).search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.tr(),
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
padding: EdgeInsets.only(
bottom: context.viewInsets.bottom,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LocationPicker(
onSelected: handleOnSelect,
filter: location,
),
),
),
),
),
);
}
return SearchFilterChip(
icon: Icons.location_pin,
onTap: showLocationPicker,
label: 'search_filter_location'.tr(),
currentFilter: Text(
getFormattedText(location.city, location.state, location.country),
style: context.textTheme.labelLarge,
),
);
}
String getFormattedText(String? city, String? state, String? country) {
return [
if (city != null) city,
if (state != null) state,
if (country != null) country,
].join(', ');
}
}

View File

@@ -0,0 +1,66 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
class ShowMediaTypePicker extends ConsumerWidget {
const ShowMediaTypePicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mediaType =
ref.watch(searchFiltersProvider.select((filters) => filters.mediaType));
showMediaTypePicker() {
handleOnSelected(AssetType assetType) {
ref.read(searchFiltersProvider.notifier).mediaType = assetType;
}
handleClear() {
ref.read(searchFiltersProvider.notifier).mediaType = AssetType.other;
ref.read(searchFiltersProvider.notifier).search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.tr(),
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
onClear: handleClear,
child: MediaTypePicker(
onSelect: handleOnSelected,
filter: mediaType,
),
),
);
}
return SearchFilterChip(
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.tr(),
currentFilter: Text(
getFormattedText(mediaType),
style: context.textTheme.labelLarge,
),
);
}
String getFormattedText(AssetType mediaType) {
switch (mediaType) {
case AssetType.image:
return 'search_filter_media_type_image'.tr();
case AssetType.video:
return 'search_filter_media_type_video'.tr();
default:
return 'search_filter_media_type_all'.tr();
}
}
}

View File

@@ -0,0 +1,65 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/interfaces/person_api.interface.dart';
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/providers/search/search_filters.provider.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
class ShowPeoplePicker extends ConsumerWidget {
const ShowPeoplePicker({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people =
ref.watch(searchFiltersProvider.select((filters) => filters.people));
showPeoplePicker() {
handleOnSelect(Set<Person> value) {
ref.read(searchFiltersProvider.notifier).people = value;
}
handleClear() {
ref.read(searchFiltersProvider.notifier).people = const {};
ref.read(searchFiltersProvider.notifier).search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.tr(),
expanded: true,
onSearch: () => ref.read(isSearchingProvider.notifier).value = true,
onClear: handleClear,
child: PeoplePicker(
onSelect: handleOnSelect,
filter: people,
),
),
),
);
}
return SearchFilterChip(
icon: Icons.people_alt_rounded,
onTap: showPeoplePicker,
label: 'search_filter_people'.tr(),
currentFilter: Text(
getFormattedText(people),
style: context.textTheme.labelLarge,
),
);
}
String getFormattedText(Set<Person> people) {
final noName = 'no_name'.tr();
return people.map((e) => e.name != '' ? e.name : noName).join(', ');
}
}

View File

@@ -17,6 +17,7 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart'; import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/background.service.dart'; import 'package:immich_mobile/services/background.service.dart';
import 'package:isar/isar.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
enum AppLifeCycleEnum { enum AppLifeCycleEnum {
@@ -114,11 +115,13 @@ class AppLifeCycleNotifier extends StateNotifier<AppLifeCycleEnum> {
_ref.read(websocketProvider.notifier).disconnect(); _ref.read(websocketProvider.notifier).disconnect();
} }
unawaited(LogService.I.flush()); LogService.I.flush();
} }
void handleAppDetached() { Future<void> handleAppDetached() async {
state = AppLifeCycleEnum.detached; state = AppLifeCycleEnum.detached;
LogService.I.flush();
await Isar.getInstance()?.close();
// no guarantee this is called at all // no guarantee this is called at all
_ref.read(manualUploadProvider.notifier).cancelBackup(); _ref.read(manualUploadProvider.notifier).cancelBackup();
} }

View File

@@ -59,7 +59,11 @@ class AssetNotifier extends StateNotifier<bool> {
await clearAllAssets(); await clearAllAssets();
log.info("Manual refresh requested, cleared assets and albums from db"); log.info("Manual refresh requested, cleared assets and albums from db");
} }
final bool changedUsers = await _userService.refreshUsers(); final users = await _userService.getUsersFromServer();
bool changedUsers = false;
if (users != null) {
changedUsers = await _syncService.syncUsersFromServer(users);
}
final bool newRemote = await _assetService.refreshRemoteAssets(); final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums(); final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint( debugPrint(

View File

@@ -104,7 +104,7 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
} }
void _taskProgressCallback(TaskProgressUpdate update) { void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is cancled or completed // Ignore if the task is canceled or completed
if (update.progress == -2 || update.progress == -1) { if (update.progress == -2 || update.progress == -1) {
return; return;
} }

View File

@@ -117,7 +117,7 @@ class ShareIntentUploadStateNotifier
} }
void _taskProgressCallback(TaskProgressUpdate update) { void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is cancled or completed // Ignore if the task is canceled or completed
if (update.progress == downloadFailed || if (update.progress == downloadFailed ||
update.progress == downloadCompleted) { update.progress == downloadCompleted) {
return; return;

View File

@@ -47,7 +47,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
} }
/// Validating the url is the alternative connecting server url without /// Validating the url is the alternative connecting server url without
/// saving the infomation to the local database /// saving the information to the local database
Future<bool> validateAuxilaryServerUrl(String url) async { Future<bool> validateAuxilaryServerUrl(String url) async {
try { try {
final validEndpoint = await _apiService.resolveEndpoint(url); final validEndpoint = await _apiService.resolveEndpoint(url);

View File

@@ -6,7 +6,7 @@ import 'package:permission_handler/permission_handler.dart';
class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> { class GalleryPermissionNotifier extends StateNotifier<PermissionStatus> {
GalleryPermissionNotifier() GalleryPermissionNotifier()
: super(PermissionStatus.denied) // Denied is the intitial state : super(PermissionStatus.denied) // Denied is the initial state
{ {
// Sets the initial state // Sets the initial state
getGalleryPermissionStatus(); getGalleryPermissionStatus();

View File

@@ -0,0 +1,22 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// Whether to display the video part of a motion photo
final isSearchingProvider = StateNotifierProvider<IsSearching, bool>((ref) {
return IsSearching(ref);
});
class IsSearching extends StateNotifier<bool> {
IsSearching(this.ref) : super(false);
final Ref ref;
bool get value => state;
set value(bool value) {
state = value;
}
void toggle() {
state = !state;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/search/search_result.model.dart'; import 'package:immich_mobile/models/search/search_result.model.dart';
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/services/timeline.service.dart'; import 'package:immich_mobile/services/timeline.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart'; import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
@@ -10,36 +11,41 @@ part 'paginated_search.provider.g.dart';
final paginatedSearchProvider = final paginatedSearchProvider =
StateNotifierProvider<PaginatedSearchNotifier, SearchResult>( StateNotifierProvider<PaginatedSearchNotifier, SearchResult>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), (ref) => PaginatedSearchNotifier(ref),
); );
class PaginatedSearchNotifier extends StateNotifier<SearchResult> { class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
final SearchService _searchService; final Ref ref;
PaginatedSearchNotifier(this._searchService) PaginatedSearchNotifier(this.ref)
: super(SearchResult(assets: [], nextPage: 1)); : super(const SearchResult(assets: [], nextPage: 1));
Future<bool> search(SearchFilter filter) async { Future<bool> search(SearchFilter filter) async {
if (state.nextPage == null) { if (state.nextPage == null) {
return false; return false;
} }
final result = await _searchService.search(filter, state.nextPage!); ref.read(isSearchingProvider.notifier).value = true;
try {
final result =
await ref.read(searchServiceProvider).search(filter, state.nextPage!);
if (result == null) {
return false;
}
if (result == null) { state = SearchResult(
return false; assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
);
} finally {
ref.read(isSearchingProvider.notifier).value = false;
} }
state = SearchResult(
assets: [...state.assets, ...result.assets],
nextPage: result.nextPage,
);
return true; return true;
} }
clear() { clear() {
state = SearchResult(assets: [], nextPage: 1); state = const SearchResult(assets: [], nextPage: 1);
} }
} }

View File

@@ -0,0 +1,81 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/person_api.interface.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/is_searching.provider.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
final searchFiltersProvider =
StateNotifierProvider<SearchFilterNotifier, SearchFilter>((ref) {
return SearchFilterNotifier(ref);
});
const searchFiltersDefault = SearchFilter(
people: {},
location: SearchLocationFilter(),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(
isNotInAlbum: false,
isArchive: false,
isFavorite: false,
),
mediaType: AssetType.other,
);
class SearchFilterNotifier extends StateNotifier<SearchFilter> {
final Ref ref;
SearchFilterNotifier(this.ref) : super(searchFiltersDefault);
SearchFilter get value => state;
set value(SearchFilter value) {
state = value;
}
void reset() {
state = searchFiltersDefault;
}
Set<Person> get people => state.people;
SearchLocationFilter get location => state.location;
SearchCameraFilter get camera => state.camera;
SearchDateFilter get date => state.date;
SearchDisplayFilters get display => state.display;
AssetType get mediaType => state.mediaType;
set people(Set<Person> value) {
state = state.copyWith(people: value);
}
set location(SearchLocationFilter value) {
state = state.copyWith(location: value);
}
set camera(SearchCameraFilter value) {
state = state.copyWith(camera: value);
}
set date(SearchDateFilter value) {
state = state.copyWith(date: value);
}
set display(SearchDisplayFilters value) {
state = state.copyWith(display: value);
}
set mediaType(AssetType value) {
state = state.copyWith(mediaType: value);
}
Future<void> search() {
ref.read(paginatedSearchProvider.notifier).clear();
return ref.read(paginatedSearchProvider.notifier).search(state);
}
}

View File

@@ -6,7 +6,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/api.service.dart'; import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/user.service.dart'; import 'package:immich_mobile/services/timeline.service.dart';
class CurrentUserProvider extends StateNotifier<User?> { class CurrentUserProvider extends StateNotifier<User?> {
CurrentUserProvider(this._apiService) : super(null) { CurrentUserProvider(this._apiService) : super(null) {
@@ -46,14 +46,15 @@ final currentUserProvider =
}); });
class TimelineUserIdsProvider extends StateNotifier<List<int>> { class TimelineUserIdsProvider extends StateNotifier<List<int>> {
TimelineUserIdsProvider(this._userService) : super([]) { TimelineUserIdsProvider(this._timelineService) : super([]) {
_userService.getTimelineUserIds().then((users) => state = users); _timelineService.getTimelineUserIds().then((users) => state = users);
streamSub = streamSub = _timelineService
_userService.watchTimelineUserIds().listen((users) => state = users); .watchTimelineUserIds()
.listen((users) => state = users);
} }
late final StreamSubscription<List<int>> streamSub; late final StreamSubscription<List<int>> streamSub;
final UserService _userService; final TimelineService _timelineService;
@override @override
void dispose() { void dispose() {
@@ -64,5 +65,5 @@ class TimelineUserIdsProvider extends StateNotifier<List<int>> {
final timelineUsersIdsProvider = final timelineUsersIdsProvider =
StateNotifierProvider<TimelineUserIdsProvider, List<int>>((ref) { StateNotifierProvider<TimelineUserIdsProvider, List<int>>((ref) {
return TimelineUserIdsProvider(ref.watch(userServiceProvider)); return TimelineUserIdsProvider(ref.watch(timelineServiceProvider));
}); });

View File

@@ -2,6 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/timeline.interface.dart'; import 'package:immich_mobile/interfaces/timeline.interface.dart';
import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/repositories/database.repository.dart'; import 'package:immich_mobile/repositories/database.repository.dart';
@@ -15,6 +16,28 @@ class TimelineRepository extends DatabaseRepository
implements ITimelineRepository { implements ITimelineRepository {
TimelineRepository(super.db); TimelineRepository(super.db);
@override
Future<List<int>> getTimelineUserIds(int id) {
return db.users
.filter()
.inTimelineEqualTo(true)
.or()
.isarIdEqualTo(id)
.isarIdProperty()
.findAll();
}
@override
Stream<List<int>> watchTimelineUsers(int id) {
return db.users
.filter()
.inTimelineEqualTo(true)
.or()
.isarIdEqualTo(id)
.isarIdProperty()
.watch();
}
@override @override
Stream<RenderList> watchArchiveTimeline(int userId) { Stream<RenderList> watchArchiveTimeline(int userId) {
final query = db.assets final query = db.assets

View File

@@ -70,26 +70,4 @@ class UserRepository extends DatabaseRepository implements IUserRepository {
await db.users.clear(); await db.users.clear();
}); });
} }
@override
Future<List<int>> getTimelineUserIds(int id) {
return db.users
.filter()
.inTimelineEqualTo(true)
.or()
.isarIdEqualTo(id)
.isarIdProperty()
.findAll();
}
@override
Stream<List<int>> watchTimelineUsers(int id) {
return db.users
.filter()
.inTimelineEqualTo(true)
.or()
.isarIdEqualTo(id)
.isarIdProperty()
.watch();
}
} }

View File

@@ -169,7 +169,10 @@ class AlbumService {
final Stopwatch sw = Stopwatch()..start(); final Stopwatch sw = Stopwatch()..start();
bool changes = false; bool changes = false;
try { try {
await _userService.refreshUsers(); final users = await _userService.getUsersFromServer();
if (users != null) {
await _syncService.syncUsersFromServer(users);
}
final (sharedAlbum, ownedAlbum) = await ( final (sharedAlbum, ownedAlbum) = await (
// Note: `shared: true` is required to get albums that don't belong to // Note: `shared: true` is required to get albums that don't belong to
// us due to unusual behaviour on the API but this will also return our // us due to unusual behaviour on the API but this will also return our

View File

@@ -84,15 +84,17 @@ class ApiService implements Authentication {
/// port - optional (default: based on schema) /// port - optional (default: based on schema)
/// path - optional /// path - optional
Future<String> resolveEndpoint(String serverUrl) async { Future<String> resolveEndpoint(String serverUrl) async {
final url = sanitizeUrl(serverUrl); String url = sanitizeUrl(serverUrl);
if (!await _isEndpointAvailable(serverUrl)) {
throw ApiException(503, "Server is not reachable");
}
// Check for /.well-known/immich // Check for /.well-known/immich
final wellKnownEndpoint = await _getWellKnownEndpoint(url); final wellKnownEndpoint = await _getWellKnownEndpoint(url);
if (wellKnownEndpoint.isNotEmpty) return wellKnownEndpoint; if (wellKnownEndpoint.isNotEmpty) {
url = sanitizeUrl(wellKnownEndpoint);
}
if (!await _isEndpointAvailable(url)) {
throw ApiException(503, "Server is not reachable");
}
// Otherwise, assume the URL provided is the api endpoint // Otherwise, assume the URL provided is the api endpoint
return url; return url;
@@ -128,10 +130,12 @@ class ApiService implements Authentication {
var headers = {"Accept": "application/json"}; var headers = {"Accept": "application/json"};
headers.addAll(getRequestHeaders()); headers.addAll(getRequestHeaders());
final res = await client.get( final res = await client
Uri.parse("$baseUrl/.well-known/immich"), .get(
headers: headers, Uri.parse("$baseUrl/.well-known/immich"),
); headers: headers,
)
.timeout(const Duration(seconds: 5));
if (res.statusCode == 200) { if (res.statusCode == 200) {
final data = jsonDecode(res.body); final data = jsonDecode(res.body);

View File

@@ -75,7 +75,7 @@ class AuthService {
isValid = true; isValid = true;
} }
} catch (error) { } catch (error) {
_log.severe("Error validating auxilary endpoint", error); _log.severe("Error validating auxiliary endpoint", error);
} finally { } finally {
httpclient.close(); httpclient.close();
} }
@@ -187,7 +187,7 @@ class AuthService {
_log.severe("Cannot resolve endpoint", error); _log.severe("Cannot resolve endpoint", error);
continue; continue;
} catch (_) { } catch (_) {
_log.severe("Auxilary server is not valid"); _log.severe("Auxiliary server is not valid");
continue; continue;
} }
} }

View File

@@ -329,7 +329,7 @@ class BackgroundService {
try { try {
_clearErrorNotifications(); _clearErrorNotifications();
// iOS should time out after some threshhold so it doesn't wait // iOS should time out after some threshold so it doesn't wait
// indefinitely and can run later // indefinitely and can run later
// Android is fine to wait here until the lock releases // Android is fine to wait here until the lock releases
final waitForLock = Platform.isIOS final waitForLock = Platform.isIOS
@@ -410,7 +410,6 @@ class BackgroundService {
partnerApiRepository, partnerApiRepository,
userApiRepository, userApiRepository,
userRepository, userRepository,
syncSerive,
); );
AlbumService albumService = AlbumService( AlbumService albumService = AlbumService(
userService, userService,

View File

@@ -26,7 +26,7 @@ class MemoryService {
try { try {
final now = DateTime.now(); final now = DateTime.now();
final data = await _apiService.memoriesApi.searchMemories( final data = await _apiService.memoriesApi.searchMemories(
for_: now, for_: DateTime.utc(now.year, now.month, now.day, 0, 0, 0),
); );
if (data == null) { if (data == null) {

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart'; import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/etag.entity.dart'; import 'package:immich_mobile/entities/etag.entity.dart';
import 'package:immich_mobile/entities/user.entity.dart'; import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/interfaces/album.interface.dart'; import 'package:immich_mobile/interfaces/album.interface.dart';
import 'package:immich_mobile/interfaces/album_api.interface.dart'; import 'package:immich_mobile/interfaces/album_api.interface.dart';
import 'package:immich_mobile/interfaces/album_media.interface.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart';
@@ -23,7 +24,6 @@ import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/services/entity.service.dart'; import 'package:immich_mobile/services/entity.service.dart';
import 'package:immich_mobile/services/hash.service.dart'; import 'package:immich_mobile/services/hash.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/utils/datetime_comparison.dart'; import 'package:immich_mobile/utils/datetime_comparison.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -639,7 +639,7 @@ class SyncService {
} }
/// fast path for common case: only new assets were added to device album /// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false` /// returns `true` if successful, else `false`
Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async { Future<bool> _syncDeviceAlbumFast(Album deviceAlbum, Album dbAlbum) async {
if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) { if (!deviceAlbum.modifiedAt.isAfter(dbAlbum.modifiedAt)) {
return false; return false;

View File

@@ -21,12 +21,23 @@ class TimelineService {
final ITimelineRepository _timelineRepository; final ITimelineRepository _timelineRepository;
final IUserRepository _userRepository; final IUserRepository _userRepository;
final AppSettingsService _appSettingsService; final AppSettingsService _appSettingsService;
TimelineService(
const TimelineService(
this._timelineRepository, this._timelineRepository,
this._userRepository, this._userRepository,
this._appSettingsService, this._appSettingsService,
); );
Future<List<int>> getTimelineUserIds() async {
final me = await _userRepository.me();
return _timelineRepository.getTimelineUserIds(me.isarId);
}
Stream<List<int>> watchTimelineUserIds() async* {
final me = await _userRepository.me();
yield* _timelineRepository.watchTimelineUsers(me.isarId);
}
Stream<RenderList> watchHomeTimeline(int userId) { Stream<RenderList> watchHomeTimeline(int userId) {
return _timelineRepository.watchHomeTimeline(userId, _getGroupByOption()); return _timelineRepository.watchHomeTimeline(userId, _getGroupByOption());
} }

View File

@@ -1,14 +1,13 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/interfaces/partner_api.interface.dart'; import 'package:immich_mobile/interfaces/partner_api.interface.dart';
import 'package:immich_mobile/interfaces/user.interface.dart'; import 'package:immich_mobile/interfaces/user.interface.dart';
import 'package:immich_mobile/interfaces/user_api.interface.dart'; import 'package:immich_mobile/interfaces/user_api.interface.dart';
import 'package:immich_mobile/repositories/partner_api.repository.dart'; import 'package:immich_mobile/repositories/partner_api.repository.dart';
import 'package:immich_mobile/repositories/user.repository.dart'; import 'package:immich_mobile/repositories/user.repository.dart';
import 'package:immich_mobile/repositories/user_api.repository.dart'; import 'package:immich_mobile/repositories/user_api.repository.dart';
import 'package:immich_mobile/entities/user.entity.dart';
import 'package:immich_mobile/services/sync.service.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@@ -17,7 +16,6 @@ final userServiceProvider = Provider(
ref.watch(partnerApiRepositoryProvider), ref.watch(partnerApiRepositoryProvider),
ref.watch(userApiRepositoryProvider), ref.watch(userApiRepositoryProvider),
ref.watch(userRepositoryProvider), ref.watch(userRepositoryProvider),
ref.watch(syncServiceProvider),
), ),
); );
@@ -25,14 +23,12 @@ class UserService {
final IPartnerApiRepository _partnerApiRepository; final IPartnerApiRepository _partnerApiRepository;
final IUserApiRepository _userApiRepository; final IUserApiRepository _userApiRepository;
final IUserRepository _userRepository; final IUserRepository _userRepository;
final SyncService _syncService;
final Logger _log = Logger("UserService"); final Logger _log = Logger("UserService");
UserService( UserService(
this._partnerApiRepository, this._partnerApiRepository,
this._userApiRepository, this._userApiRepository,
this._userRepository, this._userRepository,
this._syncService,
); );
Future<List<User>> getUsers({bool self = false}) { Future<List<User>> getUsers({bool self = false}) {
@@ -98,23 +94,7 @@ class UserService {
return users; return users;
} }
Future<bool> refreshUsers() async {
final users = await getUsersFromServer();
if (users == null) return false;
return _syncService.syncUsersFromServer(users);
}
Future<void> clearTable() { Future<void> clearTable() {
return _userRepository.clearTable(); return _userRepository.clearTable();
} }
Future<List<int>> getTimelineUserIds() async {
final me = await _userRepository.me();
return _userRepository.getTimelineUserIds(me.isarId);
}
Stream<List<int>> watchTimelineUserIds() async* {
final me = await _userRepository.me();
yield* _userRepository.watchTimelineUsers(me.isarId);
}
} }

View File

@@ -49,8 +49,8 @@ abstract final class Bootstrap {
static Future<void> initDomain(Isar db) async { static Future<void> initDomain(Isar db) async {
await StoreService.init(storeRepository: IsarStoreRepository(db)); await StoreService.init(storeRepository: IsarStoreRepository(db));
await LogService.init( await LogService.init(
logRepo: IsarLogRepository(db), logRepository: IsarLogRepository(db),
storeRepo: IsarStoreRepository(db), storeRepository: IsarStoreRepository(db),
); );
} }
} }

View File

@@ -5,7 +5,7 @@ import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart'
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart'; import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
/// [ImageCache] that uses two caches for small and large images /// [ImageCache] that uses two caches for small and large images
/// so that a single large image does not evict all small iamges /// so that a single large image does not evict all small images
final class CustomImageCache implements ImageCache { final class CustomImageCache implements ImageCache {
final _small = ImageCache(); final _small = ImageCache();
final _large = ImageCache()..maximumSize = 5; // Maximum 5 images final _large = ImageCache()..maximumSize = 5; // Maximum 5 images

View File

@@ -26,7 +26,7 @@ double getScaleForScaleState(
} }
/// Internal class to wraps custom scale boundaries (min, max and initial) /// Internal class to wraps custom scale boundaries (min, max and initial)
/// Also, stores values regarding the two sizes: the container and teh child. /// Also, stores values regarding the two sizes: the container and the child.
class ScaleBoundaries { class ScaleBoundaries {
const ScaleBoundaries( const ScaleBoundaries(
this._minScale, this._minScale,

View File

@@ -2,13 +2,12 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store; import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart'; import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/providers/network.provider.dart'; import 'package:immich_mobile/providers/network.provider.dart';
import 'package:immich_mobile/services/app_settings.service.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/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/external_network_preference.dart';
import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart'; import 'package:immich_mobile/widgets/settings/networking_settings/local_network_preference.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart'; import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@@ -18,7 +17,7 @@ class NetworkingSettings extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentEndpoint = Store.get(StoreKey.serverEndpoint); final currentEndpoint = getServerUrl();
final featureEnabled = final featureEnabled =
useAppSettingsState(AppSettingsEnum.autoEndpointSwitching); useAppSettingsState(AppSettingsEnum.autoEndpointSwitching);
@@ -102,7 +101,7 @@ class NetworkingSettings extends HookConsumerWidget {
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8), padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8),
child: NetworkPreferenceTitle( child: NetworkPreferenceTitle(
title: "current_server_address".tr().toUpperCase(), title: "current_server_address".tr().toUpperCase(),
icon: currentEndpoint.startsWith('https') icon: (currentEndpoint?.startsWith('https') ?? false)
? Icons.https_outlined ? Icons.https_outlined
: Icons.http_outlined, : Icons.http_outlined,
), ),
@@ -119,10 +118,16 @@ class NetworkingSettings extends HookConsumerWidget {
), ),
), ),
child: ListTile( child: ListTile(
leading: leading: currentEndpoint != null
const Icon(Icons.check_circle_rounded, color: Colors.green), ? const Icon(
Icons.check_circle_rounded,
color: Colors.green,
)
: const Icon(
Icons.circle_outlined,
),
title: Text( title: Text(
currentEndpoint, currentEndpoint ?? "--",
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontFamily: 'Inconsolata', fontFamily: 'Inconsolata',

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.127.0 - API version: 1.129.0
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -425,6 +425,8 @@ Class | Method | HTTP request | Description
- [SyncAckDto](doc//SyncAckDto.md) - [SyncAckDto](doc//SyncAckDto.md)
- [SyncAckSetDto](doc//SyncAckSetDto.md) - [SyncAckSetDto](doc//SyncAckSetDto.md)
- [SyncEntityType](doc//SyncEntityType.md) - [SyncEntityType](doc//SyncEntityType.md)
- [SyncPartnerDeleteV1](doc//SyncPartnerDeleteV1.md)
- [SyncPartnerV1](doc//SyncPartnerV1.md)
- [SyncRequestType](doc//SyncRequestType.md) - [SyncRequestType](doc//SyncRequestType.md)
- [SyncStreamDto](doc//SyncStreamDto.md) - [SyncStreamDto](doc//SyncStreamDto.md)
- [SyncUserDeleteV1](doc//SyncUserDeleteV1.md) - [SyncUserDeleteV1](doc//SyncUserDeleteV1.md)

View File

@@ -232,6 +232,8 @@ part 'model/sync_ack_delete_dto.dart';
part 'model/sync_ack_dto.dart'; part 'model/sync_ack_dto.dart';
part 'model/sync_ack_set_dto.dart'; part 'model/sync_ack_set_dto.dart';
part 'model/sync_entity_type.dart'; part 'model/sync_entity_type.dart';
part 'model/sync_partner_delete_v1.dart';
part 'model/sync_partner_v1.dart';
part 'model/sync_request_type.dart'; part 'model/sync_request_type.dart';
part 'model/sync_stream_dto.dart'; part 'model/sync_stream_dto.dart';
part 'model/sync_user_delete_v1.dart'; part 'model/sync_user_delete_v1.dart';

View File

@@ -520,6 +520,10 @@ class ApiClient {
return SyncAckSetDto.fromJson(value); return SyncAckSetDto.fromJson(value);
case 'SyncEntityType': case 'SyncEntityType':
return SyncEntityTypeTypeTransformer().decode(value); return SyncEntityTypeTypeTransformer().decode(value);
case 'SyncPartnerDeleteV1':
return SyncPartnerDeleteV1.fromJson(value);
case 'SyncPartnerV1':
return SyncPartnerV1.fromJson(value);
case 'SyncRequestType': case 'SyncRequestType':
return SyncRequestTypeTypeTransformer().decode(value); return SyncRequestTypeTypeTransformer().decode(value);
case 'SyncStreamDto': case 'SyncStreamDto':

View File

@@ -25,11 +25,15 @@ class SyncEntityType {
static const userV1 = SyncEntityType._(r'UserV1'); static const userV1 = SyncEntityType._(r'UserV1');
static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1'); static const userDeleteV1 = SyncEntityType._(r'UserDeleteV1');
static const partnerV1 = SyncEntityType._(r'PartnerV1');
static const partnerDeleteV1 = SyncEntityType._(r'PartnerDeleteV1');
/// List of all possible values in this [enum][SyncEntityType]. /// List of all possible values in this [enum][SyncEntityType].
static const values = <SyncEntityType>[ static const values = <SyncEntityType>[
userV1, userV1,
userDeleteV1, userDeleteV1,
partnerV1,
partnerDeleteV1,
]; ];
static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value); static SyncEntityType? fromJson(dynamic value) => SyncEntityTypeTypeTransformer().decode(value);
@@ -70,6 +74,8 @@ class SyncEntityTypeTypeTransformer {
switch (data) { switch (data) {
case r'UserV1': return SyncEntityType.userV1; case r'UserV1': return SyncEntityType.userV1;
case r'UserDeleteV1': return SyncEntityType.userDeleteV1; case r'UserDeleteV1': return SyncEntityType.userDeleteV1;
case r'PartnerV1': return SyncEntityType.partnerV1;
case r'PartnerDeleteV1': return SyncEntityType.partnerDeleteV1;
default: default:
if (!allowNull) { if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data'); throw ArgumentError('Unknown enum value to decode: $data');

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