Compare commits

...

28 Commits

Author SHA1 Message Date
shenlong-tanwen
bd8ef9e1bf simplify _buildAssetRow 2025-11-19 18:14:44 +05:30
shenlong-tanwen
02de19df6d feat(mobile): dynamic layout in new timeline 2025-11-19 18:14:41 +05:30
Min Idzelis
2152f20b6c fix: unarchive action doesn't update archive page (#23987) 2025-11-19 12:29:02 +01:00
Matthew Momjian
a6c76e78d6 fix(docs): update Readme links (#23959)
* fix(docs): Update star history links and image sources

* Update star history link in README.md
2025-11-18 21:32:11 -06:00
renovate[bot]
644a3bf090 chore(deps): update github-actions (#23962)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 21:32:00 -06:00
Min Idzelis
42dd3315f8 refactor(web): fix TimelineManager import - use value import instead of type-only (#23983) 2025-11-18 21:26:15 -06:00
Kevin Puertas
3a694219bf feat: add originalPath for external library assets in dedupe (#23710)
* Add original path info row to duplicate asset component

View path of images, useful when using external Library

* Make if for not show path in internal images

* Update web/src/lib/components/utilities-page/duplicates/duplicate-asset.svelte

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* Refactor original path display logic in duplicate-asset

* Update duplicate-asset.svelte

* Add full path localization string

* Change translated data

* format: fix

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-11-19 03:24:17 +00:00
Min Idzelis
d9fd52ea18 feat: timeline e2e tests (#23895)
* feat: timeline e2e tests

* Fix flakiness, mock all apis, allow parallel tests

* Upload playwright reports

* wrong report path

* Add CI=true, disable flaky/failing tests

* Re-enable tests, fix worker thread config

* fix maintance e2e test

* increase retries
2025-11-18 21:08:55 -06:00
Brandon Wees
2a281e7906 feat(mobile): location edit from asset viewer (#23925)
* chore: break sheet tile into own file

* feat: set location from bottom sheet

* refactor: location picker

There was a lot of confusing controls here, simplified to 1 mode

* fix: local asset check

* chore: refactoring of location details widget

* fix: update currentAssetExifProvider when changing location

* chore: use SheetTile for location header

* chore: remove coordinate change check

* chore: remove comment
2025-11-18 21:06:51 -06:00
Daniel Dietzler
5f987a95f5 fix: feature flags manager race condition (#23973)
Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-11-19 03:05:53 +00:00
bo0tzz
edf577d7f7 feat: publish on release pr merge (#23867) 2025-11-18 21:03:49 -06:00
Sergey Katsubo
5e482dabc6 feat(server): support running medium tests in devcontainer (#23882)
* Support running medium tests in devcontainer

* Add "pnpm run test:medium" to the devcontainer doc

* Fix indentation for inline comments in the doc

* Fix a couple of words in the doc
2025-11-18 21:03:21 -06:00
Mees Frensel
76c73549ae feat(web): always view original of animated images (#23842) 2025-11-18 21:02:52 -06:00
Mees Frensel
271a42ac7f fix(server): copy relevant panorama tags to preview image (#23953) 2025-11-19 03:02:12 +00:00
bo0tzz
4462952564 fix: proper docker caching for plugin mise deps (#23967)
* fix: proper docker caching for plugin mise deps

* fix: mount mise cache for build too
2025-11-19 03:00:03 +00:00
shenlong
38d4d1a573 chore: reset remote sync on app update (#23969)
reset remote sync on update

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-19 02:55:01 +00:00
Daniel Dietzler
d310c6f3cd feat: library details page (#23908)
* feat: library details page

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-11-18 15:27:41 -05:00
Alex
c086a65fa8 chore: update drift (#23877)
* chore: update drift

* update drift dep

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-11-18 16:07:33 +00:00
renovate[bot]
7134dd29ca chore(deps): pin ghcr.io/jdx/mise docker tag to ac26f59 (#23961)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:21:28 +01:00
renovate[bot]
3e08953a43 chore(deps): update dependency @types/node to ^22.19.1 (#23963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 12:20:52 +01:00
Min Idzelis
58c3c7e26b feat: run e2e server in dev mode (#23921)
* feat: run e2e server in dev mode

* Use bash syntax: [[ and ==
2025-11-17 14:16:39 -06:00
Min Idzelis
237ddcb648 fix: incorrect header height calculation in estimated month height (#23923) 2025-11-17 14:14:06 -06:00
Min Idzelis
fbaeffd65c fix: flaky user-admin.e2e-spec.ts (#23929)
* fix: flaky user-admin.e2e-spec.ts

* lint
2025-11-17 14:12:44 -06:00
Min Idzelis
d64c339b4f fix: null dereference when canceling bucket in album (#23924) 2025-11-17 14:12:28 -06:00
Min Idzelis
69880ee165 fix: deep link to last asset (#23920) 2025-11-17 14:12:07 -06:00
Paul Makles
15e00f82f0 feat: maintenance mode (#23431)
* feat: add a `maintenance.enabled` config flag

* feat: implement graceful restart
feat: restart when maintenance config is toggled

* feat: boot a stripped down maintenance api if enabled

* feat: cli command to toggle maintenance mode

* chore: fallback IMMICH_SERVER_URL environment variable in process

* chore: add additional routes to maintenance controller

* fix: don't wait for nest application to close to finish request response

* chore: add a failsafe on restart to prevent other exit codes from preventing restart

* feat: redirect into/from maintenance page

* refactor: use system metadata for maintenance status

* refactor: wait on WebSocket connection to refresh

* feat: broadcast websocket event on server restart
refactor: listen to WS instead of polling

* refactor: bubble up maintenance information instead of hijacking in fetch function
feat: show modal when server is restarting

* chore: increase timeout for ungraceful restart

* refactor: deduplicate code between api/maintenance workers

* fix: skip config check if database is not initialised

* fix: add `maintenanceMode` field to system config test

* refactor: move maintenance resolution code to static method in service

* chore: clean up linter issues

* chore: generate dart openapi

* refactor: use try{} block for maintenance mode check

* fix: logic error in server redirect

* chore: include `maintenanceMode` key in e2e test

* chore: add i18n entries for maintenance screens

* chore: remove negated condition from hook

* fix: should set default value not override in service

* fix: minor error in page

* feat: initial draft of maintenance module, repo., worker controller, worker service

* refactor: move broadcast code into notification service

* chore: connect websocket on client if in maintenance

* chore: set maintenance module app name

* refactor: rename repository to include worker
chore: configure websocket adapter

* feat: reimplement maintenance mode exit with new module

* refactor: add a constant enum for ExitCode

* refactor: remove redundant route for maintenance

* refactor: only spin up kysely on boot (rather than a Nest app)

* refactor(web): move redirect logic into +layout file where modal is setup

* feat: add Maintenance permission

* refactor: merge common code between api/maintenance

* fix: propagate changes from the CLI to servers

* feat: maintenance authentication guard

* refactor: unify maintenance code into repository
feat: add a step to generate maintenance mode token

* feat: jwt auth for maintenance

* refactor: switch from nest jwt to just jsonwebtokens

* feat: log into maintenance mode from CLI command

* refactor: use `secret` instead of `token` in jwt terminology
chore: log maintenance mode login URL on boot
chore: don't make CLI actions reload if already in target state

* docs: initial draft for maintenance mode page

* refactor: always validate the maintenance auth on the server

* feat: add a link to maintenance mode documentation

* feat: redirect users back to the last page they were on when exiting maintenance

* refactor: provide closeFn in both maintenance repos.

* refactor: ensure the user is also redirected by the server

* chore: swap jsonwebtoken for jose

* refactor: introduce AppRestartEvent w/o secret passing

* refactor: use navigation goto

* refactor: use `continue` instead of `next`

* chore: lint fixes for server

* chore: lint fixes for web

* test: add mock for maintenance repository

* test: add base service dependency to maintenance

* chore: remove @types/jsonwebtoken

* refactor: close database connection after startup check

* refactor: use `request#auth` key

* refactor: use service instead of repository
chore: read token from cookie if possible
chore: rename client event to AppRestartV1

* refactor: more concise redirect logic on web

* refactor: move redirect check into utils
refactor: update translation strings to be more sensible

* refactor: always validate login (i.e. check cookie)

* refactor: lint, open-api, remove old dto

* refactor: encode at point of usage

* refactor: remove business logic from repositories

* chore: fix server/web lints

* refactor: remove repository mock

* chore: fix formatting

* test: write service mocks for maintenance mode

* test: write cli service tests

* fix: catch errors when closing app

* fix: always report no maintenance when usual API is available

* test: api e2e maintenance spec

* chore: add response builder

* chore: add helper to set maint. auth cookie

* feat: add SSR to maintenance API

* test(e2e): write web spec for maintenance

* chore: clean up lint issues

* chore: format files

* feat: perform 302 redirect at server level during maintenance

* fix: keep trying to stop immich until it succeeds (CLI issue)

* chore: lint/format

* refactor: annotate references to other services in worker service

* chore: lint

* refactor: remove unnecessary await

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>

* refactor: move static methods into util

* refactor: assert secret exists in maintenance worker

* refactor: remove assertion which isn't necessary anymore

* refactor: remove assertion

* refactor: remove outer try {} catch block from loadMaintenanceAuth

* refactor: undo earlier change to vite.config.ts

* chore: update tests due to refactors

* revert: vite.config.ts

* test: expect string jwt

* chore: move blanket exceptions into controllers

* test: update tests according with last change

* refactor: use respondWithCookie
refactor: merge start/end into one route
refactor: rename MaintenanceRepository to AppRepository
chore: use new ApiTag/Endpoint
refactor: apply other requested changes

* chore: regenerate openapi

* chore: lint/format

* chore: remove secureOnly for maint. cookie

* refactor: move maintenance worker code into src/maintenance\nfix: various test fixes

* refactor: use `action` property for setting maint. mode

* refactor: remove Websocket#restartApp in favour of individual methods

* chore: incomplete commit

* chore: remove stray log

* fix: call exitApp from maintenance worker on exit

* fix: add app repository mock

* fix: ensure maintenance cookies are secure

* fix: run playwright tests over secure context (localhost)

* test: update other references to 127.0.0.1

* refactor: use serverSideEmitWithAck

* chore: correct the logic in tryTerminate

* test: juggle cookies ourselves

* chore: fix lint error for e2e spec

* chore: format e2e test

* fix: set cookie secure/non-secure depending on context

* chore: format files

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
2025-11-17 17:15:44 +00:00
Jason Rasmussen
ce82e27f4b fix: workflow medium tests (#23952) 2025-11-17 16:26:30 +00:00
Yaros
eeee5147cc fix(mobile): delete from device warning shows incorrectly (#23935)
fix(mobile): delete warning on multiple assets
2025-11-17 10:17:04 -06:00
180 changed files with 7480 additions and 1666 deletions

View File

@@ -29,6 +29,12 @@
]
}
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
// https://github.com/devcontainers/features/issues/1466
"moby": false
}
},
"forwardPorts": [3000, 9231, 9230, 2283],
"portsAttributes": {
"3000": {

View File

@@ -96,7 +96,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
@@ -194,7 +194,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false

View File

@@ -25,7 +25,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Check out code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -35,7 +35,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -78,7 +78,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -50,14 +50,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/init@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/autobuild@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
# 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@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2
uses: github/codeql-action/analyze@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3
with:
category: '/language:${{matrix.language}}'

View File

@@ -60,7 +60,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -125,7 +125,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -23,7 +23,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -22,7 +22,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: 'Checkout'
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ steps.generate-token.outputs.token }}

View File

@@ -55,14 +55,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
@@ -132,7 +132,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
@@ -144,7 +144,7 @@ jobs:
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
draft: true
tag_name: ${{ env.IMMICH_VERSION }}

View File

@@ -23,14 +23,14 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: true
ref: main
- name: Install uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0

147
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,147 @@
name: release.yml
on:
pull_request:
types: [closed]
paths:
- CHANGELOG.md
jobs:
# Maybe double check PR source branch?
merge_translations:
uses: ./.github/workflows/merge-translations.yml
permissions:
pull-requests: write
secrets:
PUSH_O_MATIC_APP_ID: ${{ secrets.PUSH_O_MATIC_APP_ID }}
PUSH_O_MATIC_APP_KEY: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
WEBLATE_TOKEN: ${{ secrets.WEBLATE_TOKEN }}
build_mobile:
uses: ./.github/workflows/build-mobile.yml
needs: merge_translations
permissions:
contents: read
secrets:
KEY_JKS: ${{ secrets.KEY_JKS }}
ALIAS: ${{ secrets.ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
# iOS secrets
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
IOS_CERTIFICATE_P12: ${{ secrets.IOS_CERTIFICATE_P12 }}
IOS_CERTIFICATE_PASSWORD: ${{ secrets.IOS_CERTIFICATE_PASSWORD }}
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
IOS_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_SHARE_EXTENSION }}misc/release/notes.tmpl
IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_SHARE_EXTENSION }}
IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION: ${{ secrets.IOS_DEVELOPMENT_PROVISIONING_PROFILE_WIDGET_EXTENSION }}
FASTLANE_TEAM_ID: ${{ secrets.FASTLANE_TEAM_ID }}
with:
ref: main
environment: production
prepare_release:
runs-on: ubuntu-latest
needs: build_mobile
permissions:
actions: read # To download the app artifact
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
token: ${{ steps.generate-token.outputs.token }}
persist-credentials: false
ref: main
- name: Extract changelog
id: changelog
run: |
CHANGELOG_PATH=$RUNNER_TEMP/changelog.md
sed -n '1,/^---$/p' CHANGELOG.md | head -n -1 > $CHANGELOG_PATH
echo "path=$CHANGELOG_PATH" >> $GITHUB_OUTPUT
VERSION=$(sed -n 's/^# //p' $CHANGELOG_PATH)
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download APK
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
with:
name: release-apk-signed
github-token: ${{ steps.generate-token.outputs.token }}
- name: Create draft release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
tag_name: ${{ steps.version.outputs.result }}
token: ${{ steps.generate-token.outputs.token }}
body_path: ${{ steps.changelog.outputs.path }}
files: |
docker/docker-compose.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml
docker/prometheus.yml
*.apk
- name: Rename Outline document
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
continue-on-error: true
env:
OUTLINE_API_KEY: ${{ secrets.OUTLINE_API_KEY }}
VERSION: ${{ steps.changelog.outputs.version }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const outlineKey = process.env.OUTLINE_API_KEY;
const version = process.env.VERSION;
const parentDocumentId = 'da856355-0844-43df-bd71-f8edce5382d9';
const baseUrl = 'https://outline.immich.cloud';
const listResponse = await fetch(`${baseUrl}/api/documents.list`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ parentDocumentId })
});
if (!listResponse.ok) {
throw new Error(`Outline list failed: ${listResponse.statusText}`);
}
const listData = await listResponse.json();
const allDocuments = listData.data || [];
const document = allDocuments.find(doc => doc.title === 'next');
if (document) {
console.log(`Found document 'next', renaming to '${version}'...`);
const updateResponse = await fetch(`${baseUrl}/api/documents.update`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${outlineKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: document.id,
title: version
})
});
if (!updateResponse.ok) {
throw new Error(`Failed to rename document: ${updateResponse.statusText}`);
}
} else {
console.log('No document titled "next" found to rename');
}

View File

@@ -22,7 +22,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -55,7 +55,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -69,7 +69,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -114,7 +114,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -161,7 +161,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -203,7 +203,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -247,7 +247,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -285,7 +285,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -333,7 +333,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -379,7 +379,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -418,7 +418,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -473,7 +473,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
submodules: 'recursive'
@@ -500,8 +500,16 @@ jobs:
run: docker compose build
if: ${{ !cancelled() }}
- name: Run e2e tests (web)
env:
CI: true
run: npx playwright test
if: ${{ !cancelled() }}
- name: Archive test results
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
if: success() || failure()
with:
name: e2e-web-test-results-${{ matrix.runner }}
path: e2e/playwright-report/
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
@@ -526,7 +534,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -558,12 +566,12 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
- uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
@@ -602,7 +610,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -631,7 +639,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -653,7 +661,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
@@ -715,7 +723,7 @@ jobs:
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
with:
persist-credentials: false
token: ${{ steps.token.outputs.token }}

View File

@@ -17,6 +17,9 @@ dev-docs:
e2e:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --remove-orphans
e2e-dev:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.dev.yml up --remove-orphans
e2e-update:
@trap 'make e2e-down' EXIT; COMPOSE_BAKE=true docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans

View File

@@ -118,16 +118,16 @@ Read more about translations [here](https://docs.immich.app/developer/translatio
## Star history
<a href="https://star-history.com/#immich-app/immich&Date">
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=Date" width="100%" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
</picture>
</a>
## Contributors
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<a href="https://github.com/immich-app/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

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.0",
"@types/node": "^22.19.1",
"@vitest/coverage-v8": "^3.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",

View File

@@ -0,0 +1,18 @@
# Maintenance Mode
Maintenance mode is used to perform administrative tasks such as restoring backups to Immich.
You can enter maintenance mode by either:
- Selecting "enable maintenance mode" in system settings in administration.
- Running the enable maintenance mode [administration command](./server-commands.md).
## Logging in during maintenance
Maintenance mode uses a separate login system which is handled automatically behind the scenes in most cases. Enabling maintenance mode in settings will automatically log you into maintenance mode when the server comes back up.
If you find that you've been logged out, you can:
- Open the logs for the Immich server and look for _"🚧 Immich is in maintenance mode, you can log in using the following URL:"_
- Run the enable maintenance mode [administration command](./server-commands.md) again, this will give you a new URL to login with.
- Run the disable maintenance mode [administration command](./server-commands.md) then re-enter through system settings.

View File

@@ -2,17 +2,19 @@
The `immich-server` docker image comes preinstalled with an administrative CLI (`immich-admin`) that supports the following commands:
| Command | Description |
| ------------------------ | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
| Command | Description |
| -------------------------- | ------------------------------------------------------------- |
| `help` | Display help |
| `reset-admin-password` | Reset the password for the admin user |
| `disable-password-login` | Disable password login |
| `enable-password-login` | Enable password login |
| `disable-maintenance-mode` | Disable maintenance mode |
| `enable-maintenance-mode` | Enable maintenance mode |
| `enable-oauth-login` | Enable OAuth login |
| `disable-oauth-login` | Disable OAuth login |
| `list-users` | List Immich users |
| `version` | Print Immich version |
| `change-media-location` | Change database file paths to align with a new media location |
## How to run a command
@@ -47,6 +49,23 @@ immich-admin enable-password-login
Password login has been enabled.
```
Disable Maintenance Mode
```
immich-admin disable-maintenace-mode
Maintenance mode has been disabled.
```
Enable Maintenance Mode
```
immich-admin enable-maintenance-mode
Maintenance mode has been enabled.
Log in using the following URL:
https://my.immich.app/maintenance?token=<token>
```
Enable OAuth login
```

View File

@@ -256,7 +256,7 @@ The Dev Container supports multiple ways to run tests:
```bash
# Run tests for specific components
make test-server # Server unit tests
make test-server # Server unit tests
make test-web # Web unit tests
make test-e2e # End-to-end tests
make test-cli # CLI tests
@@ -268,12 +268,13 @@ make test-all # Runs tests for all components
make test-medium-dev # End-to-end tests
```
#### Using NPM Directly
#### Using PNPM Directly
```bash
# Server tests
cd /workspaces/immich/server
pnpm test # Run all tests
pnpm test # Run all tests
pnpm run test:medium # Medium tests (integration tests)
pnpm run test:watch # Watch mode
pnpm run test:cov # Coverage report
@@ -293,21 +294,21 @@ pnpm run test:web # Run web UI tests
```bash
# Linting
make lint-server # Lint server code
make lint-web # Lint web code
make lint-all # Lint all components
make lint-web # Lint web code
make lint-all # Lint all components
# Formatting
make format-server # Format server code
make format-web # Format web code
make format-all # Format all code
make format-web # Format web code
make format-all # Format all code
# Type checking
make check-server # Type check server
make check-web # Type check web
make check-all # Check all components
make check-web # Type check web
make check-all # Check all components
# Complete hygiene check
make hygiene-all # Runs lint, format, check, SQL sync, and audit
make hygiene-all # Run lint, format, check, SQL sync, and audit
```
### Additional Make Commands
@@ -315,21 +316,21 @@ make hygiene-all # Runs lint, format, check, SQL sync, and audit
```bash
# Build commands
make build-server # Build server
make build-web # Build web app
make build-all # Build everything
make build-web # Build web app
make build-all # Build everything
# API generation
make open-api # Generate OpenAPI specs
make open-api # Generate OpenAPI specs
make open-api-typescript # Generate TypeScript SDK
make open-api-dart # Generate Dart SDK
make open-api-dart # Generate Dart SDK
# Database
make sql # Sync database schema
make sql # Sync database schema
# Dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
make install-server # Install server dependencies
make install-web # Install web dependencies
make install-all # Install all dependencies
```
### Debugging

1
e2e/.gitignore vendored
View File

@@ -4,3 +4,4 @@ node_modules/
/blob-report/
/playwright/.cache/
/dist
.env

105
e2e/docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,105 @@
name: immich-e2e
services:
immich-server:
container_name: immich-e2e-server
command: ['immich-dev']
image: immich-server-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
environment:
- DB_HOSTNAME=database
- DB_USERNAME=postgres
- DB_PASSWORD=postgres
- DB_DATABASE_NAME=immich
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_TELEMETRY_INCLUDE=all
- IMMICH_ENV=testing
- IMMICH_PORT=2285
- IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
volumes:
- ./test-assets:/test-assets
- ..:/usr/src/app
- ${UPLOAD_LOCATION}/photos:/data
- /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin
depends_on:
redis:
condition: service_started
database:
condition: service_healthy
immich-web:
container_name: immich-e2e-web
image: immich-web-dev:latest
build:
context: ../
dockerfile: server/Dockerfile.dev
target: dev
command: ['immich-web']
ports:
- 2285:3000
environment:
- IMMICH_SERVER_URL=http://immich-server:2285/
volumes:
- ..:/usr/src/app
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
restart: unless-stopped
redis:
image: redis:6.2-alpine@sha256:37e002448575b32a599109664107e374c8709546905c372a34d64919043b9ceb
database:
image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0@sha256:6f3e9d2c2177af16c2988ff71425d79d89ca630ec2f9c8db03209ab716542338
command: -c fsync=off -c shared_preload_libraries=vchord.so -c config_file=/var/lib/postgresql/data/postgresql.conf
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
POSTGRES_DB: immich
ports:
- 5435:5432
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d immich']
interval: 1s
timeout: 5s
retries: 30
start_period: 10s
volumes:
model-cache:
prometheus-data:
grafana-data:
pnpm-store:
server-node_modules:
web-node_modules:
github-node_modules:
cli-node_modules:
docs-node_modules:
e2e-node_modules:
sdk-node_modules:
app-node_modules:
sveltekit:
coverage:

View File

@@ -20,16 +20,18 @@
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/js": "^9.8.0",
"@faker-js/faker": "^10.1.0",
"@immich/cli": "file:../cli",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"@types/oidc-provider": "^9.0.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
"dotenv": "^17.2.3",
"eslint": "^9.14.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.1.3",

View File

@@ -1,23 +1,50 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices, PlaywrightTestConfig } from '@playwright/test';
import dotenv from 'dotenv';
import { cpus } from 'node:os';
import { resolve } from 'node:path';
export default defineConfig({
dotenv.config({ path: resolve(import.meta.dirname, '.env') });
export const playwrightHost = process.env.PLAYWRIGHT_HOST ?? '127.0.0.1';
export const playwrightDbHost = process.env.PLAYWRIGHT_DB_HOST ?? '127.0.0.1';
export const playwriteBaseUrl = process.env.PLAYWRIGHT_BASE_URL ?? `http://${playwrightHost}:2285`;
export const playwriteSlowMo = parseInt(process.env.PLAYWRIGHT_SLOW_MO ?? '0');
export const playwrightDisableWebserver = process.env.PLAYWRIGHT_DISABLE_WEBSERVER;
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1';
const config: PlaywrightTestConfig = {
testDir: './src/web/specs',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
retries: process.env.CI ? 4 : 0,
reporter: 'html',
use: {
baseURL: 'http://127.0.0.1:2285',
baseURL: playwriteBaseUrl,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
launchOptions: {
slowMo: playwriteSlowMo,
},
},
testMatch: /.*\.e2e-spec\.ts/,
workers: process.env.CI ? 4 : Math.round(cpus().length * 0.75),
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.e2e-spec\.ts/,
workers: 1,
},
{
name: 'parallel tests',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.parallel-e2e-spec\.ts/,
fullyParallel: true,
workers: process.env.CI ? 3 : Math.max(1, Math.round(cpus().length * 0.75) - 1),
},
// {
@@ -59,4 +86,8 @@ export default defineConfig({
stderr: 'pipe',
reuseExistingServer: true,
},
});
};
if (playwrightDisableWebserver) {
delete config.webServer;
}
export default defineConfig(config);

View File

@@ -0,0 +1,172 @@
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/admin/maintenance', () => {
let cookie: string | undefined;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
// => outside of maintenance mode
describe('GET ~/server/config', async () => {
it('should indicate we are out of maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeFalsy();
});
});
describe('POST /login', async () => {
it('should not work out of maintenance mode', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({ token: 'token' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Not in maintenance mode'));
});
});
// => enter maintenance mode
describe.sequential('POST /', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/admin/maintenance').send({
action: 'end',
});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should be a no-op if try to exit maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ action: 'end' });
expect(status).toBe(201);
});
it('should enter maintenance mode', async () => {
const { status, headers } = await request(app)
.post('/admin/maintenance')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
action: 'start',
});
expect(status).toBe(201);
cookie = headers['set-cookie'][0].split(';')[0];
expect(cookie).toEqual(
expect.stringMatching(/^immich_maintenance_token=[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*$/),
);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeTruthy();
});
});
// => in maintenance mode
describe.sequential('in maintenance mode', () => {
describe('GET ~/server/config', async () => {
it('should indicate we are in maintenance mode', async () => {
const { status, body } = await request(app).get('/server/config');
expect(status).toBe(200);
expect(body.maintenanceMode).toBeTruthy();
});
});
describe('POST /login', async () => {
it('should fail without cookie or token in body', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').send({});
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorizedWithMessage('Missing JWT Token'));
});
it('should succeed with cookie', async () => {
const { status, body } = await request(app).post('/admin/maintenance/login').set('cookie', cookie!).send({});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
it('should succeed with token', async () => {
const { status, body } = await request(app)
.post('/admin/maintenance/login')
.send({
token: cookie!.split('=')[1].trim(),
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
username: 'Immich Admin',
}),
);
});
});
describe('POST /', async () => {
it('should be a no-op if try to enter maintenance mode', async () => {
const { status } = await request(app)
.post('/admin/maintenance')
.set('cookie', cookie!)
.send({ action: 'start' });
expect(status).toBe(201);
});
});
});
// => exit maintenance mode
describe.sequential('POST /', () => {
it('should exit maintenance mode', async () => {
const { status } = await request(app).post('/admin/maintenance').set('cookie', cookie!).send({
action: 'end',
});
expect(status).toBe(201);
await expect
.poll(
async () => {
const { body } = await request(app).get('/server/config');
return body.maintenanceMode;
},
{
interval: 5e2,
timeout: 1e4,
},
)
.toBeFalsy();
});
});
});

View File

@@ -136,6 +136,7 @@ describe('/server', () => {
externalDomain: '',
publicUsers: true,
isOnboarded: false,
maintenanceMode: false,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});

View File

@@ -0,0 +1,37 @@
export { generateTimelineData } from './timeline/model-objects';
export { createDefaultTimelineConfig, validateTimelineConfig } from './timeline/timeline-config';
export type {
MockAlbum,
MonthSpec,
SerializedTimelineData,
MockTimelineAsset as TimelineAssetConfig,
TimelineConfig,
MockTimelineData as TimelineData,
} from './timeline/timeline-config';
export {
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
toAssetResponseDto,
toColumnarFormat,
} from './timeline/rest-response';
export type { Changes } from './timeline/rest-response';
export { randomImage, randomImageFromString, randomPreview, randomThumbnail } from './timeline/images';
export {
SeededRandom,
getMockAsset,
parseTimeBucketKey,
selectRandom,
selectRandomDays,
selectRandomMultiple,
} from './timeline/utils';
export { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './timeline/distribution-patterns';
export type { DayPattern, MonthDistribution } from './timeline/distribution-patterns';

View File

@@ -0,0 +1,183 @@
import { generateConsecutiveDays, generateDayAssets } from 'src/generators/timeline/model-objects';
import { SeededRandom, selectRandomDays } from 'src/generators/timeline/utils';
import type { MockTimelineAsset } from './timeline-config';
import { GENERATION_CONSTANTS } from './timeline-config';
type AssetDistributionStrategy = (rng: SeededRandom) => number;
type DayDistributionStrategy = (
year: number,
month: number,
daysInMonth: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
) => MockTimelineAsset[];
/**
* Strategies for determining total asset count per month
*/
export const ASSET_DISTRIBUTION: Record<MonthDistribution, AssetDistributionStrategy | null> = {
empty: null, // Special case - handled separately
sparse: (rng) => rng.nextInt(3, 9), // 3-8 assets
medium: (rng) => rng.nextInt(15, 31), // 15-30 assets
dense: (rng) => rng.nextInt(50, 81), // 50-80 assets
'very-dense': (rng) => rng.nextInt(80, 151), // 80-150 assets
};
/**
* Strategies for distributing assets across days within a month
*/
export const DAY_DISTRIBUTION: Record<DayPattern, DayDistributionStrategy> = {
'single-day': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// All assets on one day in the middle of the month
const day = Math.floor(daysInMonth / 2);
return generateDayAssets(year, month, day, totalAssets, ownerId, rng);
},
'consecutive-large': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// 3-5 consecutive days with evenly distributed assets
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
return generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
},
'consecutive-small': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Multiple consecutive days with 1-3 assets each (side-by-side layout)
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth / 2));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
let assetIndex = 0;
for (let i = 0; i < numDays && assetIndex < totalAssets; i++) {
const dayAssets = Math.min(3, rng.nextInt(1, 4));
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, startDay + i, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
}
return assets;
},
alternating: (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Alternate between large (15-25) and small (1-3) days
const assets: MockTimelineAsset[] = [];
let day = 1;
let isLarge = true;
let assetIndex = 0;
while (assetIndex < totalAssets && day <= daysInMonth) {
const dayAssets = isLarge ? Math.min(25, rng.nextInt(15, 26)) : rng.nextInt(1, 4);
const actualAssets = Math.min(dayAssets, totalAssets - assetIndex);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, day, actualAssets, ownerId, dayRng));
assetIndex += actualAssets;
day += isLarge ? 1 : 1; // Could add gaps here
isLarge = !isLarge;
}
return assets;
},
'sparse-scattered': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Spread assets across random days with gaps
const assets: MockTimelineAsset[] = [];
const numDays = Math.min(totalAssets, Math.floor(daysInMonth * GENERATION_CONSTANTS.SPARSE_DAY_COVERAGE));
const daysWithPhotos = selectRandomDays(daysInMonth, numDays, rng);
let assetIndex = 0;
for (let i = 0; i < daysWithPhotos.length && assetIndex < totalAssets; i++) {
const dayAssets =
Math.floor(totalAssets / numDays) + (i === daysWithPhotos.length - 1 ? totalAssets % numDays : 0);
// Create a new RNG for this day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysWithPhotos[i], dayAssets, ownerId, dayRng));
assetIndex += dayAssets;
}
return assets;
},
'start-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in first week
const assets: MockTimelineAsset[] = [];
const firstWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - firstWeekAssets;
// First 7 days
assets.push(...generateConsecutiveDays(year, month, 1, 7, firstWeekAssets, ownerId, rng));
// Remaining scattered
if (remainingAssets > 0) {
const midDay = Math.floor(daysInMonth / 2);
// Create a new RNG for the remaining assets
const remainingRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, midDay, remainingAssets, ownerId, remainingRng));
}
return assets;
},
'end-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in last week
const assets: MockTimelineAsset[] = [];
const lastWeekAssets = Math.floor(totalAssets * 0.7);
const remainingAssets = totalAssets - lastWeekAssets;
// Remaining at start
if (remainingAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, remainingAssets, ownerId, startRng));
}
// Last 7 days
const startDay = daysInMonth - 6;
assets.push(...generateConsecutiveDays(year, month, startDay, 7, lastWeekAssets, ownerId, rng));
return assets;
},
'mid-heavy': (year, month, daysInMonth, totalAssets, ownerId, rng) => {
// Most assets in middle of month
const assets: MockTimelineAsset[] = [];
const midAssets = Math.floor(totalAssets * 0.7);
const sideAssets = Math.floor((totalAssets - midAssets) / 2);
// Start
if (sideAssets > 0) {
// Create a new RNG for the start assets
const startRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, 2, sideAssets, ownerId, startRng));
}
// Middle
const midStart = Math.floor(daysInMonth / 2) - 3;
assets.push(...generateConsecutiveDays(year, month, midStart, 7, midAssets, ownerId, rng));
// End
const endAssets = totalAssets - midAssets - sideAssets;
if (endAssets > 0) {
// Create a new RNG for the end assets
const endRng = new SeededRandom(rng.nextInt(0, 1_000_000));
assets.push(...generateDayAssets(year, month, daysInMonth - 1, endAssets, ownerId, endRng));
}
return assets;
},
};
export type MonthDistribution =
| 'empty' // 0 assets
| 'sparse' // 3-8 assets
| 'medium' // 15-30 assets
| 'dense' // 50-80 assets
| 'very-dense'; // 80-150 assets
export type DayPattern =
| 'single-day' // All images in one day
| 'consecutive-large' // Multiple days with 15-25 images each
| 'consecutive-small' // Multiple days with 1-3 images each (side-by-side)
| 'alternating' // Alternating large/small days
| 'sparse-scattered' // Few images scattered across month
| 'start-heavy' // Most images at start of month
| 'end-heavy' // Most images at end of month
| 'mid-heavy'; // Most images in middle of month

View File

@@ -0,0 +1,111 @@
import sharp from 'sharp';
import { SeededRandom } from 'src/generators/timeline/utils';
export const randomThumbnail = async (seed: string, ratio: number) => {
const height = 235;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomPreview = async (seed: string, ratio: number) => {
const height = 500;
const width = Math.round(height * ratio);
return randomImageFromString(seed, { width, height });
};
export const randomImageFromString = async (
seed: string = '',
{ width = 100, height = 100 }: { width: number; height: number },
) => {
// Convert string to number for seeding
let seedNumber = 0;
for (let i = 0; i < seed.length; i++) {
seedNumber = (seedNumber << 5) - seedNumber + (seed.codePointAt(i) ?? 0);
seedNumber = seedNumber & seedNumber; // Convert to 32bit integer
}
return randomImage(new SeededRandom(Math.abs(seedNumber)), { width, height });
};
export const randomImage = async (rng: SeededRandom, { width, height }: { width: number; height: number }) => {
const r1 = rng.nextInt(0, 256);
const g1 = rng.nextInt(0, 256);
const b1 = rng.nextInt(0, 256);
const r2 = rng.nextInt(0, 256);
const g2 = rng.nextInt(0, 256);
const b2 = rng.nextInt(0, 256);
const patternType = rng.nextInt(0, 5);
let svgPattern = '';
switch (patternType) {
case 0: {
// Solid color
svgPattern = `<svg width="${width}" height="${height}">
<rect x="0" y="0" width="${width}" height="${height}" fill="rgb(${r1},${g1},${b1})"/>
</svg>`;
break;
}
case 1: {
// Horizontal stripes
const stripeHeight = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: height / stripeHeight },
(_, i) =>
`<rect x="0" y="${i * stripeHeight}" width="${width}" height="${stripeHeight}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 2: {
// Vertical stripes
const stripeWidth = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from(
{ length: width / stripeWidth },
(_, i) =>
`<rect x="${i * stripeWidth}" y="0" width="${stripeWidth}" height="${height}"
fill="rgb(${i % 2 ? r1 : r2},${i % 2 ? g1 : g2},${i % 2 ? b1 : b2})"/>`,
).join('')}
</svg>`;
break;
}
case 3: {
// Checkerboard
const squareSize = 10;
svgPattern = `<svg width="${width}" height="${height}">
${Array.from({ length: height / squareSize }, (_, row) =>
Array.from({ length: width / squareSize }, (_, col) => {
const isEven = (row + col) % 2 === 0;
return `<rect x="${col * squareSize}" y="${row * squareSize}"
width="${squareSize}" height="${squareSize}"
fill="rgb(${isEven ? r1 : r2},${isEven ? g1 : g2},${isEven ? b1 : b2})"/>`;
}).join(''),
).join('')}
</svg>`;
break;
}
case 4: {
// Diagonal stripes
svgPattern = `<svg width="${width}" height="${height}">
<defs>
<pattern id="diagonal" x="0" y="0" width="20" height="20" patternUnits="userSpaceOnUse">
<rect x="0" y="0" width="10" height="20" fill="rgb(${r1},${g1},${b1})"/>
<rect x="10" y="0" width="10" height="20" fill="rgb(${r2},${g2},${b2})"/>
</pattern>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" fill="url(#diagonal)" transform="rotate(45 50 50)"/>
</svg>`;
break;
}
}
const svgBuffer = Buffer.from(svgPattern);
const jpegData = await sharp(svgBuffer).jpeg({ quality: 50 }).toBuffer();
return jpegData;
};

View File

@@ -0,0 +1,265 @@
/**
* Generator functions for timeline model objects
*/
import { faker } from '@faker-js/faker';
import { AssetVisibility } from '@immich/sdk';
import { DateTime } from 'luxon';
import { writeFileSync } from 'node:fs';
import { SeededRandom } from 'src/generators/timeline/utils';
import type { DayPattern, MonthDistribution } from './distribution-patterns';
import { ASSET_DISTRIBUTION, DAY_DISTRIBUTION } from './distribution-patterns';
import type { MockTimelineAsset, MockTimelineData, SerializedTimelineData, TimelineConfig } from './timeline-config';
import { ASPECT_RATIO_WEIGHTS, GENERATION_CONSTANTS, validateTimelineConfig } from './timeline-config';
/**
* Generate a random aspect ratio based on weighted probabilities
*/
export function generateAspectRatio(rng: SeededRandom): string {
const random = rng.next();
let cumulative = 0;
for (const [ratio, weight] of Object.entries(ASPECT_RATIO_WEIGHTS)) {
cumulative += weight;
if (random < cumulative) {
return ratio;
}
}
return '16:9'; // Default fallback
}
export function generateThumbhash(rng: SeededRandom): string {
return Array.from({ length: 10 }, () => rng.nextInt(0, 256).toString(16).padStart(2, '0')).join('');
}
export function generateDuration(rng: SeededRandom): string {
return `${rng.nextInt(GENERATION_CONSTANTS.MIN_VIDEO_DURATION_SECONDS, GENERATION_CONSTANTS.MAX_VIDEO_DURATION_SECONDS)}.${rng.nextInt(0, 1000).toString().padStart(3, '0')}`;
}
export function generateUUID(): string {
return faker.string.uuid();
}
export function generateAsset(
year: number,
month: number,
day: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset {
const from = DateTime.fromObject({ year, month, day }).setZone('UTC');
const to = from.endOf('day');
const date = faker.date.between({ from: from.toJSDate(), to: to.toJSDate() });
const isVideo = rng.next() < GENERATION_CONSTANTS.VIDEO_PROBABILITY;
const assetId = generateUUID();
const hasGPS = rng.next() < GENERATION_CONSTANTS.GPS_PERCENTAGE;
const ratio = generateAspectRatio(rng);
const asset: MockTimelineAsset = {
id: assetId,
ownerId,
ratio: Number.parseFloat(ratio.split(':')[0]) / Number.parseFloat(ratio.split(':')[1]),
thumbhash: generateThumbhash(rng),
localDateTime: date.toISOString(),
fileCreatedAt: date.toISOString(),
isFavorite: rng.next() < GENERATION_CONSTANTS.FAVORITE_PROBABILITY,
isTrashed: false,
isVideo,
isImage: !isVideo,
duration: isVideo ? generateDuration(rng) : null,
projectionType: null,
livePhotoVideoId: null,
city: hasGPS ? faker.location.city() : null,
country: hasGPS ? faker.location.country() : null,
people: null,
latitude: hasGPS ? faker.location.latitude() : null,
longitude: hasGPS ? faker.location.longitude() : null,
visibility: AssetVisibility.Timeline,
stack: null,
fileSizeInByte: faker.number.int({ min: 510, max: 5_000_000 }),
checksum: faker.string.alphanumeric({ length: 5 }),
};
return asset;
}
/**
* Generate assets for a specific day
*/
export function generateDayAssets(
year: number,
month: number,
day: number,
assetCount: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
return Array.from({ length: assetCount }, () => generateAsset(year, month, day, ownerId, rng));
}
/**
* Distribute assets evenly across consecutive days
*
* @returns Array of generated timeline assets
*/
export function generateConsecutiveDays(
year: number,
month: number,
startDay: number,
numDays: number,
totalAssets: number,
ownerId: string,
rng: SeededRandom,
): MockTimelineAsset[] {
const assets: MockTimelineAsset[] = [];
const assetsPerDay = Math.floor(totalAssets / numDays);
for (let i = 0; i < numDays; i++) {
const dayAssets =
i === numDays - 1
? totalAssets - assetsPerDay * (numDays - 1) // Remainder on last day
: assetsPerDay;
// Create a new RNG with a different seed for each day
const dayRng = new SeededRandom(rng.nextInt(0, 1_000_000) + i * 100);
assets.push(...generateDayAssets(year, month, startDay + i, dayAssets, ownerId, dayRng));
}
return assets;
}
/**
* Generate assets for a month with specified distribution pattern
*/
export function generateMonthAssets(
year: number,
month: number,
ownerId: string,
distribution: MonthDistribution = 'medium',
pattern: DayPattern = 'consecutive-large',
rng: SeededRandom,
): MockTimelineAsset[] {
const daysInMonth = new Date(year, month, 0).getDate();
if (distribution === 'empty') {
return [];
}
const distributionStrategy = ASSET_DISTRIBUTION[distribution];
if (!distributionStrategy) {
console.warn(`Unknown distribution: ${distribution}, defaulting to medium`);
return [];
}
const totalAssets = distributionStrategy(rng);
const dayStrategy = DAY_DISTRIBUTION[pattern];
if (!dayStrategy) {
console.warn(`Unknown pattern: ${pattern}, defaulting to consecutive-large`);
// Fallback to consecutive-large pattern
const numDays = Math.min(5, Math.floor(totalAssets / 15));
const startDay = rng.nextInt(1, daysInMonth - numDays + 2);
const assets = generateConsecutiveDays(year, month, startDay, numDays, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
const assets = dayStrategy(year, month, daysInMonth, totalAssets, ownerId, rng);
assets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
return assets;
}
/**
* Main generator function for timeline data
*/
export function generateTimelineData(config: TimelineConfig): MockTimelineData {
validateTimelineConfig(config);
const buckets = new Map<string, MockTimelineAsset[]>();
const monthStats: Record<string, { count: number; distribution: MonthDistribution; pattern: DayPattern }> = {};
const globalRng = new SeededRandom(config.seed || GENERATION_CONSTANTS.DEFAULT_SEED);
faker.seed(globalRng.nextInt(0, 1_000_000));
for (const monthConfig of config.months) {
const { year, month, distribution, pattern } = monthConfig;
const monthSeed = globalRng.nextInt(0, 1_000_000);
const monthRng = new SeededRandom(monthSeed);
const monthAssets = generateMonthAssets(
year,
month,
config.ownerId || generateUUID(),
distribution,
pattern,
monthRng,
);
if (monthAssets.length > 0) {
const monthKey = `${year}-${month.toString().padStart(2, '0')}`;
monthStats[monthKey] = {
count: monthAssets.length,
distribution,
pattern,
};
// Create bucket key (YYYY-MM-01)
const bucketKey = `${year}-${month.toString().padStart(2, '0')}-01`;
buckets.set(bucketKey, monthAssets);
}
}
// Create a mock album from random assets
const allAssets = [...buckets.values()].flat();
// Select 10-30 random assets for the album (or all assets if less than 10)
const albumSize = Math.min(allAssets.length, globalRng.nextInt(10, 31));
const selectedAssetConfigs: MockTimelineAsset[] = [];
const usedIndices = new Set<number>();
while (selectedAssetConfigs.length < albumSize && usedIndices.size < allAssets.length) {
const randomIndex = globalRng.nextInt(0, allAssets.length);
if (!usedIndices.has(randomIndex)) {
usedIndices.add(randomIndex);
selectedAssetConfigs.push(allAssets[randomIndex]);
}
}
// Sort selected assets by date (newest first)
selectedAssetConfigs.sort(
(a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds,
);
const selectedAssets = selectedAssetConfigs.map((asset) => asset.id);
const now = new Date().toISOString();
const album = {
id: generateUUID(),
albumName: 'Test Album',
description: 'A mock album for testing',
assetIds: selectedAssets,
thumbnailAssetId: selectedAssets.length > 0 ? selectedAssets[0] : null,
createdAt: now,
updatedAt: now,
};
// Write to file if configured
if (config.writeToFile) {
const outputPath = config.outputPath || '/tmp/timeline-data.json';
// Convert Map to object for serialization
const serializedData: SerializedTimelineData = {
buckets: Object.fromEntries(buckets),
album,
};
try {
writeFileSync(outputPath, JSON.stringify(serializedData, null, 2));
console.log(`Timeline data written to ${outputPath}`);
} catch (error) {
console.error(`Failed to write timeline data to ${outputPath}:`, error);
}
}
return { buckets, album };
}

View File

@@ -0,0 +1,436 @@
/**
* REST API output functions for converting timeline data to API response formats
*/
import {
AssetTypeEnum,
AssetVisibility,
UserAvatarColor,
type AlbumResponseDto,
type AssetResponseDto,
type ExifResponseDto,
type TimeBucketAssetResponseDto,
type TimeBucketsResponseDto,
type UserResponseDto,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { signupDto } from 'src/fixtures';
import { parseTimeBucketKey } from 'src/generators/timeline/utils';
import type { MockTimelineAsset, MockTimelineData } from './timeline-config';
/**
* Convert timeline/asset models to columnar format (parallel arrays)
*/
export function toColumnarFormat(assets: MockTimelineAsset[]): TimeBucketAssetResponseDto {
const result: TimeBucketAssetResponseDto = {
id: [],
ownerId: [],
ratio: [],
thumbhash: [],
fileCreatedAt: [],
localOffsetHours: [],
isFavorite: [],
isTrashed: [],
isImage: [],
duration: [],
projectionType: [],
livePhotoVideoId: [],
city: [],
country: [],
visibility: [],
};
for (const asset of assets) {
result.id.push(asset.id);
result.ownerId.push(asset.ownerId);
result.ratio.push(asset.ratio);
result.thumbhash.push(asset.thumbhash);
result.fileCreatedAt.push(asset.fileCreatedAt);
result.localOffsetHours.push(0); // Assuming UTC for mocks
result.isFavorite.push(asset.isFavorite);
result.isTrashed.push(asset.isTrashed);
result.isImage.push(asset.isImage);
result.duration.push(asset.duration);
result.projectionType.push(asset.projectionType);
result.livePhotoVideoId.push(asset.livePhotoVideoId);
result.city.push(asset.city);
result.country.push(asset.country);
result.visibility.push(asset.visibility);
}
if (assets.some((a) => a.latitude !== null || a.longitude !== null)) {
result.latitude = assets.map((a) => a.latitude);
result.longitude = assets.map((a) => a.longitude);
}
result.stack = assets.map(() => null);
return result;
}
/**
* Extract a single bucket from timeline data (mimics getTimeBucket API)
* Automatically handles both ISO timestamp and simple month formats
* Returns data in columnar format matching the actual API
* When albumId is provided, only returns assets from that album
*/
export function getTimeBucket(
timelineData: MockTimelineData,
timeBucket: string,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketAssetResponseDto {
const bucketKey = parseTimeBucketKey(timeBucket);
let assets = timelineData.buckets.get(bucketKey);
if (!assets) {
return toColumnarFormat([]);
}
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// Filter assets based on trashed/archived status
assets = assets.filter((asset) =>
shouldIncludeAsset(asset, isTrashed, isArchived, isFavorite, deletedAssetIds, archivedAssetIds, favoritedAssetIds),
);
// Filter to only include assets from the specified album
if (albumId) {
const album = timelineData.album;
if (!album || album.id !== albumId) {
return toColumnarFormat([]);
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
assets = assets.filter((asset) => albumAssetIds.has(asset.id));
}
// Override properties for assets in changes arrays
const assetsWithOverrides = assets.map((asset) => {
if (deletedAssetIds.has(asset.id) || archivedAssetIds.has(asset.id) || favoritedAssetIds.has(asset.id)) {
return {
...asset,
isFavorite: favoritedAssetIds.has(asset.id) ? true : asset.isFavorite,
isTrashed: deletedAssetIds.has(asset.id) ? true : asset.isTrashed,
visibility: archivedAssetIds.has(asset.id) ? AssetVisibility.Archive : asset.visibility,
};
}
return asset;
});
return toColumnarFormat(assetsWithOverrides);
}
export type Changes = {
// ids of assets that are newly added to the album
albumAdditions: string[];
// ids of assets that are newly deleted
assetDeletions: string[];
// ids of assets that are newly archived
assetArchivals: string[];
// ids of assets that are newly favorited
assetFavorites: string[];
};
/**
* Helper function to determine if an asset should be included based on filter criteria
* @param asset - The asset to check
* @param isTrashed - Filter for trashed status (undefined means no filter)
* @param isArchived - Filter for archived status (undefined means no filter)
* @param isFavorite - Filter for favorite status (undefined means no filter)
* @param deletedAssetIds - Set of IDs for assets that have been deleted
* @param archivedAssetIds - Set of IDs for assets that have been archived
* @param favoritedAssetIds - Set of IDs for assets that have been favorited
* @returns true if the asset matches all filter criteria
*/
function shouldIncludeAsset(
asset: MockTimelineAsset,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
deletedAssetIds: Set<string>,
archivedAssetIds: Set<string>,
favoritedAssetIds: Set<string>,
): boolean {
// Determine actual status (property or in changes)
const actuallyTrashed = asset.isTrashed || deletedAssetIds.has(asset.id);
const actuallyArchived = asset.visibility === 'archive' || archivedAssetIds.has(asset.id);
const actuallyFavorited = asset.isFavorite || favoritedAssetIds.has(asset.id);
// Apply filters
if (isTrashed !== undefined && actuallyTrashed !== isTrashed) {
return false;
}
if (isArchived !== undefined && actuallyArchived !== isArchived) {
return false;
}
if (isFavorite !== undefined && actuallyFavorited !== isFavorite) {
return false;
}
return true;
}
/**
* Get summary for all buckets (mimics getTimeBuckets API)
* When albumId is provided, only includes buckets that contain assets from that album
*/
export function getTimeBuckets(
timelineData: MockTimelineData,
isTrashed: boolean | undefined,
isArchived: boolean | undefined,
isFavorite: boolean | undefined,
albumId: string | undefined,
changes: Changes,
): TimeBucketsResponseDto[] {
const summary: TimeBucketsResponseDto[] = [];
// Create sets for quick lookups
const deletedAssetIds = new Set(changes.assetDeletions);
const archivedAssetIds = new Set(changes.assetArchivals);
const favoritedAssetIds = new Set(changes.assetFavorites);
// If no albumId is specified, return summary for all assets
if (albumId) {
// Filter to only include buckets with assets from the specified album
const album = timelineData.album;
if (!album || album.id !== albumId) {
return [];
}
// Create a Set for faster lookup
const albumAssetIds = new Set([...album.assetIds, ...changes.albumAdditions]);
for (const removed of changes.assetDeletions) {
albumAssetIds.delete(removed);
}
for (const [bucketKey, assets] of timelineData.buckets) {
// Count how many assets in this bucket are in the album and match trashed/archived filters
const albumAssetsInBucket = assets.filter((asset) => {
// Must be in the album
if (!albumAssetIds.has(asset.id)) {
return false;
}
return shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
);
});
if (albumAssetsInBucket.length > 0) {
summary.push({
timeBucket: bucketKey,
count: albumAssetsInBucket.length,
});
}
}
} else {
for (const [bucketKey, assets] of timelineData.buckets) {
// Filter assets based on trashed/archived status
const filteredAssets = assets.filter((asset) =>
shouldIncludeAsset(
asset,
isTrashed,
isArchived,
isFavorite,
deletedAssetIds,
archivedAssetIds,
favoritedAssetIds,
),
);
if (filteredAssets.length > 0) {
summary.push({
timeBucket: bucketKey,
count: filteredAssets.length,
});
}
}
}
// Sort summary by date (newest first) using luxon
summary.sort((a, b) => {
const dateA = DateTime.fromISO(a.timeBucket);
const dateB = DateTime.fromISO(b.timeBucket);
return dateB.diff(dateA).milliseconds;
});
return summary;
}
const createDefaultOwner = (ownerId: string) => {
const defaultOwner: UserResponseDto = {
id: ownerId,
email: signupDto.admin.email,
name: signupDto.admin.name,
profileImagePath: '',
profileChangedAt: new Date().toISOString(),
avatarColor: UserAvatarColor.Blue,
};
return defaultOwner;
};
/**
* Convert a TimelineAssetConfig to a full AssetResponseDto
* This matches the response from GET /api/assets/:id
*/
export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserResponseDto): AssetResponseDto {
const now = new Date().toISOString();
// Default owner if not provided
const defaultOwner = createDefaultOwner(asset.ownerId);
const exifInfo: ExifResponseDto = {
make: null,
model: null,
exifImageWidth: asset.ratio > 1 ? 4000 : 3000,
exifImageHeight: asset.ratio > 1 ? Math.round(4000 / asset.ratio) : Math.round(3000 * asset.ratio),
fileSizeInByte: asset.fileSizeInByte,
orientation: '1',
dateTimeOriginal: asset.fileCreatedAt,
modifyDate: asset.fileCreatedAt,
timeZone: asset.latitude === null ? null : 'UTC',
lensModel: null,
fNumber: null,
focalLength: null,
iso: null,
exposureTime: null,
latitude: asset.latitude,
longitude: asset.longitude,
city: asset.city,
country: asset.country,
state: null,
description: null,
};
return {
id: asset.id,
deviceAssetId: `device-${asset.id}`,
ownerId: asset.ownerId,
owner: owner || defaultOwner,
libraryId: `library-${asset.ownerId}`,
deviceId: `device-${asset.ownerId}`,
type: asset.isVideo ? AssetTypeEnum.Video : AssetTypeEnum.Image,
originalPath: `/original/${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalFileName: `${asset.id}.${asset.isVideo ? 'mp4' : 'jpg'}`,
originalMimeType: asset.isVideo ? 'video/mp4' : 'image/jpeg',
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
updatedAt: now,
createdAt: asset.fileCreatedAt,
isFavorite: asset.isFavorite,
isArchived: false,
isTrashed: asset.isTrashed,
visibility: asset.visibility,
duration: asset.duration || '0:00:00.00000',
exifInfo,
livePhotoVideoId: asset.livePhotoVideoId,
tags: [],
people: [],
unassignedFaces: [],
stack: asset.stack,
isOffline: false,
hasMetadata: true,
duplicateId: null,
resized: true,
checksum: asset.checksum,
};
}
/**
* Get a single asset by ID from timeline data
* This matches the response from GET /api/assets/:id
*/
export function getAsset(
timelineData: MockTimelineData,
assetId: string,
owner?: UserResponseDto,
): AssetResponseDto | undefined {
// Search through all buckets for the asset
const buckets = [...timelineData.buckets.values()];
for (const assets of buckets) {
const asset = assets.find((a) => a.id === assetId);
if (asset) {
return toAssetResponseDto(asset, owner);
}
}
return undefined;
}
/**
* Get a mock album from timeline data
* This matches the response from GET /api/albums/:id
*/
export function getAlbum(
timelineData: MockTimelineData,
ownerId: string,
albumId: string | undefined,
changes: Changes,
): AlbumResponseDto | undefined {
if (!timelineData.album) {
return undefined;
}
// If albumId is provided and doesn't match, return undefined
if (albumId && albumId !== timelineData.album.id) {
return undefined;
}
const album = timelineData.album;
const albumOwner = createDefaultOwner(ownerId);
// Get the actual asset objects from the timeline data
const albumAssets: AssetResponseDto[] = [];
const allAssets = [...timelineData.buckets.values()].flat();
for (const assetId of album.assetIds) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
for (const assetId of changes.albumAdditions ?? []) {
const assetConfig = allAssets.find((a) => a.id === assetId);
if (assetConfig) {
albumAssets.push(toAssetResponseDto(assetConfig, albumOwner));
}
}
albumAssets.sort((a, b) => DateTime.fromISO(b.localDateTime).diff(DateTime.fromISO(a.localDateTime)).milliseconds);
// For a basic mock album, we don't include any albumUsers (shared users)
// The owner is represented by the owner field, not in albumUsers
const response: AlbumResponseDto = {
id: album.id,
albumName: album.albumName,
description: album.description,
albumThumbnailAssetId: album.thumbnailAssetId,
createdAt: album.createdAt,
updatedAt: album.updatedAt,
ownerId: albumOwner.id,
owner: albumOwner,
albumUsers: [], // Empty array for non-shared album
shared: false,
hasSharedLink: false,
isActivityEnabled: true,
assetCount: albumAssets.length,
assets: albumAssets,
startDate: albumAssets.length > 0 ? albumAssets.at(-1)?.fileCreatedAt : undefined,
endDate: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
lastModifiedAssetTimestamp: albumAssets.length > 0 ? albumAssets[0].fileCreatedAt : undefined,
};
return response;
}

View File

@@ -0,0 +1,200 @@
import type { AssetVisibility } from '@immich/sdk';
import { DayPattern, MonthDistribution } from 'src/generators/timeline/distribution-patterns';
// Constants for generation parameters
export const GENERATION_CONSTANTS = {
VIDEO_PROBABILITY: 0.15, // 15% of assets are videos
GPS_PERCENTAGE: 0.7, // 70% of assets have GPS data
FAVORITE_PROBABILITY: 0.1, // 10% of assets are favorited
MIN_VIDEO_DURATION_SECONDS: 5,
MAX_VIDEO_DURATION_SECONDS: 300,
DEFAULT_SEED: 12_345,
DEFAULT_OWNER_ID: 'user-1',
MAX_SELECT_ATTEMPTS: 10,
SPARSE_DAY_COVERAGE: 0.4, // 40% of days have photos in sparse pattern
} as const;
// Aspect ratio distribution weights (must sum to 1)
export const ASPECT_RATIO_WEIGHTS = {
'4:3': 0.35, // 35% 4:3 landscape
'3:2': 0.25, // 25% 3:2 landscape
'16:9': 0.2, // 20% 16:9 landscape
'2:3': 0.1, // 10% 2:3 portrait
'1:1': 0.09, // 9% 1:1 square
'3:1': 0.01, // 1% 3:1 panorama
} as const;
export type AspectRatio = {
width: number;
height: number;
ratio: number;
name: string;
};
// Mock configuration for asset generation - will be transformed to API response formats
export type MockTimelineAsset = {
id: string;
ownerId: string;
ratio: number;
thumbhash: string | null;
localDateTime: string;
fileCreatedAt: string;
isFavorite: boolean;
isTrashed: boolean;
isVideo: boolean;
isImage: boolean;
duration: string | null;
projectionType: string | null;
livePhotoVideoId: string | null;
city: string | null;
country: string | null;
people: string[] | null;
latitude: number | null;
longitude: number | null;
visibility: AssetVisibility;
stack: null;
checksum: string;
fileSizeInByte: number;
};
export type MonthSpec = {
year: number;
month: number; // 1-12
distribution: MonthDistribution;
pattern: DayPattern;
};
/**
* Configuration for timeline data generation
*/
export type TimelineConfig = {
ownerId?: string;
months: MonthSpec[];
seed?: number;
writeToFile?: boolean;
outputPath?: string;
};
export type MockAlbum = {
id: string;
albumName: string;
description: string;
assetIds: string[]; // IDs of assets in the album
thumbnailAssetId: string | null;
createdAt: string;
updatedAt: string;
};
export type MockTimelineData = {
buckets: Map<string, MockTimelineAsset[]>;
album: MockAlbum; // Mock album created from random assets
};
export type SerializedTimelineData = {
buckets: Record<string, MockTimelineAsset[]>;
album: MockAlbum;
};
/**
* Validates a TimelineConfig object to ensure all values are within expected ranges
*/
export function validateTimelineConfig(config: TimelineConfig): void {
if (!config.months || config.months.length === 0) {
throw new Error('TimelineConfig must contain at least one month');
}
const seenMonths = new Set<string>();
for (const month of config.months) {
if (month.month < 1 || month.month > 12) {
throw new Error(`Invalid month: ${month.month}. Must be between 1 and 12`);
}
if (month.year < 1900 || month.year > 2100) {
throw new Error(`Invalid year: ${month.year}. Must be between 1900 and 2100`);
}
const monthKey = `${month.year}-${month.month}`;
if (seenMonths.has(monthKey)) {
throw new Error(`Duplicate month found: ${monthKey}`);
}
seenMonths.add(monthKey);
// Validate distribution if provided
if (month.distribution && !['empty', 'sparse', 'medium', 'dense', 'very-dense'].includes(month.distribution)) {
throw new Error(
`Invalid distribution: ${month.distribution}. Must be one of: empty, sparse, medium, dense, very-dense`,
);
}
const validPatterns = [
'single-day',
'consecutive-large',
'consecutive-small',
'alternating',
'sparse-scattered',
'start-heavy',
'end-heavy',
'mid-heavy',
];
if (month.pattern && !validPatterns.includes(month.pattern)) {
throw new Error(`Invalid pattern: ${month.pattern}. Must be one of: ${validPatterns.join(', ')}`);
}
}
// Validate seed if provided
if (config.seed !== undefined && (config.seed < 0 || !Number.isInteger(config.seed))) {
throw new Error('Seed must be a non-negative integer');
}
// Validate ownerId if provided
if (config.ownerId !== undefined && config.ownerId.trim() === '') {
throw new Error('Owner ID cannot be an empty string');
}
}
/**
* Create a default timeline configuration
*/
export function createDefaultTimelineConfig(): TimelineConfig {
const months: MonthSpec[] = [
// 2024 - Mix of patterns
{ year: 2024, month: 12, distribution: 'very-dense', pattern: 'alternating' },
{ year: 2024, month: 11, distribution: 'dense', pattern: 'consecutive-large' },
{ year: 2024, month: 10, distribution: 'medium', pattern: 'mid-heavy' },
{ year: 2024, month: 9, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2024, month: 8, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 7, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2024, month: 6, distribution: 'medium', pattern: 'sparse-scattered' },
{ year: 2024, month: 5, distribution: 'sparse', pattern: 'single-day' },
{ year: 2024, month: 4, distribution: 'very-dense', pattern: 'consecutive-large' },
{ year: 2024, month: 3, distribution: 'empty', pattern: 'single-day' },
{ year: 2024, month: 2, distribution: 'medium', pattern: 'end-heavy' },
{ year: 2024, month: 1, distribution: 'dense', pattern: 'alternating' },
// 2023 - Testing year boundaries and more patterns
{ year: 2023, month: 12, distribution: 'very-dense', pattern: 'end-heavy' },
{ year: 2023, month: 11, distribution: 'sparse', pattern: 'consecutive-small' },
{ year: 2023, month: 10, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 9, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 8, distribution: 'dense', pattern: 'mid-heavy' },
{ year: 2023, month: 7, distribution: 'sparse', pattern: 'sparse-scattered' },
{ year: 2023, month: 6, distribution: 'medium', pattern: 'consecutive-large' },
{ year: 2023, month: 5, distribution: 'empty', pattern: 'single-day' },
{ year: 2023, month: 4, distribution: 'sparse', pattern: 'single-day' },
{ year: 2023, month: 3, distribution: 'dense', pattern: 'start-heavy' },
{ year: 2023, month: 2, distribution: 'medium', pattern: 'alternating' },
{ year: 2023, month: 1, distribution: 'very-dense', pattern: 'consecutive-large' },
];
for (let year = 2022; year >= 2000; year--) {
for (let month = 12; month >= 1; month--) {
months.push({ year, month, distribution: 'medium', pattern: 'sparse-scattered' });
}
}
return {
months,
seed: 42,
};
}

View File

@@ -0,0 +1,186 @@
import { DateTime } from 'luxon';
import { GENERATION_CONSTANTS, MockTimelineAsset } from 'src/generators/timeline/timeline-config';
/**
* Linear Congruential Generator for deterministic pseudo-random numbers
*/
export class SeededRandom {
private seed: number;
constructor(seed: number) {
this.seed = seed;
}
/**
* Generate next random number in range [0, 1)
*/
next(): number {
// LCG parameters from Numerical Recipes
this.seed = (this.seed * 1_664_525 + 1_013_904_223) % 2_147_483_647;
return this.seed / 2_147_483_647;
}
/**
* Generate random integer in range [min, max)
*/
nextInt(min: number, max: number): number {
return Math.floor(this.next() * (max - min)) + min;
}
/**
* Generate random boolean with given probability
*/
nextBoolean(probability = 0.5): boolean {
return this.next() < probability;
}
}
/**
* Select random days using seed variation to avoid collisions.
*
* @param daysInMonth - Total number of days in the month
* @param numDays - Number of days to select
* @param rng - Random number generator instance
* @returns Array of selected day numbers, sorted in descending order
*/
export function selectRandomDays(daysInMonth: number, numDays: number, rng: SeededRandom): number[] {
const selectedDays = new Set<number>();
const maxAttempts = numDays * GENERATION_CONSTANTS.MAX_SELECT_ATTEMPTS; // Safety limit
let attempts = 0;
while (selectedDays.size < numDays && attempts < maxAttempts) {
const day = rng.nextInt(1, daysInMonth + 1);
selectedDays.add(day);
attempts++;
}
// Fallback: if we couldn't select enough random days, fill with sequential days
if (selectedDays.size < numDays) {
for (let day = 1; day <= daysInMonth && selectedDays.size < numDays; day++) {
selectedDays.add(day);
}
}
return [...selectedDays].sort((a, b) => b - a);
}
/**
* Select item from array using seeded random
*/
export function selectRandom<T>(arr: T[], rng: SeededRandom): T {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
const index = rng.nextInt(0, arr.length);
return arr[index];
}
/**
* Select multiple random items from array using seeded random without duplicates
*/
export function selectRandomMultiple<T>(arr: T[], count: number, rng: SeededRandom): T[] {
if (arr.length === 0) {
throw new Error('Cannot select from empty array');
}
if (count < 0) {
throw new Error('Count must be non-negative');
}
if (count > arr.length) {
throw new Error('Count cannot exceed array length');
}
const result: T[] = [];
const selectedIndices = new Set<number>();
while (result.length < count) {
const index = rng.nextInt(0, arr.length);
if (!selectedIndices.has(index)) {
selectedIndices.add(index);
result.push(arr[index]);
}
}
return result;
}
/**
* Parse timeBucket parameter to extract year-month key
* Handles both formats:
* - ISO timestamp: "2024-12-01T00:00:00.000Z" -> "2024-12-01"
* - Simple format: "2024-12-01" -> "2024-12-01"
*/
export function parseTimeBucketKey(timeBucket: string): string {
if (!timeBucket) {
throw new Error('timeBucket parameter cannot be empty');
}
const dt = DateTime.fromISO(timeBucket, { zone: 'utc' });
if (!dt.isValid) {
// Fallback to regex if not a valid ISO string
const match = timeBucket.match(/^(\d{4}-\d{2}-\d{2})/);
return match ? match[1] : timeBucket;
}
// Format as YYYY-MM-01 (first day of month)
return `${dt.year}-${String(dt.month).padStart(2, '0')}-01`;
}
export function getMockAsset(
asset: MockTimelineAsset,
sortedDescendingAssets: MockTimelineAsset[],
direction: 'next' | 'previous',
unit: 'day' | 'month' | 'year' = 'day',
): MockTimelineAsset | null {
const currentDateTime = DateTime.fromISO(asset.localDateTime, { zone: 'utc' });
const currentIndex = sortedDescendingAssets.findIndex((a) => a.id === asset.id);
if (currentIndex === -1) {
return null;
}
const step = direction === 'next' ? 1 : -1;
const startIndex = currentIndex + step;
if (direction === 'next' && currentIndex >= sortedDescendingAssets.length - 1) {
return null;
}
if (direction === 'previous' && currentIndex <= 0) {
return null;
}
const isInDifferentPeriod = (date1: DateTime, date2: DateTime): boolean => {
if (unit === 'day') {
return !date1.startOf('day').equals(date2.startOf('day'));
} else if (unit === 'month') {
return date1.year !== date2.year || date1.month !== date2.month;
} else {
return date1.year !== date2.year;
}
};
if (direction === 'next') {
// Search forward in array (backwards in time)
for (let i = startIndex; i < sortedDescendingAssets.length; i++) {
const nextAsset = sortedDescendingAssets[i];
const nextDate = DateTime.fromISO(nextAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(nextDate, currentDateTime)) {
return nextAsset;
}
}
} else {
// Search backward in array (forwards in time)
for (let i = startIndex; i >= 0; i--) {
const prevAsset = sortedDescendingAssets[i];
const prevDate = DateTime.fromISO(prevAsset.localDateTime, { zone: 'utc' });
if (isInDifferentPeriod(prevDate, currentDateTime)) {
return prevAsset;
}
}
}
return null;
}

View File

@@ -0,0 +1,285 @@
import { BrowserContext } from '@playwright/test';
import { playwrightHost } from 'playwright.config';
export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserId: string) => {
await context.addCookies([
{
name: 'immich_is_authenticated',
value: 'true',
domain: playwrightHost,
path: '/',
},
]);
await context.route('**/api/users/me', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
id: adminUserId,
email: 'admin@immich.cloud',
name: 'Immich Admin',
profileImagePath: '',
avatarColor: 'orange',
profileChangedAt: '2025-01-22T21:31:23.996Z',
storageLabel: 'admin',
shouldChangePassword: true,
isAdmin: true,
createdAt: '2025-01-22T21:31:23.996Z',
deletedAt: null,
updatedAt: '2025-11-14T00:00:00.369Z',
oauthId: '',
quotaSizeInBytes: null,
quotaUsageInBytes: 20_849_000_159,
status: 'active',
license: null,
},
});
});
await context.route('**/users/me/preferences', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
albums: {
defaultAssetOrder: 'desc',
},
folders: {
enabled: false,
sidebarWeb: false,
},
memories: {
enabled: true,
duration: 5,
},
people: {
enabled: true,
sidebarWeb: false,
},
sharedLinks: {
enabled: true,
sidebarWeb: false,
},
ratings: {
enabled: false,
},
tags: {
enabled: false,
sidebarWeb: false,
},
emailNotifications: {
enabled: true,
albumInvite: true,
albumUpdate: true,
},
download: {
archiveSize: 4_294_967_296,
includeEmbeddedVideos: false,
},
purchase: {
showSupportBadge: true,
hideBuyButtonUntil: '2100-02-12T00:00:00.000Z',
},
cast: {
gCastEnabled: false,
},
},
});
});
await context.route('**/server/about', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
version: 'v2.2.3',
versionUrl: 'https://github.com/immich-app/immich/releases/tag/v2.2.3',
licensed: false,
build: '1234567890',
buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890',
buildImage: 'e2e',
buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server',
repository: 'immich-app/immich',
repositoryUrl: 'https://github.com/immich-app/immich',
sourceRef: 'e2e',
sourceCommit: 'e2eeeeeeeeeeeeeeeeee',
sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee',
nodejs: 'v22.18.0',
exiftool: '13.41',
ffmpeg: '7.1.1-6',
libvips: '8.17.2',
imagemagick: '7.1.2-2',
},
});
});
await context.route('**/api/server/features', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
smartSearch: false,
facialRecognition: false,
duplicateDetection: false,
map: true,
reverseGeocoding: true,
importFaces: false,
sidecar: true,
search: true,
trash: true,
oauth: false,
oauthAutoLaunch: false,
ocr: false,
passwordLogin: true,
configFile: false,
email: false,
},
});
});
await context.route('**/api/server/config', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
loginPageMessage: '',
trashDays: 30,
userDeleteDelay: 7,
oauthButtonText: 'Login with OAuth',
isInitialized: true,
isOnboarded: true,
externalDomain: '',
publicUsers: true,
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
maintenanceMode: false,
},
});
});
await context.route('**/api/server/media-types', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
video: [
'.3gp',
'.3gpp',
'.avi',
'.flv',
'.insv',
'.m2t',
'.m2ts',
'.m4v',
'.mkv',
'.mov',
'.mp4',
'.mpe',
'.mpeg',
'.mpg',
'.mts',
'.vob',
'.webm',
'.wmv',
],
image: [
'.3fr',
'.ari',
'.arw',
'.cap',
'.cin',
'.cr2',
'.cr3',
'.crw',
'.dcr',
'.dng',
'.erf',
'.fff',
'.iiq',
'.k25',
'.kdc',
'.mrw',
'.nef',
'.nrw',
'.orf',
'.ori',
'.pef',
'.psd',
'.raf',
'.raw',
'.rw2',
'.rwl',
'.sr2',
'.srf',
'.srw',
'.x3f',
'.avif',
'.gif',
'.jpeg',
'.jpg',
'.png',
'.webp',
'.bmp',
'.heic',
'.heif',
'.hif',
'.insp',
'.jp2',
'.jpe',
'.jxl',
'.svg',
'.tif',
'.tiff',
],
sidecar: ['.xmp'],
},
});
});
await context.route('**/api/notifications*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/albums*', async (route, request) => {
if (request.url().endsWith('albums?shared=true') || request.url().endsWith('albums')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
}
await route.fallback();
});
await context.route('**/api/memories*', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [],
});
});
await context.route('**/api/server/storage', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: {
diskSize: '100.0 GiB',
diskUse: '74.4 GiB',
diskAvailable: '25.6 GiB',
diskSizeRaw: 107_374_182_400,
diskUseRaw: 79_891_660_800,
diskAvailableRaw: 27_482_521_600,
diskUsagePercentage: 74.4,
},
});
});
await context.route('**/api/server/version-history', async (route) => {
return route.fulfill({
status: 200,
contentType: 'application/json',
json: [
{
id: 'd1fbeadc-cb4f-4db3-8d19-8c6a921d5d8e',
createdAt: '2025-11-15T20:14:01.935Z',
version: '2.2.3',
},
],
});
});
};

View File

@@ -0,0 +1,139 @@
import { BrowserContext, Page, Request, Route } from '@playwright/test';
import { basename } from 'node:path';
import {
Changes,
getAlbum,
getAsset,
getTimeBucket,
getTimeBuckets,
randomPreview,
randomThumbnail,
TimelineData,
} from 'src/generators/timeline';
import { sleep } from 'src/web/specs/timeline/utils';
export class TimelineTestContext {
slowBucket = false;
adminId = '';
}
export const setupTimelineMockApiRoutes = async (
context: BrowserContext,
timelineRestData: TimelineData,
changes: Changes,
testContext: TimelineTestContext,
) => {
await context.route('**/api/timeline**', async (route, request) => {
const url = new URL(request.url());
const pathname = url.pathname;
if (pathname === '/api/timeline/buckets') {
const albumId = url.searchParams.get('albumId') || undefined;
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
return route.fulfill({
status: 200,
contentType: 'application/json',
json: getTimeBuckets(timelineRestData, isTrashed, isArchived, isFavorite, albumId, changes),
});
} else if (pathname === '/api/timeline/bucket') {
const timeBucket = url.searchParams.get('timeBucket');
if (!timeBucket) {
return route.continue();
}
const isTrashed = url.searchParams.get('isTrashed') ? url.searchParams.get('isTrashed') === 'true' : undefined;
const isArchived = url.searchParams.get('visibility')
? url.searchParams.get('visibility') === 'archive'
: undefined;
const isFavorite = url.searchParams.get('isFavorite') ? url.searchParams.get('isFavorite') === 'true' : undefined;
const albumId = url.searchParams.get('albumId') || undefined;
const assets = getTimeBucket(timelineRestData, timeBucket, isTrashed, isArchived, isFavorite, albumId, changes);
if (testContext.slowBucket) {
await sleep(5000);
}
return route.fulfill({
status: 200,
contentType: 'application/json',
json: assets,
});
}
return route.continue();
});
await context.route('**/api/assets/**', 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?.size === 'preview') {
if (!route.request().serviceWorker()) {
return route.continue();
}
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,
(asset?.exifInfo?.exifImageWidth ?? 0) / (asset?.exifInfo?.exifImageHeight ?? 1),
),
});
}
if (match.groups?.size === 'thumbnail') {
if (!route.request().serviceWorker()) {
return route.continue();
}
const asset = getAsset(timelineRestData, match.groups?.assetId);
return route.fulfill({
status: 200,
headers: { 'content-type': 'image/jpeg' },
body: await randomThumbnail(
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);
if (!match) {
return route.continue();
}
const album = getAlbum(timelineRestData, testContext.adminId, match.groups?.albumId, changes);
return route.fulfill({
status: 200,
contentType: 'application/json',
json: album,
});
});
};
export const pageRoutePromise = async (
page: Page,
route: string,
callback: (route: Route, request: Request) => Promise<void>,
) => {
let resolveRequest: ((value: unknown | PromiseLike<unknown>) => void) | undefined;
const deleteRequest = new Promise((resolve) => {
resolveRequest = resolve;
});
await page.route(route, async (route, request) => {
await callback(route, request);
const requestJson = request.postDataJSON();
resolveRequest?.(requestJson);
});
return deleteRequest;
};

View File

@@ -7,6 +7,12 @@ export const errorDto = {
message: 'Authentication required',
correlationId: expect.any(String),
},
unauthorizedWithMessage: (message: string) => ({
error: 'Unauthorized',
statusCode: 401,
message,
correlationId: expect.any(String),
}),
forbidden: {
error: 'Forbidden',
statusCode: 403,

View File

@@ -6,6 +6,7 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
MaintenanceAction,
MetadataSearchDto,
Permission,
PersonCreateDto,
@@ -36,6 +37,7 @@ import {
scanLibrary,
searchAssets,
setBaseUrl,
setMaintenanceMode,
signUpAdmin,
tagAssets,
updateAdminOnboarding,
@@ -52,7 +54,7 @@ import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path, { dirname } from 'node:path';
import { dirname, resolve } from 'node:path';
import { setTimeout as setAsyncTimeout } from 'node:timers/promises';
import { promisify } from 'node:util';
import pg from 'pg';
@@ -60,6 +62,8 @@ import { io, type Socket } from 'socket.io-client';
import { loginDto, signupDto } from 'src/fixtures';
import { makeRandomImage } from 'src/generators';
import request from 'supertest';
import { playwrightDbHost, playwrightHost, playwriteBaseUrl } from '../playwright.config';
export type { Emitter } from '@socket.io/component-emitter';
type CommandResponse = { stdout: string; stderr: string; exitCode: number | null };
@@ -68,12 +72,12 @@ type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: nu
type AdminSetupOptions = { onboarding?: boolean };
type FileData = { bytes?: Buffer; filename: string };
const dbUrl = 'postgres://postgres:postgres@127.0.0.1:5435/immich';
export const baseUrl = 'http://127.0.0.1:2285';
const dbUrl = `postgres://postgres:postgres@${playwrightDbHost}:5435/immich`;
export const baseUrl = playwriteBaseUrl;
export const shareUrl = `${baseUrl}/share`;
export const app = `${baseUrl}/api`;
// TODO move test assets into e2e/assets
export const testAssetDir = path.resolve('./test-assets');
export const testAssetDir = resolve(import.meta.dirname, '../test-assets');
export const testAssetDirInternal = '/test-assets';
export const tempDir = tmpdir();
export const asBearerAuth = (accessToken: string) => ({ Authorization: `Bearer ${accessToken}` });
@@ -480,7 +484,7 @@ export const utils = {
queueCommand: async (accessToken: string, name: QueueName, queueCommandDto: QueueCommandDto) =>
runQueueCommandLegacy({ name, queueCommandDto }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = playwrightHost) =>
await context.addCookies([
{
name: 'immich_access_token',
@@ -514,6 +518,42 @@ export const utils = {
},
]),
setMaintenanceAuthCookie: async (context: BrowserContext, token: string, domain = '127.0.0.1') =>
await context.addCookies([
{
name: 'immich_maintenance_token',
value: token,
domain,
path: '/',
expires: 2_058_028_213,
httpOnly: true,
secure: false,
sameSite: 'Lax',
},
]),
enterMaintenance: async (accessToken: string) => {
let setCookie: string[] | undefined;
await setMaintenanceMode(
{
setMaintenanceModeDto: {
action: MaintenanceAction.Start,
},
},
{
headers: asBearerAuth(accessToken),
fetch: (...args: Parameters<typeof fetch>) =>
fetch(...args).then((response) => {
setCookie = response.headers.getSetCookie();
return response;
}),
},
);
return setCookie;
},
resetTempFolder: () => {
rmSync(`${testAssetDir}/temp`, { recursive: true, force: true });
mkdirSync(`${testAssetDir}/temp`, { recursive: true });

View File

@@ -0,0 +1,51 @@
import { LoginResponseDto } from '@immich/sdk';
import { expect, test } from '@playwright/test';
import { utils } from 'src/utils';
test.describe.configure({ mode: 'serial' });
test.describe('Maintenance', () => {
let admin: LoginResponseDto;
test.beforeAll(async () => {
utils.initSdk();
await utils.resetDatabase();
admin = await utils.adminSetup();
});
test('enter and exit maintenance mode', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
await page.goto('/admin/system-settings?isOpen=maintenance');
await page.getByRole('button', { name: 'Start maintenance mode' }).click();
await expect(page.getByText('Temporarily Unavailable')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/admin/system-settings*', { timeout: 10_000 });
});
test('maintenance shows no options to users until they authenticate', async ({ page }) => {
const setCookie = await utils.enterMaintenance(admin.accessToken);
const cookie = setCookie
?.map((cookie) => cookie.split(';')[0].split('='))
?.find(([name]) => name === 'immich_maintenance_token');
expect(cookie).toBeTruthy();
await expect(async () => {
await page.goto('/');
await page.waitForURL('**/maintenance?**', {
timeout: 1000,
});
}).toPass({ timeout: 10_000 });
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toHaveCount(0);
await page.goto(`/maintenance?${new URLSearchParams({ token: cookie![1] })}`);
await expect(page.getByText('Temporarily Unavailable')).toBeVisible();
await expect(page.getByRole('button', { name: 'End maintenance mode' })).toBeVisible();
await page.getByRole('button', { name: 'End maintenance mode' }).click();
await page.waitForURL('**/auth/login');
});
});

View File

@@ -0,0 +1,775 @@
import { faker } from '@faker-js/faker';
import { expect, test } from '@playwright/test';
import { DateTime } from 'luxon';
import {
Changes,
createDefaultTimelineConfig,
generateTimelineData,
getAsset,
getMockAsset,
SeededRandom,
selectRandom,
selectRandomMultiple,
TimelineAssetConfig,
TimelineData,
} from 'src/generators/timeline';
import { setupBaseMockApiRoutes } from 'src/mock-network/base-network';
import { pageRoutePromise, setupTimelineMockApiRoutes, TimelineTestContext } from 'src/mock-network/timeline-network';
import { utils } from 'src/utils';
import {
assetViewerUtils,
cancelAllPollers,
padYearMonth,
pageUtils,
poll,
thumbnailUtils,
timelineUtils,
} from 'src/web/specs/timeline/utils';
test.describe.configure({ mode: 'parallel' });
test.describe('Timeline', () => {
let adminUserId: string;
let timelineRestData: TimelineData;
const assets: TimelineAssetConfig[] = [];
const yearMonths: string[] = [];
const testContext = new TimelineTestContext();
const changes: Changes = {
albumAdditions: [],
assetDeletions: [],
assetArchivals: [],
assetFavorites: [],
};
test.beforeAll(async () => {
test.fail(
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
);
utils.initSdk();
adminUserId = faker.string.uuid();
testContext.adminId = adminUserId;
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
for (const timeBucket of timelineRestData.buckets.values()) {
assets.push(...timeBucket);
}
for (const yearMonth of timelineRestData.buckets.keys()) {
const [year, month] = yearMonth.split('-');
yearMonths.push(`${year}-${Number(month)}`);
}
});
test.beforeEach(async ({ context }) => {
await setupBaseMockApiRoutes(context, adminUserId);
await setupTimelineMockApiRoutes(context, timelineRestData, changes, testContext);
});
test.afterEach(() => {
cancelAllPollers();
testContext.slowBucket = false;
changes.albumAdditions = [];
changes.assetDeletions = [];
changes.assetArchivals = [];
changes.assetFavorites = [];
});
test.describe('/photos', () => {
test('Open /photos', async ({ page }) => {
await page.goto(`/photos`);
await page.waitForSelector('#asset-grid');
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
});
test('Deep link to last photo', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, lastAsset.id);
});
const rng = new SeededRandom(529);
for (let i = 0; i < 10; i++) {
test('Deep link to random asset ' + i, async ({ page }) => {
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
await thumbnailUtils.expectTimelineHasOnScreenAssets(page);
await thumbnailUtils.expectInViewport(page, asset.id);
});
}
test('Open /photos, open asset-viewer, browser back', async ({ page }) => {
const rng = new SeededRandom(22);
const asset = selectRandom(assets, rng);
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await timelineUtils.locator(page).waitFor();
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(scrollTopAfter).toBe(scrollTopBefore);
});
test('Open /photos, open asset-viewer, next photo, browser back, back', async ({ page }) => {
const rng = new SeededRandom(49);
const asset = selectRandom(assets, rng);
const assetIndex = assets.indexOf(asset);
const nextAsset = assets[assetIndex + 1];
await pageUtils.deepLinkPhotosPage(page, asset.id);
const scrollTopBefore = await timelineUtils.getScrollTop(page);
await thumbnailUtils.clickAssetId(page, asset.id);
await assetViewerUtils.waitForViewerLoad(page, asset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${asset.id}`);
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, nextAsset);
await expect.poll(() => new URL(page.url()).pathname).toBe(`/photos/${nextAsset.id}`);
await page.goBack();
await assetViewerUtils.waitForViewerLoad(page, asset);
await page.goBack();
await page.waitForURL('**/photos?at=*');
const scrollTopAfter = await timelineUtils.getScrollTop(page);
expect(Math.abs(scrollTopAfter - scrollTopBefore)).toBeLessThan(5);
});
test('Open /photos, open asset-viewer, next photo 15x, backwardsArrow', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
await thumbnailUtils.clickAssetId(page, assets[0].id);
await assetViewerUtils.waitForViewerLoad(page, assets[0]);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View next asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets[i]);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets[15].id);
await thumbnailUtils.expectBottomIsTimelineBottom(page, assets[15]!.id);
});
test('Open /photos, open asset-viewer, previous photo 15x, backwardsArrow', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await thumbnailUtils.clickAssetId(page, lastAsset.id);
await assetViewerUtils.waitForViewerLoad(page, lastAsset);
for (let i = 1; i <= 15; i++) {
await page.getByLabel('View previous asset').click();
await assetViewerUtils.waitForViewerLoad(page, assets.at(-1 - i)!);
}
await page.getByLabel('Go back').click();
await page.waitForURL('**/photos?at=*');
await thumbnailUtils.expectInViewport(page, assets.at(-1 - 15)!.id);
await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(-1 - 15)!.id);
});
});
test.describe('keyboard', () => {
/**
* This text tests keyboard nativation, and also ensures that the scroll-to-asset behavior
* scrolls the minimum amount. That is, if you are navigating using right arrow (auto scrolling
* as necessary downwards), then the asset should always be at the lowest row of the grid.
*/
test('Next/previous asset - ArrowRight/ArrowLeft', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'ArrowRight';
const leftKey = 'ArrowLeft';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectBottomIsTimelineBottom(page, assets.at(i)!.id));
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
expect(await thumbnailUtils.expectTopIsTimelineTop(page, assets.at(i)!.id));
}
});
test('Next/previous asset - Tab/Shift+Tab', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).focus();
const rightKey = 'Tab';
const leftKey = 'Shift+Tab';
for (let i = 1; i < 15; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 15; i <= 20; i++) {
await page.keyboard.press(rightKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
// now test previous asset
for (let i = 19; i >= 15; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
for (let i = 14; i > 0; i--) {
await page.keyboard.press(leftKey);
await assetViewerUtils.expectActiveAssetToBe(page, assets[i].id);
}
});
test('Next/previous day - d, Shift+D', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('d');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('d');
const next = getMockAsset(asset, assets, 'next', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+D');
const previous = getMockAsset(asset, assets, 'previous', 'day')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous month - m, Shift+M', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('m');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('m');
const next = getMockAsset(asset, assets, 'next', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+M');
const previous = getMockAsset(asset, assets, 'previous', 'month')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Next/previous year - y, Shift+Y', async ({ page }) => {
await pageUtils.openPhotosPage(page);
let asset = assets[0];
await timelineUtils.locator(page).hover();
await page.keyboard.press('y');
await assetViewerUtils.expectActiveAssetToBe(page, asset.id);
for (let i = 0; i < 15; i++) {
await page.keyboard.press('y');
const next = getMockAsset(asset, assets, 'next', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, next.id);
asset = next;
}
for (let i = 0; i < 15; i++) {
await page.keyboard.press('Shift+Y');
const previous = getMockAsset(asset, assets, 'previous', 'year')!;
await assetViewerUtils.expectActiveAssetToBe(page, previous.id);
asset = previous;
}
});
test('Navigate to time - g', async ({ page }) => {
const rng = new SeededRandom(4782);
await pageUtils.openPhotosPage(page);
for (let i = 0; i < 10; i++) {
const asset = selectRandom(assets, rng);
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
}
});
});
test.describe('selection', () => {
test('Select day, unselect day', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
await pageUtils.selectDay(page, 'Wed, Dec 11, 2024');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, click asset to select', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
// no need to hover, once selection is active
await thumbnailUtils.clickAssetId(page, assets[2].id);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(2);
});
test('Select asset, click unselect asset', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[1].id).hover();
await thumbnailUtils.selectButton(page, assets[1].id).click();
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(1);
await thumbnailUtils.clickAssetId(page, assets[1].id);
// the hover uses a checked button too, so just move mouse away
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(0);
});
test('Select asset, shift-hover candidates, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const asset = assets[0];
await thumbnailUtils.withAssetId(page, asset.id).hover();
await thumbnailUtils.selectButton(page, asset.id).click();
await page.keyboard.down('Shift');
await thumbnailUtils.withAssetId(page, assets[2].id).hover();
await expect(
thumbnailUtils.locator(page).locator('.absolute.top-0.h-full.w-full.bg-immich-primary.opacity-40'),
).toHaveCount(3);
await thumbnailUtils.selectButton(page, assets[2].id).click();
await page.keyboard.up('Shift');
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(3);
});
test('Add multiple to selection - Select day, shift-click end', async ({ page }) => {
await pageUtils.openPhotosPage(page);
await thumbnailUtils.withAssetId(page, assets[0].id).hover();
await thumbnailUtils.selectButton(page, assets[0].id).click();
await thumbnailUtils.clickAssetId(page, assets[2].id);
await page.keyboard.down('Shift');
await thumbnailUtils.clickAssetId(page, assets[4].id);
await page.mouse.move(0, 0);
await expect(thumbnailUtils.selectedAsset(page)).toHaveCount(4);
});
});
test.describe('scroll', () => {
test('Open /photos, random click scrubber 20x', async ({ page }) => {
test.slow();
await pageUtils.openPhotosPage(page);
const rng = new SeededRandom(6637);
const selectedMonths = selectRandomMultiple(yearMonths, 20, rng);
for (const month of selectedMonths) {
await page.locator(`[data-segment-year-month="${month}"]`).click({ force: true });
const visibleMockAssetsYearMonths = await poll(page, async () => {
const assetIds = await thumbnailUtils.getAllInViewport(
page,
(assetId: string) => getYearMonth(assets, assetId) === month,
);
const visibleMockAssetsYearMonths: string[] = [];
for (const assetId of assetIds!) {
const yearMonth = getYearMonth(assets, assetId);
visibleMockAssetsYearMonths.push(yearMonth);
if (yearMonth === month) {
return [yearMonth];
}
}
});
if (page.isClosed()) {
return;
}
expect(visibleMockAssetsYearMonths).toContain(month);
}
});
test('Deep link to last photo, scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, -100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, '14e5901f-fd7f-40c0-b186-4d7e7fc67968');
});
test('Deep link to first bucket, scroll down', async ({ page }) => {
const lastAsset = assets.at(0)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
await timelineUtils.locator(page).hover();
for (let i = 0; i < 100; i++) {
await page.mouse.wheel(0, 100);
await page.waitForTimeout(25);
}
await thumbnailUtils.expectInViewport(page, 'b7983a13-4b4e-4950-a731-f2962d9a1555');
});
test('Deep link to last photo, drag scrubber to scroll up', async ({ page }) => {
const lastAsset = assets.at(-1)!;
await pageUtils.deepLinkPhotosPage(page, lastAsset.id);
const lastMonth = yearMonths.at(-1);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const lastScrubSegment = page.locator(`[data-segment-year-month="${lastMonth}"]`);
const sourcebox = (await lastScrubSegment.boundingBox())!;
const targetBox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, targetBox.y, { steps: 100 });
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets[0].id);
});
test('Deep link to first bucket, drag scrubber to scroll down', async ({ page }) => {
await pageUtils.deepLinkPhotosPage(page, assets[0].id);
const firstScrubSegment = page.locator(`[data-segment-year-month="${yearMonths[0]}"]`);
const sourcebox = (await firstScrubSegment.boundingBox())!;
await firstScrubSegment.hover();
const currentY = sourcebox.y;
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, currentY);
await page.mouse.down();
const height = page.viewportSize()?.height;
expect(height).toBeDefined();
await page.mouse.move(sourcebox.x + sourcebox?.width / 2, height! - 10, {
steps: 100,
});
await page.mouse.up();
await thumbnailUtils.expectInViewport(page, assets.at(-1)!.id);
});
test('Buckets cancel on scroll', async ({ page }) => {
await pageUtils.openPhotosPage(page);
testContext.slowBucket = true;
const failedUris: string[] = [];
page.on('requestfailed', (request) => {
failedUris.push(request.url());
});
const offscreenSegment = page.locator(`[data-segment-year-month="${yearMonths[12]}"]`);
await offscreenSegment.click({ force: true });
const lastSegment = page.locator(`[data-segment-year-month="${yearMonths.at(-1)!}"]`);
await lastSegment.click({ force: true });
const uris = await poll(page, async () => (failedUris.length > 0 ? failedUris : null));
expect(uris).toEqual(expect.arrayContaining([expect.stringContaining(padYearMonth(yearMonths[12]!))]));
});
});
test.describe('/albums', () => {
test('Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, album.assetIds[0]);
});
test('Deep link to last photo', async ({ page }) => {
const album = timelineRestData.album;
const lastAsset = album.assetIds.at(-1);
await pageUtils.deepLinkAlbumPage(page, album.id, lastAsset!);
await thumbnailUtils.expectInViewport(page, album.assetIds.at(-1)!);
await thumbnailUtils.expectBottomIsTimelineBottom(page, album.assetIds.at(-1)!);
});
test('Add photos to album pre-selects existing', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.getByLabel('Add photos').click();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
});
test('Add photos to album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
await page.locator('nav button[aria-label="Add photos"]').click();
const asset = getAsset(timelineRestData, album.assetIds[0])!;
await pageUtils.goToAsset(page, asset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, asset.id);
await thumbnailUtils.expectSelectedReadonly(page, asset.id);
await pageUtils.selectDay(page, 'Tue, Feb 27, 2024');
const put = pageRoutePromise(page, `**/api/albums/${album.id}/assets`, async (route, request) => {
const requestJson = request.postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
changes.albumAdditions.push(...requestJson.ids);
});
await page.getByText('Done').click();
await expect(put).resolves.toEqual({
ids: [
'c077ea7b-cfa1-45e4-8554-f86c00ee5658',
'040fd762-dbbc-486d-a51a-2d84115e6229',
'86af0b5f-79d3-4f75-bab3-3b61f6c72b23',
],
});
const addedAsset = getAsset(timelineRestData, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658')!;
await pageUtils.goToAsset(page, addedAsset.fileCreatedAt);
await thumbnailUtils.expectInViewport(page, 'c077ea7b-cfa1-45e4-8554-f86c00ee5658');
await thumbnailUtils.expectInViewport(page, '040fd762-dbbc-486d-a51a-2d84115e6229');
await thumbnailUtils.expectInViewport(page, '86af0b5f-79d3-4f75-bab3-3b61f6c72b23');
});
});
test.describe('/trash', () => {
test('open /photos, trash photo, open /trash, restore', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToTrash = assets[0];
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.getByText('Trash', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore', { exact: true }).click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
test('open album, trash photo, open /trash, restore', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToTrash = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
await page.getByLabel('Menu').click();
const deleteRequest = pageRoutePromise(page, '**/api/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions.push(...requestJson.ids);
await route.fulfill({
status: 200,
contentType: 'application/json',
json: requestJson.ids.map((id: string) => ({ id, success: true })),
});
});
await page.getByRole('menuitem').getByText('Delete').click();
await expect(deleteRequest).resolves.toEqual({
force: false,
ids: [assetToTrash.id],
});
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByText('Trash', { exact: true }).click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
await thumbnailUtils.withAssetId(page, assetToTrash.id).hover();
await thumbnailUtils.selectButton(page, assetToTrash.id).click();
const restoreRequest = pageRoutePromise(page, '**/api/trash/restore/assets', async (route, request) => {
const requestJson = request.postDataJSON();
changes.assetDeletions = changes.assetDeletions.filter((id) => !requestJson.ids.includes(id));
await route.fulfill({
status: 200,
contentType: 'application/json',
json: { count: requestJson.ids.length },
});
});
await page.getByText('Restore', { exact: true }).click();
await expect(restoreRequest).resolves.toEqual({
ids: [assetToTrash.id],
});
await expect(thumbnailUtils.withAssetId(page, assetToTrash.id)).toHaveCount(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToTrash.id);
});
});
test.describe('/archive', () => {
test('open /photos, archive photo, open /archive, unarchive', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToArchive = assets[0];
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 expect(thumbnailUtils.withAssetId(page, assetToArchive.id)).toHaveCount(0);
await page.getByRole('link').getByText('Archive').click();
await thumbnailUtils.expectInViewport(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 page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
test('open album, archive photo, open album, unarchive', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToArchive = getAsset(timelineRestData, album.assetIds[0])!;
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();
}
changes.assetArchivals.push(...requestJson.ids);
await route.fulfill({
status: 204,
});
});
await page.getByRole('menuitem').getByText('Archive').click();
await expect(archive).resolves.toEqual({
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 page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Archive').click();
await timelineUtils.waitForTimelineLoad(page);
await thumbnailUtils.expectInViewport(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],
});
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 pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToArchive.id);
});
});
test.describe('/favorite', () => {
test('open /photos, favorite photo, open /favorites, remove favorite, open /photos', async ({ page }) => {
await pageUtils.openPhotosPage(page);
const assetToFavorite = assets[0];
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
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 page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.getByRole('link').getByText('Favorites').click();
await thumbnailUtils.expectInViewport(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(0);
await page.getByText('Photos', { exact: true }).click();
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
test('Open album, favorite photo, open /favorites, remove favorite, Open album', async ({ page }) => {
const album = timelineRestData.album;
await pageUtils.openAlbumPage(page, album.id);
const assetToFavorite = getAsset(timelineRestData, album.assetIds[0])!;
await thumbnailUtils.withAssetId(page, assetToFavorite.id).hover();
await thumbnailUtils.selectButton(page, assetToFavorite.id).click();
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 page.getByLabel('Favorite').click();
await expect(favorite).resolves.toEqual({
isFavorite: true,
ids: [assetToFavorite.id],
});
// ensure thumbnail still exists and has favorite icon
await thumbnailUtils.expectThumbnailIsFavorite(page, assetToFavorite.id);
await page.locator('#asset-selection-app-bar').getByLabel('Close').click();
await page.getByRole('link').getByText('Favorites').click();
await timelineUtils.waitForTimelineLoad(page);
await pageUtils.goToAsset(page, assetToFavorite.fileCreatedAt);
await thumbnailUtils.expectInViewport(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(0);
await pageUtils.openAlbumPage(page, album.id);
await thumbnailUtils.expectInViewport(page, assetToFavorite.id);
});
});
});
const getYearMonth = (assets: TimelineAssetConfig[], assetId: string) => {
const mockAsset = assets.find((mockAsset) => mockAsset.id === assetId)!;
const dateTime = DateTime.fromISO(mockAsset.fileCreatedAt!);
return dateTime.year + '-' + dateTime.month;
};

View File

@@ -0,0 +1,234 @@
import { BrowserContext, expect, Page } from '@playwright/test';
import { DateTime } from 'luxon';
import { TimelineAssetConfig } from 'src/generators/timeline';
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
export const padYearMonth = (yearMonth: string) => {
const [year, month] = yearMonth.split('-');
return `${year}-${month.padStart(2, '0')}`;
};
export async function throttlePage(context: BrowserContext, page: Page) {
const session = await context.newCDPSession(page);
await session.send('Network.emulateNetworkConditions', {
offline: false,
downloadThroughput: (1.5 * 1024 * 1024) / 8,
uploadThroughput: (750 * 1024) / 8,
latency: 40,
connectionType: 'cellular3g',
});
await session.send('Emulation.setCPUThrottlingRate', { rate: 10 });
}
let activePollsAbortController = new AbortController();
export const cancelAllPollers = () => {
activePollsAbortController.abort();
activePollsAbortController = new AbortController();
};
export const poll = async <T>(
page: Page,
query: () => Promise<T>,
callback?: (result: Awaited<T> | undefined) => boolean,
) => {
let result;
const timeout = Date.now() + 10_000;
const signal = activePollsAbortController.signal;
const terminate = callback || ((result: Awaited<T> | undefined) => !!result);
while (!terminate(result) && Date.now() < timeout) {
if (signal.aborted) {
return;
}
try {
result = await query();
} catch {
// ignore
}
if (signal.aborted) {
return;
}
if (page.isClosed()) {
return;
}
try {
await page.waitForTimeout(50);
} catch {
return;
}
}
if (!result) {
// rerun to trigger error if any
result = await query();
}
return result;
};
export const thumbnailUtils = {
locator(page: Page) {
return page.locator('[data-thumbnail-focus-container]');
},
withAssetId(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"]`);
},
selectButton(page: Page, assetId: string) {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
},
async queryThumbnailInViewport(page: Page, collector: (assetId: string) => boolean) {
const assetIds: string[] = [];
for (const thumb of await this.locator(page).all()) {
const box = await thumb.boundingBox();
if (box) {
const assetId = await thumb.evaluate((e) => e.dataset.asset);
if (collector?.(assetId!)) {
return [assetId!];
}
assetIds.push(assetId!);
}
}
return assetIds;
},
async getFirstInViewport(page: Page) {
return await poll(page, () => thumbnailUtils.queryThumbnailInViewport(page, () => true));
},
async getAllInViewport(page: Page, collector: (assetId: string) => boolean) {
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);
},
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);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect(
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {
const first = await thumbnailUtils.getFirstInViewport(page);
if (page.isClosed()) {
return;
}
expect(first).toBeTruthy();
},
async expectInViewport(page: Page, assetId: string) {
const box = await poll(page, () => thumbnailUtils.withAssetId(page, assetId).boundingBox());
if (page.isClosed()) {
return;
}
expect(box).toBeTruthy();
},
async expectBottomIsTimelineBottom(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y + box!.height).toBeCloseTo(gridBox!.y + gridBox!.height, 0);
},
async expectTopIsTimelineTop(page: Page, assetId: string) {
const box = await thumbnailUtils.withAssetId(page, assetId).boundingBox();
const gridBox = await timelineUtils.locator(page).boundingBox();
if (page.isClosed()) {
return;
}
expect(box!.y).toBeCloseTo(gridBox!.y, 0);
},
};
export const timelineUtils = {
locator(page: Page) {
return page.locator('#asset-grid');
},
async waitForTimelineLoad(page: Page) {
await expect(timelineUtils.locator(page)).toBeInViewport();
await expect.poll(() => thumbnailUtils.locator(page).count()).toBeGreaterThan(0);
},
async getScrollTop(page: Page) {
const queryTop = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.querySelector('#asset-grid').scrollTop;
});
await expect.poll(queryTop).toBeGreaterThan(0);
return await queryTop();
},
};
export const assetViewerUtils = {
locator(page: Page) {
return page.locator('#immich-asset-viewer');
},
async waitForViewerLoad(page: Page, asset: TimelineAssetConfig) {
await page
.locator(`img[draggable="false"][src="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`)
.or(page.locator(`video[poster="/api/assets/${asset.id}/thumbnail?size=preview&c=${asset.thumbhash}"]`))
.waitFor();
},
async expectActiveAssetToBe(page: Page, assetId: string) {
const activeElement = () =>
page.evaluate(() => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
return document.activeElement?.dataset?.asset;
});
await expect(poll(page, activeElement, (result) => result === assetId)).resolves.toBe(assetId);
},
};
export const pageUtils = {
async deepLinkPhotosPage(page: Page, assetId: string) {
await page.goto(`/photos?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async openPhotosPage(page: Page) {
await page.goto(`/photos`);
await timelineUtils.waitForTimelineLoad(page);
},
async openAlbumPage(page: Page, albumId: string) {
await page.goto(`/albums/${albumId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async deepLinkAlbumPage(page: Page, albumId: string, assetId: string) {
await page.goto(`/albums/${albumId}?at=${assetId}`);
await timelineUtils.waitForTimelineLoad(page);
},
async goToAsset(page: Page, assetDate: string) {
await timelineUtils.locator(page).hover();
const stringDate = DateTime.fromISO(assetDate).toFormat('MMddyyyy,hh:mm:ss.SSSa');
await page.keyboard.press('g');
await page.locator('#datetime').pressSequentially(stringDate);
await page.getByText('Confirm').click();
},
async selectDay(page: Page, day: string) {
await page.getByTitle(day).hover();
await page.locator('[data-group] .w-8').click();
},
async pauseTestDebug() {
console.log('NOTE: pausing test indefinately for debug');
await new Promise(() => void 0);
},
};

View File

@@ -58,8 +58,12 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(true);
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(true);
});
test('revoke admin access', async ({ context, page }) => {
@@ -83,7 +87,11 @@ test.describe('User Administration', () => {
await expect(page.getByLabel('Admin User')).not.toBeChecked();
await page.getByRole('button', { name: 'Confirm' }).click();
const updated = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
expect(updated.isAdmin).toBe(false);
await expect
.poll(async () => {
const userAdmin = await getUserAdmin({ id: user.userId }, { headers: asBearerAuth(admin.accessToken) });
return userAdmin.isAdmin;
})
.toBe(false);
});
});

View File

@@ -17,7 +17,6 @@
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_import_path": "Add import path",
"add_location": "Add location",
"add_more_users": "Add more users",
"add_partner": "Add partner",
@@ -113,13 +112,17 @@
"jobs_failed": "{jobCount, plural, other {# failed}}",
"library_created": "Created library: {library}",
"library_deleted": "Library deleted",
"library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
"library_details": "Library details",
"library_folder_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.",
"library_remove_exclusion_pattern_prompt": "Are you sure you want to remove this exclusion pattern?",
"library_remove_folder_prompt": "Are you sure you want to remove this import folder?",
"library_scanning": "Periodic Scanning",
"library_scanning_description": "Configure periodic library scanning",
"library_scanning_enable_description": "Enable periodic library scanning",
"library_settings": "External Library",
"library_settings_description": "Manage external library settings",
"library_tasks_description": "Scan external libraries for new and/or changed assets",
"library_updated": "Updated library",
"library_watching_enable_description": "Watch external libraries for file changes",
"library_watching_settings": "Library watching [EXPERIMENTAL]",
"library_watching_settings_description": "Automatically watch for changed files",
@@ -174,6 +177,10 @@
"machine_learning_smart_search_enabled": "Enable smart search",
"machine_learning_smart_search_enabled_description": "If disabled, images will not be encoded for smart search.",
"machine_learning_url_description": "The URL of the machine learning server. If more than one URL is provided, each server will be attempted one-at-a-time until one responds successfully, in order from first to last. Servers that don't respond will be temporarily ignored until they come back online.",
"maintenance_settings": "Maintenance",
"maintenance_settings_description": "Put Immich into maintenance mode.",
"maintenance_start": "Start maintenance mode",
"maintenance_start_error": "Failed to start maintenance mode.",
"manage_concurrency": "Manage Concurrency",
"manage_log_settings": "Manage log settings",
"map_dark_style": "Dark style",
@@ -897,8 +904,6 @@
"edit_description_prompt": "Please select a new description:",
"edit_exclusion_pattern": "Edit exclusion pattern",
"edit_faces": "Edit faces",
"edit_import_path": "Edit import path",
"edit_import_paths": "Edit Import Paths",
"edit_key": "Edit key",
"edit_link": "Edit link",
"edit_location": "Edit location",
@@ -970,8 +975,8 @@
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status",
"import_path_already_exists": "This import path already exists.",
"incorrect_email_or_password": "Incorrect email or password",
"library_folder_already_exists": "This import path already exists.",
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
@@ -980,7 +985,6 @@
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment",
"unable_to_add_exclusion_pattern": "Unable to add exclusion pattern",
"unable_to_add_import_path": "Unable to add import path",
"unable_to_add_partners": "Unable to add partners",
"unable_to_add_remove_archive": "Unable to {archived, select, true {remove asset from} other {add asset to}} archive",
"unable_to_add_remove_favorites": "Unable to {favorite, select, true {add asset to} other {remove asset from}} favorites",
@@ -1003,12 +1007,10 @@
"unable_to_delete_asset": "Unable to delete asset",
"unable_to_delete_assets": "Error deleting assets",
"unable_to_delete_exclusion_pattern": "Unable to delete exclusion pattern",
"unable_to_delete_import_path": "Unable to delete import path",
"unable_to_delete_shared_link": "Unable to delete shared link",
"unable_to_delete_user": "Unable to delete user",
"unable_to_download_files": "Unable to download files",
"unable_to_edit_exclusion_pattern": "Unable to edit exclusion pattern",
"unable_to_edit_import_path": "Unable to edit import path",
"unable_to_empty_trash": "Unable to empty trash",
"unable_to_enter_fullscreen": "Unable to enter fullscreen",
"unable_to_exit_fullscreen": "Unable to exit fullscreen",
@@ -1059,6 +1061,7 @@
"unable_to_update_user": "Unable to update user",
"unable_to_upload_file": "Unable to upload file"
},
"exclusion_pattern": "Exclusion pattern",
"exif": "Exif",
"exif_bottom_sheet_description": "Add Description...",
"exif_bottom_sheet_description_error": "Error updating description",
@@ -1118,6 +1121,7 @@
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward",
"full_path": "Full path: {path}",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
"general": "General",
@@ -1247,6 +1251,8 @@
"let_others_respond": "Let others respond",
"level": "Level",
"library": "Library",
"library_add_folder": "Add folder",
"library_edit_folder": "Edit folder",
"library_options": "Library options",
"library_page_device_albums": "Albums on Device",
"library_page_new_album": "New album",
@@ -1318,6 +1324,11 @@
"loop_videos_description": "Enable to automatically loop a video in the detail viewer.",
"main_branch_warning": "You're using a development version; we strongly recommend using a release version!",
"main_menu": "Main menu",
"maintenance_description": "Immich has been put into <link>maintenance mode</link>.",
"maintenance_end": "End maintenance mode",
"maintenance_end_error": "Failed to end maintenance mode.",
"maintenance_logged_in_as": "Currently logged in as {user}",
"maintenance_title": "Temporarily Unavailable",
"make": "Make",
"manage_geolocation": "Manage location",
"manage_media_access_rationale": "This permission is required for proper handling of moving assets to the trash and restoring them from it.",
@@ -1440,6 +1451,7 @@
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_libraries_message": "Create an external library to view your photos and videos",
"no_local_assets_found": "No local assets found with this checksum",
"no_location_set": "No location set",
"no_locked_photos_message": "Photos and videos in the locked folder are hidden and won't show up as you browse or search your library.",
"no_name": "No Name",
"no_notifications": "No notifications",
@@ -1830,6 +1842,8 @@
"server_offline": "Server Offline",
"server_online": "Server Online",
"server_privacy": "Server Privacy",
"server_restarting_description": "This page will refresh momentarily.",
"server_restarting_title": "Server is restarting",
"server_stats": "Server Stats",
"server_update_available": "Server update is available",
"server_version": "Server Version",

View File

@@ -3,8 +3,6 @@ class ExifInfo {
final int? fileSize;
final String? description;
final bool isFlipped;
final double? width;
final double? height;
final String? orientation;
final String? timeZone;
final DateTime? dateTimeOriginal;
@@ -46,8 +44,6 @@ class ExifInfo {
this.fileSize,
this.description,
this.orientation,
this.width,
this.height,
this.timeZone,
this.dateTimeOriginal,
this.isFlipped = false,
@@ -72,8 +68,6 @@ class ExifInfo {
return other.fileSize == fileSize &&
other.description == description &&
other.isFlipped == isFlipped &&
other.width == width &&
other.height == height &&
other.orientation == orientation &&
other.timeZone == timeZone &&
other.dateTimeOriginal == dateTimeOriginal &&
@@ -98,8 +92,6 @@ class ExifInfo {
description.hashCode ^
orientation.hashCode ^
isFlipped.hashCode ^
width.hashCode ^
height.hashCode ^
timeZone.hashCode ^
dateTimeOriginal.hashCode ^
latitude.hashCode ^
@@ -123,8 +115,6 @@ class ExifInfo {
fileSize: ${fileSize ?? 'NA'},
description: ${description ?? 'NA'},
orientation: ${orientation ?? 'NA'},
width: ${width ?? 'NA'},
height: ${height ?? 'NA'},
isFlipped: $isFlipped,
timeZone: ${timeZone ?? 'NA'},
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},

View File

@@ -65,8 +65,8 @@ class AssetService {
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = exif?.width ?? asset.width?.toDouble();
height = exif?.height ?? asset.height?.toDouble();
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();

View File

@@ -177,6 +177,12 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}
Future<void> _cleanup() async {
await runZonedGuarded(_handleCleanup, (error, stack) {
dPrint(() => "Error during background worker cleanup: $error, $stack");
});
}
Future<void> _handleCleanup() async {
// If ref is null, it means the service was never initialized properly
if (_isCleanedUp || _ref == null) {
return;
@@ -186,11 +192,16 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
_isCleanedUp = true;
final backgroundSyncManager = _ref?.read(backgroundSyncProvider);
final nativeSyncApi = _ref?.read(nativeSyncApiProvider);
await _drift.close();
await _driftLogger.close();
_ref?.dispose();
_ref = null;
_cancellationToken.cancel();
_logger.info("Cleaning up background worker");
final cleanupFutures = [
nativeSyncApi?.cancelHashing(),
workerManagerPatch.dispose().catchError((_) async {
@@ -199,8 +210,7 @@ class BackgroundWorkerBgService extends BackgroundWorkerFlutterApi {
}),
LogService.I.dispose(),
Store.dispose(),
_drift.close(),
_driftLogger.close(),
backgroundSyncManager?.cancel(),
];

View File

@@ -165,8 +165,6 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
f: fNumber?.toDouble(),
mm: focalLength?.toDouble(),
lens: lens,
width: width?.toDouble(),
height: height?.toDouble(),
isFlipped: ExifDtoConverter.isOrientationFlipped(orientation),
);
}

View File

@@ -219,8 +219,6 @@ class SyncStreamRepository extends DriftDatabaseRepository {
country: Value(exif.country),
dateTimeOriginal: Value(exif.dateTimeOriginal),
description: Value(exif.description),
height: Value(exif.exifImageHeight),
width: Value(exif.exifImageWidth),
exposureTime: Value(exif.exposureTime),
fNumber: Value(exif.fNumber),
fileSize: Value(exif.fileSizeInByte),
@@ -244,6 +242,16 @@ class SyncStreamRepository extends DriftDatabaseRepository {
);
}
});
await _db.batch((batch) {
for (final exif in data) {
batch.update(
_db.remoteAssetEntity,
RemoteAssetEntityCompanion(width: Value(exif.exifImageWidth), height: Value(exif.exifImageHeight)),
where: (row) => row.id.equals(exif.assetId),
);
}
});
} catch (error, stack) {
_logger.severe('Error: updateAssetsExifV1 - $debugLabel', error, stack);
rethrow;

View File

@@ -161,8 +161,6 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
value: exif.fileSize != null ? '${(exif.fileSize! / 1024 / 1024).toStringAsFixed(2)} MB' : null,
),
_PropertyItem(label: 'Description', value: exif.description),
_PropertyItem(label: 'EXIF Width', value: exif.width?.toString()),
_PropertyItem(label: 'EXIF Height', value: exif.height?.toString()),
_PropertyItem(label: 'Date Taken', value: exif.dateTimeOriginal?.toString()),
_PropertyItem(label: 'Time Zone', value: exif.timeZone),
_PropertyItem(label: 'Camera Make', value: exif.make),

View File

@@ -4,7 +4,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.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';
@@ -16,8 +15,8 @@ import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -97,8 +96,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
}
String _getFileInfo(BaseAsset asset, ExifInfo? exifInfo) {
final height = asset.height ?? exifInfo?.height;
final width = asset.width ?? exifInfo?.width;
final height = asset.height;
final width = asset.width;
final resolution = (width != null && height != null) ? "${width.toInt()} x ${height.toInt()}" : null;
final fileSize = exifInfo?.fileSize != null ? formatBytes(exifInfo!.fileSize!) : null;
@@ -181,7 +180,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
spacing: 12,
children: [
if (albums.isNotEmpty)
_SheetTile(
SheetTile(
title: 'appears_in'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
@@ -233,7 +232,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
future: assetMediaRepository.getOriginalFilename(asset.id),
builder: (context, snapshot) {
final displayName = snapshot.data ?? asset.name;
return _SheetTile(
return SheetTile(
title: displayName,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
@@ -250,7 +249,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
);
} else {
// For remote assets, use the name directly
return _SheetTile(
return SheetTile(
title: asset.name,
titleStyle: context.textTheme.labelLarge,
leading: Icon(
@@ -269,7 +268,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
return SliverList.list(
children: [
// Asset Date and Time
_SheetTile(
SheetTile(
title: _getDateTime(context, asset),
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
trailing: asset.hasRemote && isOwner ? const Icon(Icons.edit, size: 18) : null,
@@ -279,7 +278,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
_SheetTile(
SheetTile(
title: 'exif_bottom_sheet_details'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
@@ -290,7 +289,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
buildFileInfoTile(),
// Camera info
if (cameraTitle != null)
_SheetTile(
SheetTile(
title: cameraTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
@@ -301,7 +300,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
),
// Lens info
if (lensTitle != null)
_SheetTile(
SheetTile(
title: lensTitle,
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
@@ -319,77 +318,6 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
}
}
class _SheetTile extends ConsumerWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const _SheetTile({
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
void copyTitle(BuildContext context, WidgetRef ref) {
Clipboard.setData(ClipboardData(text: title));
ImmichToast.show(
context: context,
msg: 'copied_to_clipboard'.t(context: context),
toastType: ToastType.info,
);
ref.read(hapticFeedbackProvider.notifier).selectionClick();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final Widget titleWidget;
if (leading == null) {
titleWidget = LimitedBox(
maxWidth: double.infinity,
child: Text(title, style: titleStyle),
);
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15),
child: Text(title, style: titleStyle),
);
}
final Widget? subtitleWidget;
if (leading == null && subtitle != null) {
subtitleWidget = Text(subtitle!, style: subtitleStyle);
} else if (leading != null && subtitle != null) {
subtitleWidget = Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(subtitle!, style: subtitleStyle),
);
} else {
subtitleWidget = null;
}
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}
class _SheetAssetDescription extends ConsumerStatefulWidget {
final ExifInfo exif;
final bool isEditable;

View File

@@ -1,9 +1,12 @@
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/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -16,8 +19,6 @@ class SheetLocationDetails extends ConsumerStatefulWidget {
}
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
BaseAsset? asset;
ExifInfo? exifInfo;
MapLibreMapController? _mapController;
String? _getLocationName(ExifInfo? exifInfo) {
@@ -39,14 +40,11 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
}
void _onExifChanged(AsyncValue<ExifInfo?>? previous, AsyncValue<ExifInfo?> current) {
asset = ref.read(currentAssetNotifier);
setState(() {
exifInfo = current.valueOrNull;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
if (exifInfo != null && hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(exifInfo!.latitude!, exifInfo!.longitude!)));
}
});
final currentExif = current.valueOrNull;
if (currentExif != null && currentExif.hasCoordinates) {
_mapController?.moveCamera(CameraUpdate.newLatLng(LatLng(currentExif.latitude!, currentExif.longitude!)));
}
}
@override
@@ -55,45 +53,71 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
ref.listenManual(currentAssetExifProvider, _onExifChanged, fireImmediately: true);
}
void editLocation() async {
await ref.read(actionProvider.notifier).editLocation(ActionSource.viewer, context);
}
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard no lat/lng
if (!hasCoordinates || (asset != null && asset is LocalAsset && asset!.hasRemote)) {
// Guard local assets
if (asset != null && asset is LocalAsset && asset.hasRemote) {
return const SizedBox.shrink();
}
final remoteId = asset is LocalAsset ? (asset as LocalAsset).remoteId : (asset as RemoteAsset).id;
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
return Padding(
padding: EdgeInsets.symmetric(vertical: 16.0, horizontal: context.isMobile ? 16.0 : 56.0),
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
"exif_bottom_sheet_location".t(context: context),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
SheetTile(
title: 'exif_bottom_sheet_location'.t(context: context),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation,
),
if (hasCoordinates)
Padding(
padding: EdgeInsets.symmetric(horizontal: context.isMobile ? 16.0 : 56.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
const SizedBox(height: 16),
if (locationName != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(locationName, style: context.textTheme.labelLarge),
),
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(150),
),
),
],
),
),
),
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
const SizedBox(height: 15),
if (locationName != null)
Padding(
padding: const EdgeInsets.only(bottom: 4.0),
child: Text(locationName, style: context.textTheme.labelLarge),
if (!hasCoordinates)
SheetTile(
title: "add_a_location".t(context: context),
titleStyle: context.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
leading: const Icon(Icons.location_off),
onTap: editLocation,
),
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(color: context.textTheme.labelMedium?.color?.withAlpha(150)),
),
],
),
);

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class SheetTile extends ConsumerWidget {
final String title;
final Widget? leading;
final Widget? trailing;
final String? subtitle;
final TextStyle? titleStyle;
final TextStyle? subtitleStyle;
final VoidCallback? onTap;
const SheetTile({
super.key,
required this.title,
this.titleStyle,
this.leading,
this.subtitle,
this.subtitleStyle,
this.trailing,
this.onTap,
});
void copyTitle(BuildContext context, WidgetRef ref) {
Clipboard.setData(ClipboardData(text: title));
ImmichToast.show(
context: context,
msg: 'copied_to_clipboard'.t(context: context),
toastType: ToastType.info,
);
ref.read(hapticFeedbackProvider.notifier).selectionClick();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final Widget titleWidget;
if (leading == null) {
titleWidget = LimitedBox(
maxWidth: double.infinity,
child: Text(title, style: titleStyle),
);
} else {
titleWidget = Container(
width: double.infinity,
padding: const EdgeInsets.only(left: 15),
child: Text(title, style: titleStyle),
);
}
final Widget? subtitleWidget;
if (leading == null && subtitle != null) {
subtitleWidget = Text(subtitle!, style: subtitleStyle);
} else if (leading != null && subtitle != null) {
subtitleWidget = Padding(
padding: const EdgeInsets.only(left: 15),
child: Text(subtitle!, style: subtitleStyle),
);
} else {
subtitleWidget = null;
}
return ListTile(
dense: true,
visualDensity: VisualDensity.compact,
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
titleAlignment: ListTileTitleAlignment.center,
leading: leading,
trailing: trailing,
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
subtitle: subtitleWidget,
onTap: onTap,
);
}
}

View File

@@ -1,27 +1,45 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class FixedTimelineRow extends MultiChildRenderObjectWidget {
final double dimension;
class TimelineRow extends MultiChildRenderObjectWidget {
final double height;
final List<double> widths;
final double spacing;
final TextDirection textDirection;
const FixedTimelineRow({
const TimelineRow({
super.key,
required this.dimension,
required this.height,
required this.widths,
required this.spacing,
required this.textDirection,
required super.children,
});
factory TimelineRow.fixed({
required double dimension,
required double spacing,
required TextDirection textDirection,
required List<Widget> children,
}) => TimelineRow(
height: dimension,
widths: List.filled(children.length, dimension),
spacing: spacing,
textDirection: textDirection,
children: children,
);
@override
RenderObject createRenderObject(BuildContext context) {
return RenderFixedRow(dimension: dimension, spacing: spacing, textDirection: textDirection);
return RenderFixedRow(height: height, widths: widths, spacing: spacing, textDirection: textDirection);
}
@override
void updateRenderObject(BuildContext context, RenderFixedRow renderObject) {
renderObject.dimension = dimension;
renderObject.height = height;
renderObject.widths = widths;
renderObject.spacing = spacing;
renderObject.textDirection = textDirection;
}
@@ -29,7 +47,8 @@ class FixedTimelineRow extends MultiChildRenderObjectWidget {
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
@@ -43,21 +62,32 @@ class RenderFixedRow extends RenderBox
RenderBoxContainerDefaultsMixin<RenderBox, _RowParentData> {
RenderFixedRow({
List<RenderBox>? children,
required double dimension,
required double height,
required List<double> widths,
required double spacing,
required TextDirection textDirection,
}) : _dimension = dimension,
}) : _height = height,
_widths = widths,
_spacing = spacing,
_textDirection = textDirection {
addAll(children);
}
double get dimension => _dimension;
double _dimension;
double get height => _height;
double _height;
set dimension(double value) {
if (_dimension == value) return;
_dimension = value;
set height(double value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
}
List<double> get widths => _widths;
List<double> _widths;
set widths(List<double> value) {
if (listEquals(_widths, value)) return;
_widths = value;
markNeedsLayout();
}
@@ -86,7 +116,7 @@ class RenderFixedRow extends RenderBox
}
}
double get intrinsicWidth => dimension * childCount + spacing * (childCount - 1);
double get intrinsicWidth => widths.sum + (spacing * (childCount - 1));
@override
double computeMinIntrinsicWidth(double height) => intrinsicWidth;
@@ -95,10 +125,10 @@ class RenderFixedRow extends RenderBox
double computeMaxIntrinsicWidth(double height) => intrinsicWidth;
@override
double computeMinIntrinsicHeight(double width) => dimension;
double computeMinIntrinsicHeight(double width) => height;
@override
double computeMaxIntrinsicHeight(double width) => dimension;
double computeMaxIntrinsicHeight(double width) => height;
@override
double? computeDistanceToActualBaseline(TextBaseline baseline) {
@@ -118,7 +148,8 @@ class RenderFixedRow extends RenderBox
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DoubleProperty('dimension', dimension));
properties.add(DoubleProperty('height', height));
properties.add(DiagnosticsProperty<List<double>>('widths', widths));
properties.add(DoubleProperty('spacing', spacing));
properties.add(EnumProperty<TextDirection>('textDirection', textDirection));
}
@@ -131,19 +162,25 @@ class RenderFixedRow extends RenderBox
return;
}
// Use the entire width of the parent for the row.
size = Size(constraints.maxWidth, dimension);
// Each tile is forced to be dimension x dimension.
final childConstraints = BoxConstraints.tight(Size(dimension, dimension));
size = Size(constraints.maxWidth, height);
final flipMainAxis = textDirection == TextDirection.rtl;
Offset offset = Offset(flipMainAxis ? size.width - dimension : 0, 0);
final dx = (flipMainAxis ? -1 : 1) * (dimension + spacing);
int childIndex = 0;
double currentX = flipMainAxis ? size.width - (widths.firstOrNull ?? 0) : 0;
// Layout each child horizontally.
while (child != null) {
while (child != null && childIndex < widths.length) {
final width = widths[childIndex];
final childConstraints = BoxConstraints.tight(Size(width, height));
child.layout(childConstraints, parentUsesSize: false);
final childParentData = child.parentData! as _RowParentData;
childParentData.offset = offset;
offset += Offset(dx, 0);
childParentData.offset = Offset(currentX, 0);
child = childParentData.nextSibling;
childIndex++;
if (child != null && childIndex < widths.length) {
final nextWidth = widths[childIndex];
currentX += flipMainAxis ? -(spacing + nextWidth) : width + spacing;
}
}
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math' as math;
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
@@ -14,6 +15,7 @@ import 'package:immich_mobile/presentation/widgets/timeline/segment.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/segment_builder.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline_drag_region.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
@@ -21,6 +23,7 @@ import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.da
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
class FixedSegment extends Segment {
final double tileHeight;
@@ -78,6 +81,7 @@ class FixedSegment extends Segment {
assetCount: numberOfAssets,
tileHeight: tileHeight,
spacing: spacing,
columnCount: columnCount,
);
}
}
@@ -87,24 +91,34 @@ class _FixedSegmentRow extends ConsumerWidget {
final int assetCount;
final double tileHeight;
final double spacing;
final int columnCount;
const _FixedSegmentRow({
required this.assetIndex,
required this.assetCount,
required this.tileHeight,
required this.spacing,
required this.columnCount,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isScrubbing = ref.watch(timelineStateProvider.select((s) => s.isScrubbing));
final timelineService = ref.read(timelineServiceProvider);
final isDynamicLayout = ref.watch(
appSettingsServiceProvider.select((s) => s.getSetting(AppSettingsEnum.dynamicLayout)),
);
if (isScrubbing) {
return _buildPlaceholder(context);
}
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
return _buildAssetRow(
context,
timelineService.getAssets(assetIndex, assetCount),
timelineService,
isDynamicLayout,
);
}
return FutureBuilder<List<BaseAsset>>(
@@ -113,7 +127,7 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
return _buildAssetRow(context, snapshot.requireData, timelineService);
return _buildAssetRow(context, snapshot.requireData, timelineService, isDynamicLayout);
},
);
}
@@ -122,23 +136,58 @@ class _FixedSegmentRow extends ConsumerWidget {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
children: [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
Widget _buildAssetRow(
BuildContext context,
List<BaseAsset> assets,
TimelineService timelineService,
bool isDynamicLayout,
) {
final children = [
for (int i = 0; i < assets.length; i++)
TimelineAssetIndexWrapper(
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
segmentIndex: 0, // For simplicity, using 0 for now
child: _AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
),
],
),
];
final widths = List.filled(assets.length, tileHeight);
if (isDynamicLayout) {
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
final meanAspectRatio = aspectRatios.sum / assets.length;
// 1: mean width
// 0.5: width < mean - threshold
// 1.5: width > mean + threshold
final arConfiguration = aspectRatios.map((e) {
if (e - meanAspectRatio > 0.3) return 1.5;
if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize to get width distribution
final sum = arConfiguration.sum;
int index = 0;
for (final ratio in arConfiguration) {
// Distribute the available width proportionally based on aspect ratio configuration
widths[index++] = ((ratio * assets.length) / sum) * tileHeight;
}
}
return TimelineDragRegion(
child: TimelineRow(
height: tileHeight,
widths: widths,
spacing: spacing,
textDirection: Directionality.of(context),
children: children,
),
);
}
}

View File

@@ -24,7 +24,7 @@ abstract class SegmentBuilder {
Size size = kTimelineFixedTileExtent,
double spacing = kTimelineSpacing,
}) => RepaintBoundary(
child: FixedTimelineRow(
child: TimelineRow.fixed(
dimension: size.height,
spacing: spacing,
textDirection: Directionality.of(context),

View File

@@ -264,9 +264,8 @@ class ActionNotifier extends Notifier<void> {
}
Future<ActionResult?> deleteLocal(ActionSource source, BuildContext context) async {
// Always perform the operation if there is only one merged asset
final assets = _getAssets(source);
bool? backedUpOnly = assets.length == 1 && assets.first.storage == AssetState.merged
bool? backedUpOnly = assets.every((asset) => asset.storage == AssetState.merged)
? true
: await showDialog<bool>(
context: context,
@@ -302,6 +301,13 @@ class ActionNotifier extends Notifier<void> {
return null;
}
// This must be called since editing location
// does not update the currentAsset which means
// the exif provider will not be refreshed automatically
if (source == ActionSource.viewer) {
ref.invalidate(currentAssetExifProvider);
}
return ActionResult(count: ids.length, success: true);
} catch (error, stack) {
_logger.severe('Failed to edit location for assets', error, stack);

View File

@@ -29,7 +29,7 @@ import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 17;
const int targetVersion = 18;
Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
final hasVersion = Store.tryGet(StoreKey.version) != null;
@@ -63,7 +63,8 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
await Store.populateCache();
}
await handleBetaMigration(version, await _isNewInstallation(db, drift), SyncStreamRepository(drift));
final syncStreamRepository = SyncStreamRepository(drift);
await handleBetaMigration(version, await _isNewInstallation(db, drift), syncStreamRepository);
if (version < 17 && Store.isBetaTimelineEnabled) {
final delay = Store.get(StoreKey.backupTriggerDelay, AppSettingsEnum.backupTriggerDelay.defaultValue);
@@ -72,6 +73,11 @@ Future<void> migrateDatabaseIfNeeded(Isar db, Drift drift) async {
}
}
if (version < 18 && Store.isBetaTimelineEnabled) {
await syncStreamRepository.reset();
await Store.put(StoreKey.shouldResetSync, true);
}
if (targetVersion >= 12) {
await Store.put(StoreKey.version, targetVersion);
return;

View File

@@ -5,7 +5,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/string_extensions.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@@ -17,19 +16,36 @@ Future<LatLng?> showLocationPicker({required BuildContext context, LatLng? initi
);
}
enum _LocationPickerMode { map, manual }
class _LocationPicker extends HookWidget {
final LatLng? initialLatLng;
const _LocationPicker({this.initialLatLng});
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
@override
Widget build(BuildContext context) {
final latitude = useState(initialLatLng?.latitude ?? 0.0);
final longitude = useState(initialLatLng?.longitude ?? 0.0);
final latlng = LatLng(latitude.value, longitude.value);
final pickerMode = useState(_LocationPickerMode.map);
final latitiudeFocusNode = useFocusNode();
final longitudeFocusNode = useFocusNode();
final latitudeController = useTextEditingController(text: latitude.value.toStringAsFixed(4));
final longitudeController = useTextEditingController(text: longitude.value.toStringAsFixed(4));
useEffect(() {
latitudeController.text = latitude.value.toStringAsFixed(4);
longitudeController.text = longitude.value.toStringAsFixed(4);
return null;
}, [latitude.value, longitude.value]);
Future<void> onMapTap() async {
final newLatLng = await context.pushRoute<LatLng?>(MapLocationPickerRoute(initialLatLng: latlng));
@@ -39,23 +55,55 @@ class _LocationPicker extends HookWidget {
}
}
void onLatitudeUpdated(double value) {
latitude.value = value;
longitudeFocusNode.requestFocus();
}
void onLongitudeEditingCompleted(double value) {
longitude.value = value;
longitudeFocusNode.unfocus();
}
return AlertDialog(
contentPadding: const EdgeInsets.all(30),
alignment: Alignment.center,
content: SingleChildScrollView(
child: pickerMode.value == _LocationPickerMode.map
? _MapPicker(
key: ValueKey(latlng),
latlng: latlng,
onModeSwitch: () => pickerMode.value = _LocationPickerMode.manual,
onMapTap: onMapTap,
)
: _ManualPicker(
latlng: latlng,
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
onLatUpdated: (value) => latitude.value = value,
onLonUpdated: (value) => longitude.value = value,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text("edit_location_dialog_title", style: context.textTheme.titleMedium).tr(),
Align(
alignment: Alignment.center,
child: TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: onMapTap,
),
),
const SizedBox(height: 12),
_ManualPickerInput(
controller: latitudeController,
decorationText: "latitude",
hintText: "location_picker_latitude_hint",
errorText: "location_picker_latitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLat,
onUpdated: onLatitudeUpdated,
),
const SizedBox(height: 24),
_ManualPickerInput(
controller: longitudeController,
decorationText: "longitude",
hintText: "location_picker_longitude_hint",
errorText: "location_picker_longitude_error",
focusNode: longitudeFocusNode,
validator: _validateLong,
onUpdated: onLongitudeEditingCompleted,
),
],
),
),
actions: [
TextButton(
@@ -81,7 +129,7 @@ class _LocationPicker extends HookWidget {
}
class _ManualPickerInput extends HookWidget {
final String initialValue;
final TextEditingController controller;
final String decorationText;
final String hintText;
final String errorText;
@@ -90,7 +138,7 @@ class _ManualPickerInput extends HookWidget {
final Function(double value) onUpdated;
const _ManualPickerInput({
required this.initialValue,
required this.controller,
required this.decorationText,
required this.hintText,
required this.errorText,
@@ -101,7 +149,6 @@ class _ManualPickerInput extends HookWidget {
@override
Widget build(BuildContext context) {
final isValid = useState(true);
final controller = useTextEditingController(text: initialValue);
void onEditingComplete() {
isValid.value = validator(controller.text);
@@ -131,109 +178,3 @@ class _ManualPickerInput extends HookWidget {
);
}
}
class _ManualPicker extends HookWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function(double) onLatUpdated;
final Function(double) onLonUpdated;
const _ManualPicker({
required this.latlng,
required this.onModeSwitch,
required this.onLatUpdated,
required this.onLonUpdated,
});
bool _validateLat(String value) {
final l = double.tryParse(value);
return l != null && l > -90 && l < 90;
}
bool _validateLong(String value) {
final l = double.tryParse(value);
return l != null && l > -180 && l < 180;
}
@override
Widget build(BuildContext context) {
final latitiudeFocusNode = useFocusNode();
final longitudeFocusNode = useFocusNode();
void onLatitudeUpdated(double value) {
onLatUpdated(value);
longitudeFocusNode.requestFocus();
}
void onLongitudeEditingCompleted(double value) {
onLonUpdated(value);
longitudeFocusNode.unfocus();
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: const Text("location_picker_choose_on_map").tr(),
label: const Icon(Icons.map_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
_ManualPickerInput(
initialValue: latlng.latitude.toStringAsFixed(4),
decorationText: "latitude",
hintText: "location_picker_latitude_hint",
errorText: "location_picker_latitude_error",
focusNode: latitiudeFocusNode,
validator: _validateLat,
onUpdated: onLatitudeUpdated,
),
const SizedBox(height: 24),
_ManualPickerInput(
initialValue: latlng.longitude.toStringAsFixed(4),
decorationText: "longitude",
hintText: "location_picker_longitude_hint",
errorText: "location_picker_longitude_error",
focusNode: longitudeFocusNode,
validator: _validateLong,
onUpdated: onLongitudeEditingCompleted,
),
],
);
}
}
class _MapPicker extends StatelessWidget {
final LatLng latlng;
final Function() onModeSwitch;
final Function() onMapTap;
const _MapPicker({required this.latlng, required this.onModeSwitch, required this.onMapTap, super.key});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("edit_location_dialog_title", textAlign: TextAlign.center).tr(),
const SizedBox(height: 12),
TextButton.icon(
icon: Text("${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}"),
label: const Icon(Icons.edit_outlined, size: 16),
onPressed: onModeSwitch,
),
const SizedBox(height: 12),
MapThumbnail(
centre: latlng,
height: 200,
width: 200,
zoom: 8,
showMarkerPin: true,
onTap: (_, __) => onMapTap(),
),
],
);
}
}

View File

@@ -159,6 +159,8 @@ Class | Method | HTTP request | Description
*LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | Scan a library
*LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | Update a library
*LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | Validate library settings
*MaintenanceAdminApi* | [**maintenanceLogin**](doc//MaintenanceAdminApi.md#maintenancelogin) | **POST** /admin/maintenance/login | Log into maintenance mode
*MaintenanceAdminApi* | [**setMaintenanceMode**](doc//MaintenanceAdminApi.md#setmaintenancemode) | **POST** /admin/maintenance | Set maintenance mode
*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | Retrieve map markers
*MapApi* | [**reverseGeocode**](doc//MapApi.md#reversegeocode) | **GET** /map/reverse-geocode | Reverse geocode coordinates
*MemoriesApi* | [**addMemoryAssets**](doc//MemoriesApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | Add assets to a memory
@@ -404,6 +406,9 @@ Class | Method | HTTP request | Description
- [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md)
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
- [MaintenanceAction](doc//MaintenanceAction.md)
- [MaintenanceAuthDto](doc//MaintenanceAuthDto.md)
- [MaintenanceLoginDto](doc//MaintenanceLoginDto.md)
- [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)
@@ -496,6 +501,7 @@ Class | Method | HTTP request | Description
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SessionUnlockDto](doc//SessionUnlockDto.md)
- [SessionUpdateDto](doc//SessionUpdateDto.md)
- [SetMaintenanceModeDto](doc//SetMaintenanceModeDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
- [SharedLinkEditDto](doc//SharedLinkEditDto.md)
- [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)

View File

@@ -42,6 +42,7 @@ part 'api/duplicates_api.dart';
part 'api/faces_api.dart';
part 'api/jobs_api.dart';
part 'api/libraries_api.dart';
part 'api/maintenance_admin_api.dart';
part 'api/map_api.dart';
part 'api/memories_api.dart';
part 'api/notifications_api.dart';
@@ -163,6 +164,9 @@ part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart';
part 'model/machine_learning_availability_checks_dto.dart';
part 'model/maintenance_action.dart';
part 'model/maintenance_auth_dto.dart';
part 'model/maintenance_login_dto.dart';
part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart';
@@ -255,6 +259,7 @@ part 'model/session_create_response_dto.dart';
part 'model/session_response_dto.dart';
part 'model/session_unlock_dto.dart';
part 'model/session_update_dto.dart';
part 'model/set_maintenance_mode_dto.dart';
part 'model/shared_link_create_dto.dart';
part 'model/shared_link_edit_dto.dart';
part 'model/shared_link_response_dto.dart';

View File

@@ -0,0 +1,122 @@
//
// 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 MaintenanceAdminApi {
MaintenanceAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
Future<Response> maintenanceLoginWithHttpInfo(MaintenanceLoginDto maintenanceLoginDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance/login';
// ignore: prefer_final_locals
Object? postBody = maintenanceLoginDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Log into maintenance mode
///
/// Login with maintenance token or cookie to receive current information and perform further actions.
///
/// Parameters:
///
/// * [MaintenanceLoginDto] maintenanceLoginDto (required):
Future<MaintenanceAuthDto?> maintenanceLogin(MaintenanceLoginDto maintenanceLoginDto,) async {
final response = await maintenanceLoginWithHttpInfo(maintenanceLoginDto,);
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), 'MaintenanceAuthDto',) as MaintenanceAuthDto;
}
return null;
}
/// Set maintenance mode
///
/// Put Immich into or take it out of maintenance mode
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
Future<Response> setMaintenanceModeWithHttpInfo(SetMaintenanceModeDto setMaintenanceModeDto,) async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/maintenance';
// ignore: prefer_final_locals
Object? postBody = setMaintenanceModeDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Set maintenance mode
///
/// Put Immich into or take it out of maintenance mode
///
/// Parameters:
///
/// * [SetMaintenanceModeDto] setMaintenanceModeDto (required):
Future<void> setMaintenanceMode(SetMaintenanceModeDto setMaintenanceModeDto,) async {
final response = await setMaintenanceModeWithHttpInfo(setMaintenanceModeDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@@ -378,6 +378,12 @@ class ApiClient {
return LogoutResponseDto.fromJson(value);
case 'MachineLearningAvailabilityChecksDto':
return MachineLearningAvailabilityChecksDto.fromJson(value);
case 'MaintenanceAction':
return MaintenanceActionTypeTransformer().decode(value);
case 'MaintenanceAuthDto':
return MaintenanceAuthDto.fromJson(value);
case 'MaintenanceLoginDto':
return MaintenanceLoginDto.fromJson(value);
case 'ManualJobName':
return ManualJobNameTypeTransformer().decode(value);
case 'MapMarkerResponseDto':
@@ -562,6 +568,8 @@ class ApiClient {
return SessionUnlockDto.fromJson(value);
case 'SessionUpdateDto':
return SessionUpdateDto.fromJson(value);
case 'SetMaintenanceModeDto':
return SetMaintenanceModeDto.fromJson(value);
case 'SharedLinkCreateDto':
return SharedLinkCreateDto.fromJson(value);
case 'SharedLinkEditDto':

View File

@@ -97,6 +97,9 @@ String parameterToString(dynamic value) {
if (value is LogLevel) {
return LogLevelTypeTransformer().encode(value).toString();
}
if (value is MaintenanceAction) {
return MaintenanceActionTypeTransformer().encode(value).toString();
}
if (value is ManualJobName) {
return ManualJobNameTypeTransformer().encode(value).toString();
}

View File

@@ -0,0 +1,85 @@
//
// 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 MaintenanceAction {
/// Instantiate a new enum with the provided [value].
const MaintenanceAction._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const start = MaintenanceAction._(r'start');
static const end = MaintenanceAction._(r'end');
/// List of all possible values in this [enum][MaintenanceAction].
static const values = <MaintenanceAction>[
start,
end,
];
static MaintenanceAction? fromJson(dynamic value) => MaintenanceActionTypeTransformer().decode(value);
static List<MaintenanceAction> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceAction>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceAction.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [MaintenanceAction] to String,
/// and [decode] dynamic data back to [MaintenanceAction].
class MaintenanceActionTypeTransformer {
factory MaintenanceActionTypeTransformer() => _instance ??= const MaintenanceActionTypeTransformer._();
const MaintenanceActionTypeTransformer._();
String encode(MaintenanceAction data) => data.value;
/// Decodes a [dynamic value][data] to a MaintenanceAction.
///
/// 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.
MaintenanceAction? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'start': return MaintenanceAction.start;
case r'end': return MaintenanceAction.end;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [MaintenanceActionTypeTransformer] instance.
static MaintenanceActionTypeTransformer? _instance;
}

View File

@@ -0,0 +1,99 @@
//
// 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 MaintenanceAuthDto {
/// Returns a new [MaintenanceAuthDto] instance.
MaintenanceAuthDto({
required this.username,
});
String username;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceAuthDto &&
other.username == username;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(username.hashCode);
@override
String toString() => 'MaintenanceAuthDto[username=$username]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'username'] = this.username;
return json;
}
/// Returns a new [MaintenanceAuthDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceAuthDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceAuthDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceAuthDto(
username: mapValueOfType<String>(json, r'username')!,
);
}
return null;
}
static List<MaintenanceAuthDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceAuthDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceAuthDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceAuthDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceAuthDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceAuthDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceAuthDto-objects as value to a dart map
static Map<String, List<MaintenanceAuthDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceAuthDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceAuthDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'username',
};
}

View File

@@ -0,0 +1,108 @@
//
// 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 MaintenanceLoginDto {
/// Returns a new [MaintenanceLoginDto] instance.
MaintenanceLoginDto({
this.token,
});
///
/// 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.
///
String? token;
@override
bool operator ==(Object other) => identical(this, other) || other is MaintenanceLoginDto &&
other.token == token;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(token == null ? 0 : token!.hashCode);
@override
String toString() => 'MaintenanceLoginDto[token=$token]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
if (this.token != null) {
json[r'token'] = this.token;
} else {
// json[r'token'] = null;
}
return json;
}
/// Returns a new [MaintenanceLoginDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MaintenanceLoginDto? fromJson(dynamic value) {
upgradeDto(value, "MaintenanceLoginDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MaintenanceLoginDto(
token: mapValueOfType<String>(json, r'token'),
);
}
return null;
}
static List<MaintenanceLoginDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MaintenanceLoginDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MaintenanceLoginDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MaintenanceLoginDto> mapFromJson(dynamic json) {
final map = <String, MaintenanceLoginDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MaintenanceLoginDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MaintenanceLoginDto-objects as value to a dart map
static Map<String, List<MaintenanceLoginDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MaintenanceLoginDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MaintenanceLoginDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
};
}

View File

@@ -73,6 +73,7 @@ class Permission {
static const libraryPeriodStatistics = Permission._(r'library.statistics');
static const timelinePeriodRead = Permission._(r'timeline.read');
static const timelinePeriodDownload = Permission._(r'timeline.download');
static const maintenance = Permission._(r'maintenance');
static const memoryPeriodCreate = Permission._(r'memory.create');
static const memoryPeriodRead = Permission._(r'memory.read');
static const memoryPeriodUpdate = Permission._(r'memory.update');
@@ -214,6 +215,7 @@ class Permission {
libraryPeriodStatistics,
timelinePeriodRead,
timelinePeriodDownload,
maintenance,
memoryPeriodCreate,
memoryPeriodRead,
memoryPeriodUpdate,
@@ -390,6 +392,7 @@ class PermissionTypeTransformer {
case r'library.statistics': return Permission.libraryPeriodStatistics;
case r'timeline.read': return Permission.timelinePeriodRead;
case r'timeline.download': return Permission.timelinePeriodDownload;
case r'maintenance': return Permission.maintenance;
case r'memory.create': return Permission.memoryPeriodCreate;
case r'memory.read': return Permission.memoryPeriodRead;
case r'memory.update': return Permission.memoryPeriodUpdate;

View File

@@ -17,6 +17,7 @@ class ServerConfigDto {
required this.isInitialized,
required this.isOnboarded,
required this.loginPageMessage,
required this.maintenanceMode,
required this.mapDarkStyleUrl,
required this.mapLightStyleUrl,
required this.oauthButtonText,
@@ -33,6 +34,8 @@ class ServerConfigDto {
String loginPageMessage;
bool maintenanceMode;
String mapDarkStyleUrl;
String mapLightStyleUrl;
@@ -51,6 +54,7 @@ class ServerConfigDto {
other.isInitialized == isInitialized &&
other.isOnboarded == isOnboarded &&
other.loginPageMessage == loginPageMessage &&
other.maintenanceMode == maintenanceMode &&
other.mapDarkStyleUrl == mapDarkStyleUrl &&
other.mapLightStyleUrl == mapLightStyleUrl &&
other.oauthButtonText == oauthButtonText &&
@@ -65,6 +69,7 @@ class ServerConfigDto {
(isInitialized.hashCode) +
(isOnboarded.hashCode) +
(loginPageMessage.hashCode) +
(maintenanceMode.hashCode) +
(mapDarkStyleUrl.hashCode) +
(mapLightStyleUrl.hashCode) +
(oauthButtonText.hashCode) +
@@ -73,7 +78,7 @@ class ServerConfigDto {
(userDeleteDelay.hashCode);
@override
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -81,6 +86,7 @@ class ServerConfigDto {
json[r'isInitialized'] = this.isInitialized;
json[r'isOnboarded'] = this.isOnboarded;
json[r'loginPageMessage'] = this.loginPageMessage;
json[r'maintenanceMode'] = this.maintenanceMode;
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
json[r'oauthButtonText'] = this.oauthButtonText;
@@ -103,6 +109,7 @@ class ServerConfigDto {
isInitialized: mapValueOfType<bool>(json, r'isInitialized')!,
isOnboarded: mapValueOfType<bool>(json, r'isOnboarded')!,
loginPageMessage: mapValueOfType<String>(json, r'loginPageMessage')!,
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
@@ -160,6 +167,7 @@ class ServerConfigDto {
'isInitialized',
'isOnboarded',
'loginPageMessage',
'maintenanceMode',
'mapDarkStyleUrl',
'mapLightStyleUrl',
'oauthButtonText',

View File

@@ -0,0 +1,99 @@
//
// 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 SetMaintenanceModeDto {
/// Returns a new [SetMaintenanceModeDto] instance.
SetMaintenanceModeDto({
required this.action,
});
MaintenanceAction action;
@override
bool operator ==(Object other) => identical(this, other) || other is SetMaintenanceModeDto &&
other.action == action;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(action.hashCode);
@override
String toString() => 'SetMaintenanceModeDto[action=$action]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'action'] = this.action;
return json;
}
/// Returns a new [SetMaintenanceModeDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SetMaintenanceModeDto? fromJson(dynamic value) {
upgradeDto(value, "SetMaintenanceModeDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SetMaintenanceModeDto(
action: MaintenanceAction.fromJson(json[r'action'])!,
);
}
return null;
}
static List<SetMaintenanceModeDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SetMaintenanceModeDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SetMaintenanceModeDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SetMaintenanceModeDto> mapFromJson(dynamic json) {
final map = <String, SetMaintenanceModeDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SetMaintenanceModeDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SetMaintenanceModeDto-objects as value to a dart map
static Map<String, List<SetMaintenanceModeDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SetMaintenanceModeDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SetMaintenanceModeDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'action',
};
}

View File

@@ -452,10 +452,11 @@ packages:
drift:
dependency: "direct main"
description:
name: drift
sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
url: "https://pub.dev"
source: hosted
path: drift
ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575"
resolved-ref: "53ef7e9f19fe8f68416251760b4b99fe43f1c575"
url: "https://github.com/immich-app/drift"
source: git
version: "2.26.0"
drift_dev:
dependency: "direct dev"

View File

@@ -113,6 +113,13 @@ dev_dependencies:
riverpod_generator: ^2.6.1
riverpod_lint: ^2.6.1
dependency_overrides:
drift:
git:
url: https://github.com/immich-app/drift
ref: '53ef7e9f19fe8f68416251760b4b99fe43f1c575'
path: drift/
flutter:
uses-material-design: true
assets:

View File

@@ -322,6 +322,100 @@
"x-immich-state": "Stable"
}
},
"/admin/maintenance": {
"post": {
"description": "Put Immich into or take it out of maintenance mode",
"operationId": "setMaintenanceMode",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetMaintenanceModeDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Set maintenance mode",
"tags": [
"Maintenance (admin)"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-permission": "maintenance",
"x-immich-state": "Alpha"
}
},
"/admin/maintenance/login": {
"post": {
"description": "Login with maintenance token or cookie to receive current information and perform further actions.",
"operationId": "maintenanceLogin",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceLoginDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MaintenanceAuthDto"
}
}
},
"description": ""
}
},
"summary": "Log into maintenance mode",
"tags": [
"Maintenance (admin)"
],
"x-immich-history": [
{
"version": "v2.3.0",
"state": "Added"
},
{
"version": "v2.3.0",
"state": "Alpha"
}
],
"x-immich-state": "Alpha"
}
},
"/admin/notifications": {
"post": {
"description": "Create a new notification for a specific user.",
@@ -13917,6 +14011,10 @@
"name": "Libraries",
"description": "An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries."
},
{
"name": "Maintenance (admin)",
"description": "Maintenance mode allows you to put Immich in a read-only state to perform various operations."
},
{
"name": "Map",
"description": "Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data."
@@ -16425,6 +16523,32 @@
],
"type": "object"
},
"MaintenanceAction": {
"enum": [
"start",
"end"
],
"type": "string"
},
"MaintenanceAuthDto": {
"properties": {
"username": {
"type": "string"
}
},
"required": [
"username"
],
"type": "object"
},
"MaintenanceLoginDto": {
"properties": {
"token": {
"type": "string"
}
},
"type": "object"
},
"ManualJobName": {
"enum": [
"person-cleanup",
@@ -17380,6 +17504,7 @@
"library.statistics",
"timeline.read",
"timeline.download",
"maintenance",
"memory.create",
"memory.read",
"memory.update",
@@ -18587,6 +18712,9 @@
"loginPageMessage": {
"type": "string"
},
"maintenanceMode": {
"type": "boolean"
},
"mapDarkStyleUrl": {
"type": "string"
},
@@ -18611,6 +18739,7 @@
"isInitialized",
"isOnboarded",
"loginPageMessage",
"maintenanceMode",
"mapDarkStyleUrl",
"mapLightStyleUrl",
"oauthButtonText",
@@ -18996,6 +19125,21 @@
},
"type": "object"
},
"SetMaintenanceModeDto": {
"properties": {
"action": {
"allOf": [
{
"$ref": "#/components/schemas/MaintenanceAction"
}
]
}
},
"required": [
"action"
],
"type": "object"
},
"SharedLinkCreateDto": {
"properties": {
"albumId": {

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"typescript": "^5.3.3"
},
"repository": {

View File

@@ -40,6 +40,15 @@ export type ActivityStatisticsResponseDto = {
comments: number;
likes: number;
};
export type SetMaintenanceModeDto = {
action: MaintenanceAction;
};
export type MaintenanceLoginDto = {
token?: string;
};
export type MaintenanceAuthDto = {
username: string;
};
export type NotificationCreateDto = {
data?: object;
description?: string | null;
@@ -1183,6 +1192,7 @@ export type ServerConfigDto = {
isInitialized: boolean;
isOnboarded: boolean;
loginPageMessage: string;
maintenanceMode: boolean;
mapDarkStyleUrl: string;
mapLightStyleUrl: string;
oauthButtonText: string;
@@ -1822,6 +1832,33 @@ export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
/**
* Set maintenance mode
*/
export function setMaintenanceMode({ setMaintenanceModeDto }: {
setMaintenanceModeDto: SetMaintenanceModeDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/admin/maintenance", oazapfts.json({
...opts,
method: "POST",
body: setMaintenanceModeDto
})));
}
/**
* Log into maintenance mode
*/
export function maintenanceLogin({ maintenanceLoginDto }: {
maintenanceLoginDto: MaintenanceLoginDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: MaintenanceAuthDto;
}>("/admin/maintenance/login", oazapfts.json({
...opts,
method: "POST",
body: maintenanceLoginDto
})));
}
/**
* Create a notification
*/
@@ -5014,6 +5051,10 @@ export enum UserAvatarColor {
Gray = "gray",
Amber = "amber"
}
export enum MaintenanceAction {
Start = "start",
End = "end"
}
export enum NotificationLevel {
Success = "success",
Error = "error",
@@ -5121,6 +5162,7 @@ export enum Permission {
LibraryStatistics = "library.statistics",
TimelineRead = "timeline.read",
TimelineDownload = "timeline.download",
Maintenance = "maintenance",
MemoryCreate = "memory.create",
MemoryRead = "memory.read",
MemoryUpdate = "memory.update",

347
pnpm-lock.yaml generated
View File

@@ -63,11 +63,11 @@ importers:
specifier: ^4.13.1
version: 4.13.4
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
byte-size:
specifier: ^9.0.0
version: 9.0.1
@@ -109,16 +109,16 @@ importers:
version: 8.46.2(eslint@9.38.0(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.0.0
version: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-tsconfig-paths:
specifier: ^5.0.0
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest-fetch-mock:
specifier: ^0.4.0
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
yaml:
specifier: ^2.3.1
version: 2.8.1
@@ -195,6 +195,9 @@ importers:
'@eslint/js':
specifier: ^9.8.0
version: 9.38.0
'@faker-js/faker':
specifier: ^10.1.0
version: 10.1.0
'@immich/cli':
specifier: file:../cli
version: link:../cli
@@ -211,8 +214,8 @@ importers:
specifier: ^3.4.2
version: 3.7.1
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@types/oidc-provider':
specifier: ^9.0.0
version: 9.5.0
@@ -225,6 +228,9 @@ importers:
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
dotenv:
specifier: ^17.2.3
version: 17.2.3
eslint:
specifier: ^9.14.0
version: 9.38.0(jiti@2.6.1)
@@ -284,7 +290,7 @@ importers:
version: 5.2.1(encoding@0.1.13)
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
open-api/typescript-sdk:
dependencies:
@@ -293,8 +299,8 @@ importers:
version: 1.0.4
devDependencies:
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
typescript:
specifier: ^5.3.3
version: 5.9.3
@@ -445,6 +451,9 @@ importers:
ioredis:
specifier: ^5.8.2
version: 5.8.2
jose:
specifier: ^5.10.0
version: 5.10.0
js-yaml:
specifier: ^4.1.0
version: 4.1.0
@@ -471,7 +480,7 @@ importers:
version: 2.0.2
nest-commander:
specifier: ^3.16.0
version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3)
version: 3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3)
nestjs-cls:
specifier: ^5.0.0
version: 5.4.3(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -553,7 +562,7 @@ importers:
version: 9.38.0
'@nestjs/cli':
specifier: ^11.0.2
version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)
version: 11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
@@ -606,8 +615,8 @@ importers:
specifier: ^2.0.0
version: 2.0.0
'@types/node':
specifier: ^22.19.0
version: 22.19.0
specifier: ^22.19.1
version: 22.19.1
'@types/nodemailer':
specifier: ^7.0.0
version: 7.0.3
@@ -637,7 +646,7 @@ importers:
version: 13.15.4
'@vitest/coverage-v8':
specifier: ^3.0.0
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
eslint:
specifier: ^9.14.0
version: 9.38.0(jiti@2.6.1)
@@ -691,10 +700,10 @@ importers:
version: 1.5.8(@swc/core@1.14.0(@swc/helpers@0.5.17))(rollup@4.52.5)
vite-tsconfig-paths:
specifier: ^5.0.0
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
version: 5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
vitest:
specifier: ^3.0.0
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
web:
dependencies:
@@ -4683,8 +4692,8 @@ packages:
'@types/node@20.19.24':
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
'@types/node@22.19.0':
resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==}
'@types/node@22.19.1':
resolution: {integrity: sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==}
'@types/node@24.10.0':
resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==}
@@ -11790,11 +11799,11 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.0)(chokidar@4.0.3)':
'@angular-devkit/schematics-cli@19.2.15(@types/node@22.19.1)(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.15(chokidar@4.0.3)
'@inquirer/prompts': 7.3.2(@types/node@22.19.0)
'@inquirer/prompts': 7.3.2(@types/node@22.19.1)
ansi-colors: 4.1.3
symbol-observable: 4.0.0
yargs-parser: 21.1.1
@@ -14444,27 +14453,27 @@ snapshots:
transitivePeerDependencies:
- '@internationalized/date'
'@inquirer/checkbox@4.2.1(@types/node@22.19.0)':
'@inquirer/checkbox@4.2.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/confirm@5.1.15(@types/node@22.19.0)':
'@inquirer/confirm@5.1.15(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/core@10.1.15(@types/node@22.19.0)':
'@inquirer/core@10.1.15(@types/node@22.19.1)':
dependencies:
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
@@ -14472,115 +14481,115 @@ snapshots:
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/editor@4.2.17(@types/node@22.19.0)':
'@inquirer/editor@4.2.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/expand@4.0.17(@types/node@22.19.0)':
'@inquirer/expand@4.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/external-editor@1.0.2(@types/node@22.19.0)':
'@inquirer/external-editor@1.0.2(@types/node@22.19.1)':
dependencies:
chardet: 2.1.0
iconv-lite: 0.7.0
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/figures@1.0.13': {}
'@inquirer/input@4.2.1(@types/node@22.19.0)':
'@inquirer/input@4.2.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/number@3.0.17(@types/node@22.19.0)':
'@inquirer/number@3.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/password@4.0.17(@types/node@22.19.0)':
'@inquirer/password@4.0.17(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/prompts@7.3.2(@types/node@22.19.0)':
'@inquirer/prompts@7.3.2(@types/node@22.19.1)':
dependencies:
'@inquirer/checkbox': 4.2.1(@types/node@22.19.0)
'@inquirer/confirm': 5.1.15(@types/node@22.19.0)
'@inquirer/editor': 4.2.17(@types/node@22.19.0)
'@inquirer/expand': 4.0.17(@types/node@22.19.0)
'@inquirer/input': 4.2.1(@types/node@22.19.0)
'@inquirer/number': 3.0.17(@types/node@22.19.0)
'@inquirer/password': 4.0.17(@types/node@22.19.0)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.0)
'@inquirer/search': 3.1.0(@types/node@22.19.0)
'@inquirer/select': 4.3.1(@types/node@22.19.0)
'@inquirer/checkbox': 4.2.1(@types/node@22.19.1)
'@inquirer/confirm': 5.1.15(@types/node@22.19.1)
'@inquirer/editor': 4.2.17(@types/node@22.19.1)
'@inquirer/expand': 4.0.17(@types/node@22.19.1)
'@inquirer/input': 4.2.1(@types/node@22.19.1)
'@inquirer/number': 3.0.17(@types/node@22.19.1)
'@inquirer/password': 4.0.17(@types/node@22.19.1)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.1)
'@inquirer/search': 3.1.0(@types/node@22.19.1)
'@inquirer/select': 4.3.1(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/prompts@7.8.0(@types/node@22.19.0)':
'@inquirer/prompts@7.8.0(@types/node@22.19.1)':
dependencies:
'@inquirer/checkbox': 4.2.1(@types/node@22.19.0)
'@inquirer/confirm': 5.1.15(@types/node@22.19.0)
'@inquirer/editor': 4.2.17(@types/node@22.19.0)
'@inquirer/expand': 4.0.17(@types/node@22.19.0)
'@inquirer/input': 4.2.1(@types/node@22.19.0)
'@inquirer/number': 3.0.17(@types/node@22.19.0)
'@inquirer/password': 4.0.17(@types/node@22.19.0)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.0)
'@inquirer/search': 3.1.0(@types/node@22.19.0)
'@inquirer/select': 4.3.1(@types/node@22.19.0)
'@inquirer/checkbox': 4.2.1(@types/node@22.19.1)
'@inquirer/confirm': 5.1.15(@types/node@22.19.1)
'@inquirer/editor': 4.2.17(@types/node@22.19.1)
'@inquirer/expand': 4.0.17(@types/node@22.19.1)
'@inquirer/input': 4.2.1(@types/node@22.19.1)
'@inquirer/number': 3.0.17(@types/node@22.19.1)
'@inquirer/password': 4.0.17(@types/node@22.19.1)
'@inquirer/rawlist': 4.1.5(@types/node@22.19.1)
'@inquirer/search': 3.1.0(@types/node@22.19.1)
'@inquirer/select': 4.3.1(@types/node@22.19.1)
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/rawlist@4.1.5(@types/node@22.19.0)':
'@inquirer/rawlist@4.1.5(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/search@3.1.0(@types/node@22.19.0)':
'@inquirer/search@3.1.0(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/select@4.3.1(@types/node@22.19.0)':
'@inquirer/select@4.3.1(@types/node@22.19.1)':
dependencies:
'@inquirer/core': 10.1.15(@types/node@22.19.0)
'@inquirer/core': 10.1.15(@types/node@22.19.1)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@22.19.0)
'@inquirer/type': 3.0.8(@types/node@22.19.1)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@inquirer/type@3.0.8(@types/node@22.19.0)':
'@inquirer/type@3.0.8(@types/node@22.19.1)':
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@internationalized/date@3.8.2':
dependencies:
@@ -14618,7 +14627,7 @@ snapshots:
'@jest/schemas': 29.6.3
'@types/istanbul-lib-coverage': 2.0.6
'@types/istanbul-reports': 3.0.4
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/yargs': 17.0.34
chalk: 4.1.2
@@ -14886,12 +14895,12 @@ snapshots:
bullmq: 5.62.1
tslib: 2.8.1
'@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.0)':
'@nestjs/cli@11.0.10(@swc/core@1.14.0(@swc/helpers@0.5.17))(@types/node@22.19.1)':
dependencies:
'@angular-devkit/core': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics': 19.2.15(chokidar@4.0.3)
'@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.0)(chokidar@4.0.3)
'@inquirer/prompts': 7.8.0(@types/node@22.19.0)
'@angular-devkit/schematics-cli': 19.2.15(@types/node@22.19.1)(chokidar@4.0.3)
'@inquirer/prompts': 7.8.0(@types/node@22.19.1)
'@nestjs/schematics': 11.0.9(chokidar@4.0.3)(typescript@5.8.3)
ansis: 4.1.0
chokidar: 4.0.3
@@ -16291,7 +16300,7 @@ snapshots:
'@types/accepts@1.3.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/archiver@6.0.4':
dependencies:
@@ -16303,16 +16312,16 @@ snapshots:
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/body-parser@1.19.6':
dependencies:
'@types/connect': 3.4.38
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/bonjour@3.5.13':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/braces@3.0.5': {}
@@ -16333,21 +16342,21 @@ snapshots:
'@types/cli-progress@3.11.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/compression@1.8.1':
dependencies:
'@types/express': 5.0.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/connect-history-api-fallback@1.5.4':
dependencies:
'@types/express-serve-static-core': 5.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/connect@3.4.38':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/content-disposition@0.5.9': {}
@@ -16364,11 +16373,11 @@ snapshots:
'@types/connect': 3.4.38
'@types/express': 5.0.5
'@types/keygrip': 1.0.6
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/debug@4.1.12':
dependencies:
@@ -16378,13 +16387,13 @@ snapshots:
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2': 1.15.5
'@types/dockerode@3.3.45':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2': 1.15.5
'@types/dom-to-image@2.6.7': {}
@@ -16407,14 +16416,14 @@ snapshots:
'@types/express-serve-static-core@4.19.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
@@ -16440,7 +16449,7 @@ snapshots:
'@types/fluent-ffmpeg@2.1.28':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/geojson-vt@3.2.5':
dependencies:
@@ -16472,7 +16481,7 @@ snapshots:
'@types/http-proxy@1.17.17':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/inquirer@8.2.11':
dependencies:
@@ -16496,7 +16505,7 @@ snapshots:
'@types/jsonwebtoken@9.0.10':
dependencies:
'@types/ms': 2.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/justified-layout@4.1.4': {}
@@ -16515,7 +16524,7 @@ snapshots:
'@types/http-errors': 2.0.5
'@types/keygrip': 1.0.6
'@types/koa-compose': 3.2.8
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/leaflet@1.9.21':
dependencies:
@@ -16545,7 +16554,7 @@ snapshots:
'@types/mock-fs@4.13.4':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ms@2.1.0': {}
@@ -16555,7 +16564,7 @@ snapshots:
'@types/node-forge@1.3.14':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/node@17.0.45': {}
@@ -16567,7 +16576,7 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/node@22.19.0':
'@types/node@22.19.1':
dependencies:
undici-types: 6.21.0
@@ -16579,7 +16588,7 @@ snapshots:
'@types/nodemailer@7.0.3':
dependencies:
'@aws-sdk/client-sesv2': 3.919.0
'@types/node': 22.19.0
'@types/node': 22.19.1
transitivePeerDependencies:
- aws-crt
@@ -16587,7 +16596,7 @@ snapshots:
dependencies:
'@types/keygrip': 1.0.6
'@types/koa': 3.0.0
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/parse5@5.0.3': {}
@@ -16597,13 +16606,13 @@ snapshots:
'@types/pg@8.15.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
pg-protocol: 1.10.3
pg-types: 2.2.0
'@types/pg@8.15.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
pg-protocol: 1.10.3
pg-types: 2.2.0
@@ -16611,13 +16620,13 @@ snapshots:
'@types/pngjs@6.0.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/prismjs@1.26.5': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/qs@6.14.0': {}
@@ -16646,7 +16655,7 @@ snapshots:
'@types/readdir-glob@1.1.5':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/retry@0.12.2': {}
@@ -16656,18 +16665,18 @@ snapshots:
'@types/sax@1.2.7':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/semver@7.7.1': {}
'@types/send@0.17.6':
dependencies:
'@types/mime': 1.3.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/send@1.2.1':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/serve-index@1.9.4':
dependencies:
@@ -16676,20 +16685,20 @@ snapshots:
'@types/serve-static@1.15.10':
dependencies:
'@types/http-errors': 2.0.5
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/send': 0.17.6
'@types/sockjs@0.3.36':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2-streams@0.1.13':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2@0.5.52':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ssh2-streams': 0.1.13
'@types/ssh2@1.15.5':
@@ -16700,7 +16709,7 @@ snapshots:
dependencies:
'@types/cookiejar': 2.1.5
'@types/methods': 1.1.4
'@types/node': 22.19.0
'@types/node': 22.19.1
form-data: 4.0.4
'@types/supercluster@7.1.3':
@@ -16714,7 +16723,7 @@ snapshots:
'@types/through@0.0.33':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/ua-parser-js@0.7.39': {}
@@ -16728,7 +16737,7 @@ snapshots:
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
'@types/yargs-parser@21.0.3': {}
@@ -16833,7 +16842,7 @@ snapshots:
'@vercel/oidc@3.0.3': {}
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@ampproject/remapping': 2.3.0
'@bcoe/v8-coverage': 1.0.2
@@ -16848,7 +16857,7 @@ snapshots:
std-env: 3.10.0
test-exclude: 7.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
@@ -16879,13 +16888,13 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
'@vitest/mocker@3.2.4(vite@7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))':
dependencies:
@@ -18477,7 +18486,7 @@ snapshots:
engine.io@6.6.4:
dependencies:
'@types/cors': 2.8.19
'@types/node': 22.19.0
'@types/node': 22.19.1
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.7.2
@@ -18866,7 +18875,7 @@ snapshots:
eval@0.1.8:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
require-like: 0.1.2
event-emitter@0.3.5:
@@ -19855,9 +19864,9 @@ snapshots:
inline-style-parser@0.2.4: {}
inquirer@8.2.7(@types/node@22.19.0):
inquirer@8.2.7(@types/node@22.19.1):
dependencies:
'@inquirer/external-editor': 1.0.2(@types/node@22.19.0)
'@inquirer/external-editor': 1.0.2(@types/node@22.19.1)
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
@@ -20071,7 +20080,7 @@ snapshots:
jest-util@29.7.0:
dependencies:
'@jest/types': 29.6.3
'@types/node': 22.19.0
'@types/node': 22.19.1
chalk: 4.1.2
ci-info: 3.9.0
graceful-fs: 4.2.11
@@ -20079,13 +20088,13 @@ snapshots:
jest-worker@27.5.1:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
merge-stream: 2.0.0
supports-color: 8.1.1
jest-worker@29.7.0:
dependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
jest-util: 29.7.0
merge-stream: 2.0.0
supports-color: 8.1.1
@@ -21343,7 +21352,7 @@ snapshots:
neo-async@2.6.2: {}
nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.0)(typescript@5.9.3):
nest-commander@3.20.1(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)(@types/inquirer@8.2.11)(@types/node@22.19.1)(typescript@5.9.3):
dependencies:
'@fig/complete-commander': 3.2.0(commander@11.1.0)
'@golevelup/nestjs-discovery': 5.0.0(@nestjs/common@11.1.8(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.8)
@@ -21352,7 +21361,7 @@ snapshots:
'@types/inquirer': 8.2.11
commander: 11.1.0
cosmiconfig: 8.3.6(typescript@5.9.3)
inquirer: 8.2.7(@types/node@22.19.0)
inquirer: 8.2.7(@types/node@22.19.1)
transitivePeerDependencies:
- '@types/node'
- typescript
@@ -22450,7 +22459,7 @@ snapshots:
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 22.19.0
'@types/node': 22.19.1
long: 5.3.2
protocol-buffers-schema@3.6.0: {}
@@ -24347,13 +24356,13 @@ snapshots:
- rollup
- supports-color
vite-node@3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vite-node@3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
cac: 6.7.14
debug: 4.4.3
es-module-lexer: 1.7.0
pathe: 2.0.3
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -24389,18 +24398,18 @@ snapshots:
- tsx
- yaml
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
dependencies:
debug: 4.4.3
globrex: 0.1.2
tsconfck: 3.1.6(typescript@5.9.3)
optionalDependencies:
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
- typescript
vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -24409,7 +24418,7 @@ snapshots:
rollup: 4.52.5
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.19.0
'@types/node': 22.19.1
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
@@ -24436,15 +24445,15 @@ snapshots:
optionalDependencies:
vite: 7.1.12(@types/node@24.10.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
vitest-fetch-mock@0.4.5(vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)):
dependencies:
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2(encoding@0.1.13)))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -24462,12 +24471,12 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.19.0
'@types/node': 22.19.1
happy-dom: 20.0.10
jsdom: 26.1.0(canvas@2.11.2(encoding@0.1.13))
transitivePeerDependencies:
@@ -24484,11 +24493,11 @@ snapshots:
- tsx
- yaml
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.0)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.1)(happy-dom@20.0.10)(jiti@2.6.1)(jsdom@26.1.0(canvas@2.11.2))(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(vite@7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -24506,12 +24515,12 @@ snapshots:
tinyglobby: 0.2.15
tinypool: 1.1.1
tinyrainbow: 2.0.0
vite: 7.1.12(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.0)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite: 7.1.12(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
vite-node: 3.2.4(@types/node@22.19.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.1)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/debug': 4.1.12
'@types/node': 22.19.0
'@types/node': 22.19.1
happy-dom: 20.0.10
jsdom: 26.1.0(canvas@2.11.2)
transitivePeerDependencies:

View File

@@ -50,12 +50,14 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
FROM builder AS plugins
COPY --from=ghcr.io/jdx/mise:2025.11.3 /usr/local/bin/mise /usr/local/bin/mise
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
WORKDIR /usr/src/app
COPY ./plugins/mise.toml ./plugins/
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
RUN mise install --cd plugins
ENV MISE_DATA_DIR=/buildcache/mise
RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \
mise install --cd plugins
COPY ./plugins ./plugins/
# Build plugins
@@ -64,6 +66,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
cd plugins && mise run build
FROM ghcr.io/immich-app/base-server-prod:202511041104@sha256:57c0379977fd5521d83cdf661aecd1497c83a9a661ebafe0a5243a09fc1064cb

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
if [ "$IMMICH_ENV" != "development" ]; then
if [[ "$IMMICH_ENV" == "production" ]]; then
echo "This command can only be run in development environments"
exit 1
fi

View File

@@ -78,6 +78,7 @@
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.6.0",
"ioredis": "^5.8.2",
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.2",
@@ -133,7 +134,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^22.19.0",
"@types/node": "^22.19.1",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",

87
server/src/app.common.ts Normal file
View File

@@ -0,0 +1,87 @@
import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service';
import { useSwagger } from 'src/utils/misc';
export function configureTelemetry() {
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.apiPort);
}
}
export async function configureExpress(
app: NestExpressApplication,
{
permitSwaggerWrite = true,
ssr,
}: {
/**
* Whether to allow swagger module to write to the specs.json
* This is not desirable when the API is not available
* @default true
*/
permitSwaggerWrite?: boolean;
/**
* Service to use for server-side rendering
*/
ssr: typeof ApiService | typeof MaintenanceWorkerService;
},
) {
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
const logger = await app.resolve(LoggingRepository);
logger.setContext('Bootstrap');
app.useLogger(logger);
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
if (configRepository.isDev()) {
app.enableCors();
}
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
if (existsSync(resourcePaths.web.root)) {
// copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46
// provides serving of precompressed assets and caching of immutable assets
app.use(
sirv(resourcePaths.web.root, {
etag: true,
gzip: true,
brotli: true,
extensions: [],
setHeaders: (res, pathname) => {
if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) {
res.setHeader('cache-control', 'public,max-age=31536000,immutable');
}
},
}),
);
}
app.use(app.get(ssr).ssr(excludePaths));
app.use(compression());
const server = await (host ? app.listen(port, host) : app.listen(port));
server.requestTimeout = 24 * 60 * 60 * 1000;
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}

View File

@@ -9,15 +9,21 @@ import { commandsAndQuestions } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers';
import { ImmichWorker } from 'src/enum';
import { MaintenanceAuthGuard } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { services } from 'src/services';
@@ -28,27 +34,27 @@ import { getKyselyConfig } from 'src/utils/database';
const common = [...repositories, ...services, GlobalExceptionFilter];
export const middleware = [
FileUploadInterceptor,
const commonMiddleware = [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_GUARD, useClass: AuthGuard },
];
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();
const imports = [
BullModule.forRoot(bull.config),
BullModule.registerQueue(...bull.queues),
const commonImports = [
ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config)),
OpenTelemetryModule.forRoot(otel),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {
const bullImports = [BullModule.forRoot(bull.config), BullModule.registerQueue(...bull.queues)];
export class BaseModule implements OnModuleInit, OnModuleDestroy {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
@@ -85,20 +91,44 @@ class BaseModule implements OnModuleInit, OnModuleDestroy {
}
@Module({
imports: [...imports, ScheduleModule.forRoot()],
imports: [...bullImports, ...commonImports, ScheduleModule.forRoot()],
controllers: [...controllers],
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.Api }],
providers: [...common, ...apiMiddleware, { provide: IWorker, useValue: ImmichWorker.Api }],
})
export class ApiModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...commonImports],
controllers: [MaintenanceWorkerController],
providers: [
ConfigRepository,
LoggingRepository,
SystemMetadataRepository,
AppRepository,
MaintenanceWebsocketRepository,
MaintenanceWorkerService,
...commonMiddleware,
{ provide: APP_GUARD, useClass: MaintenanceAuthGuard },
{ provide: IWorker, useValue: ImmichWorker.Maintenance },
],
})
export class MaintenanceModule {
constructor(
@Inject(IWorker) private worker: ImmichWorker,
logger: LoggingRepository,
) {
logger.setAppName(this.worker);
}
}
@Module({
imports: [...bullImports, ...commonImports],
providers: [...common, { provide: IWorker, useValue: ImmichWorker.Microservices }, SchedulerRegistry],
})
export class MicroservicesModule extends BaseModule {}
@Module({
imports: [...imports],
imports: [...bullImports, ...commonImports],
providers: [...common, ...commandsAndQuestions, SchedulerRegistry],
})
export class ImmichAdminModule implements OnModuleDestroy {

View File

@@ -1,5 +1,6 @@
import { GrantAdminCommand, PromptEmailQuestion, RevokeAdminCommand } from 'src/commands/grant-admin';
import { ListUsersCommand } from 'src/commands/list-users.command';
import { DisableMaintenanceModeCommand, EnableMaintenanceModeCommand } from 'src/commands/maintenance-mode';
import {
ChangeMediaLocationCommand,
PromptConfirmMoveQuestions,
@@ -16,6 +17,8 @@ export const commandsAndQuestions = [
PromptEmailQuestion,
EnablePasswordLoginCommand,
DisablePasswordLoginCommand,
EnableMaintenanceModeCommand,
DisableMaintenanceModeCommand,
EnableOAuthLogin,
DisableOAuthLogin,
ListUsersCommand,

View File

@@ -0,0 +1,37 @@
import { Command, CommandRunner } from 'nest-commander';
import { CliService } from 'src/services/cli.service';
@Command({
name: 'enable-maintenance-mode',
description: 'Enable maintenance mode or regenerate the maintenance token',
})
export class EnableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { authUrl, alreadyEnabled } = await this.service.enableMaintenanceMode();
console.info(alreadyEnabled ? 'The server is already in maintenance mode!' : 'Maintenance mode has been enabled.');
console.info(`\nLog in using the following URL:\n${authUrl}`);
}
}
@Command({
name: 'disable-maintenance-mode',
description: 'Disable maintenance mode',
})
export class DisableMaintenanceModeCommand extends CommandRunner {
constructor(private service: CliService) {
super();
}
async run(): Promise<void> {
const { alreadyDisabled } = await this.service.disableMaintenanceMode();
console.log(
alreadyDisabled ? 'The server is already out of maintenance mode!' : 'Maintenance mode has been disabled.',
);
}
}

View File

@@ -150,6 +150,7 @@ export const endpointTags: Record<ApiTag, string> = {
'Queues and background jobs are used for processing tasks asynchronously. Queues can be paused and resumed as needed.',
[ApiTag.Libraries]:
'An external library is made up of input file paths or expressions that are scanned for asset files. Discovered files are automatically imported. Assets much be unique within a library, but can be duplicated across libraries. Each user has a default upload library, and can have one or more external libraries.',
[ApiTag.Maintenance]: 'Maintenance mode allows you to put Immich in a read-only state to perform various operations.',
[ApiTag.Map]:
'Map endpoints include supplemental functionality related to geolocation, such as reverse geocoding and retrieving map markers for assets with geolocation data.',
[ApiTag.Memories]:

View File

@@ -11,6 +11,7 @@ import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller';
import { NotificationAdminController } from 'src/controllers/notification-admin.controller';
@@ -49,6 +50,7 @@ export const controllers = [
FaceController,
JobController,
LibraryController,
MaintenanceController,
MapController,
MemoryController,
NotificationController,

View File

@@ -0,0 +1,49 @@
import { BadRequestException, Body, Controller, Post, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ApiTag, ImmichCookie, MaintenanceAction, Permission } from 'src/enum';
import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { MaintenanceService } from 'src/services/maintenance.service';
import { respondWithCookie } from 'src/utils/response';
@ApiTags(ApiTag.Maintenance)
@Controller('admin/maintenance')
export class MaintenanceController {
constructor(private service: MaintenanceService) {}
@Post('login')
@Endpoint({
summary: 'Log into maintenance mode',
description: 'Login with maintenance token or cookie to receive current information and perform further actions.',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
maintenanceLogin(@Body() _dto: MaintenanceLoginDto): MaintenanceAuthDto {
throw new BadRequestException('Not in maintenance mode');
}
@Post()
@Endpoint({
summary: 'Set maintenance mode',
description: 'Put Immich into or take it out of maintenance mode',
history: new HistoryBuilder().added('v2.3.0').alpha('v2.3.0'),
})
@Authenticated({ permission: Permission.Maintenance, admin: true })
async setMaintenanceMode(
@Auth() auth: AuthDto,
@Body() dto: SetMaintenanceModeDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
if (dto.action === MaintenanceAction.Start) {
const { jwt } = await this.service.startMaintenance(auth.user.name);
return respondWithCookie(res, undefined, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: jwt }],
});
}
}
}

View File

@@ -0,0 +1,16 @@
import { MaintenanceAction } from 'src/enum';
import { ValidateEnum, ValidateString } from 'src/validation';
export class SetMaintenanceModeDto {
@ValidateEnum({ enum: MaintenanceAction, name: 'MaintenanceAction' })
action!: MaintenanceAction;
}
export class MaintenanceLoginDto {
@ValidateString({ optional: true })
token?: string;
}
export class MaintenanceAuthDto {
username!: string;
}

View File

@@ -154,6 +154,7 @@ export class ServerConfigDto {
publicUsers!: boolean;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
maintenanceMode!: boolean;
}
export class ServerFeaturesDto {

View File

@@ -5,6 +5,7 @@ export enum AuthType {
export enum ImmichCookie {
AccessToken = 'immich_access_token',
MaintenanceToken = 'immich_maintenance_token',
AuthType = 'immich_auth_type',
IsAuthenticated = 'immich_is_authenticated',
SharedLinkToken = 'immich_shared_link_token',
@@ -146,6 +147,8 @@ export enum Permission {
TimelineRead = 'timeline.read',
TimelineDownload = 'timeline.download',
Maintenance = 'maintenance',
MemoryCreate = 'memory.create',
MemoryRead = 'memory.read',
MemoryUpdate = 'memory.update',
@@ -285,6 +288,7 @@ export enum SystemMetadataKey {
FacialRecognitionState = 'facial-recognition-state',
MemoriesState = 'memories-state',
AdminOnboarding = 'admin-onboarding',
MaintenanceMode = 'maintenance-mode',
SystemConfig = 'system-config',
SystemFlags = 'system-flags',
VersionCheckState = 'version-check-state',
@@ -477,6 +481,7 @@ export enum ImmichEnvironment {
export enum ImmichWorker {
Api = 'api',
Maintenance = 'maintenance',
Microservices = 'microservices',
}
@@ -655,6 +660,15 @@ export enum DatabaseLock {
MemoryCreation = 777,
}
export enum MaintenanceAction {
Start = 'start',
End = 'end',
}
export enum ExitCode {
AppRestart = 7,
}
export enum SyncRequestType {
AlbumsV1 = 'AlbumsV1',
AlbumUsersV1 = 'AlbumUsersV1',
@@ -801,6 +815,7 @@ export enum ApiTag {
Faces = 'Faces',
Jobs = 'Jobs',
Libraries = 'Libraries',
Maintenance = 'Maintenance (admin)',
Map = 'Map',
Memories = 'Memories',
Notifications = 'Notifications',

View File

@@ -1,61 +1,151 @@
import { Kysely } from 'kysely';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { PostgresError } from 'postgres';
import { ImmichAdminModule } from 'src/app.module';
import { ImmichWorker, LogLevel } from 'src/enum';
import { ExitCode, ImmichWorker, LogLevel, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type DB } from 'src/schema';
import { getKyselyConfig } from 'src/utils/database';
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
/**
* Manages worker lifecycle
*/
class Workers {
/**
* Currently running workers
*/
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
let apiProcess: ChildProcess | undefined;
/**
* Fail-safe in case anything dies during restart
*/
restarting = false;
const onError = (name: string, error: Error) => {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
};
/**
* Boot all enabled workers
*/
async bootstrap() {
const isMaintenanceMode = await this.isMaintenanceMode();
const { workers } = new ConfigRepository().getEnv();
const onExit = (name: string, exitCode: number | null) => {
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (apiProcess && name !== ImmichWorker.Api) {
console.error('Killing api process');
apiProcess.kill('SIGTERM');
apiProcess = undefined;
if (isMaintenanceMode) {
this.startWorker(ImmichWorker.Maintenance);
} else {
for (const worker of workers) {
this.startWorker(worker);
}
}
}
process.exit(exitCode);
};
/**
* Initialise a short-lived Nest application to build configuration
* @returns System configuration
*/
private async isMaintenanceMode(): Promise<boolean> {
const { database } = new ConfigRepository().getEnv();
const kysely = new Kysely<DB>(getKyselyConfig(database.config));
const systemMetadataRepository = new SystemMetadataRepository(kysely);
function bootstrapWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
try {
const value = await systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
return value?.isMaintenanceMode || false;
} catch (error) {
// Table doesn't exist (migrations haven't run yet)
if (error instanceof PostgresError && error.code === '42P01') {
return false;
}
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let worker: Worker | ChildProcess;
if (name === ImmichWorker.Api) {
worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
apiProcess = worker;
} else {
worker = new Worker(workerFile);
throw error;
} finally {
await kysely.destroy();
}
}
worker.on('error', (error) => onError(name, error));
worker.on('exit', (exitCode) => onExit(name, exitCode));
/**
* Start an individual worker
* @param name Worker
*/
private startWorker(name: ImmichWorker) {
console.log(`Starting ${name} worker`);
// eslint-disable-next-line unicorn/prefer-module
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
let anyWorker: Worker | ChildProcess;
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
kill = (signal) => void worker.kill(signal);
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
onError(name: ImmichWorker, error: Error) {
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
}
onExit(name: ImmichWorker, exitCode: number | null) {
// restart immich server
if (exitCode === ExitCode.AppRestart || this.restarting) {
this.restarting = true;
console.info(`${name} worker shutdown for restart`);
delete this.workers[name];
// once all workers shut down, bootstrap again
if (Object.keys(this.workers).length === 0) {
void this.bootstrap();
this.restarting = false;
}
return;
}
// shutdown the entire process
delete this.workers[name];
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
}
}
process.exit(exitCode);
}
}
function bootstrap() {
function main() {
const immichApp = process.argv[2];
if (immichApp) {
process.argv.splice(2, 1);
}
if (immichApp === 'immich-admin') {
process.title = 'immich_admin_cli';
process.env.IMMICH_LOG_LEVEL = LogLevel.Warn;
return CommandFactory.run(ImmichAdminModule);
}
@@ -72,10 +162,7 @@ function bootstrap() {
}
process.title = 'immich';
const { workers } = new ConfigRepository().getEnv();
for (const worker of workers) {
bootstrapWorker(worker);
}
void new Workers().bootstrap();
}
void bootstrap();
void main();

View File

@@ -0,0 +1,58 @@
import {
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Request } from 'express';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { MetadataKey } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const MaintenanceRoute = (options = {}): MethodDecorator => {
const decorators: MethodDecorator[] = [SetMetadata(MetadataKey.AuthRoute, options)];
return applyDecorators(...decorators);
};
export interface MaintenanceAuthRequest extends Request {
auth?: MaintenanceAuthDto;
}
export interface MaintenanceAuthenticatedRequest extends Request {
auth: MaintenanceAuthDto;
}
export const MaintenanceAuth = createParamDecorator((data, context: ExecutionContext): MaintenanceAuthDto => {
return context.switchToHttp().getRequest<MaintenanceAuthenticatedRequest>().auth;
});
@Injectable()
export class MaintenanceAuthGuard implements CanActivate {
constructor(
private logger: LoggingRepository,
private reflector: Reflector,
private service: MaintenanceWorkerService,
) {
this.logger.setContext(MaintenanceAuthGuard.name);
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const targets = [context.getHandler()];
const options = this.reflector.getAllAndOverride<{ _emptyObject: never } | undefined>(
MetadataKey.AuthRoute,
targets,
);
if (!options) {
return true;
}
const request = context.switchToHttp().getRequest<MaintenanceAuthRequest>();
request.auth = await this.service.authenticate(request.headers);
return true;
}
}

View File

@@ -0,0 +1,59 @@
import { Injectable } from '@nestjs/common';
import {
OnGatewayConnection,
OnGatewayDisconnect,
OnGatewayInit,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { AppRepository } from 'src/repositories/app.repository';
import { AppRestartEvent, ArgsOf } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
export const serverEvents = ['AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
AppRestartV1: [AppRestartEvent];
}
@WebSocketGateway({
cors: true,
path: '/api/socket.io',
transports: ['websocket'],
})
@Injectable()
export class MaintenanceWebsocketRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
@WebSocketServer()
private websocketServer?: Server;
constructor(
private logger: LoggingRepository,
private appRepository: AppRepository,
) {
this.logger.setContext(MaintenanceWebsocketRepository.name);
}
afterInit(websocketServer: Server) {
this.logger.log('Initialized websocket server');
websocketServer.on('AppRestart', () => this.appRepository.exitApp());
}
clientBroadcast<T extends keyof ClientEventMap>(event: T, ...data: ClientEventMap[T]) {
this.websocketServer?.emit(event, ...data);
}
serverSend<T extends ServerEvents>(event: T, ...args: ArgsOf<T>): void {
this.logger.debug(`Server event: ${event} (send)`);
this.websocketServer?.serverSideEmit(event, ...args);
}
handleConnection(client: Socket) {
this.logger.log(`Websocket Connect: ${client.id}`);
}
handleDisconnect(client: Socket) {
this.logger.log(`Websocket Disconnect: ${client.id}`);
}
}

View File

@@ -0,0 +1,43 @@
import { Body, Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import { MaintenanceAuthDto, MaintenanceLoginDto, SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { ServerConfigDto } from 'src/dtos/server.dto';
import { ImmichCookie, MaintenanceAction } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
import { LoginDetails } from 'src/services/auth.service';
import { respondWithCookie } from 'src/utils/response';
@Controller()
export class MaintenanceWorkerController {
constructor(private service: MaintenanceWorkerService) {}
@Get('server/config')
getServerConfig(): Promise<ServerConfigDto> {
return this.service.getSystemConfig();
}
@Post('admin/maintenance/login')
async maintenanceLogin(
@Req() request: Request,
@Body() dto: MaintenanceLoginDto,
@GetLoginDetails() loginDetails: LoginDetails,
@Res({ passthrough: true }) res: Response,
): Promise<MaintenanceAuthDto> {
const token = dto.token ?? request.cookies[ImmichCookie.MaintenanceToken];
const auth = await this.service.login(token);
return respondWithCookie(res, auth, {
isSecure: loginDetails.isSecure,
values: [{ key: ImmichCookie.MaintenanceToken, value: token }],
});
}
@Post('admin/maintenance')
@MaintenanceRoute()
async setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): Promise<void> {
if (dto.action === MaintenanceAction.End) {
await this.service.endMaintenance();
}
}
}

View File

@@ -0,0 +1,128 @@
import { UnauthorizedException } from '@nestjs/common';
import { SignJWT } from 'jose';
import { SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { automock, getMocks, ServiceMocks } from 'test/utils';
describe(MaintenanceWorkerService.name, () => {
let sut: MaintenanceWorkerService;
let mocks: ServiceMocks;
let maintenanceWorkerRepositoryMock: MaintenanceWebsocketRepository;
beforeEach(() => {
mocks = getMocks();
maintenanceWorkerRepositoryMock = automock(MaintenanceWebsocketRepository, { args: [mocks.logger], strict: false });
sut = new MaintenanceWorkerService(
mocks.logger as never,
mocks.app,
mocks.config,
mocks.systemMetadata as never,
maintenanceWorkerRepositoryMock,
);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getSystemConfig', () => {
it('should respond the server is in maintenance mode', async () => {
await expect(sut.getSystemConfig()).resolves.toMatchObject(
expect.objectContaining({
maintenanceMode: true,
}),
);
expect(mocks.systemMetadata.get).toHaveBeenCalled();
});
});
describe('logSecret', () => {
const RE_LOGIN_URL = /https:\/\/my.immich.app\/maintenance\?token=([A-Za-z0-9-_]*\.[A-Za-z0-9-_]*\.[A-Za-z0-9-_]*)/;
it('should log a valid login URL', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(sut.logSecret()).resolves.toBeUndefined();
expect(mocks.logger.log).toHaveBeenCalledWith(expect.stringMatching(RE_LOGIN_URL));
const [url] = mocks.logger.log.mock.lastCall!;
const token = RE_LOGIN_URL.exec(url)![1];
await expect(sut.login(token)).resolves.toEqual(
expect.objectContaining({
username: 'immich-admin',
}),
);
});
});
describe('authenticate', () => {
it('should fail without a cookie', async () => {
await expect(sut.authenticate({})).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should parse cookie properly', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
await expect(
sut.authenticate({
cookie: 'immich_maintenance_token=invalid-jwt',
}),
).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
});
describe('login', () => {
it('should fail without token', async () => {
await expect(sut.login()).rejects.toThrowError(new UnauthorizedException('Missing JWT Token'));
});
it('should fail with expired JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('0s')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).rejects.toThrowError(new UnauthorizedException('Invalid JWT Token'));
});
it('should succeed with valid JWT', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: true, secret: 'secret' });
const jwt = await new SignJWT({ _mockValue: true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('4h')
.sign(new TextEncoder().encode('secret'));
await expect(sut.login(jwt)).resolves.toEqual(
expect.objectContaining({
_mockValue: true,
}),
);
});
});
describe('endMaintenance', () => {
it('should set maintenance mode', async () => {
mocks.systemMetadata.get.mockResolvedValue({ isMaintenanceMode: false });
await expect(sut.endMaintenance()).resolves.toBeUndefined();
expect(mocks.systemMetadata.set).toHaveBeenCalledWith(SystemMetadataKey.MaintenanceMode, {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.clientBroadcast).toHaveBeenCalledWith('AppRestartV1', {
isMaintenanceMode: false,
});
expect(maintenanceWorkerRepositoryMock.serverSend).toHaveBeenCalledWith('AppRestart', {
isMaintenanceMode: false,
});
});
});
});

View File

@@ -0,0 +1,161 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { parse } from 'cookie';
import { NextFunction, Request, Response } from 'express';
import { jwtVerify } from 'jose';
import { readFileSync } from 'node:fs';
import { IncomingHttpHeaders } from 'node:http';
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
import { ImmichCookie, SystemMetadataKey } from 'src/enum';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { type ApiService as _ApiService } from 'src/services/api.service';
import { type BaseService as _BaseService } from 'src/services/base.service';
import { type ServerService as _ServerService } from 'src/services/server.service';
import { MaintenanceModeState } from 'src/types';
import { getConfig } from 'src/utils/config';
import { createMaintenanceLoginUrl } from 'src/utils/maintenance';
import { getExternalDomain } from 'src/utils/misc';
/**
* This service is available inside of maintenance mode to manage maintenance mode
*/
@Injectable()
export class MaintenanceWorkerService {
constructor(
protected logger: LoggingRepository,
private appRepository: AppRepository,
private configRepository: ConfigRepository,
private systemMetadataRepository: SystemMetadataRepository,
private maintenanceWorkerRepository: MaintenanceWebsocketRepository,
) {
this.logger.setContext(this.constructor.name);
}
/**
* {@link _BaseService.configRepos}
*/
private get configRepos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
}
/**
* {@link _BaseService.prototype.getConfig}
*/
private getConfig(options: { withCache: boolean }) {
return getConfig(this.configRepos, options);
}
/**
* {@link _ServerService.getSystemConfig}
*/
async getSystemConfig() {
const config = await this.getConfig({ withCache: false });
return {
loginPageMessage: config.server.loginPageMessage,
trashDays: config.trash.days,
userDeleteDelay: config.user.deleteDelay,
oauthButtonText: config.oauth.buttonText,
isInitialized: true,
isOnboarded: true,
externalDomain: config.server.externalDomain,
publicUsers: config.server.publicUsers,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
maintenanceMode: true,
};
}
/**
* {@link _ApiService.ssr}
*/
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
let index = '';
try {
index = readFileSync(resourcePaths.web.indexHtml).toString();
} catch {
this.logger.warn(`Unable to open ${resourcePaths.web.indexHtml}, skipping SSR.`);
}
return (request: Request, res: Response, next: NextFunction) => {
if (
request.url.startsWith('/api') ||
request.method.toLowerCase() !== 'get' ||
excludePaths.some((item) => request.url.startsWith(item))
) {
return next();
}
const maintenancePath = '/maintenance';
if (!request.url.startsWith(maintenancePath)) {
const params = new URLSearchParams();
params.set('continue', request.path);
return res.redirect(`${maintenancePath}?${params}`);
}
res.status(200).type('text/html').header('Cache-Control', 'no-store').send(index);
};
}
private async secret(): Promise<string> {
const state = (await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode)) as {
secret: string;
};
return state.secret;
}
async logSecret(): Promise<void> {
const { server } = await this.getConfig({ withCache: true });
const baseUrl = getExternalDomain(server);
const url = await createMaintenanceLoginUrl(
baseUrl,
{
username: 'immich-admin',
},
await this.secret(),
);
this.logger.log(`\n\n🚧 Immich is in maintenance mode, you can log in using the following URL:\n${url}\n`);
}
async authenticate(headers: IncomingHttpHeaders): Promise<MaintenanceAuthDto> {
const jwtToken = parse(headers.cookie || '')[ImmichCookie.MaintenanceToken];
return this.login(jwtToken);
}
async login(jwt?: string): Promise<MaintenanceAuthDto> {
if (!jwt) {
throw new UnauthorizedException('Missing JWT Token');
}
const secret = await this.secret();
try {
const result = await jwtVerify<MaintenanceAuthDto>(jwt, new TextEncoder().encode(secret));
return result.payload;
} catch {
throw new UnauthorizedException('Invalid JWT Token');
}
}
async endMaintenance(): Promise<void> {
const state: MaintenanceModeState = { isMaintenanceMode: false as const };
await this.systemMetadataRepository.set(SystemMetadataKey.MaintenanceMode, state);
// => corresponds to notification.service.ts#onAppRestart
this.maintenanceWorkerRepository.clientBroadcast('AppRestartV1', state);
this.maintenanceWorkerRepository.serverSend('AppRestart', state);
this.appRepository.exitApp();
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ExitCode } from 'src/enum';
@Injectable()
export class AppRepository {
private closeFn?: () => Promise<void>;
exitApp() {
/* eslint-disable unicorn/no-process-exit */
void this.closeFn?.().finally(() => process.exit(ExitCode.AppRestart));
// in exceptional circumstance, the application may hang
setTimeout(() => process.exit(ExitCode.AppRestart), 2000);
/* eslint-enable unicorn/no-process-exit */
}
setCloseFn(fn: () => Promise<void>) {
this.closeFn = fn;
}
}

View File

@@ -26,6 +26,7 @@ type EventMap = {
// app events
AppBootstrap: [];
AppShutdown: [];
AppRestart: [AppRestartEvent];
ConfigInit: [{ newConfig: SystemConfig }];
// config events
@@ -96,6 +97,10 @@ type EventMap = {
WebsocketConnect: [{ userId: string }];
};
export type AppRestartEvent = {
isMaintenanceMode: boolean;
};
type JobSuccessEvent = { job: JobItem; response?: JobStatus };
type JobErrorEvent = { job: JobItem; error: Error | any };

View File

@@ -3,6 +3,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
import { AppRepository } from 'src/repositories/app.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
@@ -56,6 +57,7 @@ export const repositories = [
AlbumUserRepository,
AuditRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
AssetJobRepository,
ConfigRepository,

View File

@@ -121,6 +121,23 @@ export class MediaRepository {
}
}
async copyTagGroup(tagGroup: string, source: string, target: string): Promise<boolean> {
try {
await exiftool.write(
target,
{},
{
ignoreMinorErrors: true,
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
},
);
return true;
} catch (error: any) {
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
return false;
}
}
decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
return this.getImageDecodingPipeline(input, options).raw().toBuffer({ resolveWithObject: true });
}

View File

@@ -12,11 +12,11 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { NotificationDto } from 'src/dtos/notification.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { SyncAssetExifV1, SyncAssetV1 } from 'src/dtos/sync.dto';
import { ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { AppRestartEvent, ArgsOf, EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { handlePromiseError } from 'src/utils/misc';
export const serverEvents = ['ConfigUpdate'] as const;
export const serverEvents = ['ConfigUpdate', 'AppRestart'] as const;
export type ServerEvents = (typeof serverEvents)[number];
export interface ClientEventMap {
@@ -36,6 +36,7 @@ export interface ClientEventMap {
on_session_delete: [string];
AssetUploadReadyV1: [{ asset: SyncAssetV1; exif: SyncAssetExifV1 }];
AppRestartV1: [AppRestartEvent];
}
export type AuthFn = (client: Socket) => Promise<AuthDto>;

View File

@@ -11,7 +11,7 @@ import { SharedLinkService } from 'src/services/shared-link.service';
import { VersionService } from 'src/services/version.service';
import { OpenGraphTags } from 'src/utils/misc';
const render = (index: string, meta: OpenGraphTags) => {
export const render = (index: string, meta: OpenGraphTags) => {
const [title, description, imageUrl] = [meta.title, meta.description, meta.imageUrl].map((item) =>
item ? sanitizeHtml(item, { allowedTags: [] }) : '',
);

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