Compare commits

...

67 Commits

Author SHA1 Message Date
Adam Gastineau
5302d6ea0e fix(mobile): allow URL validation to pass when scheme is not provided 2026-07-22 12:39:23 -07:00
Pavel Miniutka
564cda5088 chore(mobile): Adds Belarusian language option in settings on mobile (#29939)
chore(mobile): add missing Belarusian (be) language option in settings
2026-07-22 17:53:21 +00:00
Alex
a7f1d495c6 fix: wrong corner radius of recently added link (#30140) 2026-07-22 16:44:14 +00:00
Jason Rasmussen
b9f6c4aaf2 feat: password invalidate sessions (#30125) 2026-07-22 12:09:05 -04:00
shenlong
23778551f7 fix: locked view and asset view provider (#30136)
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-07-22 09:51:33 -05:00
bo0tzz
457da17c5d fix: always set extension from provided file (#29839)
* fix: always set extension from provided file

The livephoto case can also happen with other files; for example, we
receive a DNG file as JPEG but then still call it .dng

Fixes #29805, partially addresses #28602

* chore: test cases for file extension naming
2026-07-22 18:29:54 +05:30
Jan Bader
12f741d911 chore(cli): remove -h shorthand for --skip-hash (#30119)
Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-07-22 13:25:29 +02:00
Aditya Raj Singh
a0c7b81142 fix(server): dissolve stack when its non-primary assets are deleted (#29354) 2026-07-22 13:00:52 +02:00
Daniel Dietzler
20997823d8 chore: mise monorepo support is now stable (#30131) 2026-07-22 12:55:47 +02:00
okxint
8a875f5978 fix(web): use correct date field for shift-click range in Recently Added (#30071) 2026-07-22 11:53:45 +02:00
renovate[bot]
8435d219e4 chore(deps): update dependency opentofu to v1.12.4 (#28491) 2026-07-22 09:30:08 +00:00
Ben Beckford
8bbc252703 feat: exif metadata workflow filter (#29295) 2026-07-22 11:20:57 +02:00
renovate[bot]
2d3cf8ffaa chore(deps): update dependency terragrunt to v1.1.1 (#29399) 2026-07-22 11:16:09 +02:00
renovate[bot]
33cba2f9af chore(deps): update grafana/grafana docker tag to v12.4.5-ubuntu (#29670) 2026-07-22 10:59:29 +02:00
renovate[bot]
75053ebfba chore(deps): update terraform cloudflare to v4.52.8 (#29671) 2026-07-22 10:59:04 +02:00
Yaros
ba9ec7c80e fix: backup delay translation key parsing (#30112) 2026-07-22 10:58:38 +02:00
Yaros
99370b2ac4 fix: admin user details responsive layout (#30114) 2026-07-22 10:54:26 +02:00
Ben Beckford
0f5bf6071f chore(server): test assetDateFilter workflow method (#30110) 2026-07-22 10:48:31 +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
488 changed files with 6200 additions and 4729 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,12 +20,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - 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: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
@@ -38,7 +38,7 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false

View File

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

View File

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

View File

@@ -51,14 +51,14 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - 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. # 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 # 📚 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 # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0 uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -85,7 +85,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -114,7 +114,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -146,7 +146,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -178,7 +178,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -223,7 +223,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -262,7 +262,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -291,7 +291,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -342,7 +342,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -375,7 +375,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
@@ -412,7 +412,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
@@ -422,7 +422,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node - name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -490,7 +490,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
submodules: 'recursive' submodules: 'recursive'
@@ -500,7 +500,7 @@ jobs:
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Setup Node - name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
cache: 'pnpm' cache: 'pnpm'
@@ -598,7 +598,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -637,7 +637,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -669,7 +669,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -699,7 +699,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }} private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
permission-contents: read permission-contents: read
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -722,7 +722,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}
@@ -781,7 +781,7 @@ jobs:
permission-contents: read permission-contents: read
- name: Checkout code - name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
token: ${{ steps.token.outputs.token }} token: ${{ steps.token.outputs.token }}

38
.vscode/settings.json vendored
View File

@@ -29,9 +29,6 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"tailwindCSS.lint.suggestCanonicalClasses": "ignore" "tailwindCSS.lint.suggestCanonicalClasses": "ignore"
}, },
"svelte.plugin.svelte.compilerWarnings": {
"state_referenced_locally": "ignore"
},
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true
@@ -43,37 +40,40 @@
"eslint.useFlatConfig": true, "eslint.useFlatConfig": true,
"eslint.validate": ["javascript", "typescript", "svelte"], "eslint.validate": ["javascript", "typescript", "svelte"],
"eslint.workingDirectories": [ "eslint.workingDirectories": [
{ "directory": "cli", "changeProcessCWD": true }, { "changeProcessCWD": true, "directory": "cli" },
{ "directory": "e2e", "changeProcessCWD": true }, { "changeProcessCWD": true, "directory": "e2e" },
{ "directory": "server", "changeProcessCWD": true }, { "changeProcessCWD": true, "directory": "server" },
{ "directory": "web", "changeProcessCWD": true } { "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.enabled": true,
"explorer.fileNesting.patterns": { "explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart", "*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",
"*.ts": "${capture}.spec.ts,${capture}.mock.ts",
"*.js": "${capture}.spec.js,${capture}.mock.js", "*.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" "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": { "search.exclude": {
"**/node_modules": true, "**/.svelte-kit": true,
"**/build": true, "**/build": true,
"**/dist": true, "**/dist": true,
"**/.svelte-kit": true, "**/node_modules": true,
"**/open-api/typescript-sdk/src": true "**/open-api/typescript-sdk/src": true
}, },
"svelte.enable-ts-plugin": true, "svelte.enable-ts-plugin": true,
"svelte.plugin.svelte.compilerWarnings": {
"state_referenced_locally": "ignore"
},
"tailwindCSS.experimental.configFile": { "tailwindCSS.experimental.configFile": {
"web/src/app.css": "web/src/**" "web/src/app.css": "web/src/**"
}, },
"js/ts.preferences.importModuleSpecifier": "non-relative",
"vitest.maximumConfigs": 10 "vitest.maximumConfigs": 10
} }

View File

@@ -32,6 +32,7 @@
<a href="readme_i18n/README_zh_TW.md">正體中文</a> <a href="readme_i18n/README_zh_TW.md">正體中文</a>
<a href="readme_i18n/README_uk_UA.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_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_pt_BR.md">Português Brasileiro</a>
<a href="readme_i18n/README_sv_SE.md">Svenska</a> <a href="readme_i18n/README_sv_SE.md">Svenska</a>
<a href="readme_i18n/README_ar_JO.md">العربية</a> <a href="readme_i18n/README_ar_JO.md">العربية</a>

View File

@@ -1,6 +1,6 @@
[tools] [tools]
terragrunt = "1.0.3" terragrunt = "1.1.1"
opentofu = "1.11.6" opentofu = "1.12.4"
[tasks."tg:fmt"] [tasks."tg:fmt"]
run = "terragrunt hclfmt" run = "terragrunt hclfmt"

View File

@@ -2,37 +2,35 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.7" version = "4.52.8"
constraints = "4.52.7" constraints = "4.52.8"
hashes = [ hashes = [
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=", "h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=", "h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=", "h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=", "h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=", "h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=", "h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=", "h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=", "h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=", "h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=", "h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=", "h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=", "h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", "h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=", "zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", "zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", "zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", "zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", "zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", "zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", "zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", "zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", "zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", "zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", "zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", "zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", "zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
] ]
} }

View File

@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.7" version = "4.52.8"
} }
} }
} }

View File

@@ -2,37 +2,35 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.7" version = "4.52.8"
constraints = "4.52.7" constraints = "4.52.8"
hashes = [ hashes = [
"h1:+O72J3QYiZtYmYYZM/Eh0f4NNfl1BvjX1eju43qTQsQ=", "h1:2uxVaMaGPDJljXqYzuD6xtyfOKdNudtGIgawRTkFJIs=",
"h1:0oqjYIPXcXh7XiDiKI085cHDYQQ5mh8kDl9dmBtvtog=", "h1:34HL1GDpqYdn2iEC+85pCi+NddZf30qH3fJ8y3P6CAM=",
"h1:4b4ESb87MGv5bnadgYe7sK5rEkKMZhbkQcwPubQTsR4=", "h1:4Z+uD+YY/7HrqdypM7DyUK+RtWepoLBc9fqtRQYZx4I=",
"h1:6mTr3eA1Ddb348lLmJuyvn98z4KF+ejqaUEJ76D1rzQ=", "h1:8Ss6wlJL2BHvkIZU1iaHvyUntqNhPFZ0fl6mjsxhctc=",
"h1:9/3YH+9k9HqsvFtbmBf7SO2+xqZeZrXNKzLkjNuhUEA=", "h1:AYeWWMTWaNg01N3hivxr/6YlG41j1RietMnft9vLjUI=",
"h1:Jcq4tBWgyH4/2JsojNBSRaN0mcItVMchO+lynonrlqc=", "h1:BGRNOzo8NUgbx5RwpWkWmr38f/s3txb7mzQhqfM5blI=",
"h1:Y4Vv/2RdP0Q+uxqhOxzOdKxuuEMjXPDcU0vPc5bCQzI=", "h1:EEXRMdC8bPlpLuOU8u+dE8mFJe3BpquFdH6vXcKseY4=",
"h1:a0gW8FBKsbP9Fi0HEDoy49WIbEWVHk9+BR4/iwuBdDQ=", "h1:ENp29e6O6OnaHJlbtmRlyjlAmm3Wt4mGctWUpBY3d0g=",
"h1:gElv6iqJtg8OKN77gbw+MjrkrQmJHPkkMEi1J+0xkpU=", "h1:IAtBAbGVe1npj/n1NvCIZkSYGl7qaPYT+FnVdZ2CZ00=",
"h1:oslXUugD/NQ+duJgT4BhKQyfGbuFOANknMvR73fiOeM=", "h1:QRGGKngrxLORr4CImKXLDDJL5s67zUNBDAgk6FIIFNk=",
"h1:pPItIWii5oymR+geZB219ROSPuSODPLTlM4S/u8xLvM=", "h1:bNql6cC+MfDbaJK3q7a8C1m3A21o7e5mrjBIGtk0RdI=",
"h1:u67GWw8GwD9NDlDzp9Y5VRnSQGcCrE8rSpkGPaBpDl0=", "h1:gmNv+PEOkYDdu+c15bfpcoAE3EI7pgmov9RMf3GZLsI=",
"h1:uUUa9dY0XQOycI8pxg16PFFtL0WCTi9uEJz8trTQ7pU=", "h1:yieZ7NWRYQcLYHUevzDDrrmDkoQICM+3ZeX4i/mw9ME=",
"h1:y3rV8KF2q6GEMANNlf5EkKJurlfbKlIKpjGcdxoy7pQ=", "zh:08b305329a680a9213b2d8e642fbce7e4d97a524b1d2cef59e190ba9d678c477",
"zh:0c904ce31a4c6c4a5b3bf7ff1560e77c0cc7e2450c8553ded8e8c90398e1418b", "zh:47975bd711ee18a46e589822171fa87474a552b332bfc8dea8fd1a64504eed8d",
"zh:36183d310c36373fe4cb936b83c595c6fd3b0a94bc7827f28e5789ccbf59752e", "zh:5640d0d226bbafff3395542456c29feb942ca9c55ac01b4245a34c0590a33363",
"zh:556a568a6f0235e8f41647de9e4d3a1e7b1d6502df8b19b54ec441f1c653ea10", "zh:5b0ad839fafba938c60a95d6b4a865843643591e97573ed2b9c4af3754064f74",
"zh:633ebbd5b0245e75e500ef9be4d9e62288f97e8da3baaa51323892a786d90285",
"zh:6acfe60cf52a65ba8f044f748548d2119e7f4fd7f8ebcb14698960d87c68f529",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f", "zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:904acc31ebb9d6ef68c792074b30532ee61bf515f19e0a3c75b46f126cca1f13", "zh:93a9bc1139f5c02a44fdbf51fb2ce0891e2a42033b66febb928ccf72e870408f",
"zh:a1d0a81246afc8750286d3f6fe7a8fbe6460dd2662407b28dbfbabb612e5fa9d", "zh:977f75cdf365686aa16ae02dcc0fc1769bae6f86be6e165393756c940bfb4af0",
"zh:a41a36fe253fc365fe2b7ffc749624688b2693b4634862fda161179ab100029f", "zh:9afcda2660b3dc6ee6329acea532a719225bd1f6cf46f695feb2d77160847c1b",
"zh:a7ef269e77ffa8715c8945a2c14322c7ff159ea44c15f62505f3cbb2cae3b32d", "zh:9e3da67b1b05b03d1f0c18b8677edf3797815b5dd49629f10eb2f457dccbed30",
"zh:b01aa3bed30610633b762df64332b26f8844a68c3960cebcb30f04918efc67fe", "zh:b8d7da230f5266367c1b6b1cf31aa39087e231a87d55b71bb9d5c854ca1fcfe6",
"zh:b069cc2cd18cae10757df3ae030508eac8d55de7e49eda7a5e3e11f2f7fe6455", "zh:cc96e7cd7350456b7a11a53d1c76c73a542de8359f9750f637ff865e8e77be91",
"zh:b2d2c6313729ebb7465dceece374049e2d08bda34473901be9ff46a8836d42b2", "zh:da1f58d067def243047bb7178cb197e2b9c3a791a9eb380ad92b73290615ae29",
"zh:db0e114edaf4bc2f3d4769958807c83022bfbc619a00bdf4c4bd17faa4ab2d8b", "zh:ed5f3e1f59a338bcdce0a2537a8a2f299cfe18c168626799b2a9c97af3d8d3cb",
"zh:ecc0aa8b9044f664fd2aaf8fa992d976578f78478980555b4b8f6148e8d1a5fe", "zh:f16cc31f73a58a26ffe2d93223eddb7c340623a4e2ef38c6091766fd611e3e11",
] ]
} }

View File

@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.7" version = "4.52.8"
} }
} }
} }

View File

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

View File

@@ -56,7 +56,7 @@ services:
redis: redis:
container_name: immich_redis container_name: immich_redis
image: docker.io/valkey/valkey:9@sha256:4963247afc4cd33c7d3b2d2816b9f7f8eeebab148d29056c2ca4d7cbc966f2d9 image: docker.io/valkey/valkey:9@sha256:8e8d64b405ce18f41b8e5ee20aa4687a8ed0022d1298f2ce31cdcf3a76e09411
healthcheck: healthcheck:
test: redis-cli ping || exit 1 test: redis-cli ping || exit 1
restart: always restart: always
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting'] command: ['./run.sh', '-disable-reporting']
ports: ports:
- 3000:3000 - 3000:3000
image: grafana/grafana:12.4.4-ubuntu@sha256:df2e7ef5f32f771794cf76bad5f2bceac227036460a2cc269a9045e5662abc58 image: grafana/grafana:12.4.5-ubuntu@sha256:00396460e499415c828b7c298f19287c8a0f95e72412ee37ac11723655c2d6b9
volumes: volumes:
- grafana-data:/var/lib/grafana - grafana-data:/var/lib/grafana

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

View File

@@ -35,6 +35,7 @@ Found Admin:
- Email=admin@example.com - Email=admin@example.com
- Name=Immich Admin - Name=Immich Admin
? Please choose a new password (optional) immich-is-cool ? Please choose a new password (optional) immich-is-cool
? Invalidate existing sessions? Yes
The admin password has been updated. The admin password has been updated.
``` ```

View File

@@ -7,7 +7,7 @@ sidebar_position: 85
:::note :::note
This is a community contribution and not officially supported by the Immich team, but included here for convenience. 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. 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

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

View File

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

View File

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

View File

@@ -41,9 +41,18 @@ export default typescriptEslint.config([
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off', 'unicorn/prefer-module': 'off',
'unicorn/import-style': '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, curly: 2,
'prettier/prettier': 0, 'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'off', 'unicorn/name-replacements': 'off',
'unicorn/filename-case': 'off', 'unicorn/filename-case': 'off',
'unicorn/no-null': 'off', 'unicorn/no-null': 'off',
'unicorn/prefer-top-level-await': 'off', 'unicorn/prefer-top-level-await': 'off',

View File

@@ -40,7 +40,7 @@
"eslint": "^10.0.0", "eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^64.0.0", "eslint-plugin-unicorn": "^72.0.0",
"exiftool-vendored": "^35.0.0", "exiftool-vendored": "^35.0.0",
"globals": "^17.0.0", "globals": "^17.0.0",
"luxon": "^3.4.4", "luxon": "^3.4.4",
@@ -51,7 +51,8 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"socket.io-client": "^4.7.4", "socket.io-client": "^4.7.4",
"supertest": "^7.0.0", "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", "typescript-eslint": "^8.28.0",
"utimes": "^5.2.1", "utimes": "^5.2.1",
"vitest": "^4.0.0" "vitest": "^4.0.0"

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ test.describe('Maintenance', () => {
test('maintenance shows no options to users until they authenticate', async ({ page }) => { test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken); const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie const cookie = setCookie
?.map((cookie) => cookie.split(';')[0].split('=')) ?.map((cookie) => cookie.split(';', 1)[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token'); ?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy(); 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([ [user2Albums[0]] = await Promise.all([
getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }), getAlbumInfo({ id: user2Albums[0].id }, { headers: asBearerAuth(user2.accessToken) }),
deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }), deleteUserAdmin({ id: user3.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) }),

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ const loginWithOAuth = async (sub: OAuthUser | string, redirectUri?: string) =>
}); });
// login // 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) const response2 = await request(authServer.external + response1.location)
.post('') .post('')
.set('Cookie', response1.cookies) .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 // note: the coordinates here are not the actual coordinates of the images and are random for most of them
const coordinates = [ const coordinates = [
{ latitude: 48.853_41, longitude: 2.3488 }, // paris { latitude: 48.85341, longitude: 2.3488 }, // paris
{ latitude: 35.6895, longitude: 139.691_71 }, // tokyo { latitude: 35.6895, longitude: 139.69171 }, // tokyo
{ latitude: 52.524_37, longitude: 13.410_53 }, // berlin { latitude: 52.52437, longitude: 13.41053 }, // berlin
{ latitude: 1.314_663_1, longitude: 103.845_409_3 }, // singapore { latitude: 1.3146631, longitude: 103.8454093 }, // singapore
{ latitude: 41.013_84, longitude: 28.949_66 }, // istanbul { latitude: 41.01384, longitude: 28.94966 }, // istanbul
{ latitude: 5.556_02, longitude: -0.1969 }, // accra { latitude: 5.55602, longitude: -0.1969 }, // accra
{ latitude: 37.544_270_6, longitude: -4.727_752_8 }, // andalusia { latitude: 37.5442706, longitude: -4.7277528 }, // andalusia
{ latitude: 23.133_02, longitude: -82.383_04 }, // havana { latitude: 23.13302, longitude: -82.38304 }, // havana
{ latitude: 41.694_11, longitude: 44.833_68 }, // tbilisi { latitude: 41.69411, longitude: 44.83368 }, // tbilisi
{ latitude: 31.222_22, longitude: 121.458_06 }, // shanghai { latitude: 31.22222, longitude: 121.45806 }, // shanghai
{ latitude: 38.9711, longitude: -109.7137 }, // thompson springs { latitude: 38.9711, longitude: -109.7137 }, // thompson springs
{ latitude: 40.714_27, longitude: -74.005_97 }, // new york { latitude: 40.71427, longitude: -74.00597 }, // new york
{ latitude: 47.040_57, longitude: 9.068_04 }, // glarus { latitude: 47.04057, longitude: 9.06804 }, // glarus
{ latitude: 32.771_52, longitude: -89.116_73 }, // philadelphia { latitude: 32.77152, longitude: -89.11673 }, // philadelphia
{ latitude: 31.634_16, longitude: -7.999_94 }, // marrakesh { latitude: 31.63416, longitude: -7.99994 }, // marrakesh
{ latitude: 38.523_735_4, longitude: -78.488_619_4 }, // tanners ridge { latitude: 38.5237354, longitude: -78.4886194 }, // tanners ridge
{ latitude: 59.938_63, longitude: 30.314_13 }, // st. petersburg { latitude: 59.93863, longitude: 30.31413 }, // st. petersburg
{ latitude: 0, longitude: 0 }, // null island { latitude: 0, longitude: 0 }, // null island
]; ];
@@ -112,7 +112,7 @@ describe('/search', () => {
); );
await Promise.all(updates); 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 }); 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 () => { it('should login and save auth.yml with 600', async () => {
const admin = await utils.adminSetup(); const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]); 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([ expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285/api', 'Logging in to http://127.0.0.1:2285/api',
'Logged in as admin@immich.cloud', 'Logged in as admin@immich.cloud',
@@ -48,7 +48,7 @@ describe(`immich login`, () => {
it('should login without /api in the url', async () => { it('should login without /api in the url', async () => {
const admin = await utils.adminSetup(); const admin = await utils.adminSetup();
const key = await utils.createApiKey(admin.accessToken, [Permission.All]); 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([ expect(stdout.split('\n')).toEqual([
'Logging in to http://127.0.0.1:2285', 'Logging in to http://127.0.0.1:2285',
'Discovered API at http://127.0.0.1:2285/api', 'Discovered API at http://127.0.0.1:2285/api',

View File

@@ -119,7 +119,9 @@ describe(`immich upload`, () => {
const baseDir = `/tmp/upload/`; const baseDir = `/tmp/upload/`;
const testPaths = Object.keys(files).map((filePath) => `${baseDir}/${filePath}`); 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}`); const commandLine = paths.map((argument) => `${baseDir}/${argument}`);
@@ -135,7 +137,9 @@ describe(`immich upload`, () => {
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });
expect(assets.total).toBe(expectedCount); expect(assets.total).toBe(expectedCount);
testPaths.map((filePath) => utils.removeImageFile(filePath)); for (const filePath of testPaths) {
utils.removeImageFile(filePath);
}
}); });
} }
}); });
@@ -646,7 +650,7 @@ describe(`immich upload`, () => {
]); ]);
expect(stdout).toBe(''); expect(stdout).toBe('');
expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '-h, --skip-hash'`); expect(stderr).toEqual(`error: option '-n, --dry-run' cannot be used with option '--skip-hash'`);
expect(exitCode).not.toBe(0); expect(exitCode).not.toBe(0);
const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) }); const assets = await getAssetStatistics({}, { headers: asKeyAuth(key) });

View File

@@ -65,6 +65,10 @@ describe(`immich-admin`, () => {
child.stdout.on('data', (chunk) => { child.stdout.on('data', (chunk) => {
data += chunk; data += chunk;
if (data.includes('Please choose a new password (optional)')) { if (data.includes('Please choose a new password (optional)')) {
child.stdin.write('\n');
}
if (data.includes('Invalidate existing sessions?')) {
child.stdin.end('\n'); child.stdin.end('\n');
} }
}); });

View File

@@ -21,7 +21,7 @@ export const randomImageFromString = async (
let seedNumber = 0; let seedNumber = 0;
for (let i = 0; i < seed.length; i++) { for (let i = 0; i < seed.length; i++) {
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0); 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 }); return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
}; };

View File

@@ -64,7 +64,7 @@ export function generateAsset(
const asset: MockTimelineAsset = { const asset: MockTimelineAsset = {
id: assetId, id: assetId,
ownerId, 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), thumbhash: generateThumbhash(rng),
localDateTime: date.toISOString(), localDateTime: date.toISOString(),
fileCreatedAt: date.toISOString(), fileCreatedAt: date.toISOString(),
@@ -214,7 +214,7 @@ export function generateTimelineData(config: TimelineConfig): MockTimelineData {
} }
// Create a mock album from random assets // 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) // 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)); const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));

View File

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

View File

@@ -143,7 +143,7 @@ export function validateTimelineConfig(config: TimelineConfig): void {
} }
// Validate seed if provided // 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'); 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 => { const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') { if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('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') { if (direction === 'next') {

View File

@@ -40,7 +40,8 @@ export const setupTimelineMockApiRoutes = async (
contentType: 'application/json', contentType: 'application/json',
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes), 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'); const timeBucket = url.searchParams.get('timeBucket');
if (!timeBucket) { if (!timeBucket) {
return route.continue(); return route.continue();

View File

@@ -38,7 +38,7 @@ test.describe('asset-viewer', () => {
assets.push(...timeBucket); assets.push(...timeBucket);
} }
for (const yearMonth of timelineRestData.buckets.keys()) { for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-'); const [year, month] = yearMonth.split('-', 2);
yearMonths.push(`${year}-${Number(month)}`); 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 type { AssetResponseDto } from '@immich/sdk';
import { expect, Page } from '@playwright/test'; import { expect, Page } from '@playwright/test';

View File

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

View File

@@ -7,7 +7,7 @@ export const sleep = (ms: number) => {
}; };
export const padYearMonth = (yearMonth: string) => { export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-'); const [year, month] = yearMonth.split('-', 2);
return `${year}-${month.padStart(2, '0')}`; return `${year}-${month.padStart(2, '0')}`;
}; };
@@ -72,6 +72,7 @@ export const thumbnailUtils = {
}, },
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) { async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = []; const assetIds: string[] = [];
// eslint-disable-next-line unicorn/no-this-outside-of-class
for (const thumb of await this.locator(page).all()) { for (const thumb of await this.locator(page).all()) {
const box = await thumb.boundingBox(); const box = await thumb.boundingBox();
if (box) { if (box) {
@@ -151,6 +152,7 @@ export const timelineUtils = {
page.evaluate(() => { page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
return document.querySelector('#asset-grid').scrollTop; return document.querySelector('#asset-grid').scrollTop;
}); });
await expect.poll(queryTop).toBeGreaterThan(0); await expect.poll(queryTop).toBeGreaterThan(0);
@@ -177,6 +179,7 @@ export const assetViewerUtils = {
page.evaluate(() => { page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
// eslint-disable-next-line unicorn/no-optional-chaining-on-undeclared-variable
return document.activeElement?.dataset?.asset; return document.activeElement?.dataset?.asset;
}); });
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId); 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 { import {
AssetMediaCreateDto, AssetMediaCreateDto,
AssetMediaResponseDto, AssetMediaResponseDto,
@@ -177,7 +178,7 @@ export const utils = {
resetDatabase: async (tables?: string[]) => { resetDatabase: async (tables?: string[]) => {
client = await utils.connectDatabase(); client = await utils.connectDatabase();
tables = tables || [ tables ||= [
// TODO e2e test for deleting a stack, since it is quite complex // TODO e2e test for deleting a stack, since it is quite complex
'stack', 'stack',
'library', 'library',
@@ -304,7 +305,7 @@ export const utils = {
}, },
adminSetup: async (options?: AdminSetupOptions) => { adminSetup: async (options?: AdminSetupOptions) => {
options = options || { onboarding: true }; options ||= { onboarding: true };
await signUpAdmin({ signUpDto: signupDto.admin }); await signUpAdmin({ signUpDto: signupDto.admin });
const response = await login({ loginCredentialDto: loginDto.admin }); const response = await login({ loginCredentialDto: loginDto.admin });
@@ -545,6 +546,7 @@ export const utils = {
{ {
headers: asBearerAuth(accessToken), headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) => fetch: (...args: Parameters<typeof fetch>) =>
// eslint-disable-next-line unicorn/no-invalid-argument-count, unicorn/prefer-await
fetch(...args).then((response) => { fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie(); setCookie = response.headers.getSetCookie();
return response; return response;
@@ -674,7 +676,7 @@ export const utils = {
cliLogin: async (accessToken: string) => { cliLogin: async (accessToken: string) => {
const key = await utils.createApiKey(accessToken, [Permission.All]); const key = await utils.createApiKey(accessToken, [Permission.All]);
await immichCli(['login', app, `${key.secret}`]); await immichCli(['login', app, key.secret]);
return key.secret; return key.secret;
}, },
@@ -706,6 +708,7 @@ export const utils = {
}, },
}; };
// eslint-disable-next-line unicorn/no-top-level-side-effects
utils.initSdk(); utils.initSdk();
if (!existsSync(`${testAssetDir}/albums`)) { if (!existsSync(`${testAssetDir}/albums`)) {

View File

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

170
mise.lock
View File

@@ -94,6 +94,23 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
version = "7.1.3-6" version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options] [tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz" asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
@@ -112,17 +129,12 @@ version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options] [tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] [tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]] [[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6" version = "7.1.3-6"
@@ -133,18 +145,6 @@ checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip" url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_win64-clang-gpl.zip"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409036094"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-x64"]
checksum = "sha256:066ede9774aaae97a18098aaeea8b7e0d286653eb8618f640476e99c59a536c2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_mac64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995889"
[[tools."github:webassembly/binaryen"]] [[tools."github:webassembly/binaryen"]]
version = "version_124" version = "version_124"
backend = "github:webassembly/binaryen" backend = "github:webassembly/binaryen"
@@ -248,119 +248,119 @@ version = "7.5.0"
backend = "npm:oazapfts" backend = "npm:oazapfts"
[[tools.opentofu]] [[tools.opentofu]]
version = "1.11.6" version = "1.12.4"
backend = "aqua:opentofu/opentofu" backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"] [tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
[tools.opentofu."platforms.linux-arm64-musl"] [tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" checksum = "sha256:dc7bfcd93ce9795a86c58fbf71efd013c39dcd1febb13c9cd3555c43b9c2403a"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646678"
[tools.opentofu."platforms.linux-x64"] [tools.opentofu."platforms.linux-x64"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
[tools.opentofu."platforms.linux-x64-musl"] [tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" checksum = "sha256:81836d0f12b4fe9013b85586349f993def9429b6383bb77cdd6c2f3a9d9aac24"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646677"
[tools.opentofu."platforms.macos-arm64"] [tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1" checksum = "sha256:7c06e4390d9ccd467773e37ff1c3d833c7ca0c24742cd9e9ad47284bea472247"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646544"
[tools.opentofu."platforms.macos-x64"] [tools.opentofu."platforms.macos-x64"]
checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6" checksum = "sha256:ead1d2ce643addb4ffeb93240b9377ae1c2fd793a6bd22d65922ac37adfdf546"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646688"
[tools.opentofu."platforms.windows-x64"] [tools.opentofu."platforms.windows-x64"]
checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e" checksum = "sha256:a4d86a07755c8d151f20f945e6cfb5b40deeed942af36a9bd385c5c2e965d5dd"
url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz" url = "https://github.com/opentofu/opentofu/releases/download/v1.12.4/tofu_1.12.4_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406" url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/475646543"
[[tools.pnpm]] [[tools.pnpm]]
version = "11.11.0" version = "11.13.1"
backend = "aqua:pnpm/pnpm" backend = "aqua:pnpm/pnpm"
[tools.pnpm."platforms.linux-arm64"] [tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:4871093439f036b3082df6102255ac119f3b6f4f50c51c5711fc7c358b8b22d7" checksum = "sha256:b52db99d215ed7dc9563aed815953c62a6c1ffd7cd75803d3a07ad7e4f246aed"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-arm64.tar.gz" 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/471734816" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563983"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.linux-arm64-musl"] [tools.pnpm."platforms.linux-arm64-musl"]
checksum = "sha256:f97316cee0deb3ae3fd64e8a09e1c8c88a76da33373a2b465bd6a4c3e85e0690" checksum = "sha256:2cadd4fc815c591f498a0a84c9e74a836e3e8c1236275f6e1cfd355bae6ae957"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-arm64-musl.tar.gz" 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/471734819" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563985"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"] [tools.pnpm."platforms.linux-x64"]
checksum = "sha256:df4699e897012ab14df2cc6eaa942910e830eb7fcaa420a2a1421a9461fd9108" checksum = "sha256:bd6d4b0e14536207ad76bc838f5980cecd968da15f69aae0b207380cca3f2e98"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-x64.tar.gz" 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/471734818" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563986"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64-musl"] [tools.pnpm."platforms.linux-x64-musl"]
checksum = "sha256:aad186f8a8ae72d827d5efe63e99e801456715fb7f2798949405fa33f20260db" checksum = "sha256:ba19690f4ed1b64f1203ade14e9216352b46232d5582468b26a0160e0c9618c5"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-linux-x64-musl.tar.gz" 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/471734821" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563980"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"] [tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:ad46ad16c2376c2b78354ac488c1b166e076aa59bfcbcfd567d55957d755a690" checksum = "sha256:765c2bf04e8129cb58c0f946e324262e418370b35a203b50b1f06a0567ef8bc1"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-darwin-arm64.tar.gz" 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/471734814" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563984"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"] [tools.pnpm."platforms.windows-x64"]
checksum = "sha256:13ad8a9b139c4c829fea33462cf04d061d72f0926b2b76a92ac03c9a36e405f9" checksum = "sha256:d8bebbc71df2702961c1d34a5e61196bc0aa3bbde33c33253f6afa3dd4546a6d"
url = "https://github.com/pnpm/pnpm/releases/download/v11.11.0/pnpm-win32-x64.zip" 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/471734820" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/478563981"
provenance = "github-attestations" provenance = "github-attestations"
[[tools.terragrunt]] [[tools.terragrunt]]
version = "1.0.3" version = "1.1.1"
backend = "aqua:gruntwork-io/terragrunt" backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"] [tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
[tools.terragrunt."platforms.linux-arm64-musl"] [tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
[tools.terragrunt."platforms.linux-x64"] [tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
[tools.terragrunt."platforms.linux-x64-musl"] [tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
[tools.terragrunt."platforms.macos-arm64"] [tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa" checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628"
[tools.terragrunt."platforms.macos-x64"] [tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b" checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520"
[tools.terragrunt."platforms.windows-x64"] [tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db" checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412" url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421"

View File

@@ -1,4 +1,4 @@
experimental_monorepo_root = true monorepo_root = true
[monorepo] [monorepo]
config_roots = [ config_roots = [
@@ -16,9 +16,9 @@ config_roots = [
[tools] [tools]
node = "24.15.0" node = "24.15.0"
pnpm = "11.11.0" pnpm = "11.13.1"
terragrunt = "1.0.3" terragrunt = "1.1.1"
opentofu = "1.11.6" opentofu = "1.12.4"
"npm:oazapfts" = "7.5.0" "npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3" "github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124" "github:webassembly/binaryen" = "version_124"
@@ -35,7 +35,6 @@ macos-x64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" }
macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" } macos-arm64 = { asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" }
[settings] [settings]
experimental = true
pin = true pin = true
lockfile = true lockfile = true

View File

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

View File

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

View File

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

View File

@@ -586,7 +586,7 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; 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 */ = { BAEA01ACA3F5C9CD3D732370 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;

View File

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

View File

@@ -43,15 +43,16 @@ class ConnectivityApiImpl: ConnectivityApi {
capabilities.append(.vpn) capabilities.append(.vpn)
} }
// Determine if connection is unmetered: // Determine if connection is unmetered from the OS metered flags rather than
// - Must be on WiFi (not cellular) // the interface type, so wired ethernet (iPhone USB adapters, Apple Silicon
// - Must not be expensive (rules out personal hotspot) // Macs) is treated as unmetered like Wi-Fi:
// - Must not be constrained (Low Data Mode) // - Not on cellular
// Note: VPN over cellular should still be considered metered // - 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 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) capabilities.append(.unmetered)
} }

View File

@@ -203,7 +203,10 @@ class ImmichAPI {
func fetchMemory(for date: Date) async throws -> [MemoryResult] { func fetchMemory(for date: Date) async throws -> [MemoryResult] {
// get URL // 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 guard
let searchURL = buildRequestURL( let searchURL = buildRequestURL(
serverConfig: serverConfig, 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

@@ -6,6 +6,7 @@ const Map<String, Locale> locales = {
// Additional locales // Additional locales
'Arabic (ar)': Locale('ar'), 'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'), 'Basque (eu)': Locale('eu'),
'Belarusian (be)': Locale('be'),
'Bosnian (bl)': Locale('bn'), 'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'), 'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'), 'Bulgarian (bg)': Locale('bg'),

View File

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

View File

@@ -1,17 +1,16 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/colors.dart'; import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/log.model.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/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/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
enum SettingsKey<T> { enum SettingsKey<T> {
// Theme // Theme
themePrimaryColor<ImmichColorPreset>(codec: _EnumCodec(ImmichColorPreset.values)), themePrimaryColor<ImmichColorPreset>(codec: EnumCodec(ImmichColorPreset.values)),
themeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)), themeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
themeDynamic<bool>(), themeDynamic<bool>(),
themeColorfulInterface<bool>(), themeColorfulInterface<bool>(),
@@ -27,13 +26,13 @@ enum SettingsKey<T> {
// Network // Network
networkAutoEndpointSwitching<bool>(), networkAutoEndpointSwitching<bool>(),
networkExternalEndpointList<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: MapCodec(PrimitiveCodec.string, PrimitiveCodec.string)),
networkPreferredWifiName<String?>(), networkPreferredWifiName<String?>(),
networkLocalEndpoint<String?>(), networkLocalEndpoint<String?>(),
networkExternalEndpointList<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)),
networkCustomHeaders<Map<String, String>>(codec: _MapCodec(_PrimitiveCodec.string, _PrimitiveCodec.string)),
// Album // Album
albumSortMode<AlbumSortMode>(codec: _EnumCodec(AlbumSortMode.values)), albumSortMode<AlbumSortMode>(codec: EnumCodec(AlbumSortMode.values)),
albumIsReverse<bool>(), albumIsReverse<bool>(),
albumIsGrid<bool>(), albumIsGrid<bool>(),
@@ -47,195 +46,45 @@ enum SettingsKey<T> {
// Timeline // Timeline
timelineTilesPerRow<int>(), timelineTilesPerRow<int>(),
timelineGroupAssetsBy<GroupAssetsBy>(codec: _EnumCodec(GroupAssetsBy.values)), timelineGroupAssetsBy<GroupAssetsBy>(codec: EnumCodec(GroupAssetsBy.values)),
timelineStorageIndicator<bool>(), timelineStorageIndicator<bool>(),
// Log // Log
logLevel<LogLevel>(codec: _EnumCodec(LogLevel.values)), logLevel<LogLevel>(codec: EnumCodec(LogLevel.values)),
// Map // Map
mapShowFavoriteOnly<bool>(), mapShowFavoriteOnly<bool>(),
mapRelativeDate<int>(), mapRelativeDate<int>(),
mapIncludeArchived<bool>(), mapIncludeArchived<bool>(),
mapThemeMode<ThemeMode>(codec: _EnumCodec(ThemeMode.values)), mapThemeMode<ThemeMode>(codec: EnumCodec(ThemeMode.values)),
mapWithPartners<bool>(), mapWithPartners<bool>(),
// Cleanup // Cleanup
cleanupKeepFavorites<bool>(), cleanupKeepFavorites<bool>(),
cleanupKeepMediaType<AssetKeepType>(codec: _EnumCodec(AssetKeepType.values)), cleanupKeepMediaType<AssetKeepType>(codec: EnumCodec(AssetKeepType.values)),
cleanupKeepAlbumIds<List<String>>(codec: _ListCodec(_PrimitiveCodec.string)), cleanupKeepAlbumIds<List<String>>(codec: ListCodec(PrimitiveCodec.string)),
cleanupCutoffDaysAgo<int>(), cleanupCutoffDaysAgo<int>(),
cleanupDefaultsInitialized<bool>(), cleanupDefaultsInitialized<bool>(),
// Share // Share
shareFileType<ShareAssetType>(codec: _EnumCodec(ShareAssetType.values)), shareFileType<ShareAssetType>(codec: EnumCodec(ShareAssetType.values)),
// Slideshow // Slideshow
slideshowRepeat<bool>(), slideshowRepeat<bool>(),
slideshowDuration<int>(), slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)), slideshowLook<SlideshowLook>(codec: EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)), slideshowDirection<SlideshowDirection>(codec: EnumCodec(SlideshowDirection.values)),
// Feature message // 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); String encode(T value) => _codec.encode(value);
T decode(String raw) => _codec.decode(raw); 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; return updatedAlbum;
} }
FutureOr<(DateTime, DateTime)> getDateRange(String albumId) { Stream<(DateTime, DateTime)> watchDateRange(String albumId) {
return _repository.getDateRange(albumId); return _repository.watchDateRange(albumId);
} }
Future<List<UserDto>> getSharedUsers(String albumId) { Future<List<UserDto>> getSharedUsers(String albumId) {
@@ -175,12 +175,12 @@ class RemoteAlbumService {
return _repository.getAssets(albumId); 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); final album = await _albumApiRepository.addAssets(albumId, assetIds);
await _repository.addAssets(albumId, album.added); 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, /// !TODO The name here is not clear as we have addAssets method above,
@@ -196,7 +196,7 @@ class RemoteAlbumService {
}) async { }) async {
int addedCount = 0; int addedCount = 0;
if (candidates.remoteAssetIds.isNotEmpty) { 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) { if (candidates.localAssetsToUpload.isNotEmpty) {
addedCount += await _uploadAndAddLocals( addedCount += await _uploadAndAddLocals(

View File

@@ -328,8 +328,6 @@ class SyncStreamService {
return _syncStreamRepository.updateAssetOcrV1(data.cast()); return _syncStreamRepository.updateAssetOcrV1(data.cast());
case SyncEntityType.assetOcrDeleteV1: case SyncEntityType.assetOcrDeleteV1:
return _syncStreamRepository.deleteAssetOcrV1(data.cast()); 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.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive, api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked, api.AssetVisibility.locked => AssetVisibility.locked,
_ => AssetVisibility.timeline,
}; };
} }
@@ -68,6 +67,5 @@ extension on api.AssetTypeEnum {
api.AssetTypeEnum.VIDEO => AssetType.video, api.AssetTypeEnum.VIDEO => AssetType.video,
api.AssetTypeEnum.AUDIO => AssetType.audio, api.AssetTypeEnum.AUDIO => AssetType.audio,
api.AssetTypeEnum.OTHER => AssetType.other, api.AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
}; };
} }

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 _deleteAssets(deletes);
await _upsertAssets(updates); 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 // The ugly casting below is required for now because the generated code
// casts the returned values from the platform during decoding them // casts the returned values from the platform during decoding them
// and iterating over them causes the type to be List<Object?> instead of // and iterating over them causes the type to be List<Object?> instead of
// List<String> // 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 { await _db.batch((batch) async {
assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) { assetAlbums.cast<String, List<Object?>>().forEach((assetId, albumIds) {
batch.insertAll( 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() final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId)) ..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.createdAt.min(), _db.remoteAssetEntity.createdAt.max()]) ..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 minDate = row.read(_db.remoteAssetEntity.createdAt.min());
final maxDate = row.read(_db.remoteAssetEntity.createdAt.max()); final maxDate = row.read(_db.remoteAssetEntity.createdAt.max());
return (minDate ?? DateTime.now(), maxDate ?? DateTime.now()); return (minDate ?? DateTime.now(), maxDate ?? DateTime.now());
}).getSingle(); }).watchSingle();
} }
Future<List<UserDto>> getSharedUsers(String albumId) async { Future<List<UserDto>> getSharedUsers(String albumId) async {

View File

@@ -71,7 +71,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
} }
final query = _db.remoteAssetEntity.select() 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)]); ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get(); 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/config/app_config.dart';
import 'package:immich_mobile/domain/models/settings_key.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/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'; import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class SettingsRepository extends DriftDatabaseRepository { class SettingsRepository extends CachedKeyValueRepository<SettingsKey, AppConfig> {
final Drift _db; final Drift _db;
SettingsRepository._(this._db) : super(_db); SettingsRepository._(this._db) : super(const .new());
static SettingsRepository? _instance; static SettingsRepository? _instance;
@@ -19,9 +20,6 @@ class SettingsRepository extends DriftDatabaseRepository {
return instance; return instance;
} }
AppConfig _appConfig = const .new();
AppConfig get appConfig => _appConfig;
static Future<SettingsRepository> ensureInitialized(Drift db) async { static Future<SettingsRepository> ensureInitialized(Drift db) async {
if (_instance == null) { if (_instance == null) {
final instance = SettingsRepository._(db); final instance = SettingsRepository._(db);
@@ -31,7 +29,20 @@ class SettingsRepository extends DriftDatabaseRepository {
return _instance!; 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 { Future<void> clear(Iterable<SettingsKey> keys) async {
if (keys.isEmpty) { if (keys.isEmpty) {
@@ -41,13 +52,15 @@ class SettingsRepository extends DriftDatabaseRepository {
final names = keys.map((key) => key.name).toList(); final names = keys.map((key) => key.name).toList();
await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go(); await (_db.delete(_db.settingsEntity)..where((row) => row.key.isIn(names))).go();
var config = snapshot;
for (final key in keys) { 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 { Future<void> write<T, U extends T>(SettingsKey<T> key, U value) async {
if (value == _appConfig.read(key)) { if (value == snapshot.read(key)) {
return; return;
} }
@@ -65,29 +78,8 @@ class SettingsRepository extends DriftDatabaseRepository {
.insertOnConflictUpdate( .insertOnConflictUpdate(
SettingsEntityCompanion.insert(key: key.name, value: .new(resolvedValue), updatedAt: .new(DateTime.now())), 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) { Stream<AppConfig> watchConfig() => watchSnapshot();
_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};
}),
);
}
} }

View File

@@ -937,7 +937,6 @@ extension on AssetTypeEnum {
AssetTypeEnum.VIDEO => AssetType.video, AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio, AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other, AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception('Unknown AssetType value: $this'),
}; };
} }
@@ -945,14 +944,12 @@ extension on AssetOrder {
AlbumAssetOrder toAlbumAssetOrder() => switch (this) { AlbumAssetOrder toAlbumAssetOrder() => switch (this) {
AssetOrder.asc => AlbumAssetOrder.asc, AssetOrder.asc => AlbumAssetOrder.asc,
AssetOrder.desc => AlbumAssetOrder.desc, AssetOrder.desc => AlbumAssetOrder.desc,
_ => throw Exception('Unknown AssetOrder value: $this'),
}; };
} }
extension on MemoryType { extension on MemoryType {
MemoryTypeEnum toMemoryType() => switch (this) { MemoryTypeEnum toMemoryType() => switch (this) {
MemoryType.onThisDay => MemoryTypeEnum.onThisDay, 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.editor => AlbumUserRole.editor,
api.AlbumUserRole.viewer => AlbumUserRole.viewer, api.AlbumUserRole.viewer => AlbumUserRole.viewer,
api.AlbumUserRole.owner => AlbumUserRole.owner, 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.hidden => AssetVisibility.hidden,
api.AssetVisibility.archive => AssetVisibility.archive, api.AssetVisibility.archive => AssetVisibility.archive,
api.AssetVisibility.locked => AssetVisibility.locked, 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.onboarding => UserMetadataKey.onboarding,
api.UserMetadataKey.preferences => UserMetadataKey.preferences, api.UserMetadataKey.preferences => UserMetadataKey.preferences,
api.UserMetadataKey.license => UserMetadataKey.license, api.UserMetadataKey.license => UserMetadataKey.license,
_ => throw Exception('Unknown UserMetadataKey value: $this'),
}; };
} }
extension on UserAvatarColor { 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 { extension on api.AssetEditAction {
@@ -993,6 +987,5 @@ extension on api.AssetEditAction {
api.AssetEditAction.crop => AssetEditAction.crop, api.AssetEditAction.crop => AssetEditAction.crop,
api.AssetEditAction.rotate => AssetEditAction.rotate, api.AssetEditAction.rotate => AssetEditAction.rotate,
api.AssetEditAction.mirror => AssetEditAction.mirror, api.AssetEditAction.mirror => AssetEditAction.mirror,
_ => AssetEditAction.other,
}; };
} }

View File

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

View File

@@ -51,6 +51,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
int? _crossfadeFromIndex; int? _crossfadeFromIndex;
int? _crossfadeToIndex; int? _crossfadeToIndex;
int _zoomCycle = 0; int _zoomCycle = 0;
bool _disableAnimations = false;
@override @override
initState() { initState() {
@@ -70,6 +71,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
unawaited(WakelockPlus.enable()); unawaited(WakelockPlus.enable());
} }
@override
void didChangeDependencies() {
super.didChangeDependencies();
_disableAnimations = MediaQuery.disableAnimationsOf(context);
}
@override @override
dispose() { dispose() {
_timer.cancel(); _timer.cancel();
@@ -166,6 +173,11 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
} }
void _crossFadeToPage(int page) { void _crossFadeToPage(int page) {
if (_disableAnimations) {
_pageController.jumpToPage(page);
return;
}
final previousIndex = _index; final previousIndex = _index;
_pageController.jumpToPage(page); _pageController.jumpToPage(page);
setState(() { setState(() {
@@ -273,19 +285,12 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
} }
if (asset.isImage) { if (asset.isImage) {
final elapsed = _stopwatch.elapsedMilliseconds; return _SlideshowProgressBar(
final duration = _config.duration * 1000;
return TweenAnimationBuilder(
key: Key(_index.toString()), key: Key(_index.toString()),
tween: Tween<double>(begin: elapsed / duration.toDouble(), end: _paused ? elapsed / duration.toDouble() : 1.0), durationMs: _config.duration * 1000,
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), elapsedMs: _stopwatch.elapsedMilliseconds,
builder: (context, value, _) => LinearProgressIndicator( paused: _paused,
color: context.colorScheme.primary, color: context.colorScheme.primary,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: value,
),
); );
} else { } else {
return LinearProgressIndicator( return LinearProgressIndicator(
@@ -334,6 +339,21 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
final imageProvider = getFullImageProvider(asset, size: context.sizeData); final imageProvider = getFullImageProvider(asset, size: context.sizeData);
if (asset.isImage) { if (asset.isImage) {
PhotoView buildPhotoView(PhotoViewComputedScale initialScale) => PhotoView(
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: initialScale,
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
);
if (_disableAnimations) {
return buildPhotoView(scale);
}
final zoomOut = _zoomCycle.isOdd; final zoomOut = _zoomCycle.isOdd;
final elapsed = _stopwatch.elapsedMilliseconds; final elapsed = _stopwatch.elapsedMilliseconds;
final duration = _config.duration * 1000; final duration = _config.duration * 1000;
@@ -349,16 +369,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
: 1.0, : 1.0,
), ),
duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)), duration: Duration(milliseconds: _paused ? 1 : max(duration - elapsed, 1)),
builder: (context, value, _) => PhotoView( builder: (context, value, _) => buildPhotoView(scale * (1.0 + value * _kenBurnsZoom)),
imageProvider: imageProvider,
index: index,
disableScaleGestures: true,
gaplessPlayback: true,
filterQuality: FilterQuality.high,
initialScale: scale * (1.0 + value * _kenBurnsZoom),
controller: PhotoViewController(),
onTapUp: (_, _, _) => _onTapUp(),
),
); );
} else { } else {
final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status)); final status = ref.watch(videoPlayerProvider(asset.heroTag).select((s) => s.status));
@@ -463,3 +474,75 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
); );
} }
} }
/// Progress bar for image slides, driven by an explicit [AnimationController].
///
/// [TweenAnimationBuilder] creates its controller internally with the default
/// [AnimationBehavior.normal], which makes it run ~20x too fast while the system
/// "reduce motion" setting is on (flutter/flutter#164287). This owns its
/// controller so it can use [AnimationBehavior.preserve] and animate at the real
/// slide duration regardless of that setting.
class _SlideshowProgressBar extends StatefulWidget {
final int durationMs;
final int elapsedMs;
final bool paused;
final Color color;
const _SlideshowProgressBar({
super.key,
required this.durationMs,
required this.elapsedMs,
required this.paused,
required this.color,
});
@override
State<_SlideshowProgressBar> createState() => _SlideshowProgressBarState();
}
class _SlideshowProgressBarState extends State<_SlideshowProgressBar> with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: widget.durationMs),
animationBehavior: AnimationBehavior.preserve,
)..value = (widget.elapsedMs / widget.durationMs).clamp(0.0, 1.0);
if (!widget.paused) {
_controller.forward();
}
}
@override
void didUpdateWidget(_SlideshowProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.durationMs != oldWidget.durationMs) {
_controller.duration = Duration(milliseconds: widget.durationMs);
}
if (widget.paused != oldWidget.paused) {
widget.paused ? _controller.stop() : _controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) => LinearProgressIndicator(
color: widget.color,
borderRadius: const BorderRadius.all(Radius.zero),
minHeight: 5,
value: _controller.value,
),
);
}
}

View File

@@ -886,7 +886,6 @@ class _QuickLinkList extends StatelessWidget {
_QuickLink( _QuickLink(
title: context.t.recently_added, title: context.t.recently_added,
icon: Icons.upload_outlined, icon: Icons.upload_outlined,
isTop: true,
onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()), onTap: () => context.pushRoute(const DriftRecentlyAddedRoute()),
), ),
_QuickLink( _QuickLink(

View File

@@ -2,6 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
@@ -154,12 +155,8 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
return; return;
} }
if (result.count == 0) { // Only report the failure when nothing was added; if some succeeded we show "added".
ImmichToast.show( if (result.count > 0) {
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
);
} else {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}), msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
@@ -167,6 +164,17 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
// Refresh the "Appears in" list on the asset's info panel. // Refresh the "Appears in" list on the asset's info panel.
ref.invalidate(albumsContainingAssetProvider(latest.remoteId!)); ref.invalidate(albumsContainingAssetProvider(latest.remoteId!));
} else if (result.failedCount > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failedCount}),
toastType: ToastType.error,
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name}),
);
} }
if (!context.mounted) { if (!context.mounted) {

View File

@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart'; import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -59,13 +61,19 @@ class DeleteActionButton extends ConsumerWidget {
} }
} }
if (source == ActionSource.viewer) { final currentAsset = ref.read(assetViewerProvider).currentAsset;
EventStream.shared.emit(const ViewerReloadAssetEvent()); final stackIndex = ref.read(assetViewerProvider).stackIndex;
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source); final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset(); ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer && result.success) {
final shouldRefreshStack = currentAsset is RemoteAsset && currentAsset.stackId != null;
EventStream.shared.emit(
shouldRefreshStack ? ViewerStackAssetDeletedEvent(stackIndex: stackIndex) : const ViewerReloadAssetEvent(),
);
}
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()}); final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) { if (context.mounted) {

View File

@@ -7,7 +7,6 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
@@ -129,12 +128,6 @@ class ShareActionButton extends ConsumerWidget {
return; return;
} }
await ref.read(settingsProvider).write(SettingsKey.shareFileType, fileType);
if (!context.mounted) {
return;
}
await _share(context, ref, fileType); await _share(context, ref, fileType);
} }

View File

@@ -413,7 +413,8 @@ class _AssetPageState extends ConsumerState<AssetPage> {
final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash)); final showAssetStack = ref.watch(timelineServiceProvider.select((s) => s.origin != TimelineOrigin.trash));
final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null; final stackChildren = showAssetStack ? ref.watch(stackChildrenNotifier(asset)).valueOrNull : null;
if (stackChildren != null && stackChildren.isNotEmpty) { if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(stackIndex); final safeStackIndex = stackIndex.clamp(0, stackChildren.length - 1);
displayAsset = stackChildren.elementAt(safeStackIndex);
} }
final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset); final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset);

View File

@@ -11,6 +11,10 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAs
return ref.watch(assetServiceProvider).getStack(asset); return ref.watch(assetServiceProvider).getStack(asset);
} }
void setStack(List<RemoteAsset> stack) {
state = AsyncData(stack);
}
} }
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose final stackChildrenNotifier = AsyncNotifierProvider.autoDispose

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@@ -174,6 +175,12 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return; return;
} }
// The viewer is closing; don't flip the current asset now. Flipping it swaps
// the grid tile hero keys mid pop and animates the close on two tiles (#23779).
if (!mounted || !(ModalRoute.of(context)?.isActive ?? true)) {
return;
}
AssetViewer._setAsset(ref, asset); AssetViewer._setAsset(ref, asset);
_preloader.preload(index, context.sizeData); _preloader.preload(index, context.sizeData);
_handleCasting(); _handleCasting();
@@ -215,6 +222,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onTimelineReloadEvent(); _onTimelineReloadEvent();
case ViewerReloadAssetEvent(): case ViewerReloadAssetEvent():
_onViewerReloadEvent(); _onViewerReloadEvent();
case ViewerStackAssetDeletedEvent event:
_onViewerStackAssetDeletedEvent(event);
default: default:
} }
} }
@@ -230,6 +239,33 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_onAssetChanged(target); _onAssetChanged(target);
} }
Future<void> _onViewerStackAssetDeletedEvent(ViewerStackAssetDeletedEvent event) async {
final timelineAsset = ref.read(timelineServiceProvider).getAssetSafe(_currentPage);
if (timelineAsset == null) {
_onViewerReloadEvent();
return;
}
final stackProvider = stackChildrenNotifier(timelineAsset);
ref.invalidate(stackProvider);
final stack = await ref.read(stackProvider.future);
if (!mounted) {
return;
}
if (stack.isEmpty) {
_onViewerReloadEvent();
return;
}
final targetIndex = math.min(event.stackIndex, stack.length - 1);
ref.read(assetViewerProvider.notifier)
..setAsset(stack[targetIndex])
..setStackIndex(targetIndex);
}
void _onTimelineReloadEvent() { void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider); final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets; final totalAssets = timelineService.totalAssets;
@@ -246,6 +282,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (index != _currentPage) { if (index != _currentPage) {
_pageController.jumpToPage(index); _pageController.jumpToPage(index);
_onAssetChanged(index); _onAssetChanged(index);
} else if (currentAsset is RemoteAsset && currentAsset.stackId != null && assetIndex == null) {
final timelineAsset = timelineService.getAssetSafe(index);
if (timelineAsset is! RemoteAsset || currentAsset.stackId != timelineAsset.stackId) {
_onAssetChanged(index);
}
} else if (currentAsset != null && assetIndex == null) { } else if (currentAsset != null && assetIndex == null) {
_onAssetChanged(index); _onAssetChanged(index);
} }

View File

@@ -101,7 +101,7 @@ class ViewerBottomBar extends ConsumerWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
OcrToggleButton(asset: asset), if (asset.isImage) OcrToggleButton(asset: asset),
if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag), if (asset.isVideo) VideoControls(videoPlayerName: asset.heroTag),
if (!isReadonlyModeEnabled) if (!isReadonlyModeEnabled)
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions), Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: actions),

View File

@@ -91,7 +91,7 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
} }
case AppLifecycleState.paused: case AppLifecycleState.paused:
_shouldPlayOnForeground = await _controller?.isPlaying() ?? true; _shouldPlayOnForeground = await _controller?.isPlaying() ?? true;
if (_shouldPlayOnForeground) { if (_shouldPlayOnForeground && mounted) {
await _notifier.pause(); await _notifier.pause();
} }
default: default:
@@ -268,10 +268,13 @@ class _NativeVideoViewerState extends ConsumerState<NativeVideoViewer> with Widg
return; return;
} }
await _notifier.load(source); // Grab refs to prevent reading after dispose
final loopVideo = ref.read(appConfigProvider).viewer.loopVideo; final loopVideo = ref.read(appConfigProvider).viewer.loopVideo;
await _notifier.setLoop(!widget.asset.isMotionPhoto && loopVideo); final localNotifier = _notifier;
await _notifier.setVolume(1);
await localNotifier.load(source);
await localNotifier.setLoop(!widget.asset.isMotionPhoto && loopVideo);
await localNotifier.setVolume(1);
} }
void _initController(NativeVideoPlayerController nc) { void _initController(NativeVideoPlayerController nc) {

View File

@@ -41,7 +41,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
} }
final remoteAssets = selectedAssets.whereType<RemoteAsset>(); final remoteAssets = selectedAssets.whereType<RemoteAsset>();
final addedCount = await ref final result = await ref
.read(remoteAlbumProvider.notifier) .read(remoteAlbumProvider.notifier)
.addAssets(album.id, remoteAssets.map((e) => e.id).toList()); .addAssets(album.id, remoteAssets.map((e) => e.id).toList());
@@ -52,15 +52,22 @@ class FavoriteBottomSheet extends ConsumerWidget {
); );
} }
if (addedCount != remoteAssets.length) { // Only report the failure when nothing was added; if some succeeded we show "added".
if (result.added > 0) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}), msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}),
);
} else if (result.failed > 0) {
ImmichToast.show(
context: context,
msg: 'assets_cannot_be_added_to_album_count'.t(context: context, args: {'count': result.failed}),
toastType: ToastType.error,
); );
} else { } else {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'add_to_album_bottom_sheet_added'.t(args: {"album": album.name}), msg: 'add_to_album_bottom_sheet_already_exists'.t(args: {"album": album.name}),
); );
} }

View File

@@ -1,23 +1,76 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/album/album_selector.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart'; import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class PartnerDetailBottomSheet extends ConsumerWidget { class PartnerDetailBottomSheet extends ConsumerStatefulWidget {
const PartnerDetailBottomSheet({super.key}); const PartnerDetailBottomSheet({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<PartnerDetailBottomSheet> createState() => _PartnerDetailBottomSheetState();
return const BaseBottomSheet( }
class _PartnerDetailBottomSheetState extends ConsumerState<PartnerDetailBottomSheet> {
late final DraggableScrollableController sheetController;
@override
void initState() {
super.initState();
sheetController = DraggableScrollableController();
}
@override
void dispose() {
sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
Future<void> addToAlbum(RemoteAlbum album) async {
final result = await ref.read(actionProvider.notifier).addToAlbum(ActionSource.timeline, album);
if (!context.mounted) {
return;
}
if (!result.success) {
ImmichToast.show(context: context, msg: 'scaffold_body_error_occurred'.tr(), toastType: ToastType.error);
return;
}
ImmichToast.show(
context: context,
msg: result.count == 0
? 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {'album': album.name})
: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {'album': album.name}),
);
}
Future<void> onKeyboardExpand() {
return sheetController.animateTo(0.85, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut);
}
return BaseBottomSheet(
controller: sheetController,
initialChildSize: 0.25, initialChildSize: 0.25,
maxChildSize: 0.4, maxChildSize: 0.85,
shouldCloseOnMinExtent: false, shouldCloseOnMinExtent: false,
actions: [ actions: const [
ShareActionButton(source: ActionSource.timeline), ShareActionButton(source: ActionSource.timeline),
DownloadActionButton(source: ActionSource.timeline), DownloadActionButton(source: ActionSource.timeline),
], ],
slivers: [
const AddToAlbumHeader(),
AlbumSelector(onAlbumSelected: addToAlbum, onKeyboardExpanded: onKeyboardExpand),
],
); );
} }
} }

View File

@@ -16,7 +16,6 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/map_bottom_sheet
import 'package:immich_mobile/presentation/widgets/map/map.state.dart'; import 'package:immich_mobile/presentation/widgets/map/map.state.dart';
import 'package:immich_mobile/presentation/widgets/map/map_utils.dart'; import 'package:immich_mobile/presentation/widgets/map/map_utils.dart';
import 'package:immich_mobile/providers/routes.provider.dart'; import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/async_mutex.dart'; import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/debounce.dart'; import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart'; import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -133,8 +132,7 @@ class _DriftMapState extends ConsumerState<DriftMap> {
// When the AssetViewer is open, the DriftMap route stays alive in the background. // When the AssetViewer is open, the DriftMap route stays alive in the background.
// If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed, // If we continue to update bounds, the map-scoped timeline service gets recreated and the previous one disposed,
// which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever"). // which can invalidate the TimelineService instance that was passed into AssetViewerRoute (causing "loading forever").
final currentRoute = ref.read(currentRouteNameProvider); if (ref.read(isAssetViewerOpenProvider)) {
if (currentRoute == AssetViewerRoute.name) {
return; return;
} }
@@ -183,6 +181,11 @@ class _DriftMapState extends ConsumerState<DriftMap> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.listen<bool>(isAssetViewerOpenProvider, (previous, current) {
if (previous == true && !current) {
_debouncer.run(() => setBounds(forceReload: true));
}
});
return Stack( return Stack(
children: [ children: [
_Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady), _Map(initialLocation: widget.initialLocation, onMapCreated: onMapCreated, onMapReady: onMapReady),

View File

@@ -1,9 +1,6 @@
import 'dart:async';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/download/download_state.model.dart'; import 'package:immich_mobile/models/download/download_state.model.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/services/download.service.dart'; import 'package:immich_mobile/services/download.service.dart';
class DownloadStateNotifier extends StateNotifier<DownloadState> { class DownloadStateNotifier extends StateNotifier<DownloadState> {
@@ -17,79 +14,9 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
taskProgress: <String, DownloadInfo>{}, taskProgress: <String, DownloadInfo>{},
), ),
) { ) {
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
_downloadService.onTaskProgress = _taskProgressCallback; _downloadService.onTaskProgress = _taskProgressCallback;
} }
void _updateDownloadStatus(String taskId, TaskStatus status) {
if (status == TaskStatus.canceled) {
return;
}
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..addAll({
taskId: DownloadInfo(
progress: state.taskProgress[taskId]?.progress ?? 0,
fileName: state.taskProgress[taskId]?.fileName ?? '',
status: status,
),
}),
);
}
// Download live photo callback
void _downloadLivePhotoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
if (update.task.metaData.isEmpty) {
return;
}
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
_downloadService.saveLivePhotos(update.task, livePhotosId);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download image callback
void _downloadImageCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveImageWithPath(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
// Download video callback
void _downloadVideoCallback(TaskStatusUpdate update) {
_updateDownloadStatus(update.task.taskId, update.status);
switch (update.status) {
case TaskStatus.complete:
_downloadService.saveVideo(update.task);
_onDownloadComplete(update.task.taskId);
break;
default:
break;
}
}
void _taskProgressCallback(TaskProgressUpdate update) { void _taskProgressCallback(TaskProgressUpdate update) {
// Ignore if the task is canceled or completed // Ignore if the task is canceled or completed
if (update.progress == -2 || update.progress == -1) { if (update.progress == -2 || update.progress == -1) {
@@ -110,20 +37,6 @@ class DownloadStateNotifier extends StateNotifier<DownloadState> {
); );
} }
void _onDownloadComplete(String id) {
Future.delayed(const Duration(seconds: 2), () {
state = state.copyWith(
taskProgress: <String, DownloadInfo>{}
..addAll(state.taskProgress)
..remove(id),
);
if (state.taskProgress.isEmpty) {
state = state.copyWith(showProgress: false);
}
});
}
void cancelDownload(String id) async { void cancelDownload(String id) async {
final isCanceled = await _downloadService.cancelDownload(id); final isCanceled = await _downloadService.cancelDownload(id);

View File

@@ -1,7 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/constants/enums.dart';
@@ -10,7 +9,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/asset_edit.model.dart'; import 'package:immich_mobile/domain/models/asset_edit.model.dart';
import 'package:immich_mobile/domain/services/asset.service.dart'; import 'package:immich_mobile/domain/services/asset.service.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart'; import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'; import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart'; import 'package:immich_mobile/providers/backup/asset_upload_progress.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
@@ -23,7 +21,6 @@ import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart'; import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/action.service.dart'; import 'package:immich_mobile/services/action.service.dart';
import 'package:immich_mobile/services/download.service.dart';
import 'package:immich_mobile/services/foreground_upload.service.dart'; import 'package:immich_mobile/services/foreground_upload.service.dart';
import 'package:immich_mobile/utils/semver.dart'; import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart'; import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
@@ -37,18 +34,25 @@ class ActionResult {
final bool success; final bool success;
final String? error; final String? error;
final List<String> remoteAssetIds; final List<String> remoteAssetIds;
final int failedCount;
const ActionResult({required this.count, required this.success, this.error, this.remoteAssetIds = const []}); const ActionResult({
required this.count,
required this.success,
this.error,
this.remoteAssetIds = const [],
this.failedCount = 0,
});
@override @override
String toString() => 'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds)'; String toString() =>
'ActionResult(count: $count, success: $success, error: $error, remoteAssetIds: $remoteAssetIds, failedCount: $failedCount)';
} }
class ActionNotifier extends Notifier<void> { class ActionNotifier extends Notifier<void> {
final Logger _logger = Logger('ActionNotifier'); final Logger _logger = Logger('ActionNotifier');
late ActionService _service; late ActionService _service;
late ForegroundUploadService _foregroundUploadService; late ForegroundUploadService _foregroundUploadService;
late DownloadService _downloadService;
late AssetService _assetService; late AssetService _assetService;
ActionNotifier() : super(); ActionNotifier() : super();
@@ -58,29 +62,6 @@ class ActionNotifier extends Notifier<void> {
_foregroundUploadService = ref.watch(foregroundUploadServiceProvider); _foregroundUploadService = ref.watch(foregroundUploadServiceProvider);
_service = ref.watch(actionServiceProvider); _service = ref.watch(actionServiceProvider);
_assetService = ref.watch(assetServiceProvider); _assetService = ref.watch(assetServiceProvider);
_downloadService = ref.watch(downloadServiceProvider);
_downloadService.onImageDownloadStatus = _downloadImageCallback;
_downloadService.onVideoDownloadStatus = _downloadVideoCallback;
_downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
}
void _downloadImageCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveImageWithPath(update.task);
}
}
void _downloadVideoCallback(TaskStatusUpdate update) {
if (update.status == TaskStatus.complete) {
_downloadService.saveVideo(update.task);
}
}
void _downloadLivePhotoCallback(TaskStatusUpdate update) async {
if (update.status == TaskStatus.complete) {
final livePhotosId = LivePhotosMetadata.fromJson(update.task.metaData).id;
unawaited(_downloadService.saveLivePhotos(update.task, livePhotosId));
}
} }
List<String> _getRemoteIdsForSource(ActionSource source) { List<String> _getRemoteIdsForSource(ActionSource source) {
@@ -393,9 +374,12 @@ class ActionNotifier extends Notifier<void> {
final albumNotifier = ref.read(remoteAlbumProvider.notifier); final albumNotifier = ref.read(remoteAlbumProvider.notifier);
int addedRemote = 0; int addedRemote = 0;
int failedRemote = 0;
if (remoteIds.isNotEmpty) { if (remoteIds.isNotEmpty) {
try { try {
addedRemote = await albumNotifier.addAssets(album.id, remoteIds); final result = await albumNotifier.addAssets(album.id, remoteIds);
addedRemote = result.added;
failedRemote = result.failed;
} catch (error, stack) { } catch (error, stack) {
_logger.severe('Failed to add assets to album ${album.id}', error, stack); _logger.severe('Failed to add assets to album ${album.id}', error, stack);
return ActionResult(count: 0, success: false, error: error.toString()); return ActionResult(count: 0, success: false, error: error.toString());
@@ -409,7 +393,7 @@ class ActionNotifier extends Notifier<void> {
} }
if (localAssets.isEmpty) { if (localAssets.isEmpty) {
return ActionResult(count: addedRemote, success: true); return ActionResult(count: addedRemote, success: true, failedCount: failedRemote);
} }
final uploadResult = await upload( final uploadResult = await upload(
@@ -424,6 +408,7 @@ class ActionNotifier extends Notifier<void> {
count: addedRemote + uploadResult.count, count: addedRemote + uploadResult.count,
success: uploadResult.success, success: uploadResult.success,
error: uploadResult.error, error: uploadResult.error,
failedCount: failedRemote,
); );
} }

View File

@@ -200,12 +200,12 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return _remoteAlbumService.getAssets(albumId); return _remoteAlbumService.getAssets(albumId);
} }
Future<int> addAssets(String albumId, List<String> assetIds) async { Future<({int added, int failed})> addAssets(String albumId, List<String> assetIds) async {
final added = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds); final result = await _remoteAlbumService.addAssets(albumId: albumId, assetIds: assetIds);
if (added > 0) { if (result.added > 0) {
await _refreshAlbumInState(albumId); await _refreshAlbumInState(albumId);
} }
return added; return result;
} }
/// Links a freshly-uploaded local asset to an album using its new remote ID, /// Links a freshly-uploaded local asset to an album using its new remote ID,
@@ -313,9 +313,9 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
} }
} }
final remoteAlbumDateRangeProvider = FutureProvider.family<(DateTime, DateTime), String>((ref, albumId) async { final remoteAlbumDateRangeProvider = StreamProvider.autoDispose.family<(DateTime, DateTime), String>((ref, albumId) {
final service = ref.watch(remoteAlbumServiceProvider); final service = ref.watch(remoteAlbumServiceProvider);
return service.getDateRange(albumId); return service.watchDateRange(albumId);
}); });
final remoteAlbumSharedUsersProvider = FutureProvider.autoDispose.family<List<UserDto>, String>((ref, albumId) async { final remoteAlbumSharedUsersProvider = FutureProvider.autoDispose.family<List<UserDto>, String>((ref, albumId) async {

View File

@@ -1,7 +1,19 @@
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/routing/router.dart';
@visibleForTesting
bool isRouteInStack(Ref ref, String routeName) {
final router = ref.watch(appRouterProvider);
void onChange() => ref.invalidateSelf();
router.addListener(onChange);
ref.onDispose(() => router.removeListener(onChange));
return router.stackData.any((route) => route.name == routeName);
}
final inLockedViewProvider = Provider<bool>((ref) => isRouteInStack(ref, DriftLockedFolderRoute.name));
final isAssetViewerOpenProvider = Provider<bool>((ref) => isRouteInStack(ref, AssetViewerRoute.name));
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null); final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null); final previousRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null); final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);

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