Compare commits

...

63 Commits

Author SHA1 Message Date
Alex
a739be31f3 refactor: login form 2025-12-02 13:41:34 -06:00
Jonathan Jogenfors
62628dfcfa fix(web): folder view sort oder (#24337)
fix: folder view sort oder
2025-12-02 11:48:12 -06:00
Hai Sullivan
b11aecd184 fix(mobile): use correct timezone displayed in the info sheet (#24310)
* fixed the timezone issue in the Immich mobile app's metadata sheet to match the web app's behavior

* format dart

* now uses the shared applyTimezoneOffset() utility function from mobile/lib/utils/timezone.dart

* add tests

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-12-02 16:37:19 +00:00
Jason Rasmussen
116012f6f8 feat: less asset-metadata validation (#24342) 2025-12-02 10:56:31 -05:00
renovate[bot]
7594136050 chore(deps): update dependency express to v5.2.0 [security] (#24323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:27:12 -05:00
renovate[bot]
bb341cc774 chore(deps): update docker.io/valkey/valkey docker tag to v9 (#24336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:26:39 -05:00
Dionysius
af1d4afb95 fix(docs): server and machine-learning use IMMICH_HOST and IMMICH_PORT (#24335) 2025-12-02 09:25:39 -05:00
renovate[bot]
75b1ef2c57 chore(deps): update machine-learning (#24334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 01:01:17 -05:00
renovate[bot]
1e37f7c8c8 chore(deps): update dependency nodemailer to v7.0.11 [security] (#24330)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-01 23:16:59 -05:00
Yaros
a32f450059 feat(mobile): persist album sorting & layout in settings (#22133)
* fix(mobile): persist album sorting in settings

* fix(mobile): persist album layout

* fix: fixed store model id

* fix: corrupted AppSettingsEnum

* chore: refactor to remove RemoteAlbumSortMode

* refactor: use t instead of tr
2025-12-01 20:51:35 -06:00
carbonemys
b452ab463b fix(web): open onboarding documentation link in new tab (#24289)
* fix(web): open onboarding documentation link in new tab

* Update web/src/lib/components/onboarding-page/onboarding-storage-template.svelte

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-12-01 20:49:31 -06:00
Sergey Katsubo
79bed80226 feat(server): log all thumbnail generation attempts at verbose level (#24324)
Log thumbnail generation at verbose level
2025-12-01 20:26:13 -06:00
Mert
6249996cdb fix(ml): do not upscale preview (#24322)
do not upscale
2025-12-01 20:26:01 -06:00
Jonathan Jogenfors
a3f281caa3 docs(faq): add more info on archiving (#24326)
docs: add more info on archive to faq
2025-12-01 20:25:31 -06:00
Mert
7c19b0591f fix(server): cjk migration (#24320)
* join string

* use pagination instead
2025-12-01 15:41:19 -06:00
Mert
95c29a8aea fix(server): use bigrams for cjk (#24285)
* use bigrams for cjk

* update sql

* linting

* actually migrate ocr

* fix backwards test

* use array

* tweaks
2025-12-01 17:24:37 +00:00
idubnori
d8ca210641 chore(web): minor UX improvements of "view asset owners" feature (#24319)
* feat: toggle in options modal

* feat(i18n): add labels to display who uploaded each asset and show asset owners

* feat: migrate asset owner settings to TimelineManager and update AlbumOptionsModal

* Revert "feat(i18n): add labels to display who uploaded each asset and show asset owners"

This reverts commit cf8f4eb135.

* fix: simplify AlbumOptionsModal invocation and update aria-label for asset owners

* feat(i18n): add label for viewing asset owners in the interface

* feat: add tests for showAssetOwners functionality in TimelineManager

* chore: move asset owner visibility toggle to kebabu menu
2025-12-01 10:25:12 -06:00
Min Idzelis
ab35afd3b1 refactor(web): reimplement operation-support as part of timeline-manager (#24056)
* refactor(web): reimplement operation-support as part of timeline-manager

Improve clarity of methods. 
Add inline method documentation.  
Make return type of AssetOperation optional.

* Review comments - self document code. remove optional return from callback
2025-12-01 09:04:39 -06:00
idubnori
65e4fdf98d refactor(web): i18n-ize "view asset owners" (#24317) 2025-12-01 15:01:57 +00:00
Matthew Momjian
fa43fae2a5 fix(mobile): docs link (#24277)
update docs link
2025-11-30 13:01:33 -06:00
Alex
46afd6a101 fix: only generate memory based on users assets (#24151) 2025-11-30 13:01:12 -06:00
Hai Sullivan
46e1967760 chore: optimisation of several UI components of the mobile app (#24098)
* fix(mobile): normalize scrolling behavior in networking settings

Remove ClampingScrollPhysics from networking settings page to match
the scrolling behavior of other settings pages. This restores the
standard iOS bounce/elastic scrolling effect.

* fix(mobile): use consistent native transitions for Library pages

Change Trash, Shared Links, and Folders routes from CustomRoute to AutoRoute to enable native iOS transitions with swipe-back gesture support.

* fix(mobile): remove SafeArea wrapper and ClampingScrollPhysics from Settings

Remove SafeArea wrapper (Scaffold handles safe areas automatically) and ClampingScrollPhysics to enable native iOS bounce scrolling.

* fix(mobile): remove bottom white space in Sync Status page

Replace Padding wrapper with ListView padding to match other Settings pages and eliminate bottom white space.

* chore: fix Dart formatting

Run dart format to fix formatting issues in settings.page.dart and sync_status_and_actions.dart

* Format Dart files

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: kao-byte <benjaminliu@MacBook-Air.local>
2025-11-30 13:01:01 -06:00
Chris Peckover
922282b2b4 feat(web): Shared album owner labels (#21171)
* - pass available album users along to the thumbnail through the asset-date-group
- show a small user-avatar in bottom right of thumbnail

* - change owner to their name in white text instead of the avatar

* cleanup

* - cleanup albumUsers creation
- use font-light for the user's name

* fix lint

* format

* - add toggle to show/hide asset owner names

* update new Timeline with albumUsers

* add @idubnori suggestion for the name font

* Don't show 'view owners' button if the album doesn't have editors

* add missing import

* format

* fix(web): #21171 (#24298)

fix: Bind timelineManager to Timeline component

---------

Co-authored-by: idubnori <i.dub.nori@gmail.com>
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-30 18:56:03 +00:00
Alex
e3ab16a5bd chore: refactor mobile events (#24263)
chore: refactor mobile evets
2025-11-30 12:43:33 -06:00
Niklas von Moers
08f320c801 fix(web): use full tag path when creating nested subtags (#24249) 2025-11-29 12:09:32 +00:00
Mees Frensel
e36261b552 fix(web): integrate zoom toggle button into panorama photo viewer (#24189) 2025-11-28 18:50:16 +01:00
Daniel Dietzler
c0a3b58bba fix: rare cases of assets not loading in when scrolling backwards (#24245) 2025-11-28 10:18:49 -06:00
Yaros
f12f609038 fix(mobile): enable backup text overflows (#24227) 2025-11-28 10:18:44 -06:00
renovate[bot]
1f6eb662e5 chore(deps): update dependency opentofu to v1.10.7 (#23964)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 14:41:23 +00:00
renovate[bot]
0c1fe35f2f chore(deps): update dependency terragrunt to v0.93.10 (#24149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-28 15:17:25 +01:00
Robert Schäfer
e98a33cf9d fix(docs): build cli for e2e tests (#24184) 2025-11-28 15:11:17 +01:00
Dionysius
d38305360c docs: DB_STORAGE_TYPE is only used by the database container (#24215)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-28 12:43:48 +00:00
Tijs
3e3ca4c104 feat(server): exclude syncthing folders from external libraries (#24240)
* Add SyncThing folders to External library exclusion

SyncThing is a popular library for syncing files (like pictures) between systems. It can really mess up your library if an external library, which is also used by SyncThing, is added and these folders are not excluded.

* Plural

* fix formatting

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
2025-11-28 11:40:33 +00:00
Jacob Bundgaard
81edf0749f fix: label 'for' attributes in user-api-key-grid (#24232) 2025-11-27 23:28:38 +00:00
renovate[bot]
01f83ae964 fix(deps): update dependency exiftool-vendored to v33 (#24172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:45:35 +00:00
renovate[bot]
5eec0dc981 chore(deps): update github-actions (#24038)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 21:41:41 +00:00
renovate[bot]
ca4fd07656 chore(deps): update dependency eslint-plugin-unicorn to v62 (#24167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-27 21:31:16 +00:00
renovate[bot]
7ce43b3824 chore(deps): update dependency node-gyp to v12 (#24168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-27 16:13:57 +01:00
Daniel Dietzler
ce00119926 chore: update sharp to 0.34.5 (#24170) 2025-11-27 15:13:16 +00:00
Daniel Dietzler
fffee80e2f feat: command palette (#23693) 2025-11-26 22:18:50 +01:00
Jason Rasmussen
64cd4e96e3 fix: theme switcher (#24209) 2025-11-26 21:17:26 +00:00
renovate[bot]
955a3bfaa6 chore(deps): update base-image to v202511261514 (major) (#24165)
chore(deps): update base-image to v202511261514

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-26 15:10:48 -05:00
renovate[bot]
e699d8f170 chore(deps): update machine-learning (#23352) 2025-11-26 19:09:39 +00:00
Jason Rasmussen
13104d49cd feat(web): shared link card tweaks (#24192) 2025-11-25 19:35:21 -06:00
Jason Rasmussen
2d5ec528d5 fix(web): user admin pages (#24185)
fix: user admin pages
2025-11-25 16:35:37 -05:00
Min Idzelis
5226898184 fix: update timeline-manager after archive actions (#24010)
* fix: update timeline-manager after archive actions

* Add locators to thumb icons
2025-11-25 15:06:29 -05:00
Luka Prebil Grintal
dd4169876c fix(ml): Upgrade ONNX Runtime to v1.22.1 to fix ROCm build failures (#24045)
* fix: update ONNX runtime version to 1.21.0 to fix the failing checksum of 1.20.1

* update patch

* update to 1.22.1

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-11-25 13:27:21 -05:00
renovate[bot]
8321c275b8 chore(deps): update dependency body-parser to v2.2.1 [security] (#24179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 18:29:13 +01:00
renovate[bot]
3d6c26350a fix(deps): update typescript-projects (#24163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2025-11-25 17:26:36 +00:00
Jason Rasmussen
db15e5e423 fix: duration extraction (#24178) 2025-11-25 10:26:25 -05:00
renovate[bot]
35d18da14a chore(deps): update node to v24 (major) (#24169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:48 +01:00
renovate[bot]
cb56a11f0b chore(deps): update dependency @types/archiver to v7 (#24166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 15:40:12 +01:00
Jason Rasmussen
104fa09f69 feat: queues (#24142) 2025-11-25 08:19:40 -05:00
Alex
66ae07ee39 fix: don't get OCR data in shared link (#24152) 2025-11-25 07:58:27 -05:00
Daniel Dietzler
939d2c8b27 chore: minor admin pages refactorings (#24160) 2025-11-25 07:57:30 -05:00
renovate[bot]
2801a6e672 chore(deps): update actions/download-artifact action to v6 (#24164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:46:34 +01:00
renovate[bot]
4742360469 chore(deps): update grafana/grafana docker tag to v12.3.0 (#24162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 13:45:50 +01:00
Daniel Dietzler
b56fa62b32 fix: revert "chore(deps): update dependency sharp to v0.34.5" (#24173) 2025-11-25 12:37:08 +00:00
renovate[bot]
ddbe485074 chore(deps): update dependency sharp to v0.34.5 (#24146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:56:11 +01:00
renovate[bot]
01310c6d86 chore(deps): update node.js to v24.11.1 (#24147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-25 11:55:36 +01:00
Brandon Wees
512327ef69 feat: java in mise (#24154) 2025-11-24 23:18:44 -06:00
Daniel Dietzler
8755cd59fd chore: refactor svelte reactivity (#24072) 2025-11-24 18:57:46 -05:00
Min Idzelis
7694b342ed refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components (#23338)
* refactor(web): Extract asset grid layout component from TimelineDateGroup and split into AssetLayout and Month components

* chore: cleanup

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-24 23:09:46 +00:00
232 changed files with 9569 additions and 6135 deletions

2
.github/.nvmrc vendored
View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

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

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
# 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
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with:
category: '/language:${{matrix.language}}'

View File

@@ -132,7 +132,7 @@ jobs:
suffixes: '-rocm'
platforms: linux/amd64
runner-mapping: '{"linux/amd64": "mich"}'
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read
@@ -155,7 +155,7 @@ jobs:
name: Build and Push Server
needs: pre-job
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == true }}
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@47a2ee86898ccff51592d6572391fb1abcd7f782 # multi-runner-build-workflow-v2.0.1
uses: immich-app/devtools/.github/workflows/multi-runner-build.yml@0477486d82313fba68f7c82c034120a4b8981297 # multi-runner-build-workflow-v2.1.0
permissions:
contents: read
actions: read

View File

@@ -16,7 +16,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -31,7 +31,7 @@ jobs:
- name: Generate a token
id: generate_token
if: ${{ inputs.skip != true }}
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -49,7 +49,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -62,7 +62,7 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -126,7 +126,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -30,7 +30,7 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -159,7 +159,7 @@ jobs:
- name: Create PR
id: create-pr
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9
with:
token: ${{ steps.generate-token.outputs.token }}
commit-message: 'chore: release ${{ steps.bump-type.outputs.next }}'

View File

@@ -52,7 +52,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
@@ -74,7 +74,7 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}

View File

@@ -571,7 +571,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:

View File

@@ -52,7 +52,7 @@
},
"cSpell.words": ["immich"],
"editor.formatOnSave": true,
"eslint.validate": ["javascript", "svelte"],
"eslint.validate": ["javascript", "typescript", "svelte"],
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"*.dart": "${capture}.g.dart,${capture}.gr.dart,${capture}.drift.dart",

View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -1,4 +1,4 @@
FROM node:22.16.0-alpine3.20@sha256:2289fb1fba0f4633b08ec47b94a89c7e20b829fc5679f9b7b298eaa2f1ed8b7e AS core
FROM node:24.1.0-alpine3.20@sha256:8fe019e0d57dbdce5f5c27c0b63d2775cf34b00e3755a7dea969802d7e0c2b25 AS core
WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./

View File

@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.19.1",
"@types/node": "^24.10.1",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -28,7 +28,7 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"eslint-plugin-unicorn": "^62.0.0",
"globals": "^16.0.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
@@ -69,6 +69,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@@ -299,7 +299,7 @@ describe('crawl', () => {
.map(([file]) => file);
// Compare file's content instead of path since a file can be represent in multiple ways.
expect(actual.map((path) => readContent(path)).sort()).toEqual(expected.sort());
expect(actual.map((path) => readContent(path)).toSorted()).toEqual(expected.toSorted());
});
}
});

View File

@@ -160,7 +160,7 @@ export const crawl = async (options: CrawlOptions): Promise<string[]> => {
ignore: [`**/${exclusionPattern}`],
});
globbedFiles.push(...crawledFiles);
return globbedFiles.sort();
return globbedFiles.toSorted();
};
export const sha1 = (filepath: string) => {

View File

@@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@@ -1,6 +1,6 @@
[tools]
terragrunt = "0.91.2"
opentofu = "1.10.6"
terragrunt = "0.93.10"
opentofu = "1.10.7"
[tasks."tg:fmt"]
run = "terragrunt hclfmt"

View File

@@ -135,7 +135,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1

View File

@@ -56,7 +56,7 @@ services:
redis:
container_name: immich_redis
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
image: docker.io/valkey/valkey:9@sha256:4503e204c900a00ad393bec83c8c7c4c76b0529cd629e23b34b52011aefd1d27
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -95,7 +95,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.2.1-ubuntu@sha256:797530c642f7b41ba7848c44cfda5e361ef1f3391a98bed1e5d448c472b6826a
image: grafana/grafana:12.3.0-ubuntu@sha256:cee936306135e1925ab21dffa16f8a411535d16ab086bef2309339a8e74d62df
volumes:
- grafana-data:/var/lib/grafana

View File

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

View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -133,9 +133,9 @@ There are a few different scenarios that can lead to this situation. The solutio
The job is only automatically run once per asset after upload. If metadata extraction originally failed, the jobs were cleared/canceled, etc.,
the job may not have run automatically the first time.
### How can I hide photos from the timeline?
### How can I hide a photo or video from the timeline?
You can _archive_ them.
You can _archive_ them. This will hide the asset from the main timeline and folder view, but it will still show up in searches. All archived assets can be found in the _Archive_ view
### How can I backup data from Immich?

View File

@@ -18,6 +18,7 @@ make e2e
Before you can run the tests, you need to run the following commands _once_:
- `pnpm install` (in `e2e/`)
- `pnpm run build` (in `cli/`)
- `make open-api` (in the project root `/`)
Once the test environment is running, the e2e tests can be run via:

View File

@@ -62,10 +62,10 @@ Information on the current workers can be found [here](/administration/jobs-work
## Ports
| Variable | Description | Default |
| :------------ | :------------- | :----------------------------------------: |
| `IMMICH_HOST` | Listening host | `0.0.0.0` |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) |
| Variable | Description | Default | Containers |
| :------------ | :------------- | :----------------------------------------: | :----------------------- |
| `IMMICH_HOST` | Listening host | `0.0.0.0` | server, machine learning |
| `IMMICH_PORT` | Listening port | `2283` (server), `3003` (machine learning) | server, machine learning |
## Database
@@ -80,7 +80,7 @@ Information on the current workers can be found [here](/administration/jobs-work
| `DB_SSL_MODE` | Database SSL mode | | server |
| `DB_VECTOR_EXTENSION`<sup>\*2</sup> | Database vector extension (one of [`vectorchord`, `pgvector`, `pgvecto.rs`]) | | server |
| `DB_SKIP_MIGRATIONS` | Whether to skip running migrations on startup (one of [`true`, `false`]) | `false` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | server |
| `DB_STORAGE_TYPE` | Optimize concurrent IO on SSDs or sequential IO on HDDs ([`SSD`, `HDD`])<sup>\*3</sup> | `SSD` | database |
\*1: The values of `DB_USERNAME`, `DB_PASSWORD`, and `DB_DATABASE_NAME` are passed to the Postgres container as the variables `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DB` in `docker-compose.yml`.

View File

@@ -57,6 +57,6 @@
"node": ">=20"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@@ -1 +1 @@
24.11.0
24.11.1

View File

@@ -26,7 +26,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.19.1",
"@types/node": "^24.10.1",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
@@ -35,8 +35,8 @@
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-unicorn": "^60.0.0",
"exiftool-vendored": "^31.1.0",
"eslint-plugin-unicorn": "^62.0.0",
"exiftool-vendored": "^33.0.0",
"globals": "^16.0.0",
"jose": "^5.6.3",
"luxon": "^3.4.4",
@@ -45,7 +45,7 @@
"pngjs": "^7.0.0",
"prettier": "^3.2.5",
"prettier-plugin-organize-imports": "^4.0.0",
"sharp": "^0.34.4",
"sharp": "^0.34.5",
"socket.io-client": "^4.7.4",
"supertest": "^7.0.0",
"typescript": "^5.3.3",
@@ -54,6 +54,6 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.11.0"
"node": "24.11.1"
}
}

View File

@@ -61,7 +61,7 @@ export function selectRandomDays(daysInMonth: number, numDays: number, rng: Seed
}
}
return [...selectedDays].sort((a, b) => b - a);
return [...selectedDays].toSorted((a, b) => b - a);
}
/**

View File

@@ -62,50 +62,60 @@ export const setupTimelineMockApiRoutes = async (
return route.continue();
});
await context.route('**/api/assets/**', async (route, request) => {
await context.route('**/api/assets/*', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
});
await context.route('**/api/assets/*/ocr', async (route) => {
return route.fulfill({ status: 200, contentType: 'application/json', json: [] });
});
await context.route('**/api/assets/*/thumbnail?size=*', async (route, request) => {
const pattern = /\/api\/assets\/(?<assetId>[^/]+)\/thumbnail\?size=(?<size>preview|thumbnail)/;
const match = request.url().match(pattern);
if (!match) {
const url = new URL(request.url());
const pathname = url.pathname;
const assetId = basename(pathname);
const asset = getAsset(timelineRestData, assetId);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: asset,
});
if (!match?.groups) {
throw new Error(`Invalid URL for thumbnail endpoint: ${request.url()}`);
}
if (match.groups?.size === 'preview') {
if (match.groups.size === 'preview') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg', ETag: 'abc123', 'Cache-Control': 'public, max-age=3600' },
body: await randomPreview(
match.groups?.assetId,
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
if (match.groups?.size === 'thumbnail') {
if (match.groups.size === 'thumbnail') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
const asset = getAsset(timelineRestData, match.groups.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail(
match.groups?.assetId,
match.groups.assetId,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
return route.continue();
});
await context.route('**/api/albums/**', async (route, request) => {
const pattern = /\/api\/albums\/(?<albumId>[^/?]+)/;
const match = request.url().match(pattern);

View File

@@ -12,7 +12,7 @@ import {
PersonCreateDto,
QueueCommandDto,
QueueName,
QueuesResponseDto,
QueuesResponseLegacyDto,
SharedLinkCreateDto,
UpdateLibraryDto,
UserAdminCreateDto,
@@ -564,13 +564,13 @@ export const utils = {
await updateConfig({ systemConfigDto: defaultConfig }, { headers: asBearerAuth(accessToken) });
},
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseDto) => {
isQueueEmpty: async (accessToken: string, queue: keyof QueuesResponseLegacyDto) => {
const queues = await getQueuesLegacy({ headers: asBearerAuth(accessToken) });
const jobCounts = queues[queue].jobCounts;
return !jobCounts.active && !jobCounts.waiting;
},
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseDto, ms?: number) => {
waitForQueueFinish: (accessToken: string, queue: keyof QueuesResponseLegacyDto, ms?: number) => {
// eslint-disable-next-line no-async-promise-executor
return new Promise<void>(async (resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Timed out waiting for queue to empty')), ms || 10_000);

View File

@@ -611,6 +611,53 @@ test.describe('Timeline', () => {
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
test('open /archive, favorite photo, unfavorite', async ({ page }) => {
const assetToFavorite = assets[0];
changes.assetArchivals.push(assetToFavorite.id);
await pageUtils.openArchivePage(page);
const favorite = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
const isFavorite = requestJson.isFavorite;
if (isFavorite) {
changes.assetFavorites.push(...requestJson.ids);
}
await route.fulfill({
status: 204,
});
});
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
await page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
const unFavoriteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.isFavorite === undefined) {
return await route.continue();
}
changes.assetFavorites = changes.assetFavorites.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Remove from favorites').click();
await expect(unFavoriteRequest).resolves.toEqual({
isFavorite: false,
ids: [assetToFavorite.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToFavorite.id)).toHaveCount(1);
await thumbnailUtils.expectThumbnailIsNotFavorite(page, assetToFavorite.id);
});
test('open album, archive photo, open album, unarchive', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
@@ -633,8 +680,7 @@ test.describe('Timeline', () => {
visibility: 'archive',
ids: [assetToArchive.id],
});
console.log('Skipping assertion - TODO - fix that archiving in album doesnt add icon');
// await thumbnail.expectThumbnailIsArchive(page, assetToArchive.id);
await thumbnailUtils.expectThumbnailIsArchive(page, assetToArchive.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
@@ -656,8 +702,7 @@ test.describe('Timeline', () => {
visibility: 'timeline',
ids: [assetToArchive.id],
});
console.log('Skipping assertion - TODO - fix bug with not removing asset from timeline-manager after unarchive');
// await expect(thumbnail.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
@@ -712,6 +757,50 @@ test.describe('Timeline', () => {
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
test('open /favorites, archive photo, unarchive photo', async ({ page }) => {
await pageUtils.openFavorites(page);
const assetToArchive = getAsset(timelineRestData, 'ad31e29f-2069-4574-b9a9-ad86523c92cb')!;
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
await page.getByLabel('Menu').click();
const archive = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'archive') {
return await route.continue();
}
await route.fulfill({
status: 204,
});
changes.assetArchivals.push(...requestJson.ids);
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
visibility: 'archive',
ids: [assetToArchive.id],
});
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
await thumbnailUtils.withAssetId(page, assetToArchive.id).hover();
await thumbnailUtils.selectButton(page, assetToArchive.id).click();
const unarchiveRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
if (requestJson.visibility !== 'timeline') {
return await route.continue();
}
changes.assetArchivals = changes.assetArchivals.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 204,
});
});
await page.getByLabel('Unarchive').click();
await expect(unarchiveRequest).resolves.toEqual({
visibility: 'timeline',
ids: [assetToArchive.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await thumbnailUtils.expectThumbnailIsNotArchive(page, assetToArchive.id);
});
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);

View File

@@ -105,20 +105,16 @@ export const thumbnailUtils = {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, collector));
},
async expectThumbnailIsFavorite(page: Page, assetId: string) {
await expect(
thumbnailUtils
.withAssetId(page, assetId)
.locator(
'path[d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z"]',
),
).toHaveCount(1);
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(1);
},
async expectThumbnailIsNotFavorite(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-favorite]')).toHaveCount(0);
},
async expectThumbnailIsArchive(page: Page, assetId: string) {
await expect(
thumbnailUtils
.withAssetId(page, assetId)
.locator('path[d="M20 21H4V10H6V19H18V10H20V21M3 3H21V9H3V3M5 5V7H19V5M10.5 11V14H8L12 18L16 14H13.5V11"]'),
).toHaveCount(1);
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(1);
},
async expectThumbnailIsNotArchive(page: Page, assetId: string) {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
@@ -208,10 +204,18 @@ export const pageUtils = {
await page.goto(`/photos`);
await timelineUtils.waitForTimelineLoad(page);
},
async openFavorites(page: Page) {
await page.goto(`/favorites`);
await timelineUtils.waitForTimelineLoad(page);
},
async openAlbumPage(page: Page, albumId: string) {
await page.goto(`/albums/${albumId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openArchivePage(page: Page) {
await page.goto(`/archive`);
await timelineUtils.waitForTimelineLoad(page);
},
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
await page.goto(`/albums/${albumId}?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);

View File

@@ -54,7 +54,7 @@ test.describe('User Administration', () => {
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
@@ -83,7 +83,7 @@ test.describe('User Administration', () => {
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByText('Admin User').click();
await page.getByLabel('Admin User').click();
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();

View File

@@ -9,7 +9,7 @@
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"target": "es2022",
"target": "es2023",
"sourceMap": true,
"outDir": "./dist",
"incremental": true,

View File

@@ -67,6 +67,7 @@
"confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.",
"confirm_user_password_reset": "Are you sure you want to reset {user}'s password?",
"confirm_user_pin_code_reset": "Are you sure you want to reset {user}'s PIN code?",
"copy_config_to_clipboard_description": "Copy the current system config as a JSON object to the clipboard",
"create_job": "Create job",
"cron_expression": "Cron expression",
"cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. <link>Crontab Guru</link>",
@@ -74,6 +75,8 @@
"disable_login": "Disable login",
"duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search",
"exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.",
"export_config_as_json_description": "Download the current system config as a JSON file",
"external_libraries_page_description": "Admin external library page",
"external_library_management": "External Library Management",
"face_detection": "Face detection",
"face_detection_description": "Detect the faces in assets using machine learning. For videos, only the thumbnail is considered. \"Refresh\" (re-)processes all assets. \"Reset\" additionally clears all current face data. \"Missing\" queues assets that haven't been processed yet. Detected faces will be queued for Facial Recognition after Face Detection is complete, grouping them into existing or new people.",
@@ -102,6 +105,7 @@
"image_thumbnail_description": "Small thumbnail with stripped metadata, used when viewing groups of photos like the main timeline",
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
"image_thumbnail_title": "Thumbnail Settings",
"import_config_from_json_description": "Import system config by uploading a JSON config file",
"job_concurrency": "{job} concurrency",
"job_created": "Job created",
"job_not_concurrency_safe": "This job is not concurrency-safe.",
@@ -110,6 +114,7 @@
"job_status": "Job Status",
"jobs_delayed": "{jobCount, plural, other {# delayed}}",
"jobs_failed": "{jobCount, plural, other {# failed}}",
"jobs_page_description": "Admin jobs page",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
"library_details": "Library details",
@@ -182,6 +187,7 @@
"maintenance_start": "Start maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.",
"manage_concurrency": "Manage Concurrency",
"manage_concurrency_description": "Navigate to the jobs page to manage job concurrency",
"manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style",
"map_enable_description": "Enable map features",
@@ -287,8 +293,10 @@
"server_public_users_description": "All users (name and email) are listed when adding a user to shared albums. When disabled, the user list will only be available to admin users.",
"server_settings": "Server Settings",
"server_settings_description": "Manage server settings",
"server_stats_page_description": "Admin server statistics page",
"server_welcome_message": "Welcome message",
"server_welcome_message_description": "A message that is displayed on the login page.",
"settings_page_description": "Admin settings page",
"sidecar_job": "Sidecar metadata",
"sidecar_job_description": "Discover or synchronize sidecar metadata from the filesystem",
"slideshow_duration_description": "Number of seconds to display each image",
@@ -407,6 +415,8 @@
"user_restore_scheduled_removal": "Restore user - scheduled removal on {date, date, long}",
"user_settings": "User Settings",
"user_settings_description": "Manage user settings",
"user_successfully_removed": "User {email} has been successfully removed.",
"users_page_description": "Admin users page",
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with github.com",
"version_check_settings": "Version Check",
@@ -727,6 +737,7 @@
"collapse_all": "Collapse all",
"color": "Color",
"color_theme": "Color theme",
"command": "Command",
"comment_deleted": "Comment deleted",
"comment_options": "Comment options",
"comments_and_likes": "Comments & likes",
@@ -1511,6 +1522,7 @@
"other_variables": "Other variables",
"owned": "Owned",
"owner": "Owner",
"page": "Page",
"partner": "Partner",
"partner_can_access": "{partner} can access",
"partner_can_access_assets": "All your photos and videos except those in Archived and Deleted",
@@ -2071,6 +2083,7 @@
"to_select": "to select",
"to_trash": "Trash",
"toggle_settings": "Toggle settings",
"toggle_theme_description": "Toggle theme",
"total": "Total",
"total_usage": "Total usage",
"trash": "Trash",
@@ -2179,6 +2192,7 @@
"view_album": "View Album",
"view_all": "View All",
"view_all_users": "View all users",
"view_asset_owners": "View asset owners",
"view_details": "View Details",
"view_in_timeline": "View in timeline",
"view_link": "View link",

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:fc1f2e357c307c4044133952b203e66a47e7726821a664f603a180a0c5823844 AS builder-cpu
FROM python:3.11-bookworm@sha256:e39286476f84ffedf7c3564b0b74e32c9e1193ec9ca32ee8a11f8c09dbf6aafe AS builder-cpu
FROM builder-cpu AS builder-openvino
@@ -22,10 +22,10 @@ FROM builder-cpu AS builder-rknn
# Warning: 25GiB+ disk space required to pull this image
# TODO: find a way to reduce the image size
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS builder-rocm
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS builder-rocm
# renovate: datasource=github-releases depName=Microsoft/onnxruntime
ARG ONNXRUNTIME_VERSION="v1.20.1"
ARG ONNXRUNTIME_VERSION="v1.22.1"
WORKDIR /code
RUN apt-get update && apt-get install -y --no-install-recommends wget git python3.10-venv
@@ -68,12 +68,12 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
MACHINE_LEARNING_MODEL_ARENA=false
FROM python:3.11-slim-bookworm@sha256:873f91540d53b36327ed4fb018c9669107a4e2a676719720edb4209c4b15d029 AS prod-openvino
FROM python:3.11-slim-bookworm@sha256:2c5bc243b1cc47985ee4fb768bb0bbd4490481c5d0897a62da31b7f30b7304a7 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
@@ -102,7 +102,7 @@ COPY --from=builder-cuda /usr/local/bin/python3 /usr/local/bin/python3
COPY --from=builder-cuda /usr/local/lib/python3.11 /usr/local/lib/python3.11
COPY --from=builder-cuda /usr/local/lib/libpython3.11.so /usr/local/lib/libpython3.11.so
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:1f7e92ca7e3a3785680473329ed1091fc99db3e90fcb3a1688f2933e870ed76b AS prod-rocm
FROM rocm/dev-ubuntu-22.04:6.4.3-complete@sha256:6cda50e312f3aac068cea9ec06c560ca1f522ad546bc8b3d2cf06da0fe8e8a76 AS prod-rocm
FROM prod-cpu AS prod-armnn

View File

@@ -82,6 +82,7 @@ class TextDetector(InferenceModel):
ratio = float(self.max_resolution) / img.height
else:
ratio = float(self.max_resolution) / img.width
ratio = min(ratio, 1.0)
resize_h = int(img.height * ratio)
resize_w = int(img.width * ratio)

View File

@@ -1,13 +1,13 @@
diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt
index d90a2a355..bb1a7de12 100644
index 2714e6f59..a69da76b4 100644
--- a/cmake/CMakeLists.txt
+++ b/cmake/CMakeLists.txt
@@ -295,7 +295,7 @@ if (onnxruntime_USE_ROCM)
@@ -338,7 +338,7 @@ if (onnxruntime_USE_ROCM)
if (ROCM_VERSION_DEV VERSION_LESS "6.2")
message(FATAL_ERROR "CMAKE_HIP_ARCHITECTURES is not set when ROCm version < 6.2")
else()
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
endif()
endif()
if (NOT CMAKE_HIP_ARCHITECTURES)
- set(CMAKE_HIP_ARCHITECTURES "gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx940;gfx941;gfx942;gfx1200;gfx1201")
+ set(CMAKE_HIP_ARCHITECTURES "gfx900;gfx908;gfx90a;gfx1030;gfx1100;gfx1101;gfx1102;gfx940;gfx941;gfx942;gfx1200;gfx1201")
endif()
file(GLOB rocm_cmake_components ${onnxruntime_ROCM_HOME}/lib/cmake/*)

3598
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
experimental_monorepo_root = true
[tools]
node = "24.11.0"
node = "24.11.1"
flutter = "3.35.7"
pnpm = "10.20.0"
terragrunt = "0.91.2"
opentofu = "1.10.6"
pnpm = "10.22.0"
terragrunt = "0.93.10"
opentofu = "1.10.7"
java = "25.0.1"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

View File

@@ -0,0 +1,32 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
// Timeline Events
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ScrollToTopEvent extends Event {
const ScrollToTopEvent();
}
class ScrollToDateEvent extends Event {
final DateTime date;
const ScrollToDateEvent(this.date);
}
// Asset Viewer Events
class ViewerOpenBottomSheetEvent extends Event {
final bool activitiesMode;
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
}
class ViewerReloadAssetEvent extends Event {
const ViewerReloadAssetEvent();
}
// Multi-Select Events
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}

View File

@@ -71,6 +71,7 @@ enum StoreKey<T> {
readonlyModeEnabled<bool>._(138),
autoPlayVideo<bool>._(139),
albumGridView<bool>._(140),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000),

View File

@@ -1,5 +1,3 @@
import 'package:immich_mobile/domain/utils/event_stream.dart';
enum GroupAssetsBy { day, month, auto, none }
enum HeaderType { none, month, day, monthAndDay }
@@ -31,17 +29,3 @@ class TimeBucket extends Bucket {
@override
int get hashCode => super.hashCode ^ date.hashCode;
}
class TimelineReloadEvent extends Event {
const TimelineReloadEvent();
}
class ScrollToTopEvent extends Event {
const ScrollToTopEvent();
}
class ScrollToDateEvent extends Event {
final DateTime date;
const ScrollToDateEvent(this.date);
}

View File

@@ -7,6 +7,7 @@ import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
@@ -32,16 +33,16 @@ class RemoteAlbumService {
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
AlbumSortMode sortMode, {
bool isReverse = false,
}) async {
final List<RemoteAlbum> sorted = switch (sortMode) {
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
AlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
AlbumSortMode.title => albums.sortedBy((album) => album.name),
AlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
AlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
AlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
AlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
return (isReverse ? sorted.reversed : sorted).toList();
@@ -211,16 +212,3 @@ class RemoteAlbumService {
return sorted.reversed.toList();
}
}
enum RemoteAlbumSortMode {
title("library_page_sort_title"),
assetCount("library_page_sort_asset_count"),
lastModified("library_page_sort_last_modified"),
created("library_page_sort_created"),
mostRecent("sort_newest"),
mostOldest("sort_oldest");
final String key;
const RemoteAlbumSortMode(this.key);
}

View File

@@ -4,6 +4,7 @@ import 'dart:math' as math;
import 'package:collection/collection.dart';
import 'package:immich_mobile/constants/constants.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/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/setting.service.dart';

View File

@@ -1,5 +1,5 @@
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:timezone/timezone.dart';
import 'package:immich_mobile/utils/timezone.dart';
extension TZExtension on Asset {
/// Returns the created time of the asset from the exif info (if available) or from
@@ -7,24 +7,11 @@ extension TZExtension on Asset {
/// the timezone offset in [Duration]
(DateTime, Duration) getTZAdjustedTimeAndOffset() {
DateTime dt = fileCreatedAt.toLocal();
if (exifInfo?.dateTimeOriginal != null) {
dt = exifInfo!.dateTimeOriginal!;
if (exifInfo?.timeZone != null) {
dt = dt.toUtc();
try {
final location = getLocation(exifInfo!.timeZone!);
dt = TZDateTime.from(dt, location);
} on LocationNotFoundException {
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
final m = re.firstMatch(exifInfo!.timeZone!);
if (m != null) {
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
dt = dt.add(duration);
return (dt, duration);
}
}
}
return applyTimezoneOffset(dateTime: exifInfo!.dateTimeOriginal!, timeZone: exifInfo?.timeZone);
}
return (dt, dt.timeZoneOffset);
}
}

View File

@@ -0,0 +1,7 @@
class OAuthLoginData {
final String serverUrl;
final String state;
final String codeVerifier;
const OAuthLoginData({required this.serverUrl, required this.state, required this.codeVerifier});
}

View File

@@ -3,7 +3,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';

View File

@@ -58,7 +58,7 @@ class SettingsPage extends StatelessWidget {
context.locale;
return Scaffold(
appBar: AppBar(centerTitle: false, title: const Text('settings').tr()),
body: context.isMobile ? const SafeArea(child: _MobileLayout()) : const SafeArea(child: _TabletLayout()),
body: context.isMobile ? const _MobileLayout() : const _TabletLayout(),
);
}
}
@@ -89,11 +89,7 @@ class _MobileLayout extends StatelessWidget {
],
)
.toList();
return ListView(
physics: const ClampingScrollPhysics(),
padding: const EdgeInsets.only(top: 10.0, bottom: 16),
children: [...settings],
);
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 16), children: [...settings]);
}
}

View File

@@ -5,7 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
@@ -16,7 +16,6 @@ import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()

View File

@@ -27,7 +27,7 @@ class LoginPage extends HookConsumerWidget {
});
return Scaffold(
body: LoginForm(),
body: const LoginForm(),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.only(bottom: 16.0),

View File

@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@@ -2,10 +2,10 @@ import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';

View File

@@ -9,8 +9,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
// used to allow performing unarchive action from different sources (without duplicating code)
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {

View File

@@ -7,7 +7,6 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -17,6 +16,9 @@ import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/album_filter.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
@@ -45,14 +47,28 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
List<RemoteAlbum> shownAlbums = [];
AlbumFilter filter = AlbumFilter(query: "", mode: QuickFilterMode.all);
AlbumSort sort = AlbumSort(mode: RemoteAlbumSortMode.lastModified, isReverse: true);
AlbumSort sort = AlbumSort(mode: AlbumSortMode.lastModified, isReverse: true);
@override
void initState() {
super.initState();
// Load albums when component mounts
WidgetsBinding.instance.addPostFrameCallback((_) {
final appSettings = ref.read(appSettingsServiceProvider);
final savedSortMode = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortOrder);
final savedIsReverse = appSettings.getSetting(AppSettingsEnum.selectedAlbumSortReverse);
final savedIsGrid = appSettings.getSetting(AppSettingsEnum.albumGridView);
final albumSortMode = AlbumSortMode.values.firstWhere(
(e) => e.storeIndex == savedSortMode,
orElse: () => AlbumSortMode.lastModified,
);
setState(() {
sort = AlbumSort(mode: albumSortMode, isReverse: savedIsReverse);
isGrid = savedIsGrid;
});
ref.read(remoteAlbumProvider.notifier).refresh();
});
@@ -82,6 +98,7 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
setState(() {
isGrid = !isGrid;
});
ref.read(appSettingsServiceProvider).setSetting(AppSettingsEnum.albumGridView, isGrid);
}
void changeFilter(QuickFilterMode mode) {
@@ -97,6 +114,10 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
this.sort = sort;
});
final appSettings = ref.read(appSettingsServiceProvider);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortOrder, sort.mode.storeIndex);
await appSettings.setSetting(AppSettingsEnum.selectedAlbumSortReverse, sort.isReverse);
await sortAlbums();
}
@@ -181,6 +202,8 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
onToggleViewMode: toggleViewMode,
onSortChanged: changeSort,
controller: menuController,
currentSortMode: sort.mode,
currentIsReverse: sort.isReverse,
),
isGrid
? _AlbumGrid(albums: shownAlbums, userId: userId, onAlbumSelected: widget.onAlbumSelected)
@@ -192,21 +215,46 @@ class _AlbumSelectorState extends ConsumerState<AlbumSelector> {
}
class _SortButton extends ConsumerStatefulWidget {
const _SortButton(this.onSortChanged, {this.controller});
const _SortButton(
this.onSortChanged, {
required this.initialSortMode,
required this.initialIsReverse,
this.controller,
});
final Future<void> Function(AlbumSort) onSortChanged;
final MenuController? controller;
final AlbumSortMode initialSortMode;
final bool initialIsReverse;
@override
ConsumerState<_SortButton> createState() => _SortButtonState();
}
class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
bool albumSortIsReverse = true;
late AlbumSortMode albumSortOption;
late bool albumSortIsReverse;
bool isSorting = false;
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
@override
void initState() {
super.initState();
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
}
@override
void didUpdateWidget(_SortButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.initialSortMode != widget.initialSortMode || oldWidget.initialIsReverse != widget.initialIsReverse) {
setState(() {
albumSortOption = widget.initialSortMode;
albumSortIsReverse = widget.initialIsReverse;
});
}
}
Future<void> onMenuTapped(AlbumSortMode sortMode) async {
final selected = albumSortOption == sortMode;
// Switch direction
if (selected) {
@@ -240,7 +288,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
),
consumeOutsideTap: true,
menuChildren: RemoteAlbumSortMode.values
menuChildren: AlbumSortMode.values
.map(
(sortMode) => MenuItemButton(
leadingIcon: albumSortOption == sortMode
@@ -269,7 +317,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
),
),
child: Text(
sortMode.key.t(context: context),
sortMode.label.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: albumSortOption == sortMode
@@ -298,7 +346,7 @@ class _SortButtonState extends ConsumerState<_SortButton> {
: const Icon(Icons.keyboard_arrow_up_rounded),
),
Text(
albumSortOption.key.t(context: context),
albumSortOption.label.t(context: context),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225),
@@ -465,6 +513,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
required this.isGrid,
required this.onToggleViewMode,
required this.onSortChanged,
required this.currentSortMode,
required this.currentIsReverse,
this.controller,
});
@@ -472,6 +522,8 @@ class _QuickSortAndViewMode extends StatelessWidget {
final VoidCallback onToggleViewMode;
final MenuController? controller;
final Future<void> Function(AlbumSort) onSortChanged;
final AlbumSortMode currentSortMode;
final bool currentIsReverse;
@override
Widget build(BuildContext context) {
@@ -481,7 +533,12 @@ class _QuickSortAndViewMode extends StatelessWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_SortButton(onSortChanged, controller: controller),
_SortButton(
onSortChanged,
controller: controller,
initialSortMode: currentSortMode,
initialIsReverse: currentIsReverse,
),
IconButton(
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode,

View File

@@ -7,7 +7,7 @@ import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@@ -1,17 +1,7 @@
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
class ViewerOpenBottomSheetEvent extends Event {
final bool activitiesMode;
const ViewerOpenBottomSheetEvent({this.activitiesMode = false});
}
class ViewerReloadAssetEvent extends Event {
const ViewerReloadAssetEvent();
}
class AssetViewerState {
final int backgroundOpacity;
final bool showingBottomSheet;

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/domain/models/setting.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -29,6 +30,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/action_button.utils.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
const _kSeparator = '';
@@ -85,13 +87,21 @@ class AssetDetailBottomSheet extends ConsumerWidget {
class _AssetDetailBottomSheet extends ConsumerWidget {
const _AssetDetailBottomSheet();
String _getDateTime(BuildContext ctx, BaseAsset asset) {
final dateTime = asset.createdAt.toLocal();
String _getDateTime(BuildContext ctx, BaseAsset asset, ExifInfo? exifInfo) {
DateTime dateTime = asset.createdAt.toLocal();
Duration timeZoneOffset = dateTime.timeZoneOffset;
// Use EXIF timezone information if available (matching web app behavior)
if (exifInfo?.dateTimeOriginal != null) {
(dateTime, timeZoneOffset) = applyTimezoneOffset(
dateTime: exifInfo!.dateTimeOriginal!,
timeZone: exifInfo.timeZone,
);
}
final date = DateFormat.yMMMEd(ctx.locale.toLanguageTag()).format(dateTime);
final time = DateFormat.jm(ctx.locale.toLanguageTag()).format(dateTime);
final timezone = dateTime.timeZoneOffset.isNegative
? 'UTC-${dateTime.timeZoneOffset.inHours.abs().toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}'
: 'UTC+${dateTime.timeZoneOffset.inHours.toString().padLeft(2, '0')}:${(dateTime.timeZoneOffset.inMinutes.abs() % 60).toString().padLeft(2, '0')}';
final timezone = 'GMT${timeZoneOffset.formatAsOffset()}';
return '$date$_kSeparator$time $timezone';
}
@@ -269,7 +279,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
children: [
// Asset Date and Time
SheetTile(
title: _getDateTime(context, asset),
title: _getDateTime(context, asset, exifInfo),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
onTap: asset.hasRemote && isOwner ? () async => await _editDateTime(context, ref) : null,

View File

@@ -3,7 +3,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/timeline.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@@ -143,11 +143,13 @@ class BackupToggleButtonState extends ConsumerState<BackupToggleButton> with Sin
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
"enable_backup".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
Flexible(
child: Text(
"enable_backup".t(context: context),
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
),
),
],

View File

@@ -3,8 +3,8 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/routing/router.dart';

View File

@@ -9,6 +9,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/setting.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';

View File

@@ -12,13 +12,29 @@ import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/auth.service.dart';
import 'package:immich_mobile/services/secure_storage.service.dart';
import 'package:immich_mobile/services/server_info.service.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/services/widget.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/debug_print.dart';
class ServerAuthSettings {
final String endpoint;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonText;
const ServerAuthSettings({
required this.endpoint,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonText,
});
}
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier(
ref.watch(authServiceProvider),
@@ -27,6 +43,7 @@ final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
ref.watch(uploadServiceProvider),
ref.watch(secureStorageServiceProvider),
ref.watch(widgetServiceProvider),
ref.watch(serverInfoServiceProvider),
);
});
@@ -37,6 +54,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
final UploadService _uploadService;
final SecureStorageService _secureStorageService;
final WidgetService _widgetService;
final ServerInfoService _serverInfoService;
final _log = Logger("AuthenticationNotifier");
static const Duration _timeoutDuration = Duration(seconds: 7);
@@ -48,6 +66,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
this._uploadService,
this._secureStorageService,
this._widgetService,
this._serverInfoService,
) : super(
const AuthState(
deviceId: "",
@@ -64,6 +83,27 @@ class AuthNotifier extends StateNotifier<AuthState> {
return _authService.validateServerUrl(url);
}
Future<ServerAuthSettings?> getServerAuthSettings(String serverUrl) async {
final sanitizedUrl = sanitizeUrl(serverUrl);
final encodedUrl = punycodeEncodeUrl(sanitizedUrl);
final endpoint = await _authService.validateServerUrl(encodedUrl);
final features = await _serverInfoService.getServerFeatures();
final config = await _serverInfoService.getServerConfig();
if (features == null || config == null) {
return null;
}
return ServerAuthSettings(
endpoint: endpoint,
isOAuthEnabled: features.oauthEnabled,
isPasswordLoginEnabled: features.passwordLogin,
oAuthButtonText: config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth',
);
}
/// Validating the url is the alternative connecting server url without
/// saving the information to the local database
Future<bool> validateAuxilaryServerUrl(String url) async {

View File

@@ -5,6 +5,7 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -70,7 +71,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
AlbumSortMode sortMode, {
bool isReverse = false,
}) async {
return await _remoteAlbumService.sortAlbums(albums, sortMode, isReverse: isReverse);

View File

@@ -1,5 +1,27 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/oauth.service.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/services/oauth.service.dart';
import 'package:openapi/api.dart';
export 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
final oAuthServiceProvider = Provider((ref) => OAuthService(ref.watch(apiServiceProvider)));
final oAuthProvider = StateNotifierProvider<OAuthNotifier, AsyncValue<void>>(
(ref) => OAuthNotifier(ref.watch(oAuthServiceProvider)),
);
class OAuthNotifier extends StateNotifier<AsyncValue<void>> {
final OAuthService _oAuthService;
OAuthNotifier(this._oAuthService) : super(const AsyncValue.data(null));
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) {
return _oAuthService.getOAuthLoginData(serverUrl);
}
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return _oAuthService.completeOAuthLogin(oAuthData);
}
}

View File

@@ -2,7 +2,6 @@ import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectState>(
@@ -10,11 +9,6 @@ final multiSelectProvider = NotifierProvider<MultiSelectNotifier, MultiSelectSta
dependencies: [timelineServiceProvider],
);
class MultiSelectToggleEvent extends Event {
final bool isEnabled;
const MultiSelectToggleEvent(this.isEnabled);
}
class MultiSelectState {
final Set<BaseAsset> selectedAssets;
final Set<BaseAsset> lockedSelectionAssets;

View File

@@ -245,23 +245,15 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(page: FolderRoute.page, guards: [_authGuard], transitionsBuilder: TransitionsBuilders.fadeIn),
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
AutoRoute(page: PartnerDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: PersonResultRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute(
page: TrashRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
CustomRoute(
page: SharedLinkRoute.page,
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(page: TrashRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: SharedLinkRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: SharedLinkEditRoute.page, guards: [_authGuard, _duplicateGuard]),
CustomRoute(
page: ActivitiesRoute.page,

View File

@@ -15,6 +15,7 @@ import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
@@ -175,9 +176,17 @@ class ActionService {
}
final exifData = await _remoteAssetRepository.getExif(assetId);
initialDate = asset.createdAt.toLocal();
offset = initialDate.timeZoneOffset;
timeZone = exifData?.timeZone;
// Use EXIF timezone information if available (matching web app and display behavior)
DateTime dt = asset.createdAt.toLocal();
offset = dt.timeZoneOffset;
if (exifData?.dateTimeOriginal != null) {
timeZone = exifData!.timeZone;
(dt, offset) = applyTimezoneOffset(dateTime: exifData.dateTimeOriginal!, timeZone: exifData.timeZone);
}
initialDate = dt;
}
final dateTime = await showDateTimePicker(

View File

@@ -51,9 +51,10 @@ enum AppSettingsEnum<T> {
enableBackup<bool>(StoreKey.enableBackup, null, false),
useCellularForUploadVideos<bool>(StoreKey.useWifiForUploadVideos, null, false),
useCellularForUploadPhotos<bool>(StoreKey.useWifiForUploadPhotos, null, false),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false),
albumGridView<bool>(StoreKey.albumGridView, "albumGridView", false),
backupRequireCharging<bool>(StoreKey.backupRequireCharging, null, false),
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30),
readonlyModeEnabled<bool>(StoreKey.readonlyModeEnabled, "readonlyModeEnabled", false);
backupTriggerDelay<int>(StoreKey.backupTriggerDelay, null, 30);
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);

View File

@@ -1,5 +1,11 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:immich_mobile/models/auth/oauth_login_data.model.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
@@ -11,6 +17,50 @@ class OAuthService {
final log = Logger('OAuthService');
OAuthService(this._apiService);
String _generateRandomString(int length) {
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}
List<int> _randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String _randomCodeVerifier() {
return base64Url.encode(_randomBytes(42));
}
String _generatePKCECodeChallenge(String codeVerifier) {
final bytes = utf8.encode(codeVerifier);
final digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
/// Initiates OAuth login flow.
/// Returns the OAuth server URL to redirect to, along with PKCE parameters.
Future<OAuthLoginData?> getOAuthLoginData(String serverUrl) async {
final state = _generateRandomString(32);
final codeVerifier = _randomCodeVerifier();
final codeChallenge = _generatePKCECodeChallenge(codeVerifier);
final oAuthServerUrl = await getOAuthServerUrl(sanitizeUrl(serverUrl), state, codeChallenge);
if (oAuthServerUrl == null) {
return null;
}
return OAuthLoginData(serverUrl: oAuthServerUrl, state: state, codeVerifier: codeVerifier);
}
Future<LoginResponseDto?> completeOAuthLogin(OAuthLoginData oAuthData) {
return oAuthLogin(oAuthData.serverUrl, oAuthData.state, oAuthData.codeVerifier);
}
Future<String?> getOAuthServerUrl(String serverUrl, String state, String codeChallenge) async {
// Resolve API server endpoint from user provided serverUrl
await _apiService.resolveAndSetEndpoint(serverUrl);

View File

@@ -1,5 +1,5 @@
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
class AlbumFilter {
String? userId;
@@ -14,12 +14,12 @@ class AlbumFilter {
}
class AlbumSort {
RemoteAlbumSortMode mode;
AlbumSortMode mode;
bool isReverse;
AlbumSort({required this.mode, this.isReverse = false});
AlbumSort copyWith({RemoteAlbumSortMode? mode, bool? isReverse}) {
AlbumSort copyWith({AlbumSortMode? mode, bool? isReverse}) {
return AlbumSort(mode: mode ?? this.mode, isReverse: isReverse ?? this.isReverse);
}
}

View File

@@ -0,0 +1,35 @@
import 'package:timezone/timezone.dart';
/// Applies timezone conversion to a DateTime using EXIF timezone information.
///
/// This function handles two timezone formats:
/// 1. Named timezone locations (e.g., "Asia/Hong_Kong")
/// 2. UTC offset format (e.g., "UTC+08:00", "UTC-05:00")
///
/// Returns a tuple of (adjusted DateTime, timezone offset Duration)
(DateTime, Duration) applyTimezoneOffset({required DateTime dateTime, required String? timeZone}) {
DateTime dt = dateTime.toUtc();
if (timeZone == null) {
return (dt, dt.timeZoneOffset);
}
try {
// Try to get timezone location from database
final location = getLocation(timeZone);
dt = TZDateTime.from(dt, location);
return (dt, dt.timeZoneOffset);
} on LocationNotFoundException {
// Handle UTC offset format (e.g., "UTC+08:00")
RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false);
final m = re.firstMatch(timeZone);
if (m != null) {
final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0'));
dt = dt.add(duration);
return (dt, duration);
}
}
// If timezone is invalid, return UTC
return (dt, dt.timeZoneOffset);
}

View File

@@ -193,7 +193,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
InkWell(
onTap: () {
context.pop();
launchUrl(Uri.parse('https://immich.app'), mode: LaunchMode.externalApplication);
launchUrl(Uri.parse('https://docs.immich.app'), mode: LaunchMode.externalApplication);
},
child: Text("documentation", style: context.textTheme.bodySmall).tr(),
),

View File

@@ -4,7 +4,7 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@@ -6,8 +6,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/person.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@@ -7,7 +7,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/models/events.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';

View File

@@ -1,14 +1,13 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class LoginButton extends ConsumerWidget {
final Function() onPressed;
class LoginButton extends StatelessWidget {
final VoidCallback onPressed;
const LoginButton({super.key, required this.onPressed});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
onPressed: onPressed,

View File

@@ -0,0 +1,95 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/version_compatibility_warning.dart';
class LoginCredentialsForm extends StatelessWidget {
final TextEditingController emailController;
final TextEditingController passwordController;
final TextEditingController serverEndpointController;
final FocusNode emailFocusNode;
final FocusNode passwordFocusNode;
final bool isLoading;
final bool isOAuthEnabled;
final bool isPasswordLoginEnabled;
final String oAuthButtonLabel;
final String? warningMessage;
final VoidCallback onLogin;
final VoidCallback onOAuthLogin;
final VoidCallback onBack;
const LoginCredentialsForm({
super.key,
required this.emailController,
required this.passwordController,
required this.serverEndpointController,
required this.emailFocusNode,
required this.passwordFocusNode,
required this.isLoading,
required this.isOAuthEnabled,
required this.isPasswordLoginEnabled,
required this.oAuthButtonLabel,
required this.warningMessage,
required this.onLogin,
required this.onOAuthLogin,
required this.onBack,
});
@override
Widget build(BuildContext context) {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (warningMessage != null) VersionCompatibilityWarning(message: warningMessage!),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
if (isPasswordLoginEnabled) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: onLogin),
],
isLoading
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnabled) LoginButton(onPressed: onLogin),
if (isOAuthEnabled) ...[
if (isPasswordLoginEnabled)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel,
onPressed: onOAuthLogin,
),
],
],
),
if (!isOAuthEnabled && !isPasswordLoginEnabled) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(icon: const Icon(Icons.arrow_back), onPressed: onBack, label: const Text('back').tr()),
],
),
);
}
}

View File

@@ -1,14 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:crypto/crypto.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
@@ -29,492 +25,382 @@ import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/forms/login/email_input.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/login_button.dart';
import 'package:immich_mobile/widgets/forms/login/o_auth_login_button.dart';
import 'package:immich_mobile/widgets/forms/login/password_input.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
import 'package:immich_mobile/widgets/forms/login/login_credentials_form.dart';
import 'package:immich_mobile/widgets/forms/login/server_selection_form.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginForm extends HookConsumerWidget {
LoginForm({super.key});
final log = Logger('LoginForm');
class LoginForm extends ConsumerStatefulWidget {
const LoginForm({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final emailController = useTextEditingController.fromValue(TextEditingValue.empty);
final passwordController = useTextEditingController.fromValue(TextEditingValue.empty);
final serverEndpointController = useTextEditingController.fromValue(TextEditingValue.empty);
final emailFocusNode = useFocusNode();
final passwordFocusNode = useFocusNode();
final serverEndpointFocusNode = useFocusNode();
final isLoading = useState<bool>(false);
final isLoadingServer = useState<bool>(false);
final isOauthEnable = useState<bool>(false);
final isPasswordLoginEnable = useState<bool>(false);
final oAuthButtonLabel = useState<String>('OAuth');
final logoAnimationController = useAnimationController(duration: const Duration(seconds: 60))..repeat();
final serverInfo = ref.watch(serverInfoProvider);
final warningMessage = useState<String?>(null);
final loginFormKey = GlobalKey<FormState>();
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
ConsumerState<LoginForm> createState() => _LoginFormState();
}
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
class _LoginFormState extends ConsumerState<LoginForm> with SingleTickerProviderStateMixin {
final _log = Logger('LoginForm');
final _loginFormKey = GlobalKey<FormState>();
warningMessage.value = getVersionCompatibilityMessage(
late final TextEditingController _emailController;
late final TextEditingController _passwordController;
late final TextEditingController _serverEndpointController;
late final FocusNode _emailFocusNode;
late final FocusNode _passwordFocusNode;
late final FocusNode _serverEndpointFocusNode;
late final AnimationController _logoAnimationController;
bool _isLoading = false;
bool _isLoadingServer = false;
bool _isOAuthEnabled = false;
bool _isPasswordLoginEnabled = false;
String _oAuthButtonLabel = 'OAuth';
String? _serverEndpoint;
String? _warningMessage;
@override
void initState() {
super.initState();
_emailController = TextEditingController();
_passwordController = TextEditingController();
_serverEndpointController = TextEditingController();
_emailFocusNode = FocusNode();
_passwordFocusNode = FocusNode();
_serverEndpointFocusNode = FocusNode();
_logoAnimationController = AnimationController(vsync: this, duration: const Duration(seconds: 60))..repeat();
// Load saved server URL if available
WidgetsBinding.instance.addPostFrameCallback((_) {
final serverUrl = getServerUrl();
if (serverUrl != null) {
_serverEndpointController.text = serverUrl;
}
});
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_serverEndpointController.dispose();
_emailFocusNode.dispose();
_passwordFocusNode.dispose();
_serverEndpointFocusNode.dispose();
_logoAnimationController.dispose();
super.dispose();
}
Future<void> _checkVersionMismatch() async {
try {
final serverInfo = ref.read(serverInfoProvider);
final packageInfo = await PackageInfo.fromPlatform();
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
setState(() {
_warningMessage = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
});
} catch (error) {
setState(() {
_warningMessage = 'Error checking version compatibility';
});
}
}
Future<void> _getServerAuthSettings() async {
final serverUrl = _serverEndpointController.text;
if (serverUrl.isEmpty) {
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
return;
}
/// Fetch the server login credential and enables oAuth login if necessary
/// Returns true if successful, false otherwise
Future<void> getServerAuthSettings() async {
final sanitizeServerUrl = sanitizeUrl(serverEndpointController.text);
final serverUrl = punycodeEncodeUrl(sanitizeServerUrl);
try {
setState(() {
_isLoadingServer = true;
});
// Guard empty URL
if (serverUrl.isEmpty) {
ImmichToast.show(context: context, msg: "login_form_server_empty".tr(), toastType: ToastType.error);
}
try {
isLoadingServer.value = true;
final endpoint = await ref.read(authProvider.notifier).validateServerUrl(serverUrl);
// Fetch and load server config and features
await ref.read(serverInfoProvider.notifier).getServerInfo();
final serverInfo = ref.read(serverInfoProvider);
final features = serverInfo.serverFeatures;
final config = serverInfo.serverConfig;
isOauthEnable.value = features.oauthEnabled;
isPasswordLoginEnable.value = features.passwordLogin;
oAuthButtonLabel.value = config.oauthButtonText.isNotEmpty ? config.oauthButtonText : 'OAuth';
serverEndpoint.value = endpoint;
} on ApiException catch (e) {
ImmichToast.show(
context: context,
msg: e.message ?? 'login_form_api_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} on HandshakeException {
ImmichToast.show(
context: context,
msg: 'login_form_handshake_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
} catch (e) {
final settings = await ref.read(authProvider.notifier).getServerAuthSettings(serverUrl);
if (settings == null) {
ImmichToast.show(
context: context,
msg: 'login_form_server_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isOauthEnable.value = false;
isPasswordLoginEnable.value = true;
isLoadingServer.value = false;
}
isLoadingServer.value = false;
}
useEffect(() {
final serverUrl = getServerUrl();
if (serverUrl != null) {
serverEndpointController.text = serverUrl;
}
return null;
}, []);
populateTestLoginInfo() {
emailController.text = 'demo@immich.app';
passwordController.text = 'demo';
serverEndpointController.text = 'https://demo.immich.app';
}
populateTestLoginInfo1() {
emailController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
Future<void> handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await backgroundManager.hashAssets();
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
}
getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'manage_media_access_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
const SizedBox(height: 4),
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'cancel'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
TextButton(
onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
'manage_media_access_settings'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
);
},
);
}
}
bool isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
login() async {
TextInput.finishAutofillContext();
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final result = await ref.read(authProvider.notifier).login(emailController.text, passwordController.text);
if (result.shouldChangePassword && !result.isAdmin) {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error) {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
isLoading.value = false;
}
}
String generateRandomString(int length) {
const chars = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
final random = Random.secure();
return String.fromCharCodes(Iterable.generate(length, (_) => chars.codeUnitAt(random.nextInt(chars.length))));
}
List<int> randomBytes(int length) {
final random = Random.secure();
return List<int>.generate(length, (i) => random.nextInt(256));
}
/// Per specification, the code verifier must be 43-128 characters long
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
String randomCodeVerifier() {
return base64Url.encode(randomBytes(42));
}
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
var bytes = utf8.encode(codeVerifier);
var digest = sha256.convert(bytes);
return base64Url.encode(digest.bytes).replaceAll('=', '');
}
oAuthLogin() async {
var oAuthService = ref.watch(oAuthServiceProvider);
String? oAuthServerUrl;
final state = generateRandomString(32);
final codeVerifier = randomCodeVerifier();
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
try {
oAuthServerUrl = await oAuthService.getOAuthServerUrl(
sanitizeUrl(serverEndpointController.text),
state,
codeChallenge,
);
isLoading.value = true;
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
} catch (error, stack) {
log.severe('Error getting OAuth server Url: $error', stack);
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_config".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
_resetServerState();
return;
}
if (oAuthServerUrl != null) {
try {
final loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl, state, codeVerifier);
setState(() {
_isOAuthEnabled = settings.isOAuthEnabled;
_isPasswordLoginEnabled = settings.isPasswordLoginEnabled;
_oAuthButtonLabel = settings.oAuthButtonText;
_serverEndpoint = settings.endpoint;
_isLoadingServer = false;
});
if (loginResponseDto == null) {
return;
}
await _checkVersionMismatch();
} on ApiException catch (e) {
ImmichToast.show(
context: context,
msg: e.message ?? 'login_form_api_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
} on HandshakeException {
ImmichToast.show(
context: context,
msg: 'login_form_handshake_exception'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
} catch (e) {
ImmichToast.show(
context: context,
msg: 'login_form_server_error'.tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
_resetServerState();
}
}
log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
void _resetServerState() {
setState(() {
_isOAuthEnabled = false;
_isPasswordLoginEnabled = true;
_isLoadingServer = false;
});
}
final isSuccess = await ref
.watch(authProvider.notifier)
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
void _populateTestLoginInfo() {
_emailController.text = 'demo@immich.app';
_passwordController.text = 'demo';
_serverEndpointController.text = 'https://demo.immich.app';
}
if (isSuccess) {
isLoading.value = false;
final permission = ref.watch(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
unawaited(ref.watch(backupProvider.notifier).resumeBackup());
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error, stack) {
log.severe('Error logging in with OAuth: $error', stack);
void _populateTestLoginInfo1() {
_emailController.text = 'testuser@email.com';
_passwordController.text = 'password';
_serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
ImmichToast.show(
context: context,
msg: error.toString(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
Future<void> _handleSyncFlow() async {
final backgroundManager = ref.read(backgroundSyncProvider);
await backgroundManager.syncLocal(full: true);
await backgroundManager.syncRemote();
await backgroundManager.hashAssets();
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
}
Future<void> _getManageMediaPermission() async {
final hasPermission = await ref.read(localFilesManagerRepositoryProvider).hasManageMediaPermission();
if (!hasPermission) {
await showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'manage_media_access_title',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text('manage_media_access_subtitle', style: TextStyle(fontSize: 14)).tr(),
const SizedBox(height: 4),
const Text('manage_media_access_rationale', style: TextStyle(fontSize: 12)).tr(),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(
'cancel'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
TextButton(
onPressed: () {
ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
Navigator.of(context).pop();
},
child: Text(
'manage_media_access_settings'.tr(),
style: TextStyle(fontWeight: FontWeight.w600, color: context.primaryColor),
),
),
],
);
} finally {
isLoading.value = false;
}
},
);
}
}
bool _isSyncRemoteDeletionsMode() => Platform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false);
Future<void> _login() async {
TextInput.finishAutofillContext();
setState(() {
_isLoading = true;
});
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final result = await ref.read(authProvider.notifier).login(_emailController.text, _passwordController.text);
if (result.shouldChangePassword && !result.isAdmin) {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (_isSyncRemoteDeletionsMode()) {
await _getManageMediaPermission();
}
unawaited(_handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error) {
ImmichToast.show(
context: context,
msg: "login_form_failed_login".tr(),
toastType: ToastType.error,
gravity: ToastGravity.TOP,
);
} finally {
setState(() {
_isLoading = false;
});
}
}
Future<void> _oAuthLogin() async {
setState(() {
_isLoading = true;
});
// Invalidate all api repository provider instance to take into account new access token
invalidateAllApiRepositoryProviders(ref);
try {
final oAuthData = await ref
.read(oAuthProvider.notifier)
.getOAuthLoginData(sanitizeUrl(_serverEndpointController.text));
if (oAuthData == null) {
ImmichToast.show(
context: context,
msg: "login_form_failed_get_oauth_server_disable".tr(),
toastType: ToastType.info,
gravity: ToastGravity.TOP,
);
isLoading.value = false;
setState(() {
_isLoading = false;
});
return;
}
}
buildSelectServer() {
const buttonRadius = 25.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: getServerAuthSettings,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(buttonRadius),
bottomLeft: Radius.circular(buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(buttonRadius),
bottomRight: Radius.circular(buttonRadius),
),
),
),
onPressed: isLoadingServer.value ? null : getServerAuthSettings,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoadingServer.value) const LoadingIcon(),
],
);
}
final loginResponseDto = await ref.read(oAuthProvider.notifier).completeOAuthLogin(oAuthData);
buildVersionCompatWarning() {
checkVersionMismatch();
if (warningMessage.value == null) {
return const SizedBox.shrink();
if (loginResponseDto == null) {
setState(() {
_isLoading = false;
});
return;
}
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
),
child: Text(warningMessage.value!, textAlign: TextAlign.center),
),
);
_log.info("Finished OAuth login with response: ${loginResponseDto.userEmail}");
final isSuccess = await ref.read(authProvider.notifier).saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
setState(() {
_isLoading = false;
});
final permission = ref.read(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
unawaited(ref.read(backupProvider.notifier).resumeBackup());
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (_isSyncRemoteDeletionsMode()) {
await _getManageMediaPermission();
}
unawaited(_handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
}
} catch (error, stack) {
_log.severe('Error logging in with OAuth: $error', stack);
ImmichToast.show(context: context, msg: error.toString(), toastType: ToastType.error, gravity: ToastGravity.TOP);
} finally {
setState(() {
_isLoading = false;
});
}
}
buildLogin() {
return AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
buildVersionCompatWarning(),
Text(
sanitizeUrl(serverEndpointController.text),
style: context.textTheme.displaySmall,
textAlign: TextAlign.center,
),
if (isPasswordLoginEnable.value) ...[
const SizedBox(height: 18),
EmailInput(
controller: emailController,
focusNode: emailFocusNode,
onSubmit: passwordFocusNode.requestFocus,
),
const SizedBox(height: 8),
PasswordInput(controller: passwordController, focusNode: passwordFocusNode, onSubmit: login),
],
void _goBack() {
setState(() {
_serverEndpoint = null;
});
}
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
isLoading.value
? const LoadingIcon()
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 18),
if (isPasswordLoginEnable.value) LoginButton(onPressed: login),
if (isOauthEnable.value) ...[
if (isPasswordLoginEnable.value)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Divider(color: context.isDarkTheme ? Colors.white : Colors.black),
),
OAuthLoginButton(
serverEndpointController: serverEndpointController,
buttonLabel: oAuthButtonLabel.value,
isLoading: isLoading,
onPressed: oAuthLogin,
),
],
],
),
if (!isOauthEnable.value && !isPasswordLoginEnable.value) Center(child: const Text('login_disabled').tr()),
const SizedBox(height: 12),
TextButton.icon(
icon: const Icon(Icons.arrow_back),
onPressed: () => serverEndpoint.value = null,
label: const Text('back').tr(),
),
],
),
);
}
final serverSelectionOrLogin = serverEndpoint.value == null ? buildSelectServer() : buildLogin();
@override
Widget build(BuildContext context) {
final serverSelectionOrLogin = _serverEndpoint == null
? ServerSelectionForm(
serverEndpointController: _serverEndpointController,
serverEndpointFocusNode: _serverEndpointFocusNode,
isLoading: _isLoadingServer,
onSubmit: _getServerAuthSettings,
)
: LoginCredentialsForm(
emailController: _emailController,
passwordController: _passwordController,
serverEndpointController: _serverEndpointController,
emailFocusNode: _emailFocusNode,
passwordFocusNode: _passwordFocusNode,
isLoading: _isLoading,
isOAuthEnabled: _isOAuthEnabled,
isPasswordLoginEnabled: _isPasswordLoginEnabled,
oAuthButtonLabel: _oAuthButtonLabel,
warningMessage: _warningMessage,
onLogin: _login,
onOAuthLogin: _oAuthLogin,
onBack: _goBack,
);
return LayoutBuilder(
builder: (context, constraints) {
@@ -532,20 +418,19 @@ class LoginForm extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.end,
children: [
GestureDetector(
onDoubleTap: () => populateTestLoginInfo(),
onLongPress: () => populateTestLoginInfo1(),
onDoubleTap: _populateTestLoginInfo,
onLongPress: _populateTestLoginInfo1,
child: RotationTransition(
turns: logoAnimationController,
turns: _logoAnimationController,
child: const ImmichLogo(heroTag: 'logo'),
),
),
const Padding(padding: EdgeInsets.only(top: 8.0, bottom: 16), child: ImmichTitleText()),
],
),
// Note: This used to have an AnimatedSwitcher, but was removed
// because of https://github.com/flutter/flutter/issues/120874
Form(key: loginFormKey, child: serverSelectionOrLogin),
Form(key: _loginFormKey, child: serverSelectionOrLogin),
],
),
),

View File

@@ -1,23 +1,20 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class OAuthLoginButton extends ConsumerWidget {
class OAuthLoginButton extends StatelessWidget {
final TextEditingController serverEndpointController;
final ValueNotifier<bool> isLoading;
final String buttonLabel;
final Function() onPressed;
final VoidCallback onPressed;
const OAuthLoginButton({
super.key,
required this.serverEndpointController,
required this.isLoading,
required this.buttonLabel,
required this.onPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: context.primaryColor.withAlpha(230),

View File

@@ -1,36 +1,45 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class PasswordInput extends HookConsumerWidget {
class PasswordInput extends StatefulWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
final VoidCallback? onSubmit;
const PasswordInput({super.key, required this.controller, this.focusNode, this.onSubmit});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPasswordVisible = useState<bool>(false);
State<PasswordInput> createState() => _PasswordInputState();
}
class _PasswordInputState extends State<PasswordInput> {
bool _isPasswordVisible = false;
void _togglePasswordVisibility() {
setState(() {
_isPasswordVisible = !_isPasswordVisible;
});
}
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: !isPasswordVisible.value,
controller: controller,
obscureText: !_isPasswordVisible,
controller: widget.controller,
decoration: InputDecoration(
labelText: 'password'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_password_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
suffixIcon: IconButton(
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
icon: Icon(isPasswordVisible.value ? Icons.visibility_off_sharp : Icons.visibility_sharp),
onPressed: _togglePasswordVisibility,
icon: Icon(_isPasswordVisible ? Icons.visibility_off_sharp : Icons.visibility_sharp),
),
),
autofillHints: const [AutofillHints.password],
keyboardType: TextInputType.text,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
onFieldSubmitted: (_) => widget.onSubmit?.call(),
focusNode: widget.focusNode,
textInputAction: TextInputAction.go,
);
}

View File

@@ -0,0 +1,78 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/forms/login/loading_icon.dart';
import 'package:immich_mobile/widgets/forms/login/server_endpoint_input.dart';
class ServerSelectionForm extends StatelessWidget {
final TextEditingController serverEndpointController;
final FocusNode serverEndpointFocusNode;
final bool isLoading;
final VoidCallback onSubmit;
const ServerSelectionForm({
super.key,
required this.serverEndpointController,
required this.serverEndpointFocusNode,
required this.isLoading,
required this.onSubmit,
});
static const double _buttonRadius = 25.0;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ServerEndpointInput(
controller: serverEndpointController,
focusNode: serverEndpointFocusNode,
onSubmit: onSubmit,
),
const SizedBox(height: 18),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(_buttonRadius),
bottomLeft: Radius.circular(_buttonRadius),
),
),
),
onPressed: () => context.pushRoute(const SettingsRoute()),
icon: const Icon(Icons.settings_rounded),
label: const Text(""),
),
),
const SizedBox(width: 1),
Expanded(
flex: 3,
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(_buttonRadius),
bottomRight: Radius.circular(_buttonRadius),
),
),
),
onPressed: isLoading ? null : onSubmit,
icon: const Icon(Icons.arrow_forward_rounded),
label: const Text('next', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
),
],
),
const SizedBox(height: 18),
if (isLoading) const LoadingIcon(),
],
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class VersionCompatibilityWarning extends StatelessWidget {
final String message;
const VersionCompatibilityWarning({super.key, required this.message});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
borderRadius: const BorderRadius.all(Radius.circular(8)),
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
),
child: Text(message, textAlign: TextAlign.center),
),
);
}
}

View File

@@ -108,82 +108,80 @@ class SyncStatusAndActions extends HookConsumerWidget {
);
}
return Padding(
padding: const EdgeInsets.only(top: 16, bottom: 32),
child: ListView(
children: [
const _SyncStatsCounts(),
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "jobs".t(context: context)),
ListTile(
title: Text(
"sync_local".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
onTap: () {
ref.read(backgroundSyncProvider).syncLocal(full: true);
},
return ListView(
padding: const EdgeInsets.only(top: 16, bottom: 96),
children: [
const _SyncStatsCounts(),
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "jobs".t(context: context)),
ListTile(
title: Text(
"sync_local".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
ListTile(
title: Text(
"sync_remote".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.cloud_sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus),
onTap: () {
ref.read(backgroundSyncProvider).syncRemote();
},
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).localSyncStatus),
onTap: () {
ref.read(backgroundSyncProvider).syncLocal(full: true);
},
),
ListTile(
title: Text(
"sync_remote".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
ListTile(
title: Text(
"hash_asset".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
leading: const Icon(Icons.tag),
subtitle: Text("tap_to_run_job".t(context: context)),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus),
onTap: () {
ref.read(backgroundSyncProvider).hashAssets();
},
subtitle: Text("tap_to_run_job".t(context: context)),
leading: const Icon(Icons.cloud_sync),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).remoteSyncStatus),
onTap: () {
ref.read(backgroundSyncProvider).syncRemote();
},
),
ListTile(
title: Text(
"hash_asset".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)),
ListTile(
title: Text(
"clear_file_cache".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
leading: const Icon(Icons.playlist_remove_rounded),
onTap: clearFileCache,
leading: const Icon(Icons.tag),
subtitle: Text("tap_to_run_job".t(context: context)),
trailing: _SyncStatusIcon(status: ref.watch(syncStatusProvider).hashJobStatus),
onTap: () {
ref.read(backgroundSyncProvider).hashAssets();
},
),
const Divider(height: 1, indent: 16, endIndent: 16),
const SizedBox(height: 24),
_SectionHeaderText(text: "actions".t(context: context)),
ListTile(
title: Text(
"clear_file_cache".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
ListTile(
title: Text(
"export_database".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("export_database_description".t(context: context)),
leading: const Icon(Icons.download),
onTap: exportDatabase,
leading: const Icon(Icons.playlist_remove_rounded),
onTap: clearFileCache,
),
ListTile(
title: Text(
"export_database".t(context: context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
ListTile(
title: Text(
"reset_sqlite".t(context: context),
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async {
await resetSqliteDb(context);
},
subtitle: Text("export_database_description".t(context: context)),
leading: const Icon(Icons.download),
onTap: exportDatabase,
),
ListTile(
title: Text(
"reset_sqlite".t(context: context),
style: TextStyle(color: context.colorScheme.error, fontWeight: FontWeight.w500),
),
],
),
leading: Icon(Icons.settings_backup_restore_rounded, color: context.colorScheme.error),
onTap: () async {
await resetSqliteDb(context);
},
),
],
);
}
}

View File

@@ -86,7 +86,6 @@ class NetworkingSettings extends HookConsumerWidget {
return ListView(
padding: const EdgeInsets.only(bottom: 96),
physics: const ClampingScrollPhysics(),
children: <Widget>[
Padding(
padding: const EdgeInsets.only(top: 8, left: 16, bottom: 8),

View File

@@ -137,8 +137,10 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**getAllUserAssetsByDeviceId**](doc//DeprecatedApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | Retrieve assets by device ID
*DeprecatedApi* | [**getDeltaSync**](doc//DeprecatedApi.md#getdeltasync) | **POST** /sync/delta-sync | Get delta sync for user
*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets
*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information
*DuplicatesApi* | [**deleteDuplicate**](doc//DuplicatesApi.md#deleteduplicate) | **DELETE** /duplicates/{id} | Delete a duplicate
@@ -198,6 +200,11 @@ Class | Method | HTTP request | Description
*PeopleApi* | [**updatePerson**](doc//PeopleApi.md#updateperson) | **PUT** /people/{id} | Update person
*PluginsApi* | [**getPlugin**](doc//PluginsApi.md#getplugin) | **GET** /plugins/{id} | Retrieve a plugin
*PluginsApi* | [**getPlugins**](doc//PluginsApi.md#getplugins) | **GET** /plugins | List all plugins
*QueuesApi* | [**emptyQueue**](doc//QueuesApi.md#emptyqueue) | **DELETE** /queues/{name}/jobs | Empty a queue
*QueuesApi* | [**getQueue**](doc//QueuesApi.md#getqueue) | **GET** /queues/{name} | Retrieve a queue
*QueuesApi* | [**getQueueJobs**](doc//QueuesApi.md#getqueuejobs) | **GET** /queues/{name}/jobs | Retrieve queue jobs
*QueuesApi* | [**getQueues**](doc//QueuesApi.md#getqueues) | **GET** /queues | List all queues
*QueuesApi* | [**updateQueue**](doc//QueuesApi.md#updatequeue) | **PUT** /queues/{name} | Update a queue
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | Retrieve assets by city
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | Retrieve explore data
*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | Retrieve search suggestions
@@ -396,6 +403,7 @@ Class | Method | HTTP request | Description
- [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md)
- [JobCreateDto](doc//JobCreateDto.md)
- [JobName](doc//JobName.md)
- [JobSettingsDto](doc//JobSettingsDto.md)
- [LibraryResponseDto](doc//LibraryResponseDto.md)
- [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md)
@@ -465,11 +473,16 @@ Class | Method | HTTP request | Description
- [PurchaseUpdate](doc//PurchaseUpdate.md)
- [QueueCommand](doc//QueueCommand.md)
- [QueueCommandDto](doc//QueueCommandDto.md)
- [QueueDeleteDto](doc//QueueDeleteDto.md)
- [QueueJobResponseDto](doc//QueueJobResponseDto.md)
- [QueueJobStatus](doc//QueueJobStatus.md)
- [QueueName](doc//QueueName.md)
- [QueueResponseDto](doc//QueueResponseDto.md)
- [QueueResponseLegacyDto](doc//QueueResponseLegacyDto.md)
- [QueueStatisticsDto](doc//QueueStatisticsDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [QueuesResponseDto](doc//QueuesResponseDto.md)
- [QueueStatusLegacyDto](doc//QueueStatusLegacyDto.md)
- [QueueUpdateDto](doc//QueueUpdateDto.md)
- [QueuesResponseLegacyDto](doc//QueuesResponseLegacyDto.md)
- [RandomSearchDto](doc//RandomSearchDto.md)
- [RatingsResponse](doc//RatingsResponse.md)
- [RatingsUpdate](doc//RatingsUpdate.md)

View File

@@ -50,6 +50,7 @@ part 'api/notifications_admin_api.dart';
part 'api/partners_api.dart';
part 'api/people_api.dart';
part 'api/plugins_api.dart';
part 'api/queues_api.dart';
part 'api/search_api.dart';
part 'api/server_api.dart';
part 'api/sessions_api.dart';
@@ -154,6 +155,7 @@ part 'model/folders_response.dart';
part 'model/folders_update.dart';
part 'model/image_format.dart';
part 'model/job_create_dto.dart';
part 'model/job_name.dart';
part 'model/job_settings_dto.dart';
part 'model/library_response_dto.dart';
part 'model/library_stats_response_dto.dart';
@@ -223,11 +225,16 @@ part 'model/purchase_response.dart';
part 'model/purchase_update.dart';
part 'model/queue_command.dart';
part 'model/queue_command_dto.dart';
part 'model/queue_delete_dto.dart';
part 'model/queue_job_response_dto.dart';
part 'model/queue_job_status.dart';
part 'model/queue_name.dart';
part 'model/queue_response_dto.dart';
part 'model/queue_response_legacy_dto.dart';
part 'model/queue_statistics_dto.dart';
part 'model/queue_status_dto.dart';
part 'model/queues_response_dto.dart';
part 'model/queue_status_legacy_dto.dart';
part 'model/queue_update_dto.dart';
part 'model/queues_response_legacy_dto.dart';
part 'model/random_search_dto.dart';
part 'model/ratings_response.dart';
part 'model/ratings_update.dart';

View File

@@ -248,6 +248,54 @@ class DeprecatedApi {
return null;
}
/// Retrieve queue counts and status
///
/// Retrieve the counts of the current queue, as well as the current status.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getQueuesLegacyWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/jobs';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve queue counts and status
///
/// Retrieve the counts of the current queue, as well as the current status.
Future<QueuesResponseLegacyDto?> getQueuesLegacy() async {
final response = await getQueuesLegacyWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseLegacyDto',) as QueuesResponseLegacyDto;
}
return null;
}
/// Get random assets
///
/// Retrieve a specified number of random assets for the authenticated user.
@@ -444,4 +492,65 @@ class DeprecatedApi {
}
return null;
}
/// Run jobs
///
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [QueueCommandDto] queueCommandDto (required):
Future<Response> runQueueCommandLegacyWithHttpInfo(QueueName name, QueueCommandDto queueCommandDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/jobs/{name}'
.replaceAll('{name}', name.toString());
// ignore: prefer_final_locals
Object? postBody = queueCommandDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Run jobs
///
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [QueueCommandDto] queueCommandDto (required):
Future<QueueResponseLegacyDto?> runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async {
final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseLegacyDto',) as QueueResponseLegacyDto;
}
return null;
}
}

View File

@@ -97,7 +97,7 @@ class JobsApi {
/// Retrieve queue counts and status
///
/// Retrieve the counts of the current queue, as well as the current status.
Future<QueuesResponseDto?> getQueuesLegacy() async {
Future<QueuesResponseLegacyDto?> getQueuesLegacy() async {
final response = await getQueuesLegacyWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@@ -106,7 +106,7 @@ class JobsApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseDto',) as QueuesResponseDto;
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueuesResponseLegacyDto',) as QueuesResponseLegacyDto;
}
return null;
@@ -158,7 +158,7 @@ class JobsApi {
/// * [QueueName] name (required):
///
/// * [QueueCommandDto] queueCommandDto (required):
Future<QueueResponseDto?> runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async {
Future<QueueResponseLegacyDto?> runQueueCommandLegacy(QueueName name, QueueCommandDto queueCommandDto,) async {
final response = await runQueueCommandLegacyWithHttpInfo(name, queueCommandDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
@@ -167,7 +167,7 @@ class JobsApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto;
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseLegacyDto',) as QueueResponseLegacyDto;
}
return null;

308
mobile/openapi/lib/api/queues_api.dart generated Normal file
View File

@@ -0,0 +1,308 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class QueuesApi {
QueuesApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Empty a queue
///
/// Removes all jobs from the specified queue.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [QueueDeleteDto] queueDeleteDto (required):
Future<Response> emptyQueueWithHttpInfo(QueueName name, QueueDeleteDto queueDeleteDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/queues/{name}/jobs'
.replaceAll('{name}', name.toString());
// ignore: prefer_final_locals
Object? postBody = queueDeleteDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Empty a queue
///
/// Removes all jobs from the specified queue.
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [QueueDeleteDto] queueDeleteDto (required):
Future<void> emptyQueue(QueueName name, QueueDeleteDto queueDeleteDto,) async {
final response = await emptyQueueWithHttpInfo(name, queueDeleteDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Retrieve a queue
///
/// Retrieves a specific queue by its name.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [QueueName] name (required):
Future<Response> getQueueWithHttpInfo(QueueName name,) async {
// ignore: prefer_const_declarations
final apiPath = r'/queues/{name}'
.replaceAll('{name}', name.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve a queue
///
/// Retrieves a specific queue by its name.
///
/// Parameters:
///
/// * [QueueName] name (required):
Future<QueueResponseDto?> getQueue(QueueName name,) async {
final response = await getQueueWithHttpInfo(name,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto;
}
return null;
}
/// Retrieve queue jobs
///
/// Retrieves a list of queue jobs from the specified queue.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [List<QueueJobStatus>] status:
Future<Response> getQueueJobsWithHttpInfo(QueueName name, { List<QueueJobStatus>? status, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/queues/{name}/jobs'
.replaceAll('{name}', name.toString());
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (status != null) {
queryParams.addAll(_queryParams('multi', 'status', status));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Retrieve queue jobs
///
/// Retrieves a list of queue jobs from the specified queue.
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [List<QueueJobStatus>] status:
Future<List<QueueJobResponseDto>?> getQueueJobs(QueueName name, { List<QueueJobStatus>? status, }) async {
final response = await getQueueJobsWithHttpInfo(name, status: status, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<QueueJobResponseDto>') as List)
.cast<QueueJobResponseDto>()
.toList(growable: false);
}
return null;
}
/// List all queues
///
/// Retrieves a list of queues.
///
/// Note: This method returns the HTTP [Response].
Future<Response> getQueuesWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/queues';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// List all queues
///
/// Retrieves a list of queues.
Future<List<QueueResponseDto>?> getQueues() async {
final response = await getQueuesWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<QueueResponseDto>') as List)
.cast<QueueResponseDto>()
.toList(growable: false);
}
return null;
}
/// Update a queue
///
/// Change the paused status of a specific queue.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [QueueUpdateDto] queueUpdateDto (required):
Future<Response> updateQueueWithHttpInfo(QueueName name, QueueUpdateDto queueUpdateDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/queues/{name}'
.replaceAll('{name}', name.toString());
// ignore: prefer_final_locals
Object? postBody = queueUpdateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Update a queue
///
/// Change the paused status of a specific queue.
///
/// Parameters:
///
/// * [QueueName] name (required):
///
/// * [QueueUpdateDto] queueUpdateDto (required):
Future<QueueResponseDto?> updateQueue(QueueName name, QueueUpdateDto queueUpdateDto,) async {
final response = await updateQueueWithHttpInfo(name, queueUpdateDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'QueueResponseDto',) as QueueResponseDto;
}
return null;
}
}

View File

@@ -358,6 +358,8 @@ class ApiClient {
return ImageFormatTypeTransformer().decode(value);
case 'JobCreateDto':
return JobCreateDto.fromJson(value);
case 'JobName':
return JobNameTypeTransformer().decode(value);
case 'JobSettingsDto':
return JobSettingsDto.fromJson(value);
case 'LibraryResponseDto':
@@ -496,16 +498,26 @@ class ApiClient {
return QueueCommandTypeTransformer().decode(value);
case 'QueueCommandDto':
return QueueCommandDto.fromJson(value);
case 'QueueDeleteDto':
return QueueDeleteDto.fromJson(value);
case 'QueueJobResponseDto':
return QueueJobResponseDto.fromJson(value);
case 'QueueJobStatus':
return QueueJobStatusTypeTransformer().decode(value);
case 'QueueName':
return QueueNameTypeTransformer().decode(value);
case 'QueueResponseDto':
return QueueResponseDto.fromJson(value);
case 'QueueResponseLegacyDto':
return QueueResponseLegacyDto.fromJson(value);
case 'QueueStatisticsDto':
return QueueStatisticsDto.fromJson(value);
case 'QueueStatusDto':
return QueueStatusDto.fromJson(value);
case 'QueuesResponseDto':
return QueuesResponseDto.fromJson(value);
case 'QueueStatusLegacyDto':
return QueueStatusLegacyDto.fromJson(value);
case 'QueueUpdateDto':
return QueueUpdateDto.fromJson(value);
case 'QueuesResponseLegacyDto':
return QueuesResponseLegacyDto.fromJson(value);
case 'RandomSearchDto':
return RandomSearchDto.fromJson(value);
case 'RatingsResponse':

View File

@@ -94,6 +94,9 @@ String parameterToString(dynamic value) {
if (value is ImageFormat) {
return ImageFormatTypeTransformer().encode(value).toString();
}
if (value is JobName) {
return JobNameTypeTransformer().encode(value).toString();
}
if (value is LogLevel) {
return LogLevelTypeTransformer().encode(value).toString();
}
@@ -133,6 +136,9 @@ String parameterToString(dynamic value) {
if (value is QueueCommand) {
return QueueCommandTypeTransformer().encode(value).toString();
}
if (value is QueueJobStatus) {
return QueueJobStatusTypeTransformer().encode(value).toString();
}
if (value is QueueName) {
return QueueNameTypeTransformer().encode(value).toString();
}

244
mobile/openapi/lib/model/job_name.dart generated Normal file
View File

@@ -0,0 +1,244 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class JobName {
/// Instantiate a new enum with the provided [value].
const JobName._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const assetDelete = JobName._(r'AssetDelete');
static const assetDeleteCheck = JobName._(r'AssetDeleteCheck');
static const assetDetectFacesQueueAll = JobName._(r'AssetDetectFacesQueueAll');
static const assetDetectFaces = JobName._(r'AssetDetectFaces');
static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll');
static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates');
static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll');
static const assetEncodeVideo = JobName._(r'AssetEncodeVideo');
static const assetEmptyTrash = JobName._(r'AssetEmptyTrash');
static const assetExtractMetadataQueueAll = JobName._(r'AssetExtractMetadataQueueAll');
static const assetExtractMetadata = JobName._(r'AssetExtractMetadata');
static const assetFileMigration = JobName._(r'AssetFileMigration');
static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll');
static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails');
static const auditLogCleanup = JobName._(r'AuditLogCleanup');
static const auditTableCleanup = JobName._(r'AuditTableCleanup');
static const databaseBackup = JobName._(r'DatabaseBackup');
static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll');
static const facialRecognition = JobName._(r'FacialRecognition');
static const fileDelete = JobName._(r'FileDelete');
static const fileMigrationQueueAll = JobName._(r'FileMigrationQueueAll');
static const libraryDeleteCheck = JobName._(r'LibraryDeleteCheck');
static const libraryDelete = JobName._(r'LibraryDelete');
static const libraryRemoveAsset = JobName._(r'LibraryRemoveAsset');
static const libraryScanAssetsQueueAll = JobName._(r'LibraryScanAssetsQueueAll');
static const librarySyncAssets = JobName._(r'LibrarySyncAssets');
static const librarySyncFilesQueueAll = JobName._(r'LibrarySyncFilesQueueAll');
static const librarySyncFiles = JobName._(r'LibrarySyncFiles');
static const libraryScanQueueAll = JobName._(r'LibraryScanQueueAll');
static const memoryCleanup = JobName._(r'MemoryCleanup');
static const memoryGenerate = JobName._(r'MemoryGenerate');
static const notificationsCleanup = JobName._(r'NotificationsCleanup');
static const notifyUserSignup = JobName._(r'NotifyUserSignup');
static const notifyAlbumInvite = JobName._(r'NotifyAlbumInvite');
static const notifyAlbumUpdate = JobName._(r'NotifyAlbumUpdate');
static const userDelete = JobName._(r'UserDelete');
static const userDeleteCheck = JobName._(r'UserDeleteCheck');
static const userSyncUsage = JobName._(r'UserSyncUsage');
static const personCleanup = JobName._(r'PersonCleanup');
static const personFileMigration = JobName._(r'PersonFileMigration');
static const personGenerateThumbnail = JobName._(r'PersonGenerateThumbnail');
static const sessionCleanup = JobName._(r'SessionCleanup');
static const sendMail = JobName._(r'SendMail');
static const sidecarQueueAll = JobName._(r'SidecarQueueAll');
static const sidecarCheck = JobName._(r'SidecarCheck');
static const sidecarWrite = JobName._(r'SidecarWrite');
static const smartSearchQueueAll = JobName._(r'SmartSearchQueueAll');
static const smartSearch = JobName._(r'SmartSearch');
static const storageTemplateMigration = JobName._(r'StorageTemplateMigration');
static const storageTemplateMigrationSingle = JobName._(r'StorageTemplateMigrationSingle');
static const tagCleanup = JobName._(r'TagCleanup');
static const versionCheck = JobName._(r'VersionCheck');
static const ocrQueueAll = JobName._(r'OcrQueueAll');
static const ocr = JobName._(r'Ocr');
static const workflowRun = JobName._(r'WorkflowRun');
/// List of all possible values in this [enum][JobName].
static const values = <JobName>[
assetDelete,
assetDeleteCheck,
assetDetectFacesQueueAll,
assetDetectFaces,
assetDetectDuplicatesQueueAll,
assetDetectDuplicates,
assetEncodeVideoQueueAll,
assetEncodeVideo,
assetEmptyTrash,
assetExtractMetadataQueueAll,
assetExtractMetadata,
assetFileMigration,
assetGenerateThumbnailsQueueAll,
assetGenerateThumbnails,
auditLogCleanup,
auditTableCleanup,
databaseBackup,
facialRecognitionQueueAll,
facialRecognition,
fileDelete,
fileMigrationQueueAll,
libraryDeleteCheck,
libraryDelete,
libraryRemoveAsset,
libraryScanAssetsQueueAll,
librarySyncAssets,
librarySyncFilesQueueAll,
librarySyncFiles,
libraryScanQueueAll,
memoryCleanup,
memoryGenerate,
notificationsCleanup,
notifyUserSignup,
notifyAlbumInvite,
notifyAlbumUpdate,
userDelete,
userDeleteCheck,
userSyncUsage,
personCleanup,
personFileMigration,
personGenerateThumbnail,
sessionCleanup,
sendMail,
sidecarQueueAll,
sidecarCheck,
sidecarWrite,
smartSearchQueueAll,
smartSearch,
storageTemplateMigration,
storageTemplateMigrationSingle,
tagCleanup,
versionCheck,
ocrQueueAll,
ocr,
workflowRun,
];
static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value);
static List<JobName> listFromJson(dynamic json, {bool growable = false,}) {
final result = <JobName>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = JobName.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [JobName] to String,
/// and [decode] dynamic data back to [JobName].
class JobNameTypeTransformer {
factory JobNameTypeTransformer() => _instance ??= const JobNameTypeTransformer._();
const JobNameTypeTransformer._();
String encode(JobName data) => data.value;
/// Decodes a [dynamic value][data] to a JobName.
///
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
///
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
/// and users are still using an old app with the old code.
JobName? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'AssetDelete': return JobName.assetDelete;
case r'AssetDeleteCheck': return JobName.assetDeleteCheck;
case r'AssetDetectFacesQueueAll': return JobName.assetDetectFacesQueueAll;
case r'AssetDetectFaces': return JobName.assetDetectFaces;
case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll;
case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates;
case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll;
case r'AssetEncodeVideo': return JobName.assetEncodeVideo;
case r'AssetEmptyTrash': return JobName.assetEmptyTrash;
case r'AssetExtractMetadataQueueAll': return JobName.assetExtractMetadataQueueAll;
case r'AssetExtractMetadata': return JobName.assetExtractMetadata;
case r'AssetFileMigration': return JobName.assetFileMigration;
case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll;
case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails;
case r'AuditLogCleanup': return JobName.auditLogCleanup;
case r'AuditTableCleanup': return JobName.auditTableCleanup;
case r'DatabaseBackup': return JobName.databaseBackup;
case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll;
case r'FacialRecognition': return JobName.facialRecognition;
case r'FileDelete': return JobName.fileDelete;
case r'FileMigrationQueueAll': return JobName.fileMigrationQueueAll;
case r'LibraryDeleteCheck': return JobName.libraryDeleteCheck;
case r'LibraryDelete': return JobName.libraryDelete;
case r'LibraryRemoveAsset': return JobName.libraryRemoveAsset;
case r'LibraryScanAssetsQueueAll': return JobName.libraryScanAssetsQueueAll;
case r'LibrarySyncAssets': return JobName.librarySyncAssets;
case r'LibrarySyncFilesQueueAll': return JobName.librarySyncFilesQueueAll;
case r'LibrarySyncFiles': return JobName.librarySyncFiles;
case r'LibraryScanQueueAll': return JobName.libraryScanQueueAll;
case r'MemoryCleanup': return JobName.memoryCleanup;
case r'MemoryGenerate': return JobName.memoryGenerate;
case r'NotificationsCleanup': return JobName.notificationsCleanup;
case r'NotifyUserSignup': return JobName.notifyUserSignup;
case r'NotifyAlbumInvite': return JobName.notifyAlbumInvite;
case r'NotifyAlbumUpdate': return JobName.notifyAlbumUpdate;
case r'UserDelete': return JobName.userDelete;
case r'UserDeleteCheck': return JobName.userDeleteCheck;
case r'UserSyncUsage': return JobName.userSyncUsage;
case r'PersonCleanup': return JobName.personCleanup;
case r'PersonFileMigration': return JobName.personFileMigration;
case r'PersonGenerateThumbnail': return JobName.personGenerateThumbnail;
case r'SessionCleanup': return JobName.sessionCleanup;
case r'SendMail': return JobName.sendMail;
case r'SidecarQueueAll': return JobName.sidecarQueueAll;
case r'SidecarCheck': return JobName.sidecarCheck;
case r'SidecarWrite': return JobName.sidecarWrite;
case r'SmartSearchQueueAll': return JobName.smartSearchQueueAll;
case r'SmartSearch': return JobName.smartSearch;
case r'StorageTemplateMigration': return JobName.storageTemplateMigration;
case r'StorageTemplateMigrationSingle': return JobName.storageTemplateMigrationSingle;
case r'TagCleanup': return JobName.tagCleanup;
case r'VersionCheck': return JobName.versionCheck;
case r'OcrQueueAll': return JobName.ocrQueueAll;
case r'Ocr': return JobName.ocr;
case r'WorkflowRun': return JobName.workflowRun;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [JobNameTypeTransformer] instance.
static JobNameTypeTransformer? _instance;
}

View File

@@ -152,6 +152,12 @@ class Permission {
static const userProfileImagePeriodRead = Permission._(r'userProfileImage.read');
static const userProfileImagePeriodUpdate = Permission._(r'userProfileImage.update');
static const userProfileImagePeriodDelete = Permission._(r'userProfileImage.delete');
static const queuePeriodRead = Permission._(r'queue.read');
static const queuePeriodUpdate = Permission._(r'queue.update');
static const queueJobPeriodCreate = Permission._(r'queueJob.create');
static const queueJobPeriodRead = Permission._(r'queueJob.read');
static const queueJobPeriodUpdate = Permission._(r'queueJob.update');
static const queueJobPeriodDelete = Permission._(r'queueJob.delete');
static const workflowPeriodCreate = Permission._(r'workflow.create');
static const workflowPeriodRead = Permission._(r'workflow.read');
static const workflowPeriodUpdate = Permission._(r'workflow.update');
@@ -294,6 +300,12 @@ class Permission {
userProfileImagePeriodRead,
userProfileImagePeriodUpdate,
userProfileImagePeriodDelete,
queuePeriodRead,
queuePeriodUpdate,
queueJobPeriodCreate,
queueJobPeriodRead,
queueJobPeriodUpdate,
queueJobPeriodDelete,
workflowPeriodCreate,
workflowPeriodRead,
workflowPeriodUpdate,
@@ -471,6 +483,12 @@ class PermissionTypeTransformer {
case r'userProfileImage.read': return Permission.userProfileImagePeriodRead;
case r'userProfileImage.update': return Permission.userProfileImagePeriodUpdate;
case r'userProfileImage.delete': return Permission.userProfileImagePeriodDelete;
case r'queue.read': return Permission.queuePeriodRead;
case r'queue.update': return Permission.queuePeriodUpdate;
case r'queueJob.create': return Permission.queueJobPeriodCreate;
case r'queueJob.read': return Permission.queueJobPeriodRead;
case r'queueJob.update': return Permission.queueJobPeriodUpdate;
case r'queueJob.delete': return Permission.queueJobPeriodDelete;
case r'workflow.create': return Permission.workflowPeriodCreate;
case r'workflow.read': return Permission.workflowPeriodRead;
case r'workflow.update': return Permission.workflowPeriodUpdate;

View File

@@ -0,0 +1,109 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class QueueDeleteDto {
/// Returns a new [QueueDeleteDto] instance.
QueueDeleteDto({
this.failed,
});
/// If true, will also remove failed jobs from the queue.
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? failed;
@override
bool operator ==(Object other) => identical(this, other) || other is QueueDeleteDto &&
other.failed == failed;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(failed == null ? 0 : failed!.hashCode);
@override
String toString() => 'QueueDeleteDto[failed=$failed]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.failed != null) {
json[r'failed'] = this.failed;
} else {
// json[r'failed'] = null;
}
return json;
}
/// Returns a new [QueueDeleteDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static QueueDeleteDto? fromJson(dynamic value) {
upgradeDto(value, "QueueDeleteDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return QueueDeleteDto(
failed: mapValueOfType<bool>(json, r'failed'),
);
}
return null;
}
static List<QueueDeleteDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <QueueDeleteDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = QueueDeleteDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, QueueDeleteDto> mapFromJson(dynamic json) {
final map = <String, QueueDeleteDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = QueueDeleteDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of QueueDeleteDto-objects as value to a dart map
static Map<String, List<QueueDeleteDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<QueueDeleteDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = QueueDeleteDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

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