Compare commits

...

41 Commits

Author SHA1 Message Date
Yaros 524b191ccc refactor: use event-manager 2026-03-27 13:55:42 +01:00
Yaros 075f7d507b refactor: use primary toast 2026-03-26 19:20:11 +01:00
Yaros c4df4d7852 Merge branch 'main' into feat/undo-archive 2026-03-26 19:11:36 +01:00
Diogo Tavares Sendim Fernandes 958f270f0d fix(web): keep map view open after closing asset viewer (#26980) 2026-03-26 18:11:05 +00:00
renovate[bot] 9f699fdfc3 chore(deps): update typescript-projects (#26973)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
2026-03-26 19:02:27 +01:00
renovate[bot] 00da7b88a1 chore(deps): update dependency @types/node to ^24.12.0 (#26966)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 13:52:45 -04:00
Michel Heusschen 144a57ddff refactor(server): use helpers for shared link queries (#27088)
* fix(server): prevent album shared link from breaking after uploads

* update test

* add withSharedAssets helper

* remove options

* add more helpers

* update selects
2026-03-26 13:51:00 -04:00
Daniel Dietzler 1bd2d474d7 fix: various comamnd palette usages (#27304) 2026-03-26 17:45:14 +00:00
Jason Rasmussen b33874ef12 feat: add support for helmet configuration (#27058) 2026-03-26 17:41:23 +00:00
bo0tzz dbaf4b548b fix: pin success-check-action to correct tag (#27230) 2026-03-26 17:37:23 +00:00
Jason Rasmussen 7d58d5be12 refactor: memory manager (#27206) 2026-03-26 17:36:25 +00:00
renovate[bot] 42fe86d24c chore(deps): update grafana/grafana docker tag to v12.4.1 (#26969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:31:08 +01:00
Michael Maycock eeb55c279b fix(web): preserve timezone when changing timestamp (Closes #25354) (#27095) 2026-03-26 17:30:47 +00:00
renovate[bot] 5c159d70a7 chore(deps): update node.js to v24.14.0 (#26972)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:30:38 +01:00
Paul Makles 44ae0fa7ed fix(database restores): don't assume onboarding has completed (#27052) 2026-03-26 18:30:14 +01:00
renovate[bot] f782782662 fix(deps): update dependency kysely to v0.28.14 [security] (#27068)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:29:53 +01:00
renovate[bot] 4436cab827 chore(deps): update dependency yaml to v2.8.3 [security] (#27293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:28:46 +01:00
renovate[bot] 74789ad1c4 chore(deps): update dependency picomatch to v4.0.4 [security] (#27281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 18:28:05 +01:00
Jason Rasmussen 7877097b3f refactor: asset viewing store (#27204) 2026-03-26 13:22:40 -04:00
Jason Rasmussen fb84c1cf61 chore: remove unused file (#27202) 2026-03-26 13:22:31 -04:00
Jason Rasmussen 940a1d4ab8 refactor: change location (#27201) 2026-03-26 13:22:14 -04:00
Jason Rasmussen fae25dbe65 chore: whitelist server deploy files (#27056) 2026-03-26 13:22:03 -04:00
Mert 8dd0d7f34c fix(server): memory fragmentation (#27027) 2026-03-26 18:21:52 +01:00
Jason Rasmussen 9b78f2c0ba chore: remove unused resources (#27055) 2026-03-26 13:21:36 -04:00
Timon 67cedfef17 feat(web): add RemoveFromAlbumAction to asset viewer nav bar (#27000) 2026-03-26 18:20:28 +01:00
Andreas Heinz c9c2322b9d feat(web): focus on face-editor search input (#27136) 2026-03-26 18:18:23 +01:00
Daniel Dietzler 389356149a refactor: actionable toasts (#27203) 2026-03-26 18:18:06 +01:00
Michel Heusschen 4812a2e2d8 fix(server): refresh unedited asset dimensions on metadata extraction (#27220) 2026-03-26 18:17:32 +01:00
Vogeluff 8f01d06927 feat(web): add a seperate tooltip for switching from dark to light mode (#27297) 2026-03-26 18:15:16 +01:00
github-actions a2ff075e9a chore: version v2.6.3 2026-03-26 16:23:35 +00:00
Brandon Wees d8b39906f9 fix: incorrect asset face sync (#27243)
* fix: incorrect asset face sync

* chore: sync sql
2026-03-26 09:39:02 -05:00
Michel Heusschen b36911a16b fix(server): filter out empty search suggestions (#27292)
* fix(server): filter out empty search suggestions

* make sql
2026-03-26 09:36:04 -05:00
Alex b074ee202e chore: move slideshow control button group to the left (#27287) 2026-03-26 14:31:11 +00:00
bo0tzz 78bb6cf926 chore: log id of existing asset on duplicate upload (#27266) 2026-03-26 11:50:53 +01:00
Yaros c980f5fc19 chore(docs): withPeople parameter description (#27262)
* fix(server): withPeople inconsistent

* fix: query failing in some occasions

* test: add medium tests for withPeople option

* Revert "test: add medium tests for withPeople option"

This reverts commit 6c1505ba6b.

* Revert "fix: query failing in some occasions"

This reverts commit 221feeca45.

* Revert "fix(server): withPeople inconsistent"

This reverts commit 4289a9f23d.

* chore: change endpoint description

* chore: generate open-api
2026-03-26 11:50:29 +01:00
Yaros a26d9e05ba fix(web): shifting motion image button (#27275) 2026-03-26 11:49:21 +01:00
bo0tzz c862163204 fix: explicitly specify repo in auto-close job (#27291) 2026-03-26 10:43:51 +00:00
Michel Heusschen 5fb8f9bf1a fix(web): prevent horizontal scroll bar in asset viewer side panel (#27270)
* fix(web): prevent horizontal scroll bar in asset viewer side panel

* simplify
2026-03-25 21:02:31 -05:00
Yaros 0eaa2c3419 refactor: remove ternary 2026-03-20 17:43:22 +01:00
Yaros 554e7b28a2 refactor: remove unnecessary checks 2026-03-20 17:13:18 +01:00
Yaros 167aad7ac2 feat(web): undo archive from toast 2026-03-19 22:13:34 +01:00
115 changed files with 2181 additions and 8236 deletions
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -66,7 +66,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
@@ -113,7 +113,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
@@ -121,7 +121,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
+2 -2
View File
@@ -178,7 +178,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
@@ -189,6 +189,6 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
+1 -1
View File
@@ -566,7 +566,7 @@ jobs:
runs-on: ubuntu-latest
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
mobile-unit-tests:
+1 -1
View File
@@ -68,6 +68,6 @@ jobs:
permissions: {}
if: always()
steps:
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
with:
needs: ${{ toJSON(needs) }}
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.6.2",
"version": "2.6.3",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
@@ -20,7 +20,7 @@
"@types/lodash-es": "^4.17.12",
"@types/micromatch": "^4.0.9",
"@types/mock-fs": "^4.13.1",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@vitest/coverage-v8": "^4.0.0",
"byte-size": "^9.0.0",
"cli-progress": "^3.12.0",
@@ -68,6 +68,6 @@
"micromatch": "^4.0.8"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
+1
View File
@@ -90,6 +90,7 @@ services:
IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues
IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app
IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides
IMMICH_HELMET_FILE: 'true'
ports:
- 9230:9230
- 9231:9231
+1 -1
View File
@@ -97,7 +97,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
image: grafana/grafana:12.4.1-ubuntu@sha256:1a20dea76a2778773df17dbc365db86b1a4f2d57772b8590b6311038a3acb1db
volumes:
- grafana-data:/var/lib/grafana
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+2 -2
View File
@@ -3,8 +3,8 @@
You may decide that you'd like to modify the style document which is used to
draw the maps in Immich. In addition to visual customization, this also allows
you to pick your own map tile provider instead of the default one. The default
`style.json` for [light theme](https://github.com/immich-app/immich/tree/main/server/resources/style-light.json)
and [dark theme](https://github.com/immich-app/immich/blob/main/server/resources/style-dark.json)
`style.json` for [light theme](https://tiles.immich.cloud/v1/style/light.json)
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
can be used as a basis for creating your own style.
There are several sources for already-made `style.json` map themes, as well as
+17 -16
View File
@@ -29,22 +29,23 @@ These environment variables are used by the `docker-compose.yml` file and do **N
## General
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
| Variable | Description | Default | Containers | Workers |
| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- |
| `TZ` | Timezone | <sup>\*1</sup> | server | microservices |
| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices |
| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices |
| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices |
| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**<sup>\*2</sup>⚠️ | `/data` | server | api, microservices |
| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices |
| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api, microservices |
| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | |
| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | |
| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api |
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices |
| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.
+1 -1
View File
@@ -58,6 +58,6 @@
"node": ">=20"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v2.6.2",
"url": "https://docs.v2.6.2.archive.immich.app"
"label": "v2.6.3",
"url": "https://docs.v2.6.3.archive.immich.app"
},
{
"label": "v2.5.6",
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.2",
"version": "2.6.3",
"description": "",
"main": "index.js",
"type": "module",
@@ -32,7 +32,7 @@
"@playwright/test": "^1.44.1",
"@socket.io/component-emitter": "^3.1.2",
"@types/luxon": "^3.4.2",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@types/pg": "^8.15.1",
"@types/pngjs": "^6.0.4",
"@types/supertest": "^6.0.2",
@@ -58,6 +58,6 @@
"vitest": "^4.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
@@ -10,7 +10,9 @@ describe('/admin/database-backups', () => {
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup();
admin = await utils.adminSetup({
onboarding: false,
});
await utils.resetBackups(admin.accessToken);
});
@@ -94,7 +96,9 @@ describe('/admin/database-backups', () => {
({ status, body }) => status === 200 && !body.maintenanceMode,
);
admin = await utils.adminSetup();
admin = await utils.adminSetup({
onboarding: false,
});
});
it.sequential('should not work when the server is configured', async () => {
+40 -2
View File
@@ -1,6 +1,7 @@
import { LoginResponseDto } from '@immich/sdk';
import { test } from '@playwright/test';
import { utils } from 'src/utils';
import { expect, test } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { testAssetDir, utils } from 'src/utils';
test.describe('Album', () => {
let admin: LoginResponseDto;
@@ -22,4 +23,41 @@ test.describe('Album', () => {
await page.reload();
await page.getByRole('button', { name: 'Select photos' }).waitFor();
});
test('should keep map view open after viewing an asset from the map and going back', async ({ context, page }) => {
await utils.setAuthCookies(context, admin.accessToken);
const imagePath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`;
const mapAsset = await utils.createAsset(admin.accessToken, {
assetData: {
bytes: readFileSync(imagePath),
filename: 'thompson-springs.jpg',
},
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
const mapAlbum = await utils.createAlbum(admin.accessToken, {
albumName: 'Map Test Album',
assetIds: [mapAsset.id],
});
await page.goto(`/albums/${mapAlbum.id}`);
const mapButton = page.getByRole('button', { name: 'Map' });
await expect(mapButton).toBeVisible();
await mapButton.click();
const mapModal = page.getByRole('dialog');
await expect(mapModal).toBeVisible();
const mapMarker = mapModal.getByRole('img', { name: /Map marker/i }).first();
await expect(mapMarker).toBeVisible();
await mapMarker.click();
await page.waitForSelector('#immich-asset-viewer');
await page.getByRole('button', { name: 'Go back' }).click();
await expect(page.locator('#immich-asset-viewer')).not.toBeVisible();
await expect(mapModal).toBeVisible();
});
});
+2 -1
View File
@@ -881,7 +881,7 @@
"daily_title_text_date": "E, MMM dd",
"daily_title_text_date_year": "E, MMM dd, yyyy",
"dark": "Dark",
"dark_theme": "Toggle dark theme",
"dark_theme": "Switch to dark theme",
"date": "Date",
"date_after": "Date after",
"date_and_time": "Date and Time",
@@ -1388,6 +1388,7 @@
"library_page_sort_title": "Album title",
"licenses": "Licenses",
"light": "Light",
"light_theme": "Switch to light theme",
"like": "Like",
"like_deleted": "Like deleted",
"link_motion_video": "Link motion video",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.6.2",
"version": "2.6.3",
"private": true,
"scripts": {
"format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.6.2"
version = "2.6.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.6.2"
version = "2.6.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+2 -2
View File
@@ -14,9 +14,9 @@ config_roots = [
]
[tools]
node = "24.13.1"
node = "24.14.0"
flutter = "3.35.7"
pnpm = "10.30.3"
pnpm = "10.32.1"
terragrunt = "0.99.4"
opentofu = "1.11.5"
java = "21.0.2"
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3040,
"android.injected.version.name" => "2.6.2",
"android.injected.version.code" => 3041,
"android.injected.version.name" => "2.6.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.6.2</string>
<string>2.6.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.6.2
- API version: 2.6.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -379,7 +379,7 @@ class MetadataSearchDto {
///
bool? withExif;
/// Include assets with people
/// Include people data in response
///
/// 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
+1 -1
View File
@@ -273,7 +273,7 @@ class RandomSearchDto {
///
bool? withExif;
/// Include assets with people
/// Include people data in response
///
/// 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
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.6.2+3040
version: 2.6.3+3041
environment:
sdk: '>=3.8.0 <4.0.0'
+3 -3
View File
@@ -15166,7 +15166,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.6.2",
"version": "2.6.3",
"contact": {}
},
"tags": [
@@ -19129,7 +19129,7 @@
"type": "boolean"
},
"withPeople": {
"description": "Include assets with people",
"description": "Include people data in response",
"type": "boolean"
},
"withStacked": {
@@ -20868,7 +20868,7 @@
"type": "boolean"
},
"withPeople": {
"description": "Include assets with people",
"description": "Include people data in response",
"type": "boolean"
},
"withStacked": {
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.6.2",
"version": "2.6.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"typescript": "^5.3.3"
},
"repository": {
@@ -28,6 +28,6 @@
"directory": "open-api/typescript-sdk"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 2.6.2
* 2.6.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -1741,7 +1741,7 @@ export type MetadataSearchDto = {
withDeleted?: boolean;
/** Include EXIF data in response */
withExif?: boolean;
/** Include assets with people */
/** Include people data in response */
withPeople?: boolean;
/** Include stacked assets */
withStacked?: boolean;
@@ -1855,7 +1855,7 @@ export type RandomSearchDto = {
withDeleted?: boolean;
/** Include EXIF data in response */
withExif?: boolean;
/** Include assets with people */
/** Include people data in response */
withPeople?: boolean;
/** Include stacked assets */
withStacked?: boolean;
+2 -2
View File
@@ -1,9 +1,9 @@
{
"name": "immich-monorepo",
"version": "2.6.2",
"version": "2.6.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
"engines": {
"pnpm": ">=10.0.0"
}
+107 -107
View File
@@ -15,9 +15,9 @@
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
"cpu": [
"ppc64"
],
@@ -32,9 +32,9 @@
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
"cpu": [
"arm"
],
@@ -49,9 +49,9 @@
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
"cpu": [
"arm64"
],
@@ -66,9 +66,9 @@
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
"cpu": [
"x64"
],
@@ -83,9 +83,9 @@
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
"cpu": [
"arm64"
],
@@ -100,9 +100,9 @@
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
"cpu": [
"x64"
],
@@ -117,9 +117,9 @@
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
"cpu": [
"arm64"
],
@@ -134,9 +134,9 @@
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
"cpu": [
"x64"
],
@@ -151,9 +151,9 @@
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
"cpu": [
"arm"
],
@@ -168,9 +168,9 @@
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
"cpu": [
"arm64"
],
@@ -185,9 +185,9 @@
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
"cpu": [
"ia32"
],
@@ -202,9 +202,9 @@
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
"cpu": [
"loong64"
],
@@ -219,9 +219,9 @@
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
"cpu": [
"mips64el"
],
@@ -236,9 +236,9 @@
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
"cpu": [
"ppc64"
],
@@ -253,9 +253,9 @@
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
"cpu": [
"riscv64"
],
@@ -270,9 +270,9 @@
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
"cpu": [
"s390x"
],
@@ -287,9 +287,9 @@
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
"cpu": [
"x64"
],
@@ -304,9 +304,9 @@
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
"cpu": [
"arm64"
],
@@ -321,9 +321,9 @@
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
"cpu": [
"x64"
],
@@ -338,9 +338,9 @@
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
"cpu": [
"arm64"
],
@@ -355,9 +355,9 @@
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
"cpu": [
"x64"
],
@@ -372,9 +372,9 @@
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
"cpu": [
"arm64"
],
@@ -389,9 +389,9 @@
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
"cpu": [
"x64"
],
@@ -406,9 +406,9 @@
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
"cpu": [
"arm64"
],
@@ -423,9 +423,9 @@
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
"cpu": [
"ia32"
],
@@ -440,9 +440,9 @@
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
"cpu": [
"x64"
],
@@ -467,9 +467,9 @@
}
},
"node_modules/esbuild": {
"version": "0.27.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
@@ -480,32 +480,32 @@
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.3",
"@esbuild/android-arm": "0.27.3",
"@esbuild/android-arm64": "0.27.3",
"@esbuild/android-x64": "0.27.3",
"@esbuild/darwin-arm64": "0.27.3",
"@esbuild/darwin-x64": "0.27.3",
"@esbuild/freebsd-arm64": "0.27.3",
"@esbuild/freebsd-x64": "0.27.3",
"@esbuild/linux-arm": "0.27.3",
"@esbuild/linux-arm64": "0.27.3",
"@esbuild/linux-ia32": "0.27.3",
"@esbuild/linux-loong64": "0.27.3",
"@esbuild/linux-mips64el": "0.27.3",
"@esbuild/linux-ppc64": "0.27.3",
"@esbuild/linux-riscv64": "0.27.3",
"@esbuild/linux-s390x": "0.27.3",
"@esbuild/linux-x64": "0.27.3",
"@esbuild/netbsd-arm64": "0.27.3",
"@esbuild/netbsd-x64": "0.27.3",
"@esbuild/openbsd-arm64": "0.27.3",
"@esbuild/openbsd-x64": "0.27.3",
"@esbuild/openharmony-arm64": "0.27.3",
"@esbuild/sunos-x64": "0.27.3",
"@esbuild/win32-arm64": "0.27.3",
"@esbuild/win32-ia32": "0.27.3",
"@esbuild/win32-x64": "0.27.3"
"@esbuild/aix-ppc64": "0.27.4",
"@esbuild/android-arm": "0.27.4",
"@esbuild/android-arm64": "0.27.4",
"@esbuild/android-x64": "0.27.4",
"@esbuild/darwin-arm64": "0.27.4",
"@esbuild/darwin-x64": "0.27.4",
"@esbuild/freebsd-arm64": "0.27.4",
"@esbuild/freebsd-x64": "0.27.4",
"@esbuild/linux-arm": "0.27.4",
"@esbuild/linux-arm64": "0.27.4",
"@esbuild/linux-ia32": "0.27.4",
"@esbuild/linux-loong64": "0.27.4",
"@esbuild/linux-mips64el": "0.27.4",
"@esbuild/linux-ppc64": "0.27.4",
"@esbuild/linux-riscv64": "0.27.4",
"@esbuild/linux-s390x": "0.27.4",
"@esbuild/linux-x64": "0.27.4",
"@esbuild/netbsd-arm64": "0.27.4",
"@esbuild/netbsd-x64": "0.27.4",
"@esbuild/openbsd-arm64": "0.27.4",
"@esbuild/openbsd-x64": "0.27.4",
"@esbuild/openharmony-arm64": "0.27.4",
"@esbuild/sunos-x64": "0.27.4",
"@esbuild/win32-arm64": "0.27.4",
"@esbuild/win32-ia32": "0.27.4",
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/typescript": {
+1060 -1095
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+6 -7
View File
@@ -15,13 +15,12 @@ log_message() {
log_message "Initializing Immich $IMMICH_SOURCE_REF"
# TODO: Update to mimalloc v3 when verified memory isn't released issue is fixed
# lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
# if [ -f "$lib_path" ]; then
# export LD_PRELOAD="$lib_path"
# else
# echo "skipping libmimalloc - path not found $lib_path"
# fi
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.3"
if [ -f "$lib_path" ]; then
export LD_PRELOAD="$lib_path"
else
echo "skipping libmimalloc - path not found $lib_path"
fi
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
SERVER_HOME="$(readlink -f "$(dirname "$0")/..")"
+21
View File
@@ -0,0 +1,21 @@
{
"contentSecurityPolicy": {
"directives": {
"default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval", "'unsafe-inline'", "https://www.gstatic.com"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "'data:'", "'blob:'"],
"connect-src": [
"'self'",
"blob:",
"https://pay.futo.org",
"https://static.immich.cloud",
"https://tiles.immich.cloud"
],
"worker-src": ["'self'", "blob:"],
"frame-src": ["'none'"],
"object-src": ["'none'"],
"base-uri": ["'self'"]
}
}
}
+10 -4
View File
@@ -1,10 +1,15 @@
{
"name": "immich",
"version": "2.6.2",
"version": "2.6.3",
"description": "",
"author": "",
"private": true,
"license": "GNU Affero General Public License version 3",
"files": [
"bin",
"dist",
"helmet.json"
],
"scripts": {
"build": "nest build",
"format": "prettier --cache --check .",
@@ -77,12 +82,13 @@
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"handlebars": "^4.7.8",
"helmet": "^8.1.0",
"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.11",
"kysely": "0.28.14",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
@@ -136,7 +142,7 @@
"@types/luxon": "^3.6.2",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^2.0.0",
"@types/node": "^24.11.0",
"@types/node": "^24.12.0",
"@types/nodemailer": "^7.0.0",
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
@@ -168,7 +174,7 @@
"vitest": "^3.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
},
"overrides": {
"sharp": "^0.34.5"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10 -3
View File
@@ -2,9 +2,10 @@ import { NestExpressApplication } from '@nestjs/platform-express';
import { json } from 'body-parser';
import compression from 'compression';
import cookieParser from 'cookie-parser';
import helmetMiddleware from 'helmet';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { IMMICH_SERVER_START, 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';
@@ -39,7 +40,7 @@ export async function configureExpress(
},
) {
const configRepository = app.get(ConfigRepository);
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
const { environment, host, port, helmet, resourcePaths, network } = configRepository.getEnv();
const logger = await app.resolve(LoggingRepository);
logger.setContext('Bootstrap');
@@ -47,6 +48,12 @@ export async function configureExpress(
app.set('trust proxy', ['loopback', ...network.trustedProxies]);
app.set('etag', 'strong');
if (helmet.config) {
app.use(helmetMiddleware(helmet.config));
logger.log('Initialized helmet middleware');
}
app.use(cookieParser());
app.use(json({ limit: '10mb' }));
@@ -83,5 +90,5 @@ export async function configureExpress(
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}] `);
logger.log(`${IMMICH_SERVER_START} on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
}
+2
View File
@@ -29,6 +29,7 @@ import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { teardownTelemetry, TelemetryRepository } from 'src/repositories/telemetry.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { WebsocketRepository } from 'src/repositories/websocket.repository';
import { services } from 'src/services';
import { AuthService } from 'src/services/auth.service';
@@ -111,6 +112,7 @@ export class ApiModule extends BaseModule {}
StorageRepository,
ProcessRepository,
DatabaseRepository,
UserRepository,
SystemMetadataRepository,
AppRepository,
MaintenanceHealthRepository,
+2
View File
@@ -4,6 +4,8 @@ import { dirname, join } from 'node:path';
import { SemVer } from 'semver';
import { ApiTag, AudioCodec, DatabaseExtension, ExifOrientation, VectorIndex } from 'src/enum';
export const IMMICH_SERVER_START = 'Immich Server is listening';
export const ErrorMessages = {
InconsistentMediaLocation:
'Detected an inconsistent media location. For more information, see https://docs.immich.app/errors#inconsistent-media-location',
+1
View File
@@ -345,6 +345,7 @@ export const columns = {
'asset.type',
'asset.width',
'asset.height',
'asset.isEdited',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
assetFilesForThumbnail: [
+4
View File
@@ -42,6 +42,10 @@ export class EnvDto {
@Optional()
IMMICH_CONFIG_FILE?: string;
@IsString()
@Optional()
IMMICH_HELMET_FILE?: string;
@IsEnum(ImmichEnvironment)
@Optional()
IMMICH_ENV?: ImmichEnvironment;
+1 -1
View File
@@ -146,7 +146,7 @@ export class RandomSearchDto extends BaseSearchWithResultsDto {
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
withStacked?: boolean;
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
@ValidateBoolean({ optional: true, description: 'Include people data in response' })
withPeople?: boolean;
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { IMMICH_SERVER_START } from 'src/constants';
@Injectable()
export class MaintenanceHealthRepository {
@@ -20,45 +21,27 @@ export class MaintenanceHealthRepository {
stdio: ['ignore', 'pipe', 'ignore', 'ipc'],
});
async function checkHealth() {
try {
const response = await fetch('http://127.0.0.1:33001/api/server/config');
const { isOnboarded } = await response.json();
if (isOnboarded) {
resolve();
} else {
reject(new Error('Server health check failed, no admin exists.'));
}
} catch (error) {
reject(error);
} finally {
if (worker.exitCode === null) {
worker.kill('SIGTERM');
}
}
}
let output = '',
alive = false;
let output = '';
worker.stdout?.on('data', (data) => {
if (alive) {
if (worker.exitCode !== null) {
return;
}
output += data;
if (output.includes('Immich Server is listening')) {
alive = true;
void checkHealth();
if (output.includes(IMMICH_SERVER_START)) {
resolve();
worker.kill('SIGTERM');
}
});
worker.on('exit', reject);
worker.on('error', reject);
worker.on('exit', (code, signal) => reject(`Server health check failed, server exited with ${signal ?? code}`));
worker.on('error', (error) => reject(`Server health check failed, process threw: ${error}`));
setTimeout(() => {
if (worker.exitCode === null) {
reject('Server health check failed, took too long to start.');
worker.kill('SIGTERM');
}
}, 20_000);
@@ -264,6 +264,7 @@ select
"asset"."type",
"asset"."width",
"asset"."height",
"asset"."isEdited",
(
select
coalesce(json_agg(agg), '[]')
+5
View File
@@ -254,6 +254,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "state" is not null
and "state" != $3
-- SearchRepository.getCities
select distinct
@@ -266,6 +267,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "city" is not null
and "city" != $3
-- SearchRepository.getCameraMakes
select distinct
@@ -278,6 +280,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "make" is not null
and "make" != $3
-- SearchRepository.getCameraModels
select distinct
@@ -290,6 +293,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "model" is not null
and "model" != $3
-- SearchRepository.getCameraLensModels
select distinct
@@ -302,3 +306,4 @@ where
and "visibility" = $2
and "deletedAt" is null
and "lensModel" is not null
and "lensModel" != $3
+126 -48
View File
@@ -3,37 +3,64 @@
-- SharedLinkRepository.get
select
"shared_link".*,
coalesce(
json_agg("a") filter (
where
"a"."id" is not null
),
'[]'
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"asset".*,
to_json("exifInfo") as "exifInfo"
from
"shared_link_asset"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
inner join lateral (
select
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as "exifInfo" on true
where
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
) as agg
) as "assets",
to_json("album") as "album"
from
"shared_link"
left join lateral (
select
"asset".*,
to_json("exifInfo") as "exifInfo"
from
"shared_link_asset"
inner join "asset" on "asset"."id" = "shared_link_asset"."assetId"
inner join lateral (
select
"asset_exif".*
from
"asset_exif"
where
"asset_exif"."assetId" = "asset"."id"
) as "exifInfo" on true
where
"shared_link"."id" = "shared_link_asset"."sharedLinkId"
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" asc
) as "a" on true
left join lateral (
select
"album".*,
@@ -60,7 +87,36 @@ from
"asset"
inner join lateral (
select
"asset_exif".*
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
@@ -74,7 +130,12 @@ from
) as "assets" on true
inner join lateral (
select
"user".*
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
@@ -95,9 +156,6 @@ where
"shared_link"."type" = $3
or "album"."id" is not null
)
group by
"shared_link"."id",
"album".*
order by
"shared_link"."createdAt" desc
@@ -134,21 +192,12 @@ from
"album"
inner join lateral (
select
"user"."id",
"user"."email",
"user"."createdAt",
"user"."profileImagePath",
"user"."isAdmin",
"user"."shouldChangePassword",
"user"."deletedAt",
"user"."oauthId",
"user"."updatedAt",
"user"."storageLabel",
"user"."name",
"user"."quotaSizeInBytes",
"user"."quotaUsageInBytes",
"user"."status",
"user"."profileChangedAt"
"id",
"name",
"email",
"avatarColor",
"profileImagePath",
"profileChangedAt"
from
"user"
where
@@ -267,7 +316,36 @@ from
"asset"
inner join lateral (
select
*
"asset_exif"."assetId",
"asset_exif"."autoStackId",
"asset_exif"."bitsPerSample",
"asset_exif"."city",
"asset_exif"."colorspace",
"asset_exif"."country",
"asset_exif"."dateTimeOriginal",
"asset_exif"."description",
"asset_exif"."exifImageHeight",
"asset_exif"."exifImageWidth",
"asset_exif"."exposureTime",
"asset_exif"."fileSizeInByte",
"asset_exif"."fNumber",
"asset_exif"."focalLength",
"asset_exif"."fps",
"asset_exif"."iso",
"asset_exif"."latitude",
"asset_exif"."lensModel",
"asset_exif"."livePhotoCID",
"asset_exif"."longitude",
"asset_exif"."make",
"asset_exif"."model",
"asset_exif"."modifyDate",
"asset_exif"."orientation",
"asset_exif"."profileDescription",
"asset_exif"."projectionType",
"asset_exif"."rating",
"asset_exif"."state",
"asset_exif"."tags",
"asset_exif"."timeZone"
from
"asset_exif"
where
-1
View File
@@ -582,7 +582,6 @@ where
"asset_face"."updateId" < $1
and "asset_face"."updateId" > $2
and "asset"."ownerId" = $3
and "asset_face"."isVisible" = $4
order by
"asset_face"."updateId" asc
@@ -5,9 +5,11 @@ import { QueueOptions } from 'bullmq';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { Request, Response } from 'express';
import { HelmetOptions } from 'helmet';
import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
@@ -58,6 +60,10 @@ export interface EnvData {
config: ClsModuleOptions;
};
helmet: {
config?: HelmetOptions;
};
database: {
config: DatabaseConnectionParams;
skipMigrations: boolean;
@@ -143,6 +149,25 @@ const asSet = <T>(value: string | undefined, defaults: T[]) => {
return new Set(values.length === 0 ? defaults : (values as T[]));
};
const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) => {
// default is off
if (!helmetFile || helmetFile === 'false') {
return;
}
helmetFile =
helmetFile === 'true'
? // eslint-disable-next-line unicorn/prefer-module
join(__dirname, '..', '..', 'helmet.json')
: helmetFile;
try {
return JSON.parse(readFileSync(helmetFile).toString()) as HelmetOptions;
} catch (error) {
throw new Error(`Failed to read helmet file: ${helmetFile}`, { cause: error });
}
};
const getEnv = (): EnvData => {
const dto = plainToInstance(EnvDto, process.env);
const errors = validateSync(dto);
@@ -289,6 +314,10 @@ const getEnv = (): EnvData => {
vectorExtension,
},
helmet: {
config: resolveHelmetFile(dto.IMMICH_HELMET_FILE),
},
licensePublicKey: isProd ? productionKeys : stagingKeys,
network: {
+3 -5
View File
@@ -502,10 +502,7 @@ export class SearchRepository {
return res.map((row) => row.lensModel!);
}
private getExifField<K extends 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel'>(
field: K,
userIds: string[],
) {
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
return this.db
.selectFrom('asset_exif')
.select(field)
@@ -514,6 +511,7 @@ export class SearchRepository {
.where('ownerId', '=', anyUuid(userIds))
.where('visibility', '=', AssetVisibility.Timeline)
.where('deletedAt', 'is', null)
.where(field, 'is not', null);
.where(field, 'is not', null)
.where(field, '!=', '');
}
}
+49 -105
View File
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
@@ -17,6 +17,41 @@ export type SharedLinkSearchOptions = {
albumId?: string;
};
const withSharedAssets = (eb: ExpressionBuilder<DB, 'shared_link'>) => {
return eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc');
};
export const withExifInfo = (eb: ExpressionBuilder<DB, 'asset'>) => {
return eb
.selectFrom('asset_exif')
.select(columns.exif)
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('exifInfo');
};
const withAlbumOwner = (eb: ExpressionBuilder<DB, 'album'>) => {
return eb
.selectFrom('user')
.select(columns.user)
.whereRef('user.id', '=', 'album.ownerId')
.where('user.deletedAt', 'is', null)
.as('owner');
};
const withSharedLinkAlbum = (eb: ExpressionBuilder<DB, 'shared_link'>) => {
return eb
.selectFrom('album')
.selectAll('album')
.whereRef('album.id', '=', 'shared_link.albumId')
.where('album.deletedAt', 'is', null);
};
@Injectable()
export class SharedLinkRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -26,35 +61,16 @@ export class SharedLinkRepository {
return this.db
.selectFrom('shared_link')
.selectAll('shared_link')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.innerJoinLateral(
(eb) =>
eb
.selectFrom('asset_exif')
.selectAll('asset_exif')
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('exifInfo'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo'))
.orderBy('asset.fileCreatedAt', 'asc')
.as('a'),
(join) => join.onTrue(),
.select((eb) =>
jsonArrayFrom(
withSharedAssets(eb)
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')),
).as('assets'),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('album')
.selectAll('album')
.whereRef('album.id', '=', 'shared_link.albumId')
.where('album.deletedAt', 'is', null)
withSharedLinkAlbum(eb)
.leftJoin('album_asset', 'album_asset.albumId', 'album.id')
.leftJoinLateral(
(eb) =>
@@ -63,30 +79,13 @@ export class SharedLinkRepository {
.selectAll('asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.where('asset.deletedAt', 'is', null)
.innerJoinLateral(
(eb) =>
eb
.selectFrom('asset_exif')
.selectAll('asset_exif')
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('exifInfo'),
(join) => join.onTrue(),
)
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
.select((eb) => eb.fn.toJson(eb.table('exifInfo')).as('exifInfo'))
.orderBy('asset.fileCreatedAt', 'asc')
.as('assets'),
(join) => join.onTrue(),
)
.innerJoinLateral(
(eb) =>
eb
.selectFrom('user')
.selectAll('user')
.whereRef('user.id', '=', 'album.ownerId')
.where('user.deletedAt', 'is', null)
.as('owner'),
(join) => join.onTrue(),
)
.innerJoinLateral(withAlbumOwner, (join) => join.onTrue())
.select((eb) =>
eb.fn
.coalesce(
@@ -104,17 +103,6 @@ export class SharedLinkRepository {
.as('album'),
(join) => join.onTrue(),
)
.select((eb) =>
eb.fn
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
.$castTo<
(ShallowDehydrateObject<Selectable<AssetTable>> & {
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
})[]
>()
.as('assets'),
)
.groupBy(['shared_link.id', sql`"album".*`])
.select((eb) => eb.fn.toJson(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
.where('shared_link.id', '=', id)
.where('shared_link.userId', '=', userId)
@@ -128,53 +116,13 @@ export class SharedLinkRepository {
return this.db
.selectFrom('shared_link')
.selectAll('shared_link')
.select((eb) => jsonArrayFrom(withSharedAssets(eb).limit(1)).as('assets'))
.where('shared_link.userId', '=', userId)
.select((eb) =>
jsonArrayFrom(
eb
.selectFrom('shared_link_asset')
.whereRef('shared_link.id', '=', 'shared_link_asset.sharedLinkId')
.innerJoin('asset', 'asset.id', 'shared_link_asset.assetId')
.where('asset.deletedAt', 'is', null)
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc')
.limit(1),
).as('assets'),
)
.leftJoinLateral(
(eb) =>
eb
.selectFrom('album')
.selectAll('album')
.whereRef('album.id', '=', 'shared_link.albumId')
.innerJoinLateral(
(eb) =>
eb
.selectFrom('user')
.select([
'user.id',
'user.email',
'user.createdAt',
'user.profileImagePath',
'user.isAdmin',
'user.shouldChangePassword',
'user.deletedAt',
'user.oauthId',
'user.updatedAt',
'user.storageLabel',
'user.name',
'user.quotaSizeInBytes',
'user.quotaUsageInBytes',
'user.status',
'user.profileChangedAt',
])
.whereRef('user.id', '=', 'album.ownerId')
.where('user.deletedAt', 'is', null)
.as('owner'),
(join) => join.onTrue(),
)
withSharedLinkAlbum(eb)
.innerJoinLateral(withAlbumOwner, (join) => join.onTrue())
.select((eb) => eb.fn.toJson('owner').as('owner'))
.where('album.deletedAt', 'is', null)
.as('album'),
(join) => join.onTrue(),
)
@@ -283,11 +231,7 @@ export class SharedLinkRepository {
.selectFrom('asset')
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
.selectAll('asset')
.innerJoinLateral(
(eb) =>
eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exifInfo'),
(join) => join.onTrue(),
)
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
.as('assets'),
(join) => join.onTrue(),
)
@@ -487,7 +487,6 @@ class AssetFaceSync extends BaseSync {
])
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
.where('asset.ownerId', '=', options.userId)
.where('asset_face.isVisible', '=', true)
.stream();
}
}
@@ -0,0 +1,10 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Sync query for faces was incorrect on server <=2.6.2
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('AssetFaceV1', 'AssetFaceV2')`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented
}
@@ -356,6 +356,7 @@ export class AssetMediaService extends BaseService {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
@@ -27,6 +27,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
maintenanceHealthRepositoryMock as never,
@@ -187,6 +188,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -400,6 +402,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -474,6 +477,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -536,6 +540,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
void 0 as never,
@@ -663,6 +668,7 @@ describe(DatabaseBackupService.name, () => {
mocks.systemMetadata as never,
mocks.process,
mocks.database as never,
mocks.user as never,
mocks.cron as never,
mocks.job as never,
maintenanceHealthRepositoryMock,
@@ -678,6 +684,8 @@ describe(DatabaseBackupService.name, () => {
it('should successfully restore a backup', async () => {
let writtenToPsql = '';
mocks.user.hasAdmin.mockResolvedValue(true);
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => {
@@ -740,6 +748,8 @@ describe(DatabaseBackupService.name, () => {
it('should generate pg_dumpall specific SQL instructions', async () => {
let writtenToPsql = '';
mocks.user.hasAdmin.mockResolvedValue(true);
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => mockDuplex()('command', 0, 'data', ''));
mocks.process.spawnDuplexStream.mockImplementationOnce(() => {
@@ -834,7 +844,24 @@ describe(DatabaseBackupService.name, () => {
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
});
it('should rollback if there is no admin user', async () => {
mocks.user.hasAdmin.mockResolvedValue(false);
const progress = vitest.fn();
await expect(
sut.restoreDatabaseBackup('development-filename.sql', progress),
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Server health check failed, no admin exists.]`);
expect(progress).toHaveBeenCalledWith('backup', 0.05);
expect(progress).toHaveBeenCalledWith('migrations', 0.9);
expect(progress).toHaveBeenCalledWith('rollback', 0);
expect(mocks.user.hasAdmin).toHaveBeenCalled();
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
});
it('should rollback if API healthcheck fails', async () => {
mocks.user.hasAdmin.mockResolvedValue(true);
maintenanceHealthRepositoryMock.checkApiHealth.mockRejectedValue(new Error('Health Error'));
const progress = vitest.fn();
@@ -846,6 +873,7 @@ describe(DatabaseBackupService.name, () => {
expect(progress).toHaveBeenCalledWith('migrations', 0.9);
expect(progress).toHaveBeenCalledWith('rollback', 0);
expect(mocks.user.hasAdmin).toHaveBeenCalled();
expect(maintenanceHealthRepositoryMock.checkApiHealth).toHaveBeenCalled();
expect(mocks.process.spawnDuplexStream).toHaveBeenCalledTimes(4);
});
@@ -20,6 +20,7 @@ import { LoggingRepository } from 'src/repositories/logging.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { getConfig } from 'src/utils/config';
import {
findDatabaseBackupVersion,
@@ -40,6 +41,7 @@ export class DatabaseBackupService {
private readonly systemMetadataRepository: SystemMetadataRepository,
private readonly processRepository: ProcessRepository,
private readonly databaseRepository: DatabaseRepository,
private readonly userRepository: UserRepository,
@Optional()
private readonly cronRepository: CronRepository,
@Optional()
@@ -405,7 +407,14 @@ export class DatabaseBackupService {
try {
progressCb?.('migrations', 0.9);
await this.databaseRepository.runMigrations();
const hasAdmin = await this.userRepository.hasAdmin();
if (!hasAdmin) {
throw new Error('Server health check failed, no admin exists.');
}
await this.maintenanceHealthRepository.checkApiHealth();
} catch (error) {
progressCb?.('rollback', 0);
+22 -2
View File
@@ -1641,12 +1641,32 @@ describe(MetadataService.name, () => {
);
});
it('should not overwrite existing width/height if they already exist', async () => {
const asset = AssetFactory.create({ width: 1920, height: 1080 });
it('should overwrite existing width/height for unedited assets', async () => {
const asset = AssetFactory.create({ width: 1920, height: 1080, isEdited: false });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: 1280,
height: 720,
}),
);
});
it('should not overwrite existing width/height for edited assets', async () => {
const asset = AssetFactory.create({ width: 1920, height: 1080, isEdited: true });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: undefined,
height: undefined,
}),
);
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({
width: 1280,
+3 -4
View File
@@ -327,10 +327,9 @@ export class MetadataService extends BaseService {
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
fileModifiedAt: stats.mtime,
// only update the dimensions if they don't already exist
// we don't want to overwrite width/height that are modified by edits
width: asset.width == null ? assetWidth : undefined,
height: asset.height == null ? assetHeight : undefined,
// Keep unedited assets in sync with the file on disk, but don't overwrite edited dimensions.
width: !asset.isEdited || asset.width == null ? assetWidth : undefined,
height: !asset.isEdited || asset.height == null ? assetHeight : undefined,
}),
async () => {
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
+1
View File
@@ -138,6 +138,7 @@ export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']
originalPath: asset.originalPath,
ownerId: asset.ownerId,
type: asset.type,
isEdited: asset.isEdited,
width: asset.width,
height: asset.height,
faces: asset.faces.map((face) => getDehydrated(face)),
@@ -1,4 +1,5 @@
import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -108,4 +109,25 @@ describe(SearchService.name, () => {
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
});
describe('getSearchSuggestions', () => {
it('should filter out empty search suggestions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { asset: assetWithEmptyMake } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: assetWithEmptyMake.id, make: '' });
const auth = factory.auth({ user: { id: user.id } });
const suggestions = await sut.getSearchSuggestions(auth, {
type: SearchSuggestionType.CAMERA_MAKE,
includeNull: true,
});
expect(suggestions).toEqual(['Canon', null]);
});
});
});
@@ -372,6 +372,43 @@ describe(SharedLinkService.name, () => {
});
describe('get', () => {
it('should return an album shared link with assets', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user });
const { album } = await ctx.newAlbum({ ownerId: user.id });
const [{ asset: asset1 }, { asset: asset2 }] = await Promise.all([
ctx.newAsset({ ownerId: user.id }),
ctx.newAsset({ ownerId: user.id }),
]);
await Promise.all([
ctx.newExif({ assetId: asset1.id, make: 'Canon' }),
ctx.newExif({ assetId: asset2.id, make: 'Canon' }),
]);
const sharedLinkRepo = ctx.get(SharedLinkRepository);
const sharedLink = await sharedLinkRepo.create({
key: randomBytes(16),
id: factory.uuid(),
userId: user.id,
albumId: album.id,
allowUpload: true,
type: SharedLinkType.Album,
});
await sharedLinkRepo.addAssets(sharedLink.id, [asset1.id, asset2.id]);
const result = await sut.get(auth, sharedLink.id);
const assetIds = result.assets.map((asset) => asset.id);
expect(result).toMatchObject({
id: sharedLink.id,
album: expect.objectContaining({ id: album.id }),
});
expect(assetIds).toHaveLength(2);
expect(assetIds).toEqual(expect.arrayContaining([asset1.id, asset2.id]));
});
it('should not return trashed assets for an individual shared link', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
@@ -35,6 +35,10 @@ const envData: EnvData = {
vectorExtension: DatabaseExtension.Vectors,
},
helmet: {
config: {},
},
licensePublicKey: {
client: 'client-public-key',
server: 'server-public-key',
+1 -1
View File
@@ -1 +1 @@
24.13.1
24.14.0
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.6.2",
"version": "2.6.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.65.3",
"@immich/ui": "^0.69.0",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",
@@ -100,7 +100,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.13",
"svelte": "5.54.1",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.2.2",
@@ -110,6 +110,6 @@
"vitest": "^4.0.0"
},
"volta": {
"node": "24.13.1"
"node": "24.14.0"
}
}
-33
View File
@@ -1,33 +0,0 @@
<script lang="ts">
import { Button, ToastContainer, ToastContent, type Color, type IconLike } from '@immich/ui';
type Props = {
onClose?: () => void;
color?: Color;
title: string;
icon?: IconLike | false;
description: string;
button?: {
text: string;
color?: Color;
onClick: () => void;
};
};
const { onClose, title, description, color, icon, button }: Props = $props();
const onClick = () => {
button?.onClick();
onClose?.();
};
</script>
<ToastContainer {color}>
<ToastContent {color} {title} {description} {onClose} {icon}>
{#if button}
<div class="flex justify-end gap-2 px-2 pb-2 me-3 mt-2">
<Button color={button.color ?? 'secondary'} size="small" onclick={onClick}>{button.text}</Button>
</div>
{/if}
</ToastContent>
</ToastContainer>
@@ -1,7 +1,8 @@
<script lang="ts">
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import MapModal from '$lib/modals/MapModal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { navigate } from '$lib/utils/navigation';
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
import { IconButton, modalManager } from '@immich/ui';
import { mdiMapOutline } from '@mdi/js';
@@ -14,8 +15,8 @@
let { album }: Props = $props();
let abortController: AbortController;
let { setAssetId } = assetViewingStore;
let returnToMap = $state(false);
let mapMarkers: MapMarkerResponseDto[] = $state([]);
onMount(async () => {
@@ -24,7 +25,14 @@
onDestroy(() => {
abortController?.abort();
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
});
$effect(() => {
if (!assetViewerManager.isViewing && returnToMap) {
returnToMap = false;
void onClick();
}
});
async function loadMapMarkers() {
@@ -52,13 +60,15 @@
return markers;
}
async function openMap() {
const onClick = async () => {
const assetIds = await modalManager.show(MapModal, { mapMarkers });
if (assetIds) {
await setAssetId(assetIds[0]);
await navigate({ targetRoute: 'current', assetId: assetIds[0] });
returnToMap = true;
} else {
returnToMap = false;
}
}
};
</script>
<IconButton
@@ -66,6 +76,6 @@
shape="round"
color="secondary"
icon={mdiMapOutline}
onclick={openMap}
onclick={onClick}
aria-label={$t('map')}
/>
@@ -5,12 +5,12 @@
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { handleDownloadAlbum } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
@@ -34,7 +34,6 @@
const album = sharedLink.album as AlbumResponseDto;
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
let { slideshowState, slideshowNavigation } = slideshowStore;
const options = $derived({ albumId: album.id, order: album.order });
@@ -55,7 +54,9 @@
? await timelineManager.getRandomAsset()
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
if (asset) {
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
handlePromiseError(
assetViewerManager.setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)),
);
}
};
@@ -66,7 +67,7 @@
use:shortcut={{
shortcut: { key: 'Escape' },
onShortcut: () => {
if (!$showAssetViewer && assetInteraction.selectionActive) {
if (!assetViewerManager.isViewing && assetInteraction.selectionActive) {
cancelMultiselect(assetInteraction);
}
},
@@ -8,6 +8,7 @@
import KeepThisDeleteOthersAction from '$lib/components/asset-viewer/actions/keep-this-delete-others.svelte';
import RatingAction from '$lib/components/asset-viewer/actions/rating-action.svelte';
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/remove-asset-from-stack.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/restore-action.svelte';
import SetAlbumCoverAction from '$lib/components/asset-viewer/actions/set-album-cover-action.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/set-person-featured-action.svelte';
@@ -15,8 +16,10 @@
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/set-stack-primary-asset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/set-visibility-action.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/unstack-action.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
@@ -36,8 +39,6 @@
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import {
mdiArrowLeft,
mdiArrowRight,
@@ -60,6 +61,7 @@
onUndoDelete?: OnUndoDelete;
onPlaySlideshow: () => void;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
@@ -75,11 +77,13 @@
onUndoDelete = undefined,
onPlaySlideshow,
onClose,
onRemoveFromAlbum,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
const isOwner = $derived($user && asset.ownerId === $user?.id);
const isAlbumOwner = $derived($user && album?.ownerId === $user?.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
@@ -120,10 +124,10 @@
<ActionButton action={Cast} />
<ActionButton action={Actions.Share} />
<ActionButton action={Actions.Offline} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.ZoomIn} />
<ActionButton action={Actions.ZoomOut} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.Copy} />
<ActionButton action={Actions.SharedLinkDownload} />
<ActionButton action={Actions.Info} />
@@ -154,6 +158,9 @@
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
{#if isOwner}
<AddToStackAction {asset} {stack} {onAction} />
@@ -1,5 +1,6 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { renderWithTooltips } from '$tests/helpers';
import { updateAsset } from '@immich/sdk';
@@ -41,6 +42,7 @@ describe('AssetViewer', () => {
});
afterEach(() => {
slideshowStore.slideshowState.set(SlideshowState.None);
resetSavedUser();
vi.clearAllMocks();
});
@@ -14,7 +14,6 @@
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { getAssetActions } from '$lib/services/asset.service';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { ocrManager } from '$lib/stores/ocr.svelte';
import { alwaysLoadOriginalVideo } from '$lib/stores/preferences.store';
@@ -71,6 +70,7 @@
onAction?: OnAction;
onUndoDelete?: OnUndoDelete;
onClose?: (asset: AssetResponseDto) => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
onRandom?: () => Promise<{ id: string } | undefined>;
}
@@ -86,10 +86,10 @@
onAction,
onUndoDelete,
onClose,
onRemoveFromAlbum,
onRandom,
}: Props = $props();
const { setAssetId } = assetViewingStore;
const {
restartProgress: restartSlideshowProgress,
stopProgress: stopSlideshowProgress,
@@ -188,7 +188,7 @@
if (editManager.hasAppliedEdits) {
const refreshedAsset = await getAssetInfo({ id: asset.id });
onAssetChange?.(refreshedAsset);
assetViewingStore.setAsset(refreshedAsset);
assetViewerManager.setAsset(refreshedAsset);
}
assetViewerManager.closeEditor();
};
@@ -239,7 +239,7 @@
}
if ($slideshowRepeat && slideshowStartAssetId) {
await setAssetId(slideshowStartAssetId);
await assetViewerManager.setAssetId(slideshowStartAssetId);
$restartSlideshowProgress = true;
return;
}
@@ -255,7 +255,7 @@
let assetViewerHtmlElement = $state<HTMLElement>();
const slideshowHistory = new SlideshowHistory((asset) => {
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
handlePromiseError(assetViewerManager.setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
});
const handleVideoStarted = () => {
@@ -478,6 +478,7 @@
{onUndoDelete}
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
onClose={onClose ? () => onClose(asset) : undefined}
{onRemoveFromAlbum}
{playOriginalVideo}
{setPlayOriginalVideo}
/>
@@ -485,7 +486,7 @@
{/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex justify-center">
<div class="absolute inset-s-0 top-0 flex w-full justify-start">
<SlideshowBar
{isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type}
@@ -580,17 +581,16 @@
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
class={[
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
showDetailPanel ? 'w-90' : 'w-100',
]}
translate="yes"
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} />
</div>
<DetailPanel {asset} currentAlbum={album} />
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
<EditorPanel {asset} onClose={closeEditor} />
</div>
<EditorPanel {asset} onClose={closeEditor} />
{/if}
</div>
{/if}
@@ -1,24 +1,20 @@
<script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import Portal from '$lib/elements/Portal.svelte';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
import { Icon } from '@immich/ui';
import { Icon, modalManager } from '@immich/ui';
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
isOwner: boolean;
asset: AssetResponseDto;
}
};
let { isOwner, asset = $bindable() }: Props = $props();
let isShowChangeLocation = $state(false);
const onClose = async (point?: { lng: number; lat: number }) => {
isShowChangeLocation = false;
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
if (!point) {
return;
}
@@ -38,7 +34,7 @@
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
onclick={isOwner ? onAction : undefined}
title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={isOwner}
>
@@ -72,12 +68,11 @@
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
onclick={() => (isShowChangeLocation = true)}
onclick={onAction}
title={$t('add_location')}
>
<div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
<p>{$t('add_a_location')}</p>
</div>
<div class="focus:outline-none p-1">
@@ -85,9 +80,3 @@
</div>
</button>
{/if}
{#if isShowChangeLocation}
<Portal>
<ChangeLocation {asset} {onClose} />
</Portal>
{/if}
@@ -1,16 +1,16 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { shortcut } from '$lib/actions/shortcut';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
@@ -27,6 +27,7 @@
let faceRect: Rect | undefined = $state();
let faceSelectorEl: HTMLDivElement | undefined = $state();
let scrollableListEl: HTMLDivElement | undefined = $state();
let searchInputEl: HTMLInputElement | null = $state(null);
let page = $state(1);
let candidates = $state<PersonResponseDto[]>([]);
@@ -81,6 +82,8 @@
onMount(async () => {
setupCanvas();
await getPeople();
await tick();
searchInputEl?.focus();
});
const imageContentMetrics = $derived.by(() => {
@@ -221,12 +224,15 @@
$effect(() => {
const rect = faceRect;
if (rect) {
const cvs = canvas;
if (rect && cvs) {
rect.on('moving', positionFaceSelector);
rect.on('scaling', positionFaceSelector);
cvs.on('object:modified', () => searchInputEl?.focus());
return () => {
rect.off('moving', positionFaceSelector);
rect.off('scaling', positionFaceSelector);
cvs.off('object:modified', () => searchInputEl?.focus());
};
}
});
@@ -281,7 +287,7 @@
},
});
await assetViewingStore.setAssetId(assetId);
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, 'Error tagging face');
} finally {
@@ -290,7 +296,7 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel }} />
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel, ignoreInputFields: false }} />
<div
id="face-editor-data"
@@ -310,7 +316,7 @@
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
<div class="my-3 relative">
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
<Input placeholder={$t('search_people')} bind:value={searchTerm} bind:ref={searchInputEl} size="tiny" />
</div>
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
@@ -3,7 +3,6 @@
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -179,7 +178,7 @@
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
await assetViewingStore.setAssetId(assetId);
await assetViewerManager.setAssetId(assetId);
} catch (error) {
handleError(error, $t('error_delete_face'));
}
@@ -19,13 +19,13 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { QueryParameter } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { memoryManager, type MemoryAsset } from '$lib/managers/memory-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { getAssetBulkActions } from '$lib/services/asset.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { memoryStore, type MemoryAsset } from '$lib/stores/memory.store.svelte';
import { locale, videoViewerMuted, videoViewerVolume } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetMediaUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
@@ -77,7 +77,6 @@
let isSaved = $derived(current?.memory.isSaved);
let viewerHeight = $state(0);
const { isViewing } = assetViewingStore;
const viewport: Viewport = $state({ width: 0, height: 0 });
// need to include padding in the viewport for gallery
const galleryViewport: Viewport = $derived({ height: viewport.height, width: viewport.width - 32 });
@@ -87,7 +86,7 @@
const asHref = (asset: { id: string }) => `?${QueryParameter.ID}=${asset.id}`;
const handleNavigate = async (asset?: { id: string }) => {
if ($isViewing) {
if (assetViewerManager.isViewing) {
return asset;
}
@@ -187,7 +186,7 @@
if (!current) {
return;
}
memoryStore.hideAssetsFromMemory(ids);
memoryManager.hideAssetsFromMemory(ids);
init(page);
};
@@ -196,7 +195,7 @@
return;
}
await memoryStore.deleteAssetFromMemory(current.asset.id);
await memoryManager.deleteAssetFromMemory(current.asset.id);
init(page);
};
@@ -205,7 +204,7 @@
return;
}
await memoryStore.deleteMemory(current.memory.id);
await memoryManager.deleteMemory(current.memory.id);
toastManager.primary($t('removed_memory'));
init(page);
};
@@ -216,7 +215,7 @@
}
const newSavedState = !current.memory.isSaved;
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
await memoryManager.updateMemorySaved(current.memory.id, newSavedState);
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
init(page);
};
@@ -254,11 +253,11 @@
const loadFromParams = (page: Page | NavigationTarget | null) => {
const assetId = page?.params?.assetId ?? page?.url.searchParams.get(QueryParameter.ID) ?? undefined;
return memoryStore.getMemoryAsset(assetId);
return memoryManager.getMemoryAsset(assetId);
};
const init = (target: Page | NavigationTarget | null) => {
if (memoryStore.memories.length === 0) {
if (memoryManager.memories.length === 0) {
return handlePromiseError(goto(Route.photos()));
}
@@ -281,7 +280,7 @@
if (playerInitialized || isVideoAssetButPlayerHasNotLoadedYet) {
return;
}
if ($isViewing) {
if (assetViewerManager.isViewing) {
handlePromiseError(handleAction('initPlayer[AssetViewOpen]', 'pause'));
} else if (isVideo) {
// Image assets will start playing when the image is loaded. Only autostart video assets.
@@ -291,7 +290,7 @@
};
afterNavigate(({ from, to }) => {
memoryStore.ready().then(
memoryManager.ready().then(
() => {
let target;
if (to?.params?.assetId) {
@@ -326,7 +325,7 @@
</script>
<svelte:document
use:shortcuts={$isViewing
use:shortcuts={assetViewerManager.isViewing
? []
: [
{ shortcut: { key: 'ArrowRight' }, onShortcut: () => handleNextAsset() },
@@ -3,7 +3,7 @@
import IndividualSharedViewer from '$lib/components/share-page/individual-shared-viewer.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import ThemeButton from '$lib/components/shared-components/theme-button.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { user } from '$lib/stores/user.store';
import { setSharedLink } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
@@ -31,7 +31,6 @@
const { data }: Props = $props();
let { gridScrollTarget } = assetViewingStore;
let { sharedLink, passwordRequired, key, slug, meta } = $state(data);
let { title, description } = $state(meta);
let isOwned = $derived($user ? $user.id === sharedLink?.userId : false);
@@ -48,7 +47,7 @@
$t('shared_photos_and_videos_count', { values: { assetCount: sharedLink.assets.length } });
await tick();
await navigate(
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget },
{ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: assetViewerManager.gridScrollTarget },
{ forceNavigate: true, replaceState: true },
);
} catch (error) {
@@ -2,16 +2,17 @@
import { goto } from '$app/navigation';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import Thumbnail from '$lib/components/assets/thumbnail/thumbnail.svelte';
import { AssetAction } from '$lib/constants';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import type { TimelineAsset, Viewport } from '$lib/managers/timeline-manager/types';
import AssetDeleteConfirmModal from '$lib/modals/AssetDeleteConfirmModal.svelte';
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { handlePromiseError } from '$lib/utils';
import { deleteAssets } from '$lib/utils/actions';
@@ -64,7 +65,6 @@
allowDeletion = true,
}: Props = $props();
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
const navigationAssets = $derived(viewerAssets ?? assets);
const geometry = $derived(
@@ -256,7 +256,7 @@
const shortcutList = $derived(
(() => {
if ($isViewerOpen) {
if (assetViewerManager.isViewing) {
return [];
}
@@ -351,10 +351,10 @@
}
});
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(navigationAssets, $viewingAsset),
previousAsset: getPreviousAsset(navigationAssets, $viewingAsset),
const assetCursor = $derived<AssetCursor>({
current: assetViewerManager.asset!,
nextAsset: getNextAsset(navigationAssets, assetViewerManager.asset),
previousAsset: getPreviousAsset(navigationAssets, assetViewerManager.asset),
});
</script>
@@ -408,7 +408,7 @@
{/if}
<!-- Overlay Asset Viewer -->
{#if $isViewerOpen}
{#if assetViewerManager.isViewing}
<Portal target="body">
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<AssetViewer
@@ -417,7 +417,7 @@
onRandom={handleRandom}
onAssetChange={updateCurrentAsset}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
@@ -318,12 +318,12 @@
untrack(() => map?.jumpTo({ center, zoom }));
});
const onAssetsDelete = async () => {
const onAssetsChanged = async () => {
mapMarkers = await loadMapMarkers();
};
</script>
<OnEvents {onAssetsDelete} />
<OnEvents onAssetsDelete={onAssetsChanged} onAssetsArchive={onAssetsChanged} onAssetsUnarchive={onAssetsChanged} />
<!-- We handle style loading ourselves so we set style blank here -->
<MapLibre
@@ -11,6 +11,7 @@
import HotModuleReload from '$lib/elements/HotModuleReload.svelte';
import Portal from '$lib/elements/Portal.svelte';
import Skeleton from '$lib/elements/Skeleton.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import type { DayGroup } from '$lib/managers/timeline-manager/day-group.svelte';
import { isIntersecting } from '$lib/managers/timeline-manager/internal/intersection-support.svelte';
import type { MonthGroup } from '$lib/managers/timeline-manager/month-group.svelte';
@@ -18,7 +19,6 @@
import type { TimelineAsset, TimelineManagerOptions, ViewportTopMonth } from '$lib/managers/timeline-manager/types';
import { assetsSnapshot } from '$lib/managers/timeline-manager/utils.svelte';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { mediaQueryManager } from '$lib/stores/media-query-manager.svelte';
import { isAssetViewerRoute, navigate } from '$lib/utils/navigation';
import { getTimes, type ScrubberListener } from '$lib/utils/timeline-util';
@@ -88,10 +88,7 @@
onDestroy(() => timelineManager.destroy());
$effect(() => options && void timelineManager.updateOptions(options));
let { isViewing: showAssetViewer, asset: viewingAsset, gridScrollTarget } = assetViewingStore;
let scrollableElement: HTMLElement | undefined = $state();
let timelineElement: HTMLElement | undefined = $state();
let invisible = $state(true);
// The percentage of scroll through the month that is currently intersecting the top boundary of the viewport.
@@ -209,7 +206,7 @@
timelineManager.viewportWidth = rect.width;
}
}
const scrollTarget = $gridScrollTarget?.at;
const scrollTarget = assetViewerManager.gridScrollTarget?.at;
let scrolled = false;
if (scrollTarget) {
scrolled = await scrollAndLoadAsset(scrollTarget);
@@ -518,8 +515,8 @@
});
$effect(() => {
if ($showAssetViewer) {
const { localDateTime } = getTimes($viewingAsset.fileCreatedAt, DateTime.local().offset / 60);
if (assetViewerManager.asset && assetViewerManager.isViewing) {
const { localDateTime } = getTimes(assetViewerManager.asset.fileCreatedAt, DateTime.local().offset / 60);
void timelineManager.loadMonthGroup({ year: localDateTime.year, month: localDateTime.month });
}
});
@@ -565,7 +562,7 @@
onAfterUpdate={() => {
const asset = page.url.searchParams.get('at');
if (asset) {
$gridScrollTarget = { at: asset };
assetViewerManager.gridScrollTarget = { at: asset };
}
void scrollAfterNavigate();
}}
@@ -722,7 +719,7 @@
</section>
<Portal target="body">
{#if $showAssetViewer}
{#if assetViewerManager.isViewing}
<TimelineAssetViewer bind:invisible {timelineManager} {removeAction} {withStacked} {isShared} {album} {person} />
{/if}
</Portal>
@@ -2,11 +2,11 @@
import type { Action } from '$lib/components/asset-viewer/actions/action';
import type { AssetCursor } from '$lib/components/asset-viewer/asset-viewer.svelte';
import { AssetAction } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
@@ -18,8 +18,6 @@
import { onDestroy, onMount, untrack } from 'svelte';
import { t } from 'svelte-i18n';
let { asset: viewingAsset, gridScrollTarget } = assetViewingStore;
interface Props {
timelineManager: TimelineManager;
invisible: boolean;
@@ -65,7 +63,7 @@
};
let assetCursor = $state<AssetCursor>({
current: $viewingAsset,
current: assetViewerManager.asset!,
previousAsset: undefined,
nextAsset: undefined,
});
@@ -82,9 +80,10 @@
//TODO: replace this with async derived in svelte 6
$effect(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
$viewingAsset;
untrack(() => handlePromiseError(loadCloseAssets($viewingAsset)));
const asset = assetViewerManager.asset;
if (asset) {
untrack(() => handlePromiseError(loadCloseAssets(asset)));
}
});
const handleRandom = async () => {
@@ -99,8 +98,26 @@
const handleClose = async (asset: { id: string }) => {
invisible = true;
$gridScrollTarget = { at: asset.id };
await navigate({ targetRoute: 'current', assetId: null, assetGridRouteSearchParams: $gridScrollTarget });
assetViewerManager.gridScrollTarget = { at: asset.id };
await navigate({
targetRoute: 'current',
assetId: null,
assetGridRouteSearchParams: assetViewerManager.gridScrollTarget,
});
};
const handleRemoveFromAlbum = async (assetIds: string[]) => {
timelineManager.removeAssets(assetIds);
if (!assetIds.includes(assetCursor.current.id)) {
return;
}
// keep the cleanup workflow in viewer by moving to adjacent asset first
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
(await navigateToAsset(assetCursor?.nextAsset)) ||
(await navigateToAsset(assetCursor?.previousAsset)) ||
(await handleClose(assetCursor.current));
};
const handlePreAction = async (action: Action) => {
@@ -188,7 +205,7 @@
const restoredAsset = assets[0];
const asset = await getAssetInfo({ ...authManager.params, id: restoredAsset.id });
assetViewingStore.setAsset(asset);
assetViewerManager.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: restoredAsset.id });
};
@@ -232,6 +249,7 @@
}}
onUndoDelete={handleUndoDelete}
onRandom={handleRandom}
onRemoveFromAlbum={handleRemoveFromAlbum}
onClose={handleClose}
/>
{/await}
@@ -1,26 +1,24 @@
<script lang="ts">
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
import { user } from '$lib/stores/user.store';
import { getOwnedAssetsWithWarning } from '$lib/utils/asset-utils';
import { getAssetControlContext } from '$lib/utils/context';
import { handleError } from '$lib/utils/handle-error';
import { updateAssets } from '@immich/sdk';
import { modalManager, toastManager } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
interface Props {
type Props = {
menuItem?: boolean;
}
};
let { menuItem = false }: Props = $props();
const { clearSelect, getOwnedAssets } = getAssetControlContext();
let isShowChangeLocation = $state(false);
async function handleConfirm(point?: { lng: number; lat: number }) {
isShowChangeLocation = false;
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, {});
if (!point) {
return;
}
@@ -29,20 +27,14 @@
try {
await updateAssets({ assetBulkUpdateDto: { ids, latitude: point.lat, longitude: point.lng } });
toastManager.primary();
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_update_location'));
}
}
};
</script>
{#if menuItem}
<MenuOption
text={$t('change_location')}
icon={mdiMapMarkerMultipleOutline}
onClick={() => (isShowChangeLocation = true)}
/>
{/if}
{#if isShowChangeLocation}
<ChangeLocation onClose={handleConfirm} />
<MenuOption text={$t('change_location')} icon={mdiMapMarkerMultipleOutline} onClick={onAction} />
{/if}
@@ -10,16 +10,19 @@
interface Props {
album: AlbumResponseDto;
onRemove: ((assetIds: string[]) => void) | undefined;
assetIds?: string[];
menuItem?: boolean;
}
let { album = $bindable(), onRemove, menuItem = false }: Props = $props();
let { album = $bindable(), onRemove, assetIds, menuItem = false }: Props = $props();
const { getAssets, clearSelect } = getAssetControlContext();
const context = getAssetControlContext();
const removeFromAlbum = async () => {
const ids = assetIds ?? context?.getAssets().map(({ id }) => id) ?? [];
const isConfirmed = await modalManager.showDialog({
prompt: $t('remove_assets_album_confirmation', { values: { count: getAssets().length } }),
prompt: $t('remove_assets_album_confirmation', { values: { count: ids.length } }),
});
if (!isConfirmed) {
@@ -27,7 +30,6 @@
}
try {
const ids = [...getAssets()].map((a) => a.id);
const results = await removeAssetFromAlbum({
id: album.id,
bulkIdsDto: { ids },
@@ -40,7 +42,7 @@
const count = results.filter(({ success }) => success).length;
toastManager.primary($t('assets_removed_count', { values: { count } }));
clearSelect();
context?.clearSelect();
} catch (error) {
handleError(error, $t('errors.error_removing_assets_from_album'));
}
@@ -5,6 +5,7 @@
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/timeline/actions/focus-actions';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
@@ -14,7 +15,6 @@
import ShortcutsModal from '$lib/modals/ShortcutsModal.svelte';
import { Route } from '$lib/route';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte';
import { handlePromiseError } from '$lib/utils';
@@ -32,8 +32,6 @@
let { timelineManager = $bindable(), assetInteraction, onEscape, scrollToAsset }: Props = $props();
const { isViewing: showAssetViewer } = assetViewingStore;
const trashOrDelete = async (forceRequested?: boolean) => {
const force = forceRequested || !featureFlagsManager.value.trash;
const selectedAssets = assetInteraction.selectedAssets;
@@ -142,7 +140,7 @@
};
const shortcutList = $derived.by(() => {
if (searchStore.isSearchEnabled || $showAssetViewer || isModalOpen()) {
if (searchStore.isSearchEnabled || assetViewerManager.isViewing || isModalOpen()) {
return [];
}
@@ -2,8 +2,8 @@
import { shortcuts } from '$lib/actions/shortcut';
import DuplicateAsset from '$lib/components/utilities-page/duplicates/duplicate-asset.svelte';
import Portal from '$lib/elements/Portal.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
import { suggestDuplicate } from '$lib/utils/duplicate-utils';
@@ -22,8 +22,6 @@
}
let { assets, onResolve, onStack }: Props = $props();
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
// eslint-disable-next-line svelte/no-unnecessary-state-wrap
let selectedAssetIds = $state(new SvelteSet<string>());
let trashCount = $derived(assets.length - selectedAssetIds.size);
@@ -40,7 +38,7 @@
});
onDestroy(() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
});
const onRandom = async () => {
@@ -71,7 +69,7 @@
const onViewAsset = async ({ id }: AssetResponseDto) => {
const asset = await getAssetInfo({ ...authManager.params, id });
setAsset(asset);
assetViewerManager.setAsset(asset);
await navigate({ targetRoute: 'current', assetId: asset.id });
};
@@ -86,9 +84,9 @@
};
const assetCursor = $derived({
current: $viewingAsset,
nextAsset: getNextAsset(assets, $viewingAsset),
previousAsset: getPreviousAsset(assets, $viewingAsset),
current: assetViewerManager.asset!,
nextAsset: getNextAsset(assets, assetViewerManager.asset),
previousAsset: getPreviousAsset(assets, assetViewerManager.asset),
});
</script>
@@ -166,7 +164,7 @@
</div>
</div>
{#if $showAssetViewer}
{#if assetViewerManager.isViewing}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
@@ -174,7 +172,7 @@
showNavigation={assets.length > 1}
{onRandom}
onClose={() => {
assetViewingStore.showAssetViewer(false);
assetViewerManager.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
+17 -1
View File
@@ -1,4 +1,4 @@
import { cleanClass } from '$lib';
import { cleanClass, isDefined } from '$lib';
describe('cleanClass', () => {
it('should return a string of class names', () => {
@@ -13,3 +13,19 @@ describe('cleanClass', () => {
expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3');
});
});
describe('isDefined', () => {
it('should return false for null', () => {
expect(isDefined(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isDefined(undefined)).toBe(false);
});
it('should return true for everything else', () => {
for (const value of [0, 1, 2, true, false, {}, 'foo', 'bar', []]) {
expect(isDefined(value)).toBe(true);
}
});
});
+2
View File
@@ -14,3 +14,5 @@ export const cleanClass = (...classNames: unknown[]) => {
.join(' '),
);
};
export const isDefined = <T>(value: T): value is NonNullable<T> => value !== null && value !== undefined;
@@ -1,7 +1,10 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { ImageLoaderStatus } from '$lib/utils/adaptive-image-loader.svelte';
import { canCopyImageToClipboard } from '$lib/utils/asset-utils';
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
import { PersistedLocalStorage } from '$lib/utils/persisted';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import type { ZoomImageWheelState } from '@zoom-image/core';
import { cubicOut } from 'svelte/easing';
@@ -21,7 +24,7 @@ export type Events = {
Copy: [];
};
export class AssetViewerManager extends BaseEventManager<Events> {
class AssetViewerManager extends BaseEventManager<Events> {
#zoomState = $state(createDefaultZoomState());
#animationFrameId: number | null = null;
@@ -40,6 +43,18 @@ export class AssetViewerManager extends BaseEventManager<Events> {
isPlayingMotionPhoto = $state(false);
isShowEditor = $state(false);
#viewingAssetStoreState = $state<AssetResponseDto>();
#viewState = $state<boolean>(false);
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
get asset() {
return this.#viewingAssetStoreState;
}
get isViewing() {
return this.#viewState;
}
get isImageLoading() {
return this.#isImageLoading;
}
@@ -145,6 +160,21 @@ export class AssetViewerManager extends BaseEventManager<Events> {
closeEditor() {
this.isShowEditor = false;
}
setAsset(asset: AssetResponseDto) {
this.#viewingAssetStoreState = asset;
this.#viewState = true;
}
async setAssetId(id: string): Promise<AssetResponseDto> {
const asset = await getAssetInfo({ ...authManager.params, id });
this.setAsset(asset);
return asset;
}
showAssetViewer(show: boolean) {
this.#viewState = show;
}
}
export const assetViewerManager = new AssetViewerManager();
@@ -34,6 +34,7 @@ export type Events = {
AssetUpdate: [AssetResponseDto];
AssetsArchive: [string[]];
AssetsUnarchive: [string[]];
AssetsDelete: [string[]];
AssetEditsApplied: [string];
AssetsTag: [string[]];
@@ -0,0 +1,15 @@
import type { LatLng } from '$lib/types';
class GeolocationManager {
#lastPoint = $state<LatLng>();
get lastPoint() {
return this.#lastPoint;
}
onSelected(point: LatLng) {
this.#lastPoint = point;
}
}
export const geolocationManager = new GeolocationManager();
@@ -21,7 +21,7 @@ export type MemoryAsset = MemoryIndex & {
nextMemory?: MemoryResponseDto;
};
class MemoryStoreSvelte {
class MemoryManager {
#loading: Promise<void> | undefined;
constructor() {
@@ -135,4 +135,4 @@ class MemoryStoreSvelte {
}
}
export const memoryStore = new MemoryStoreSvelte();
export const memoryManager = new MemoryManager();
@@ -23,7 +23,7 @@ import {
type TimelineDateTime,
type TimelineYearMonth,
} from '$lib/utils/timeline-util';
import { AssetOrder, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { AssetOrder, AssetVisibility, getAssetInfo, getTimeBuckets, type AssetResponseDto } from '@immich/sdk';
import { clamp, isEqual } from 'lodash-es';
import { SvelteDate, SvelteSet } from 'svelte/reactivity';
import { DayGroup } from './day-group.svelte';
@@ -114,6 +114,7 @@ export class TimelineManager extends VirtualScrollManager {
this.#unsubscribes.push(
eventManager.on({
AssetUpdate: (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]),
AssetsUnarchive: (ids) => this.update(ids, (asset) => (asset.visibility = AssetVisibility.Timeline)),
}),
);
}
@@ -0,0 +1,67 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import { getIntersectionObserverMock } from '$lib/__mocks__/intersection-observer.mock';
import { getVisualViewportMock } from '$lib/__mocks__/visual-viewport.mock';
import { fireEvent, render, screen, waitFor } from '@testing-library/svelte';
import { DateTime } from 'luxon';
import { afterAll, beforeEach, describe, expect, test, vi } from 'vitest';
import AssetChangeDateModal from './AssetChangeDateModal.svelte';
describe('AssetChangeDateModal component', () => {
const initialDate = DateTime.fromISO('2026-03-19T23:31:30.112');
const initialTimeZone = 'Europe/Lisbon';
const onClose = vi.fn();
const getDateInput = async () => (await screen.findByDisplayValue('2026-03-19T23:31:30.112')) as HTMLInputElement;
const getTimeZoneInput = () => screen.getByRole('combobox', { name: /timezone/i }) as HTMLInputElement;
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', getIntersectionObserverMock());
vi.stubGlobal('visualViewport', getVisualViewportMock());
vi.resetAllMocks();
Element.prototype.animate = getAnimateMock();
});
afterAll(async () => {
await waitFor(() => {
expect(document.body.style.pointerEvents).not.toBe('none');
});
});
test('preserves the selected timezone when changing the datetime', async () => {
render(AssetChangeDateModal, {
props: {
initialDate,
initialTimeZone,
timezoneInput: true,
asset: { id: 'asset-id' } as never,
onClose,
},
});
const timezoneInput = getTimeZoneInput();
const datetimeInput = await getDateInput();
const initialTimezoneValue = timezoneInput.value;
await fireEvent.focus(timezoneInput);
await fireEvent.input(timezoneInput, { target: { value: 'Pacific/Pitcairn' } });
const option = await screen.findByText(/Pacific\/Pitcairn/i);
await fireEvent.click(option);
expect(timezoneInput.value).toBe('Pacific/Pitcairn (-08:00)');
expect(timezoneInput.value).not.toBe(initialTimezoneValue);
const beforeDatetime = datetimeInput.value;
await fireEvent.input(datetimeInput, {
target: { value: '2026-03-19T23:31:31.113' },
});
await fireEvent.change(datetimeInput, {
target: { value: '2026-03-19T23:31:31.113' },
});
expect(datetimeInput.value).not.toBe(beforeDatetime);
expect(timezoneInput.value).toBe('Pacific/Pitcairn (-08:00)');
});
});
+13 -5
View File
@@ -23,10 +23,7 @@
let selectedDate = $state(initialDate.toFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"));
const timezones = $derived(getTimezones(selectedDate));
// svelte-ignore state_referenced_locally
let lastSelectedTimezone = $state(getPreferredTimeZone(initialDate, initialTimeZone, timezones));
// the offsets (and validity) for time zones may change if the date is changed, which is why we recompute the list
let selectedOption = $derived(getPreferredTimeZone(initialDate, initialTimeZone, timezones, lastSelectedTimezone));
let selectedOption = $state(getPreferredTimeZone(initialDate, initialTimeZone, getTimezones(selectedDate)));
const onSubmit = async () => {
if (!date.isValid || !selectedOption) {
@@ -45,6 +42,12 @@
}
};
const updateSelectedDate = (value: string) => {
selectedDate = value;
selectedOption = getPreferredTimeZone(initialDate, initialTimeZone, getTimezones(value), selectedOption);
};
// when changing the time zone, assume the configured date/time is meant for that time zone (instead of updating it)
const date = $derived(DateTime.fromISO(selectedDate, { zone: selectedOption?.value, setZone: true }));
</script>
@@ -59,7 +62,12 @@
size="small"
>
<Label for="datetime" class="block mb-1">{$t('date_and_time')}</Label>
<DateInput class="immich-form-input w-full mb-2" id="datetime" type="datetime-local" bind:value={selectedDate} />
<DateInput
class="immich-form-input w-full mb-2"
id="datetime"
type="datetime-local"
bind:value={() => selectedDate, updateSelectedDate}
/>
{#if timezoneInput}
<div class="w-full">
<Combobox bind:selectedOption label={$t('timezone')} options={timezones} placeholder={$t('search_timezone')} />
@@ -1,30 +1,27 @@
<script lang="ts">
import { isDefined } from '$lib';
import { clickOutside } from '$lib/actions/click-outside';
import { listNavigation } from '$lib/actions/list-navigation';
import CoordinatesInput from '$lib/components/shared-components/coordinates-input.svelte';
import type Map from '$lib/components/shared-components/map/map.svelte';
import { timeDebounceOnSearch, timeToLoadTheMap } from '$lib/constants';
import SearchBar from '$lib/elements/SearchBar.svelte';
import { lastChosenLocation } from '$lib/stores/asset-editor.store';
import { geolocationManager } from '$lib/managers/geolocation.manager.svelte';
import type { LatLng } from '$lib/types';
import { delay } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { searchPlaces, type AssetResponseDto, type PlacesResponseDto } from '@immich/sdk';
import { ConfirmModal, LoadingSpinner } from '@immich/ui';
import { mdiMapMarkerMultipleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
interface Point {
lng: number;
lat: number;
}
interface Props {
asset?: AssetResponseDto | undefined;
point?: Point;
onClose: (point?: Point) => void;
}
type Props = {
asset?: AssetResponseDto;
point?: LatLng;
onClose: (point?: LatLng) => void;
};
let { asset = undefined, point: initialPoint, onClose }: Props = $props();
let { asset, point: initialPoint, onClose }: Props = $props();
let places: PlacesResponseDto[] = $state([]);
let suggestedPlaces: PlacesResponseDto[] = $derived(places.slice(0, 5));
@@ -35,15 +32,22 @@
let hideSuggestion = $state(false);
let mapElement = $state<ReturnType<typeof Map>>();
let previousLocation = get(lastChosenLocation);
let assetPoint = $derived.by<LatLng | undefined>(() => {
if (!asset || !asset.exifInfo) {
return;
}
let assetLat = $derived(initialPoint?.lat ?? asset?.exifInfo?.latitude ?? undefined);
let assetLng = $derived(initialPoint?.lng ?? asset?.exifInfo?.longitude ?? undefined);
const { latitude, longitude } = asset.exifInfo;
if (!isDefined(latitude) || !isDefined(longitude)) {
return;
}
let mapLat = $derived(assetLat ?? previousLocation?.lat ?? undefined);
let mapLng = $derived(assetLng ?? previousLocation?.lng ?? undefined);
return { lat: latitude, lng: longitude };
});
let zoom = $derived(mapLat && mapLng ? 12.5 : 1);
let point = $state<LatLng | undefined>(initialPoint ?? assetPoint);
let zoom = $state(point ? 12.5 : 1);
let center = $state(point ?? geolocationManager.lastPoint);
$effect(() => {
if (mapElement && initialPoint) {
@@ -57,11 +61,9 @@
}
});
let point: Point | null = $state(initialPoint ?? null);
const handleConfirm = (confirmed?: boolean) => {
if (point && confirmed) {
lastChosenLocation.set(point);
geolocationManager.onSelected(point);
onClose(point);
} else {
onClose();
@@ -201,12 +203,12 @@
{:then { default: Map }}
<Map
bind:this={mapElement}
mapMarkers={assetLat !== undefined && assetLng !== undefined && asset
mapMarkers={asset && assetPoint
? [
{
id: asset.id,
lat: assetLat,
lon: assetLng,
lat: assetPoint.lat,
lon: assetPoint.lng,
city: asset.exifInfo?.city ?? null,
state: asset.exifInfo?.state ?? null,
country: asset.exifInfo?.country ?? null,
@@ -214,7 +216,7 @@
]
: []}
{zoom}
center={mapLat && mapLng ? { lat: mapLat, lng: mapLng } : undefined}
{center}
simplified={true}
clickable={true}
onClickPoint={(selected) => (point = selected)}
@@ -225,7 +227,7 @@
</div>
<div class="grid sm:grid-cols-2 gap-4 text-sm text-start mt-4">
<CoordinatesInput lat={point ? point.lat : assetLat} lng={point ? point.lng : assetLng} {onUpdate} />
<CoordinatesInput lat={point?.lat} lng={point?.lng} {onUpdate} />
</div>
</div>
{/snippet}
@@ -1,20 +1,21 @@
<script lang="ts">
import type { LatLng } from '$lib/types';
import { ConfirmModal } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
location: { latitude: number | undefined; longitude: number | undefined };
type Props = {
point: LatLng;
assetCount: number;
onClose: (confirm: boolean) => void;
}
};
let { location, assetCount, onClose }: Props = $props();
let { point, assetCount, onClose }: Props = $props();
</script>
<ConfirmModal title={$t('confirm')} size="small" confirmColor="primary" {onClose}>
{#snippet prompt()}
<p>{$t('update_location_action_prompt', { values: { count: assetCount } })}</p>
<p>- {$t('latitude')}: {location.latitude}</p>
<p>- {$t('longitude')}: {location.longitude}</p>
<p>- {$t('latitude')}: {point.lat}</p>
<p>- {$t('longitude')}: {point.lng}</p>
{/snippet}
</ConfirmModal>
+5 -23
View File
@@ -1,5 +1,4 @@
import { goto } from '$app/navigation';
import ToastAction from '$lib/components/ToastAction.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
@@ -138,16 +137,8 @@ const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: strin
description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } });
}
toastManager.custom(
{
component: ToastAction,
props: {
title: $t('info'),
color: 'primary',
description,
button: { text: $t('view_album'), color: 'primary', onClick: () => goto(Route.viewAlbum({ id: albumId })) },
},
},
toastManager.primary(
{ description, button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id: albumId })) } },
{ timeout: 5000 },
);
};
@@ -229,18 +220,9 @@ export const handleUpdateAlbum = async ({ id }: { id: string }, dto: UpdateAlbum
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: dto });
eventManager.emit('AlbumUpdate', response);
toastManager.custom({
component: ToastAction,
props: {
color: 'primary',
title: $t('success'),
description: $t('album_info_updated'),
button: {
text: $t('view_album'),
color: 'primary',
onClick: () => goto(Route.viewAlbum({ id })),
},
},
toastManager.primary({
description: $t('album_info_updated'),
button: { label: $t('view_album'), onclick: () => goto(Route.viewAlbum({ id })) },
});
return true;

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