Compare commits

..

61 Commits

Author SHA1 Message Date
Daniel Dietzler
479238c241 fix: bump sharp to 0.35.1 2026-07-22 10:29:43 +02:00
renovate[bot]
81f2c5fa9c fix(deps): update sharp to ^0.35.0 2026-07-22 10:29:20 +02:00
Ijas
998fd5f6a8 fix(mobile): send date-only value for memories query param (#30049)
* fix(ios-widget): send date-only value for memories query param

Server v3.0.3 (immich-app#29907) validates the /memories `for` query
param with a date-only schema (YYYY-MM-DD). The iOS widget still sent a
full ISO 8601 datetime, so every request 400s and the widget shows
"Unable to connect to your Immich instance" on any server >= v3.0.3.

Build the value from the local calendar day, matching what the web
client (memory-manager.svelte.ts) and Android already send, instead of
a UTC datetime.

Also fix a related bug in the timeline provider: fetchMemory was
called with a bare try inside a Task, so a failure silently dropped
the error and never called completion, leaving the widget in a stale
state instead of showing an error card. Wrap it in a guard/try?, same
pattern already used in getSnapshot.

Fixes immich-app#30044

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>

* address review: drop unrelated widget change and comment

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>

---------

Signed-off-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: ajuijas <189517297+ajuijas@users.noreply.github.com>
Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
2026-07-22 14:19:19 +06:00
renovate[bot]
f413c446e3 chore(deps): update github-actions (#30089) 2026-07-22 09:15:38 +02:00
Yaros
c9539a4aa8 fix(mobile): map unresponsive after viewing asset (#27036)
* fix(mobile): map unresponsive after viewing asset

* chore: better fix
2026-07-22 02:56:23 +05:30
Imperator Ruscal
a560e08693 fix: re-evaluate OIDC role claim on every login and support array values (#29991)
* fix(server): re-evaluate OIDC role claim on every login and support array values

Previously the OIDC role claim (immich_role) was only read at user
auto-registration time and only accepted as a scalar string, so admin
status never updated after the first login and array-valued role/group
claims (common with Keycloak, Entra ID, etc.) were silently ignored.

Now the role claim is normalized from either a string or an array of
strings, and existing users have their isAdmin flag synced from the
claim on every login, keeping the IdP as the source of truth for
privileges while leaving isAdmin untouched when the claim is blank.

* fix(server): use .includes() instead of .some() for role claim check

Satisfies unicorn/prefer-includes lint rule flagged by CI.

* fix(server): resolve missing role claim to standard user and fix test mock

Default the OIDC role claim to 'user' when the IdP omits it so
auto-registration doesn't crash, and add the missing getAdmin mock in the
auth.service test so it correctly simulates an existing admin rather than
the first-user-registration path.

* fix: claim sync logic

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2026-07-21 17:21:19 -04:00
Yaros
49c0baf076 fix: disable slideshow crossfade on reduced motion (#29826)
* fix: disable slideshow crossfade on reduced motion

* chore: share photoview instances

* chore: listen to changes
2026-07-22 01:59:26 +05:30
shenlong
3485dbffda refactor(mobile): extract CachedKeyValueRepository (#28789)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-21 20:23:50 +00:00
Yaros
e508781125 fix: limit ocr overlay to images (#30116) 2026-07-21 20:23:04 +00:00
Peter Ombodi
a30ebab70c fix(mobile): refresh asset stack after deleting viewer item (#28164)
* fix(mobile): refresh asset stack after deleting viewer item

* fix(mobile): clamp stack index,
filter visible stack assets

* refactor(mobile): simplify stack asset refresh handling

* fix(mobile): revert changes in DeleteLocalActionButton

* style(mobile): reformat code

* fix(mobile): remove redundant setStack

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-07-22 01:48:47 +05:30
Daniel Dietzler
5d7283e44d fix: album update event emitting (#30120) 2026-07-21 19:43:27 +00:00
Lorenzo Rota
73329a8ce8 fix(server): send id_token_hint on OIDC logout (#29720)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-21 19:19:13 +02:00
Santo Shakil
d5adfb97dd fix(mobile): treat wired ethernet as unmetered on ios (#29351)
check the metered flags instead of the interface type, so ethernet counts like android already does.
2026-07-21 11:02:36 -05:00
Priyanshu
b70cb58bc8 fix(web): improve slideshow controls visibility on bright backgrounds (#29950)
Co-authored-by: priyanshuANDcoad <priyanshu23154050@akgec.ac.in>
2026-07-21 15:58:51 +00:00
Santo Shakil
943bfafb7a fix(mobile): show real error when an asset can't be added to an album (#29754)
* fix(mobile): show real error when an asset can't be added to an album

* fix(mobile): show added toast when an add-to-album partly succeeds

* docs(mobile): explain why a partial add-to-album shows added
2026-07-21 10:56:50 -05:00
Timon
ee4bd3f833 feat: add album asset event handling (#29008)
* feat: add album asset event handling

* fix medium tests

* patch medium test

* collapse in AlbumUpdate
2026-07-21 10:55:46 -05:00
Adam Gastineau
7a7303aceb fix(mobile): properly group download tasks for Live Photos (#29952)
* fix(mobile): properly group download tasks for Live Photos

* Remove completely unused onLivePhotoDownloadStatus

* Remove dead code
2026-07-21 10:45:15 -05:00
Adam Gastineau
0a7c067b20 chore(mobile): bump maplibre to 0.26.2 in SwiftPM lock (#30108) 2026-07-21 10:44:30 -05:00
Luis Nachtigall
c8de56c422 fix(mobile): prevent duplicate album creation during submission (#30003)
* fix: prevent duplicate album creation during submission

* Nit fix

---------

Co-authored-by: Adam Gastineau <adam@agg.im>
2026-07-21 15:31:23 +00:00
shenlong
0e4572c37b chore: pin photo_manager to 3.9.0 (#30091)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-21 10:06:27 -05:00
Santo Shakil
14a2776453 fix(mobile): add album picker to the partner view bottom sheet (#30099) 2026-07-21 10:05:34 -05:00
renovate[bot]
59bc81423c chore(deps): update github-actions (major) (#30095) 2026-07-21 16:37:38 +02:00
Roberto Carlos Martinez Arriaga
e6fff3b15e feat(web): add maintenance link to command palette (#30016) 2026-07-21 16:27:23 +02:00
renovate[bot]
755df23b22 chore(deps): update ghcr.io/devcontainers/features/docker-in-docker docker tag to v4 (#30094) 2026-07-21 16:05:54 +02:00
Jason Rasmussen
899f547053 fix(web): lens model search (#30088) 2026-07-21 13:54:28 +00:00
Santo Shakil
4938fd4c89 fix(mobile): back up files moved into a watched folder on android (#29872) 2026-07-21 19:09:50 +05:30
Santo Shakil
e81a79224c fix(mobile): stop double close animation when dismissing an asset (#29413) 2026-07-21 19:08:45 +05:30
shenlong
7b0f58b2c1 refactor: gracefully stop during engine startup (#29179)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-21 19:08:13 +05:30
renovate[bot]
ae8398ffe4 chore(deps): update docker.io/valkey/valkey:9 docker digest to 8e8d64b (#30085) 2026-07-21 15:36:01 +02:00
renovate[bot]
aa08dad1f5 chore(deps): update dependency eslint-plugin-unicorn to v72 (#30092)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-21 15:33:46 +02:00
renovate[bot]
ce022233ae chore(deps): update base-image to v202607211135 (#30090) 2026-07-21 13:58:02 +02:00
Rust
1b4d41324e fix(server): file uploads for files with extension only filenames (#30024)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-21 11:57:28 +00:00
renovate[bot]
8365cdd59e chore(deps): update dependency typescript to v7 (#29903)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-21 13:36:57 +02:00
Mees Frensel
1cec7021e8 fix(web): space between device type and version (#30102) 2026-07-21 11:08:29 +00:00
renovate[bot]
1b0f473457 fix(deps): update typescript-projects (#30087)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-21 11:01:23 +00:00
renovate[bot]
e918658cc1 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.7.11 (#30086) 2026-07-21 12:24:32 +02:00
Nikola Nedevski
2e587fc7e8 feat(docs): add bulgarian readme (#29427)
* Added Bulgarian readme.md

* Updated collaborators link for BG readme collaborators section.
2026-07-21 00:10:08 -04:00
renovate[bot]
df970da59e chore(deps): update dependency eslint-plugin-unicorn to v70 - abandoned (#29684)
* chore(deps): update dependency eslint-plugin-unicorn to v70

* fix: linting

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-20 23:47:14 -04:00
Rust
8061a2e5ff fix(web): fix Country/State filters when set to Unknown (#30026)
* fix(web): create emptyStringToNull helper func and apply it to country/state search payload generation

* chore: feedback

* fix: other empty string fields

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2026-07-20 21:15:00 -05:00
Daniel Dietzler
77091b0107 fix: search statistics locked folder permissions (#30063)
fix; search statistics locked folder permissions
2026-07-20 20:08:42 -04:00
Daniel Dietzler
4c754f2999 fix: timebuckets locked permissions (#30066) 2026-07-20 20:07:56 -04:00
Timon
6fa3f2feac chore: bump openapi-generator version to v7.24.0 (#30067)
* remove patch in favor of [OpenAPITools/openapi-generator#23930](https://github.com/OpenAPITools/openapi-generator/issues/23930)

partially revert "chore(mobile): make openapi requests abortable (#28692)"

* gen client

* bump openapi-generator version to v7.24.0

* gen client

* enum migration changes

* drop enum exhaustion switch check
2026-07-20 20:06:23 -04:00
Adam Gastineau
3f6897ef80 chore(mobile): enable Xcode displaying issues from Flutter inline (#30080) 2026-07-20 14:16:12 -05:00
Matthias Keim
89d0a9d59f fix(web): attach file picker input to DOM so iOS Safari fires change … (#29660)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-20 19:03:42 +00:00
Adam Gastineau
45a6ea84af fix(mobile): prevent crash on video widget dispose (#30078) 2026-07-20 19:00:37 +00:00
shenlong
bf64f3867b fix: do not show the whats new page on fresh login (#30072)
* fix: do not show the whats new page on fresh login

* review suggestion

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-20 12:24:01 -05:00
Priyanshu
4a4d468aa2 fix(web): refresh folder view after asset deletion (#29899) 2026-07-20 18:20:52 +02:00
Priyanshu
522def1ed6 fix(web): align ContextMenu z-index with design-system token (#30015)
Co-authored-by: priyanshuANDcoad <priyanshu23154050@akgec.ac.in>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-20 13:54:59 +00:00
Timon
3adc3920fb chore: bump openapi-generator version to v7.23.0 (#28916) 2026-07-20 15:06:51 +02:00
Matthew Momjian
00cb50cc67 fix(docs): remove ref to synology channel (#30051)
synology channel
2026-07-19 21:22:17 -05:00
Alex
f2b0b696f6 fix: long press share quality override preference settings (#30030) 2026-07-18 12:07:18 -05:00
Luis Nachtigall
b5401eb120 fix(mobile): update album creation to use user-defined name from dialog (#30002)
fix: update album creation to use user-defined name from dialog
2026-07-17 23:45:20 +05:30
Ben Beckford
12fc8bac18 feat: workflow filter assets by upload path (#30000)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-07-17 09:46:48 +00:00
Ben Beckford
f2c00c107d fix(server): return workflow steps in ascending order (#29999) 2026-07-17 11:30:40 +02:00
shenlong
f19f30ec66 fix: reset override for kebab buttons (#29380)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-17 01:19:55 +05:30
shenlong
297316c7b8 refactor: owned action assets filter (#29382)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 19:49:30 +00:00
renovate[bot]
f18065b3c2 chore(deps): update dependency @types/node to ^24.13.3 (#29892) 2026-07-16 20:31:45 +02:00
renovate[bot]
a3dd19cd2f fix(deps): update typescript-projects (#29633)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-16 20:14:05 +02:00
shenlong
9cea3f2375 chore: pump drift (#29113)
* chore: pump drift

* run codegen

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 17:32:30 +00:00
shenlong
507448d797 chore: pump home_widget (#29981)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-16 21:58:11 +05:30
Yaros
fe3eb7d865 feat(web): undo archive from toast (#27061)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2026-07-16 17:53:02 +02:00
514 changed files with 8640 additions and 8598 deletions

View File

@@ -83,7 +83,7 @@
}
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"ghcr.io/devcontainers/features/docker-in-docker:4": {
// https://github.com/devcontainers/features/issues/1466
"moby": false
}

View File

@@ -15,7 +15,7 @@ jobs:
outputs:
uses_template: ${{ steps.check.outputs.uses_template }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
sparse-checkout: .github/pull_request_template.md
sparse-checkout-cone-mode: false

View File

@@ -87,7 +87,7 @@ jobs:
permission-contents: read
permission-pull-requests: write
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.ref }}
persist-credentials: false
@@ -106,14 +106,14 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
distribution: 'zulu'
java-version: '17'
- name: Restore Gradle Cache
id: cache-gradle-restore
uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.gradle/caches
@@ -184,7 +184,7 @@ jobs:
- name: Save Gradle Cache
id: cache-gradle-save
uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
if: github.ref == 'refs/heads/main'
with:
path: |
@@ -201,7 +201,7 @@ jobs:
contents: read
# Run on main branch or workflow_dispatch, or on PRs/other branches (build only, no upload)
if: ${{ !github.event.pull_request.head.repo.fork && fromJSON(needs.pre-job.outputs.should_run).mobile == true }}
runs-on: macos-15
runs-on: macos-26
steps:
- id: token
@@ -215,7 +215,7 @@ jobs:
run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -241,7 +241,7 @@ jobs:
run: flutter build ios --config-only --no-codesign
- name: Setup Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 # v1.319.0
with:
ruby-version: '3.3'
bundler-cache: true

View File

@@ -27,7 +27,7 @@ jobs:
permission-actions: write
- name: Check out code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -20,12 +20,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@291b6594ce617f22f49af5898ee038ed0b46e8fe # v0.1.5
uses: oasdiff/oasdiff-action/breaking@024f6c399f9a21ada1addb0f9a36ce1bfac995f1 # v0.1.6
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
@@ -38,7 +38,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

View File

@@ -38,7 +38,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -71,7 +71,7 @@ jobs:
permission-contents: read
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -39,7 +39,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:e3fe456f346b02c2f479462100763aee3ba8245768dfeac3c4262acb068e5c62
image: ghcr.io/immich-app/mdq:main@sha256:d603f5618c1fc9f2b4d506ffed54ba6bcfec83d56b893b5fa8b3f899a402fa0a
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:

View File

@@ -51,14 +51,14 @@ jobs:
permission-contents: read
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,7 +71,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
category: '/language:${{matrix.language}}'

View File

@@ -62,7 +62,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -137,7 +137,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -25,7 +25,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -14,7 +14,7 @@ jobs:
contents: read
steps:
- name: Checkout pubspec for versionCode
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.release.tag_name }}
persist-credentials: false

View File

@@ -23,7 +23,7 @@ jobs:
permission-pull-requests: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.head.ref }}
persist-credentials: true

View File

@@ -61,7 +61,7 @@ jobs:
permission-contents: write
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ steps.token.outputs.token }}
persist-credentials: true
@@ -140,7 +140,7 @@ jobs:
permission-actions: read
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
@@ -152,7 +152,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
draft: true
prerelease: ${{ needs.bump_version.outputs.rc }}

View File

@@ -23,7 +23,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -58,7 +58,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -85,7 +85,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -114,7 +114,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -146,7 +146,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -178,7 +178,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -223,7 +223,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -262,7 +262,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -291,7 +291,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -342,7 +342,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -375,7 +375,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: 'recursive'
@@ -412,7 +412,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: 'recursive'
@@ -422,7 +422,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -490,7 +490,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
submodules: 'recursive'
@@ -500,7 +500,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: '.nvmrc'
cache: 'pnpm'
@@ -598,7 +598,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -637,7 +637,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -669,7 +669,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -699,7 +699,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -722,7 +722,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -733,7 +733,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm --filter immich install --frozen-lockfile
run: pnpm --filter immich install --frozen-lockfile
- name: Run API generation
run: mise //:open-api
working-directory: open-api
@@ -781,7 +781,7 @@ jobs:
permission-contents: read
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -792,7 +792,7 @@ jobs:
github_token: ${{ steps.token.outputs.token }}
- name: Install server dependencies
run: SHARP_IGNORE_GLOBAL_LIBVIPS=true pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Build plugins
run: mise //:plugins

38
.vscode/settings.json vendored
View File

@@ -29,9 +29,6 @@
"editor.formatOnSave": true,
"tailwindCSS.lint.suggestCanonicalClasses": "ignore"
},
"svelte.plugin.svelte.compilerWarnings": {
"state_referenced_locally": "ignore"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true
@@ -43,37 +40,40 @@
"eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [
{ "directory": "cli", "changeProcessCWD": true },
{ "directory": "e2e", "changeProcessCWD": true },
{ "directory": "server", "changeProcessCWD": true },
{ "directory": "web", "changeProcessCWD": true }
{ "changeProcessCWD": true, "directory": "cli" },
{ "changeProcessCWD": true, "directory": "e2e" },
{ "changeProcessCWD": true, "directory": "server" },
{ "changeProcessCWD": true, "directory": "web" }
],
"files.watcherExclude": {
"**/.jj/**": true,
"**/.git/**": true,
"**/node_modules/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/.svelte-kit/**": true
},
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"*.js": "${capture}.spec.js,${capture}.mock.js",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lockb, bun.lock, pnpm-workspace.yaml, .pnpmfile.cjs"
},
"files.watcherExclude": {
"**/.git/**": true,
"**/.jj/**": true,
"**/.svelte-kit/**": true,
"**/build/**": true,
"**/dist/**": true,
"**/node_modules/**": true
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"search.exclude": {
"**/node_modules": true,
"**/.svelte-kit": true,
"**/build": true,
"**/dist": true,
"**/.svelte-kit": true,
"**/node_modules": true,
"**/open-api/typescript-sdk/src": true
},
"svelte.enable-ts-plugin": true,
"svelte.plugin.svelte.compilerWarnings": {
"state_referenced_locally": "ignore"
},
"tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**"
},
"js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10
}

View File

@@ -32,6 +32,7 @@
<a href="readme_i18n/README_zh_TW.md">正體中文</a>
<a href="readme_i18n/README_uk_UA.md">Українська</a>
<a href="readme_i18n/README_ru_RU.md">Русский</a>
<a href="readme_i18n/README_bg_BG.md">Български</a>
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
<a href="readme_i18n/README_sv_SE.md">Svenska</a>
<a href="readme_i18n/README_ar_JO.md">العربية</a>

View File

@@ -154,7 +154,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -61,7 +61,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
user: '1000:1000'
security_opt:
- no-new-privileges:true

View File

@@ -49,7 +49,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
healthcheck:
test: redis-cli ping || exit 1
restart: always

View File

@@ -7,7 +7,7 @@ sidebar_position: 85
:::note
This is a community contribution and not officially supported by the Immich team, but included here for convenience.
Community support can be found in the dedicated channel on the [Discord Server](https://discord.immich.app/).
Community support should be directed to Synology-specific support platforms.
:::
Immich can easily be installed on a Synology NAS using Container Manager within DSM. If you have not installed Container Manager already, you can install it in the Packages Center. Refer to the [Container Manager docs](https://kb.synology.com/en-us/DSM/help/ContainerManager/docker_desc?version=7) for more information on using Container Manager.

View File

@@ -121,7 +121,7 @@ alt="Go to Docker Tab and visit the address listed next to immich-web"
width="90%"
alt="Go to Docker Tab and visit the address listed next to immich-web"
/>
</details>
:::tip

View File

@@ -1,7 +1,7 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]]
version = "4.100.0"
version = "4.110.0"
backend = "npm:wrangler"
[tools.wrangler.options]

View File

@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools]
wrangler = "4.100.0"
wrangler = "4.111.0"

View File

@@ -40,7 +40,8 @@
"@docusaurus/tsconfig": "^3.10.0",
"@docusaurus/types": "^3.10.0",
"prettier": "^3.7.4",
"typescript": "^6.0.0"
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2"
},
"browserslist": {
"production": [

View File

@@ -45,7 +45,7 @@ services:
redis:
container_name: immich-e2e-redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9
image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -41,9 +41,18 @@ export default typescriptEslint.config([
'@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off',
'unicorn/import-style': 'off',
'unicorn/consistent-boolean-name': 'off',
'unicorn/no-non-function-verb-prefix': 'off',
'unicorn/no-unreadable-for-of-expression': 'off',
'unicorn/max-nested-calls': 'off',
'unicorn/prefer-uint8array-base64': 'off',
'unicorn/isolated-functions': 'off',
'unicorn/prefer-promise-with-resolvers': 'off',
'unicorn/no-declarations-before-early-exit': 'off',
'unicorn/prefer-simple-condition-first': 'off',
curly: 2,
'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'off',
'unicorn/name-replacements': 'off',
'unicorn/filename-case': 'off',
'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off',

View File

@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.13.2",
"@types/node": "^24.13.3",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^7.0.0",
@@ -40,7 +40,7 @@
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^64.0.0",
"eslint-plugin-unicorn": "^72.0.0",
"exiftool-vendored": "^35.0.0",
"globals": "^17.0.0",
"luxon": "^3.4.4",
@@ -48,10 +48,11 @@
"pngjs": "^7.0.0",
"prettier": "^3.7.4",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.5",
"sharp": "^0.35.2",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^6.0.0",
"@typescript/native": "npm:typescript@^7.0.2",
"typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "^8.28.0",
"utimes": "^5.2.1",
"vitest": "^4.0.0"

View File

@@ -118,7 +118,7 @@ describe('/admin/database-backups', () => {
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
cookie = headers['set-cookie'][0].split(';', 1)[0];
await expect
.poll(
@@ -224,7 +224,7 @@ describe('/admin/database-backups', () => {
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
cookie = headers['set-cookie'][0].split(';', 1)[0];
await expect
.poll(
@@ -295,7 +295,7 @@ describe('/admin/database-backups', () => {
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
cookie = headers['set-cookie'][0].split(';', 1)[0];
await expect
.poll(

View File

@@ -85,7 +85,7 @@ describe('/admin/maintenance', () => {
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
cookie = headers['set-cookie'][0].split(';', 1)[0];
expect(cookie).toEqual(
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
);
@@ -149,7 +149,7 @@ describe('/admin/maintenance', () => {
const { status, body } = await request(app)
.post('/admin/maintenance/login')
.send({
token: cookie!.split('=')[1].trim(),
token: cookie!.split('=', 2)[1].trim(),
});
expect(status).toBe(201);
expect(body).toEqual(

View File

@@ -27,7 +27,7 @@ test.describe('Maintenance', () => {
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie
?.map((cookie) => cookie.split(';')[0].split('='))
?.map((cookie) => cookie.split(';', 1)[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy();

View File

@@ -120,6 +120,7 @@ describe('/albums', () => {
}),
]);
// eslint-disable-next-line unicorn/no-unreadable-array-destructuring
[user2Albums[0]] = await Promise.all([
getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),

View File

@@ -781,7 +781,7 @@ describe('/asset', () => {
exifImageWidth: 4032,
exifImageHeight: 3024,
latitude: 41.2203,
longitude: -96.071_625,
longitude: -96.071625,
make: 'Apple',
model: 'iPhone 7',
lensModel: 'iPhone 7 back camera 3.99mm f/1.8',
@@ -973,9 +973,9 @@ describe('/asset', () => {
fileSizeInByte: 31_175_472,
focalLength: 18.3,
iso: 100,
latitude: 36.613_24,
latitude: 36.61324,
lensModel: '18.3mm F2.8',
longitude: -121.897_85,
longitude: -121.89785,
make: 'RICOH IMAGING COMPANY, LTD.',
model: 'RICOH GR III',
orientation: '1',

View File

@@ -75,7 +75,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400_968),
lon: expect.closeTo(-108.400968),
state: 'Colorado',
},
{
@@ -83,7 +83,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071_625),
lon: expect.closeTo(-96.071625),
state: 'Nebraska',
},
]);
@@ -123,7 +123,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(39.115),
lon: expect.closeTo(-108.400_968),
lon: expect.closeTo(-108.400968),
state: 'Colorado',
},
{
@@ -131,7 +131,7 @@ describe('/map', () => {
country: 'United States of America',
id: expect.any(String),
lat: expect.closeTo(41.2203),
lon: expect.closeTo(-96.071_625),
lon: expect.closeTo(-96.071625),
state: 'Nebraska',
},
]);
@@ -188,20 +188,20 @@ describe('/map', () => {
const reverseGeocodeTestCases = [
{
name: 'Vaucluse',
lat: -33.858_977_058_663_13,
lon: 151.278_490_730_270_48,
lat: -33.85897705866313,
lon: 151.27849073027048,
results: [{ city: 'Vaucluse', state: 'New South Wales', country: 'Australia' }],
},
{
name: 'Ravenhall',
lat: -37.765_732_399_174_75,
lon: 144.752_453_164_883_3,
lat: -37.76573239917475,
lon: 144.7524531648833,
results: [{ city: 'Ravenhall', state: 'Victoria', country: 'Australia' }],
},
{
name: 'Scarborough',
lat: -31.894_346_156_789_997,
lon: 115.757_617_103_904_64,
lat: -31.894346156789997,
lon: 115.75761710390464,
results: [{ city: 'Scarborough', state: 'Western Australia', country: 'Australia' }],
},
];

View File

@@ -44,7 +44,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
});
// login
const response1 = await redirect(url.replace(authServer.internal, authServer.external));
const response1 = await redirect(url.replace(authServer.internal, () => authServer.external));
const response2 = await request(authServer.external + response1.location)
.post('')
.set('Cookie', response1.cookies)

View File

@@ -87,23 +87,23 @@ describe('/search', () => {
// note: the coordinates here are not the actual coordinates of the images and are random for most of them
const coordinates = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul
{ latitude: 5.556_02, longitude: -0.1969 }, // accra
{ latitude: 37.544_270_6, longitude: -4.727_752_8 }, // andalusia
{ latitude: 23.133_02, longitude: -82.383_04 }, // havana
{ latitude: 41.694_11, longitude: 44.833_68 }, // tbilisi
{ latitude: 31.222_22, longitude: 121.458_06 }, // shanghai
{ latitude: 48.85341, longitude: 2.3488 }, // paris
{ latitude: 35.6895, longitude: 139.69171 }, // tokyo
{ latitude: 52.52437, longitude: 13.41053 }, // berlin
{ latitude: 1.3146631, longitude: 103.8454093 }, // singapore
{ latitude: 41.01384, longitude: 28.94966 }, // istanbul
{ latitude: 5.55602, longitude: -0.1969 }, // accra
{ latitude: 37.5442706, longitude: -4.7277528 }, // andalusia
{ latitude: 23.13302, longitude: -82.38304 }, // havana
{ latitude: 41.69411, longitude: 44.83368 }, // tbilisi
{ latitude: 31.22222, longitude: 121.45806 }, // shanghai
{ latitude: 38.9711, longitude: -109.7137 }, // thompson springs
{ latitude: 40.714_27, longitude: -74.005_97 }, // new york
{ latitude: 47.040_57, longitude: 9.068_04 }, // glarus
{ latitude: 32.771_52, longitude: -89.116_73 }, // philadelphia
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg
{ latitude: 40.71427, longitude: -74.00597 }, // new york
{ latitude: 47.04057, longitude: 9.06804 }, // glarus
{ latitude: 32.77152, longitude: -89.11673 }, // philadelphia
{ latitude: 31.63416, longitude: -7.99994 }, // marrakesh
{ latitude: 38.5237354, longitude: -78.4886194 }, // tanners ridge
{ latitude: 59.93863, longitude: 30.31413 }, // st. petersburg
{ latitude: 0, longitude: 0 }, // null island
];
@@ -112,7 +112,7 @@ describe('/search', () => {
);
await Promise.all(updates);
for (const [i] of coordinates.entries()) {
for (const i of coordinates.keys()) {
await utils.waitForWebsocketEvent({ event: 'assetUpdate', id: assets[i].id });
}

View File

@@ -31,7 +31,7 @@ describe(`immich login`, () => {
it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
const { stdout, stderr, exitCode } = await immichCli(['login', app, key.secret]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285/api',
'Logged in as admin@immich.cloud',
@@ -48,7 +48,7 @@ describe(`immich login`, () => {
it('should login without /api in the url', async () => {
const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), key.secret]);
expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285',
'Discovered API at http://127.0.0.1:2285/api',

View File

@@ -119,7 +119,9 @@ describe(`immich upload`, () => {
const baseDir = `/tmp/upload/`;
const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`);
testPaths.map((filePath) => utils.createImageFile(filePath));
for (const filePath of testPaths) {
utils.createImageFile(filePath);
}
const commandLine = paths.map((argument) => `${baseDir}/${argument}`);
@@ -135,7 +137,9 @@ describe(`immich upload`, () => {
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
expect(assets.total).toBe(expectedCount);
testPaths.map((filePath) => utils.removeImageFile(filePath));
for (const filePath of testPaths) {
utils.removeImageFile(filePath);
}
});
}
});

View File

@@ -21,7 +21,7 @@ export const randomImageFromString = async (
let seedNumber = 0;
for (let i = 0; i < seed.length; i++) {
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
seedNumber &= seedNumber; // Convert to 32bit integer
}
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
};

View File

@@ -64,7 +64,7 @@ export function generateAsset(
const asset: MockTimelineAsset = {
id: assetId,
ownerId,
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
ratio: Number(ratio.split(':', 1)[0]) / Number(ratio.split(':', 2)[1]),
thumbhash: generateThumbhash(rng),
localDateTime: date.toISOString(),
fileCreatedAt: date.toISOString(),
@@ -214,7 +214,7 @@ export function generateTimelineData(config: TimelineConfig): MockTimelineData {
}
// Create a mock album from random assets
const allAssets = [...buckets.values()].flat();
const allAssets = buckets.values().toArray().flat();
// Select 10-30 random assets for the album (or all assets if less than 10)
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));

View File

@@ -172,11 +172,7 @@ function shouldIncludeAsset(
if (isArchived !== undefined && actuallyArchived !== isArchived) {
return false;
}
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
return false;
}
return true;
return isFavorite === undefined || actuallyFavorited === isFavorite;
}
/**
* Get summary for all buckets (mimics getTimeBuckets API)
@@ -361,7 +357,7 @@ export function getAsset(
owner?: UserResponseDto,
): AssetResponseDto | undefined {
// Search through all buckets for the asset
const buckets = [...timelineData.buckets.values()];
const buckets = timelineData.buckets.values().toArray();
for (const assets of buckets) {
const asset = assets.find((a) => a.id === assetId);
if (asset) {
@@ -395,7 +391,7 @@ export function getAlbum(
// Get the actual asset objects from the timeline data
const albumAssets: AssetResponseDto[] = [];
const allAssets = [...timelineData.buckets.values()].flat();
const allAssets = timelineData.buckets.values().toArray().flat();
for (const assetId of album.assetIds) {
const assetConfig = allAssets.find((a) => a.id === assetId);

View File

@@ -143,7 +143,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
}
// Validate seed if provided
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
if (config.seed !== undefined && (config.seed < 0 || !Number.isSafeInteger(config.seed))) {
throw new Error('Seed must be a non-negative integer');
}

View File

@@ -153,11 +153,8 @@ export function getMockAsset(
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
} else if (unit === 'month') {
return date1.year !== date2.year || date1.month !== date2.month;
} else {
return date1.year !== date2.year;
}
return unit === 'month' ? date1.year !== date2.year || date1.month !== date2.month : date1.year !== date2.year;
};
if (direction === 'next') {

View File

@@ -40,7 +40,8 @@ export const setupTimelineMockApiRoutes = async (
contentType: 'application/json',
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
});
} else if (pathname === '/api/timeline/bucket') {
}
if (pathname === '/api/timeline/bucket') {
const timeBucket = url.searchParams.get('timeBucket');
if (!timeBucket) {
return route.continue();

View File

@@ -38,7 +38,7 @@ test.describe('asset-viewer', () => {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
const [year, month] = yearMonth.split('-', 2);
yearMonths.push(`${year}-${Number(month)}`);
}
});

View File

@@ -1,3 +1,4 @@
/* eslint-disable unicorn/no-this-outside-of-class */
import type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test';

View File

@@ -49,7 +49,7 @@ test.describe('Timeline', () => {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
const [year, month] = yearMonth.split('-', 2);
yearMonths.push(`${year}-${Number(month)}`);
}
});

View File

@@ -7,7 +7,7 @@ export const sleep = (ms: number) => {
};
export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-');
const [year, month] = yearMonth.split('-', 2);
return `${year}-${month.padStart(2, '0')}`;
};
@@ -72,6 +72,7 @@ export const thumbnailUtils = {
},
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = [];
// eslint-disable-next-line unicorn/no-this-outside-of-class
for (const thumb of await this.locator(page).all()) {
const box = await thumb.boundingBox();
if (box) {
@@ -151,6 +152,7 @@ export const timelineUtils = {
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.querySelector('#asset-grid').scrollTop;
});
await expect.poll(queryTop).toBeGreaterThan(0);
@@ -177,6 +179,7 @@ export const assetViewerUtils = {
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line unicorn/no-optional-chaining-on-undeclared-variable
return document.activeElement?.dataset?.asset;
});
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);

View File

@@ -1,3 +1,4 @@
/* eslint-disable unicorn/no-top-level-assignment-in-function */
import {
AssetMediaCreateDto,
AssetMediaResponseDto,
@@ -177,7 +178,7 @@ export const utils = {
resetDatabase: async (tables?: string[]) => {
client = await utils.connectDatabase();
tables = tables || [
tables ||= [
// TODO e2e test for deleting a stack, since it is quite complex
'stack',
'library',
@@ -304,7 +305,7 @@ export const utils = {
},
adminSetup: async (options?: AdminSetupOptions) => {
options = options || { onboarding: true };
options ||= { onboarding: true };
await signUpAdmin({ signUpDto: signupDto.admin });
const response = await login({ loginCredentialDto: loginDto.admin });
@@ -545,6 +546,7 @@ export const utils = {
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
@@ -674,7 +676,7 @@ export const utils = {
cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, `${key.secret}`]);
await immichCli(['login', app, key.secret]);
return key.secret;
},
@@ -706,6 +708,7 @@ export const utils = {
},
};
// eslint-disable-next-line unicorn/no-top-level-side-effects
utils.initSdk();
if (!existsSync(`${testAssetDir}/albums`)) {

View File

@@ -10,6 +10,7 @@
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2023",
"lib": ["dom", "ESNext"],
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@@ -287,43 +287,43 @@ url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406"
[[tools.pnpm]]
version = "11.6.0"
version = "11.13.1"
backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120174"
checksum = "sha256:b52db99d215ed7dc9563aed815953c62a6c1ffd7cd75803d3a07ad7e4f246aed"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563983"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120177"
checksum = "sha256:2cadd4fc815c591f498a0a84c9e74a836e3e8c1236275f6e1cfd355bae6ae957"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-arm64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563985"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"]
checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120176"
checksum = "sha256:bd6d4b0e14536207ad76bc838f5980cecd968da15f69aae0b207380cca3f2e98"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563986"
provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120175"
checksum = "sha256:ba19690f4ed1b64f1203ade14e9216352b46232d5582468b26a0160e0c9618c5"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-linux-x64-musl.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563980"
provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120180"
checksum = "sha256:765c2bf04e8129cb58c0f946e324262e418370b35a203b50b1f06a0567ef8bc1"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563984"
provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"]
checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c"
url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120173"
checksum = "sha256:d8bebbc71df2702961c1d34a5e61196bc0aa3bbde33c33253f6afa3dd4546a6d"
url = "https://github.com/pnpm/pnpm/releases/download/v11.13.1/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981"
provenance = "github-attestations"
[[tools.terragrunt]]

View File

@@ -16,7 +16,7 @@ config_roots = [
[tools]
node = "24.15.0"
pnpm = "11.6.0"
pnpm = "11.13.1"
terragrunt = "1.0.3"
opentofu = "1.11.6"
"npm:oazapfts" = "7.5.0"
@@ -65,7 +65,6 @@ dir = "open-api"
run = "bash ./bin/generate-dart-sdk.sh"
[tasks.open-api]
env = { SHARP_IGNORE_GLOBAL_LIBVIPS = true }
run = [
{ task = "//:plugins" },
{ task = "//server:install" },

View File

@@ -89,13 +89,6 @@ flutter {
}
dependencies {
constraints {
implementation("androidx.glance:glance-appwidget") {
version { strictly libs.versions.glance.get() }
because 'home_widget requests 1.+ which can resolve to pre-releases incompatible with our compileSdk/AGP'
}
}
implementation libs.okhttp
implementation libs.cronet.embedded
implementation libs.media3.datasource.okhttp

View File

@@ -23,6 +23,6 @@ class ImmichApp : Application() {
// as the previous start might have been killed without unlocking.
if (BackgroundEngineLock.connectEngines > 0) return@postDelayed
BackgroundWorkerApiImpl.enqueueBackgroundWorker(this)
}, 5000)
}, 15000)
}
}

View File

@@ -15,6 +15,7 @@ import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import app.alextran.immich.MainActivity
import app.alextran.immich.R
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import io.flutter.FlutterInjector
@@ -61,6 +62,11 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
}
override fun startWork(): ListenableFuture<Result> {
if (BackgroundWorkerPreferences(ctx).isLocked() && BackgroundEngineLock.connectEngines > 0) {
Log.i(TAG, "Foreground engine active, skipping background worker")
return Futures.immediateFuture(Result.success())
}
Log.i(TAG, "Starting background upload worker")
if (!loader.initialized()) {
@@ -77,6 +83,10 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
showNotification(notificationConfig.first, notificationConfig.second)
loader.ensureInitializationCompleteAsync(ctx, null, Handler(Looper.getMainLooper())) {
if (isStopped || isComplete) {
return@ensureInitializationCompleteAsync
}
engine = FlutterEngine(ctx)
FlutterEngineCache.getInstance().put(BackgroundWorkerApiImpl.ENGINE_CACHE_KEY, engine!!)
@@ -143,11 +153,17 @@ class BackgroundWorker(context: Context, params: WorkerParameters) :
return
}
val api = flutterApi
if (api == null) {
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
complete(Result.failure())
}
return
}
Handler(Looper.getMainLooper()).postAtFrontOfQueue {
if (flutterApi != null) {
flutterApi?.cancel {
complete(Result.failure())
}
api.cancel {
complete(Result.failure())
}
}

View File

@@ -46,7 +46,7 @@ void main() {
void sendUser(SyncStream stream, String id, String name) {
stream.send(
type: SyncEntityType.userV1.value,
type: SyncEntityType.userV1.toString(),
data: SyncUserV1(
id: id,
name: name,

View File

@@ -12,12 +12,8 @@ PODS:
- Flutter
- fluttertoast (0.0.2):
- Flutter
- home_widget (0.0.1):
- Flutter
- native_video_player (1.0.0):
- Flutter
- permission_handler_apple (9.3.0):
- Flutter
- share_handler_ios (0.0.14):
- Flutter
- share_handler_ios/share_handler_ios_models (= 0.0.14)
@@ -34,9 +30,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- home_widget (from `.symlinks/plugins/home_widget/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`)
- share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`)
@@ -53,12 +47,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
home_widget:
:path: ".symlinks/plugins/home_widget/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_handler_ios:
:path: ".symlinks/plugins/share_handler_ios/ios"
share_handler_ios_models:
@@ -71,12 +61,10 @@ SPEC CHECKSUMS:
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb
share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871
PODFILE CHECKSUM: 938abbae4114b9c2140c550a2a0d8f7c674f5dfe
PODFILE CHECKSUM: 3c43a700a4bffb4120bf696cad263aefd4bb3c8c
COCOAPODS: 1.16.2

View File

@@ -154,11 +154,15 @@
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -180,6 +184,8 @@
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Schemas;
sourceTree = "<group>";
};
@@ -372,7 +378,6 @@
FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
513DA7292DED6106813332F4 /* [CP] Embed Pods Frameworks */,
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -466,7 +471,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */,
FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */,
);
@@ -513,27 +518,6 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -558,14 +542,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -606,7 +586,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
shellScript = "/bin/bash \"$SRCROOT/scripts/xcode_flutter_build.sh\"\n";
};
BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
@@ -1266,7 +1246,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};

View File

@@ -32,8 +32,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/maplibre/maplibre-gl-native-distribution.git",
"state" : {
"revision" : "60d9bb85c94ce6e7fc4406cd32529fd12bdb7809",
"version" : "6.14.0"
"revision" : "84a79bc375a301169390ac110c868f06c857b83f",
"version" : "6.27.0"
}
},
{

View File

@@ -43,15 +43,16 @@ class ConnectivityApiImpl: ConnectivityApi {
capabilities.append(.vpn)
}
// Determine if connection is unmetered:
// - Must be on WiFi (not cellular)
// - Must not be expensive (rules out personal hotspot)
// - Must not be constrained (Low Data Mode)
// Note: VPN over cellular should still be considered metered
// Determine if connection is unmetered from the OS metered flags rather than
// the interface type, so wired ethernet (iPhone USB adapters, Apple Silicon
// Macs) is treated as unmetered like Wi-Fi:
// - Not on cellular
// - Not expensive (also rules out cellular and personal hotspot)
// - Not constrained (Low Data Mode)
// Note: VPN over cellular stays metered because the path is still expensive.
let isOnCellular = path.usesInterfaceType(.cellular)
let isOnWifi = path.usesInterfaceType(.wifi)
if isOnWifi && !isOnCellular && !path.isExpensive && !path.isConstrained {
if !isOnCellular && !path.isExpensive && !path.isConstrained {
capabilities.append(.unmetered)
}

View File

@@ -203,7 +203,10 @@ class ImmichAPI {
func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL
let memoryParams = [URLQueryItem(name: "for", value: date.ISO8601Format())]
let localDay = date.formatted(
Date.ISO8601FormatStyle(timeZone: .current).year().month().day().dateSeparator(.dash)
)
let memoryParams = [URLQueryItem(name: "for", value: localDay)]
guard
let searchURL = buildRequestURL(
serverConfig: serverConfig,

View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
# Makes Flutter's builds through the Xcode GUI properly display errors and warnings
# in the Issue navigator
#
# Flutter's `xcode_backend.dart` runs `flutter assemble` with `allowFail: true`,
# which intentionally does not prefix output with `error:`. Unsure why they do this,
# but this script rebuilds the expected output so Xcode can parse and display the errors
set -o pipefail
# The Immich mobile root (containing the Dart `lib` directory). This is used to make
# absolute paths for Xcode linking
app_root="${FLUTTER_APPLICATION_PATH:-$SRCROOT/..}"
/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" build 2>&1 \
| awk -v app_root="$app_root" '
# Match Dart CFE diagnostics: <path>.dart:<line>:<col>: <Kind>: <message>
# Written for macOS/POSIX/BSD awk
{
# Always pass the original line through to preserve the original build log
print
if ($0 ~ /^.*\.dart:[0-9]+:[0-9]+: (Error|Warning|Context|Info):/) {
# Locate the ": Kind:" separator to split location from message.
rest = $0
if (match(rest, /: Error:/)) { kind = "Error"; keyword = "error" }
else if (match(rest, /: Warning:/)) { kind = "Warning"; keyword = "warning" }
else if (match(rest, /: Context:/)) { kind = "Context"; keyword = "note" }
else if (match(rest, /: Info:/)) { kind = "Info"; keyword = "note" }
# location = everything before ": Kind:" (e.g. "lib/foo.dart:12:5")
location = substr(rest, 1, RSTART - 1)
# message = everything after ": Kind:" (leading space preserved)
message = substr(rest, RSTART + length(": " kind ":"))
# Make the path absolute so Xcode links to it
if (location !~ /^\//)
location = app_root "/" location
printf "%s: %s:%s\n", location, keyword, message
}
}
'
exit "${PIPESTATUS[0]}"

View File

@@ -70,6 +70,10 @@ class RemoteAsset extends BaseAsset {
bool get isTrashed => deletedAt != null;
bool get isStacked => stackId != null;
bool get isArchived => visibility == .archive;
@override
String toString() {
return '''Asset {

View File

@@ -24,6 +24,12 @@ class ViewerReloadAssetEvent extends Event {
const ViewerReloadAssetEvent();
}
class ViewerStackAssetDeletedEvent extends Event {
final int stackIndex;
const ViewerStackAssetDeletedEvent({required this.stackIndex});
}
// Multi-Select Events
class MultiSelectToggleEvent extends Event {
final bool isEnabled;

View File

@@ -1,17 +1,16 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/value_codec.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/semver.dart';
enum SettingsKey<T> {
// Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
themeDynamic<bool>(),
themeColorfulInterface<bool>(),
@@ -27,13 +26,13 @@ enum SettingsKey<T> {
// Network
networkAutoEndpointSwitching<bool>(),
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
networkPreferredWifiName<String?>(),
networkLocalEndpoint<String?>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)),
albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(),
albumIsGrid<bool>(),
@@ -47,195 +46,45 @@ enum SettingsKey<T> {
// Timeline
timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)),
timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(),
// Log
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)),
logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
// Map
mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(),
mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)),
mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(),
// Cleanup
cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(),
// Share
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)),
shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
// Slideshow
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values)),
// Feature message
featureMessageSeenRelease<SemVer>(codec: _SemVerCodec());
featureMessageSeenRelease<SemVer>(codec: SemVerCodec());
final _SettingsCodec<T>? _codecOverride;
final ValueCodec<T>? _codecOverride;
const SettingsKey({_SettingsCodec<T>? codec}) : _codecOverride = codec;
const SettingsKey({ValueCodec<T>? codec}) : _codecOverride = codec;
_SettingsCodec<T> get _codec => _codecOverride ?? _SettingsCodec.forType(T);
ValueCodec<T> get _codec => _codecOverride ?? ValueCodec.forType(T);
String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw);
}
sealed class _SettingsCodec<T> {
const _SettingsCodec();
String encode(T value);
T decode(String raw);
static final Map<Type, _SettingsCodec<Object>> _primitives = {
..._register<int>(_PrimitiveCodec.integer),
..._register<double>(_PrimitiveCodec.real),
..._register<bool>(_PrimitiveCodec.boolean),
..._register<String>(_PrimitiveCodec.string),
..._register<DateTime>(const _DateTimeCodec()),
};
static Map<Type, _SettingsCodec<Object>> _register<T>(_SettingsCodec<Object> codec) => {
T: codec,
// Reifies the nullable type T so it can be used as a key in the _primitives map
_typeOf<T?>(): codec,
};
static Type _typeOf<T>() => T;
static _SettingsCodec<T> forType<T>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the SettingsKey.');
}
return codec as _SettingsCodec<T>;
}
}
final class _EnumCodec<T extends Enum> extends _SettingsCodec<T> {
final List<T> values;
const _EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class _DateTimeCodec extends _SettingsCodec<DateTime> {
const _DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class _SemVerCodec extends _SettingsCodec<SemVer> {
const _SemVerCodec();
@override
String encode(SemVer value) => value.toString();
@override
SemVer decode(String raw) => SemVer.fromString(raw);
}
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
final _SettingsCodec<K> _keyCodec;
final _SettingsCodec<V> _valueCodec;
const _MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
return {};
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class _ListCodec<T extends Object> extends _SettingsCodec<List<T>> {
final _SettingsCodec<T> _elementCodec;
const _ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class _PrimitiveCodec<T extends Object> extends _SettingsCodec<T> {
final T Function(String) _parse;
const _PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = _PrimitiveCodec<int>._(int.parse);
static const real = _PrimitiveCodec<double>._(double.parse);
static const boolean = _PrimitiveCodec<bool>._(bool.parse);
static const string = _PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}

View File

@@ -0,0 +1,153 @@
import 'dart:convert';
import 'package:immich_mobile/utils/semver.dart';
sealed class ValueCodec<T> {
const ValueCodec();
String encode(T value);
T decode(String raw);
static final Map<Type, ValueCodec<Object>> _primitives = {
..._register<int>(PrimitiveCodec.integer),
..._register<double>(PrimitiveCodec.real),
..._register<bool>(PrimitiveCodec.boolean),
..._register<String>(PrimitiveCodec.string),
..._register<DateTime>(const DateTimeCodec()),
};
static Map<Type, ValueCodec<Object>> _register<T>(ValueCodec<Object> codec) => {
T: codec,
// Reifies the nullable type T so it can be used as a key in the _primitives map
_typeOf<T?>(): codec,
};
static Type _typeOf<T>() => T;
static ValueCodec<T> forType<T>(Type runtimeType) {
final codec = _primitives[runtimeType];
if (codec == null) {
throw StateError('No primitive codec for $runtimeType. Provide an explicit codec when defining the key.');
}
return codec as ValueCodec<T>;
}
}
final class EnumCodec<T extends Enum> extends ValueCodec<T> {
final List<T> values;
const EnumCodec(this.values);
@override
String encode(T value) => value.name;
@override
T decode(String raw) => values.firstWhere((v) => v.name == raw);
}
final class DateTimeCodec extends ValueCodec<DateTime> {
const DateTimeCodec();
@override
String encode(DateTime value) => value.toIso8601String();
@override
DateTime decode(String raw) => DateTime.parse(raw);
}
final class SemVerCodec extends ValueCodec<SemVer> {
const SemVerCodec();
@override
String encode(SemVer value) => value.toString();
@override
SemVer decode(String raw) => SemVer.fromString(raw);
}
final class MapCodec<K extends Object, V extends Object> extends ValueCodec<Map<K, V>> {
final ValueCodec<K> _keyCodec;
final ValueCodec<V> _valueCodec;
const MapCodec(this._keyCodec, this._valueCodec);
@override
String encode(Map<K, V> value) {
final entries = <String, String>{};
value.forEach((k, v) => entries[_keyCodec.encode(k)] = _valueCodec.encode(v));
return jsonEncode(entries);
}
@override
Map<K, V> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) {
return {};
}
final result = <K, V>{};
for (final entry in decoded.entries) {
final rawKey = entry.key;
final rawValue = entry.value;
if (rawKey is! String || rawValue is! String) {
continue;
}
final k = _keyCodec.decode(rawKey);
final v = _valueCodec.decode(rawValue);
result[k] = v;
}
return result;
} on FormatException {
return {};
}
}
}
final class ListCodec<T extends Object> extends ValueCodec<List<T>> {
final ValueCodec<T> _elementCodec;
const ListCodec(this._elementCodec);
@override
String encode(List<T> value) => jsonEncode(value.map(_elementCodec.encode).toList());
@override
List<T> decode(String raw) {
try {
final decoded = jsonDecode(raw);
if (decoded is! List) {
return [];
}
final result = <T>[];
for (final item in decoded) {
if (item is! String) {
return [];
}
final element = _elementCodec.decode(item);
result.add(element);
}
return result;
} on FormatException {
return [];
}
}
}
final class PrimitiveCodec<T extends Object> extends ValueCodec<T> {
final T Function(String) _parse;
const PrimitiveCodec._(this._parse);
@override
String encode(T value) => value.toString();
@override
T decode(String raw) => _parse(raw);
static const integer = PrimitiveCodec<int>._(int.parse);
static const real = PrimitiveCodec<double>._(double.parse);
static const boolean = PrimitiveCodec<bool>._(bool.parse);
static const string = PrimitiveCodec<String>._(_identity);
static String _identity(String s) => s;
}

View File

@@ -159,8 +159,8 @@ class RemoteAlbumService {
return updatedAlbum;
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
return _repository.getDateRange(albumId);
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
return _repository.watchDateRange(albumId);
}
Future<List<UserDto>> getSharedUsers(String albumId) {
@@ -175,12 +175,12 @@ class RemoteAlbumService {
return _repository.getAssets(albumId);
}
Future<int> addAssets({required String albumId, required List<String> assetIds}) async {
Future<({int added, int failed})> addAssets({required String albumId, required List<String> assetIds}) async {
final album = await _albumApiRepository.addAssets(albumId, assetIds);
await _repository.addAssets(albumId, album.added);
return album.added.length;
return (added: album.added.length, failed: album.failed.length);
}
/// !TODO The name here is not clear as we have addAssets method above,
@@ -196,7 +196,7 @@ class RemoteAlbumService {
}) async {
int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) {
addedCount += await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds);
addedCount += (await addAssets(albumId: albumId, assetIds: candidates.remoteAssetIds)).added;
}
if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals(

View File

@@ -328,8 +328,6 @@ class SyncStreamService {
return _syncStreamRepository.updateAssetOcrV1(data.cast());
case SyncEntityType.assetOcrDeleteV1:
return _syncStreamRepository.deleteAssetOcrV1(data.cast());
default:
_logger.warning("Unknown sync data type: $type");
}
}

View File

@@ -58,7 +58,6 @@ extension on api.AssetVisibility {
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
};
}
@@ -68,6 +67,5 @@ extension on api.AssetTypeEnum {
api.AssetTypeEnum.VIDEO => AssetType.video,
api.AssetTypeEnum.AUDIO => AssetType.audio,
api.AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}

View File

@@ -44,16 +44,7 @@ final class $$AssetEditEntityTableReferences
static i5.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$AssetEditEntityTable>('asset_edit_entity')
.assetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('asset_edit_entity__asset_id__remote_asset_entity__id');
i5.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;

View File

@@ -59,16 +59,7 @@ final class $$AssetFaceEntityTableReferences
static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.assetId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('asset_face_entity__asset_id__remote_asset_entity__id');
i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;
@@ -91,16 +82,7 @@ final class $$AssetFaceEntityTableReferences
static i6.$PersonEntityTable _personIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i6.$PersonEntityTable>('person_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$AssetFaceEntityTable>('asset_face_entity')
.personId,
i5.ReadDatabaseContainer(
db,
).resultSet<i6.$PersonEntityTable>('person_entity').id,
),
);
.createAlias('asset_face_entity__person_id__person_entity__id');
i6.$$PersonEntityTableProcessedTableManager? get personId {
final $_column = $_itemColumn<String>('person_id');

View File

@@ -61,16 +61,7 @@ final class $$AssetOcrEntityTableReferences
static i4.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$AssetOcrEntityTable>('asset_ocr_entity').assetId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('asset_ocr_entity__asset_id__remote_asset_entity__id');
i4.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;

View File

@@ -75,16 +75,7 @@ final class $$RemoteExifEntityTableReferences
static i3.$RemoteAssetEntityTable _assetIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteExifEntityTable>('remote_exif_entity')
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
);
.createAlias('remote_exif_entity__asset_id__remote_asset_entity__id');
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
final $_column = $_itemColumn<String>('asset_id')!;

View File

@@ -50,14 +50,7 @@ final class $$LocalAlbumEntityTableReferences
) => i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumEntityTable>('local_album_entity')
.linkedRemoteAlbumId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
'local_album_entity__linked_remote_album_id__remote_album_entity__id',
);
i5.$$RemoteAlbumEntityTableProcessedTableManager? get linkedRemoteAlbumId {

View File

@@ -41,16 +41,7 @@ final class $$LocalAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$LocalAssetEntityTable>('local_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumAssetEntityTable>(
'local_album_asset_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$LocalAssetEntityTable>('local_asset_entity').id,
),
'local_album_asset_entity__asset_id__local_asset_entity__id',
);
i3.$$LocalAssetEntityTableProcessedTableManager get assetId {
@@ -75,16 +66,7 @@ final class $$LocalAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i5.$LocalAlbumEntityTable>('local_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$LocalAlbumAssetEntityTable>(
'local_album_asset_entity',
)
.albumId,
i4.ReadDatabaseContainer(
db,
).resultSet<i5.$LocalAlbumEntityTable>('local_album_entity').id,
),
'local_album_asset_entity__album_id__local_album_entity__id',
);
i5.$$LocalAlbumEntityTableProcessedTableManager get albumId {

View File

@@ -53,16 +53,7 @@ final class $$MemoryEntityTableReferences
static i5.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(
db,
).resultSet<i1.$MemoryEntityTable>('memory_entity').ownerId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$UserEntityTable>('user_entity').id,
),
);
.createAlias('memory_entity__owner_id__user_entity__id');
i5.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -39,14 +39,7 @@ final class $$MemoryAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$MemoryAssetEntityTable>('memory_asset_entity')
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'memory_asset_entity__asset_id__remote_asset_entity__id',
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
@@ -70,16 +63,7 @@ final class $$MemoryAssetEntityTableReferences
static i5.$MemoryEntityTable _memoryIdTable(i0.GeneratedDatabase db) =>
i4.ReadDatabaseContainer(db)
.resultSet<i5.$MemoryEntityTable>('memory_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$MemoryAssetEntityTable>('memory_asset_entity')
.memoryId,
i4.ReadDatabaseContainer(
db,
).resultSet<i5.$MemoryEntityTable>('memory_entity').id,
),
);
.createAlias('memory_asset_entity__memory_id__memory_entity__id');
i5.$$MemoryEntityTableProcessedTableManager get memoryId {
final $_column = $_itemColumn<String>('memory_id')!;

View File

@@ -39,16 +39,7 @@ final class $$PartnerEntityTableReferences
static i4.$UserEntityTable _sharedByIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$PartnerEntityTable>('partner_entity').sharedById,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('partner_entity__shared_by_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get sharedById {
final $_column = $_itemColumn<String>('shared_by_id')!;
@@ -71,16 +62,7 @@ final class $$PartnerEntityTableReferences
static i4.$UserEntityTable _sharedWithIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$PartnerEntityTable>('partner_entity')
.sharedWithId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('partner_entity__shared_with_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get sharedWithId {
final $_column = $_itemColumn<String>('shared_with_id')!;

View File

@@ -48,16 +48,7 @@ final class $$PersonEntityTableReferences
static i4.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$PersonEntityTable>('person_entity').ownerId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('person_entity__owner_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -52,14 +52,7 @@ final class $$RemoteAlbumEntityTableReferences
) => i6.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumEntityTable>('remote_album_entity')
.thumbnailAssetId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'remote_album_entity__thumbnail_asset_id__remote_asset_entity__id',
);
i5.$$RemoteAssetEntityTableProcessedTableManager? get thumbnailAssetId {

View File

@@ -39,16 +39,7 @@ final class $$RemoteAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumAssetEntityTable>(
'remote_album_asset_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'remote_album_asset_entity__asset_id__remote_asset_entity__id',
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {
@@ -73,16 +64,7 @@ final class $$RemoteAlbumAssetEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumAssetEntityTable>(
'remote_album_asset_entity',
)
.albumId,
i4.ReadDatabaseContainer(
db,
).resultSet<i5.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
'remote_album_asset_entity__album_id__remote_album_entity__id',
);
i5.$$RemoteAlbumEntityTableProcessedTableManager get albumId {

View File

@@ -42,16 +42,7 @@ final class $$RemoteAlbumUserEntityTableReferences
i5.ReadDatabaseContainer(db)
.resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumUserEntityTable>(
'remote_album_user_entity',
)
.albumId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$RemoteAlbumEntityTable>('remote_album_entity').id,
),
'remote_album_user_entity__album_id__remote_album_entity__id',
);
i4.$$RemoteAlbumEntityTableProcessedTableManager get albumId {
@@ -75,18 +66,7 @@ final class $$RemoteAlbumUserEntityTableReferences
static i6.$UserEntityTable _userIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i6.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAlbumUserEntityTable>(
'remote_album_user_entity',
)
.userId,
i5.ReadDatabaseContainer(
db,
).resultSet<i6.$UserEntityTable>('user_entity').id,
),
);
.createAlias('remote_album_user_entity__user_id__user_entity__id');
i6.$$UserEntityTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;

View File

@@ -74,16 +74,7 @@ final class $$RemoteAssetEntityTableReferences
static i5.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAssetEntityTable>('remote_asset_entity')
.ownerId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$UserEntityTable>('user_entity').id,
),
);
.createAlias('remote_asset_entity__owner_id__user_entity__id');
i5.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -45,16 +45,7 @@ final class $$RemoteAssetCloudIdEntityTableReferences
i4.ReadDatabaseContainer(db)
.resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity')
.createAlias(
i0.$_aliasNameGenerator(
i4.ReadDatabaseContainer(db)
.resultSet<i1.$RemoteAssetCloudIdEntityTable>(
'remote_asset_cloud_id_entity',
)
.assetId,
i4.ReadDatabaseContainer(
db,
).resultSet<i3.$RemoteAssetEntityTable>('remote_asset_entity').id,
),
'remote_asset_cloud_id_entity__asset_id__remote_asset_entity__id',
);
i3.$$RemoteAssetEntityTableProcessedTableManager get assetId {

View File

@@ -38,16 +38,7 @@ final class $$StackEntityTableReferences
static i4.$UserEntityTable _ownerIdTable(i0.GeneratedDatabase db) =>
i5.ReadDatabaseContainer(db)
.resultSet<i4.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i5.ReadDatabaseContainer(
db,
).resultSet<i1.$StackEntityTable>('stack_entity').ownerId,
i5.ReadDatabaseContainer(
db,
).resultSet<i4.$UserEntityTable>('user_entity').id,
),
);
.createAlias('stack_entity__owner_id__user_entity__id');
i4.$$UserEntityTableProcessedTableManager get ownerId {
final $_column = $_itemColumn<String>('owner_id')!;

View File

@@ -40,18 +40,7 @@ final class $$UserMetadataEntityTableReferences
static i5.$UserEntityTable _userIdTable(i0.GeneratedDatabase db) =>
i6.ReadDatabaseContainer(db)
.resultSet<i5.$UserEntityTable>('user_entity')
.createAlias(
i0.$_aliasNameGenerator(
i6.ReadDatabaseContainer(db)
.resultSet<i1.$UserMetadataEntityTable>(
'user_metadata_entity',
)
.userId,
i6.ReadDatabaseContainer(
db,
).resultSet<i5.$UserEntityTable>('user_entity').id,
),
);
.createAlias('user_metadata_entity__user_id__user_entity__id');
i5.$$UserEntityTableProcessedTableManager get userId {
final $_column = $_itemColumn<String>('user_id')!;

View File

@@ -0,0 +1,41 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
// ignore: depend_on_referenced_packages
import 'package:meta/meta.dart';
abstract class CachedKeyValueRepository<K extends Enum, S> {
CachedKeyValueRepository(this._snapshot);
S _snapshot;
S get snapshot => _snapshot;
@protected
set snapshot(S value) => _snapshot = value;
List<K> get keys;
Object decodeValue(K key, String raw);
S buildSnapshot(Map<K, Object?> overrides);
Selectable<({String key, String? value})> selectable();
Future<void> refresh() async => _snapshot = _build(await selectable().get());
Stream<S> watchSnapshot() => selectable().watch().map((rows) => _snapshot = _build(rows));
S _build(List<({String key, String? value})> rows) => buildSnapshot(
rows.fold({}, (overrides, row) {
final key = keys.firstWhereOrNull((key) => key.name == row.key);
if (key == null) {
return overrides;
}
Object? decodedValue;
if (row.value != null) {
decodedValue = decodeValue(key, row.value!);
}
return {...overrides, key: decodedValue};
}),
);
}

View File

@@ -210,17 +210,19 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
await _deleteAssets(deletes);
await _upsertAssets(updates);
// Drop every existing album link for each changed asset before re-adding the
// ones the native side reports. A moved asset only reports its new album here,
// so leaving the old link around makes the per-album delete sweep wipe the
// asset entirely (it is still linked to a bucket it no longer lives in).
await _db.batch((batch) async {
for (final assetId in assetAlbums.keys) {
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.assetId.equals(assetId));
}
});
// The ugly casting below is required for now because the generated code
// casts the returned values from the platform during decoding them
// and iterating over them causes the type to be List<Object?> instead of
// List<String>
await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
for (final albumId in albumIds.cast<String?>().nonNulls) {
batch.deleteWhere(_db.localAlbumAssetEntity, (f) => f.albumId.equals(albumId) & f.assetId.equals(assetId));
}
});
});
await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
batch.insertAll(

View File

@@ -217,7 +217,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
});
}
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) {
Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()])
@@ -229,7 +229,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final minDate = row.read(_db.remoteAssetEntity.createdAt.min());
final maxDate = row.read(_db.remoteAssetEntity.createdAt.max());
return (minDate ?? DateTime.now(), maxDate ?? DateTime.now());
}).getSingle();
}).watchSingle();
}
Future<List<UserDto>> getSharedUsers(String albumId) async {

View File

@@ -71,7 +71,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
}
final query = _db.remoteAssetEntity.select()
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..where(
(row) =>
row.stackId.equals(stackId) &
row.id.equals(asset.id).not() &
row.deletedAt.isNull() &
row.visibility.equalsValue(AssetVisibility.timeline),
)
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get();

View File

@@ -1,13 +1,14 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/config/app_config.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/entities/settings.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/cached_key_value_repository.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SettingsRepository extends DriftDatabaseRepository {
class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
final Drift _db;
SettingsRepository._(this._db) : super(_db);
SettingsRepository._(this._db) : super(const .new());
static SettingsRepository? _instance;
@@ -19,9 +20,6 @@ class SettingsRepository extends DriftDatabaseRepository {
return instance;
}
AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig;
static Future<SettingsRepository> ensureInitialized(Drift db) async {
if (_instance == null) {
final instance = SettingsRepository._(db);
@@ -31,7 +29,20 @@ class SettingsRepository extends DriftDatabaseRepository {
return _instance!;
}
Future<void> refresh() async => _applyOverrides(await _db.select(_db.settingsEntity).get());
@override
List<SettingsKey> get keys => SettingsKey.values;
@override
Object decodeValue(SettingsKey key, String raw) => key.decode(raw);
@override
AppConfig buildSnapshot(Map<SettingsKey, Object?> overrides) => AppConfig.fromEntries(overrides);
@override
Selectable<({String key, String? value})> selectable() =>
_db.select(_db.settingsEntity).map((row) => (key: row.key, value: row.value));
AppConfig get appConfig => snapshot;
Future<void> clear(Iterable<SettingsKey> keys) async {
if (keys.isEmpty) {
@@ -41,13 +52,15 @@ class SettingsRepository extends DriftDatabaseRepository {
final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
var config = snapshot;
for (final key in keys) {
_appConfig = _appConfig.write(key, defaultConfig.read(key));
config = config.write(key, defaultConfig.read(key));
}
snapshot = config;
}
Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
if (value == _appConfig.read(key)) {
if (value == snapshot.read(key)) {
return;
}
@@ -65,29 +78,8 @@ class SettingsRepository extends DriftDatabaseRepository {
.insertOnConflictUpdate(
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())),
);
_appConfig = _appConfig.write(key, value);
snapshot = snapshot.write(key, value);
}
Stream<AppConfig> watchConfig() => _db.select(_db.settingsEntity).watch().map((rows) {
_applyOverrides(rows);
return _appConfig;
});
void _applyOverrides(List<SettingsEntityData> rows) {
_appConfig = AppConfig.fromEntries(
rows.fold({}, (overrides, row) {
final metadataKey = SettingsKey.values.firstWhereOrNull((key) => key.name == row.key);
if (metadataKey == null) {
return overrides;
}
Object? decodedValue;
if (row.value != null) {
decodedValue = metadataKey.decode(row.value!);
}
return {...overrides, metadataKey: decodedValue};
}),
);
}
Stream<AppConfig> watchConfig() => watchSnapshot();
}

View File

@@ -937,7 +937,6 @@ extension on AssetTypeEnum {
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
};
}
@@ -945,14 +944,12 @@ extension on AssetOrder {
AlbumAssetOrder toAlbumAssetOrder() => switch (this) {
AssetOrder.asc => AlbumAssetOrder.asc,
AssetOrder.desc => AlbumAssetOrder.desc,
_ => throw Exception('Unknown AssetOrder value: $this'),
};
}
extension on MemoryType {
MemoryTypeEnum toMemoryType() => switch (this) {
MemoryType.onThisDay => MemoryTypeEnum.onThisDay,
_ => throw Exception('Unknown MemoryType value: $this'),
};
}
@@ -961,7 +958,6 @@ extension on api.AlbumUserRole {
api.AlbumUserRole.editor => AlbumUserRole.editor,
api.AlbumUserRole.viewer => AlbumUserRole.viewer,
api.AlbumUserRole.owner => AlbumUserRole.owner,
_ => throw Exception('Unknown AlbumUserRole value: $this'),
};
}
@@ -971,7 +967,6 @@ extension on api.AssetVisibility {
api.AssetVisibility.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked,
_ => throw Exception('Unknown AssetVisibility value: $this'),
};
}
@@ -980,12 +975,11 @@ extension on api.UserMetadataKey {
api.UserMetadataKey.onboarding => UserMetadataKey.onboarding,
api.UserMetadataKey.preferences => UserMetadataKey.preferences,
api.UserMetadataKey.license => UserMetadataKey.license,
_ => throw Exception('Unknown UserMetadataKey value: $this'),
};
}
extension on UserAvatarColor {
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == value);
AvatarColor? toAvatarColor() => AvatarColor.values.firstWhereOrNull((c) => c.name == toString());
}
extension on api.AssetEditAction {
@@ -993,6 +987,5 @@ extension on api.AssetEditAction {
api.AssetEditAction.crop => AssetEditAction.crop,
api.AssetEditAction.rotate => AssetEditAction.rotate,
api.AssetEditAction.mirror => AssetEditAction.mirror,
_ => AssetEditAction.other,
};
}

View File

@@ -3,25 +3,23 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/actions/action.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/utils/asset_filter.dart';
import 'package:immich_ui/immich_ui.dart';
class FavoriteAction extends AssetAction<RemoteAsset> {
final bool shouldFavorite;
final bool favorite;
FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite);
FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite);
@override
IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded;
@override
String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite;
String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite;
@override
Iterable<RemoteAsset> filter(ActionScope scope) => assets
.where(
(asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite,
)
.cast<RemoteAsset>();
Iterable<RemoteAsset> filter(ActionScope scope) =>
AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite);
@override
bool isVisible(ActionScope scope) => filter(scope).isNotEmpty;
@@ -31,8 +29,8 @@ class FavoriteAction extends AssetAction<RemoteAsset> {
final ActionScope(:ref) = scope;
final assets = filter(scope).map((asset) => asset.id).toList(growable: false);
await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite);
final message = shouldFavorite
await ref.read(assetServiceProvider).updateFavorite(assets, favorite);
final message = favorite
? StaticTranslations.instance.favorite_action_prompt(count: assets.length)
: StaticTranslations.instance.unfavorite_action_prompt(count: assets.length);
snackbar.success(message);

View File

@@ -1,17 +1,49 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@RoutePage()
class MainTimelinePage extends TabPage {
class MainTimelinePage extends ConsumerStatefulWidget {
const MainTimelinePage({super.key});
@override
Widget scopeBuilder(BuildContext context, Widget child) => timelineChromeScope(context, child);
ConsumerState<MainTimelinePage> createState() => _MainTimelinePageState();
}
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) {
return;
}
final service = ref.read(featureMessageServiceProvider);
if (!service.shouldShow()) {
return;
}
await service.markSeen();
if (!mounted) {
return;
}
await showFeatureMessageDialog(context);
});
}
@override
ProviderListenable<PageChromeContent> get content => timelineChromeProvider;
Widget build(BuildContext context) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
topSliverWidgetHeight: hasMemories ? 200 : 0,
showStorageIndicator: true,
);
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -5,70 +7,64 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
import 'package:immich_mobile/widgets/common/primary_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
@RoutePage()
class DriftAlbumsPage extends TabPage {
class DriftAlbumsPage extends ConsumerStatefulWidget {
const DriftAlbumsPage({super.key});
@override
ProviderListenable<PageChromeContent> get content => Provider<PageChromeContent>((ref) {
ConsumerState<DriftAlbumsPage> createState() => _DriftAlbumsPageState();
}
class _DriftAlbumsPageState extends ConsumerState<DriftAlbumsPage> {
final ScrollController _scrollController = ScrollController();
Future<void> onRefresh() async {
await ref.read(remoteAlbumProvider.notifier).refresh();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final albumCount = ref.watch(remoteAlbumProvider.select((state) => state.albums.length));
final showScrollbar = albumCount > 20;
return PageChromeContent(
final scrollView = CustomScrollView(
controller: _scrollController,
physics: const AlwaysScrollableScrollPhysics(),
appBar: const PrimaryAppBar(
snap: false,
floating: false,
pinned: true,
showUploadButton: false,
actions: [_CreateAlbumAction()],
),
viewportBuilder: (scrollView) => _AlbumRefreshWrapper(showScrollbar: showScrollbar, scrollView: scrollView),
slivers: [const _AlbumSelectorSliver()],
slivers: [
ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
actions: [
IconButton(
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
icon: const Icon(Icons.add_rounded),
),
],
showUploadButton: false,
),
AlbumSelector(
onAlbumSelected: (album) {
context.router.push(RemoteAlbumRoute(album: album));
},
),
],
);
});
}
class _CreateAlbumAction extends StatelessWidget {
const _CreateAlbumAction();
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => context.pushRoute(const DriftCreateAlbumRoute()),
icon: const Icon(Icons.add_rounded),
);
}
}
class _AlbumSelectorSliver extends StatelessWidget {
const _AlbumSelectorSliver();
@override
Widget build(BuildContext context) {
return AlbumSelector(onAlbumSelected: (album) => context.router.push(RemoteAlbumRoute(album: album)));
}
}
class _AlbumRefreshWrapper extends ConsumerWidget {
final bool showScrollbar;
final CustomScrollView scrollView;
const _AlbumRefreshWrapper({required this.showScrollbar, required this.scrollView});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = PrimaryScrollController.of(context);
return RefreshIndicator(
onRefresh: () => ref.read(remoteAlbumProvider.notifier).refresh(),
onRefresh: onRefresh,
edgeOffset: 100,
child: showScrollbar
? RawScrollbar(
controller: controller,
controller: _scrollController,
interactive: true,
thickness: 8,
radius: const Radius.circular(4),

View File

@@ -26,6 +26,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
FocusNode albumTitleTextFieldFocusNode = FocusNode();
FocusNode albumDescriptionTextFieldFocusNode = FocusNode();
bool isAlbumTitleTextFieldFocus = false;
bool isCreatingAlbum = false;
Set<BaseAsset> selectedAssets = {};
@override
@@ -48,7 +49,7 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
super.dispose();
}
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty;
bool get _canCreateAlbum => albumTitleController.text.trim().isNotEmpty && !isCreatingAlbum;
String _getEffectiveTitle() {
return albumTitleController.text.isNotEmpty
@@ -167,7 +168,12 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
}
Future<void> createAlbum() async {
if (isCreatingAlbum) {
return;
}
onBackgroundTapped();
setState(() => isCreatingAlbum = true);
final title = _getEffectiveTitle().trim();
@@ -187,6 +193,10 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
if (context.mounted) {
ImmichToast.show(context: context, toastType: ToastType.error, msg: 'errors.failed_to_create_album'.t());
}
} finally {
if (mounted) {
setState(() => isCreatingAlbum = false);
}
}
}

View File

@@ -16,23 +16,27 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/page_chrome/tab_page.dart';
import 'package:immich_mobile/widgets/common/page_chrome/types.dart';
import 'package:immich_mobile/widgets/common/primary_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class DriftLibraryPage extends TabPage {
class DriftLibraryPage extends ConsumerWidget {
const DriftLibraryPage({super.key});
@override
ProviderListenable<PageChromeContent> get content => Provider<PageChromeContent>((ref) {
return const PageChromeContent(
appBar: PrimaryAppBar(snap: false, floating: false, pinned: true, showUploadButton: false),
slivers: [_ActionButtonGrid(), _CollectionCards(), _QuickAccessButtonList()],
Widget build(BuildContext context, WidgetRef ref) {
return const Scaffold(
body: CustomScrollView(
slivers: [
ImmichSliverAppBar(snap: false, floating: false, pinned: true, showUploadButton: false),
_ActionButtonGrid(),
_CollectionCards(),
_QuickAccessButtonList(),
],
),
);
});
}
}
class _ActionButtonGrid extends ConsumerWidget {

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