mirror of
https://github.com/immich-app/immich.git
synced 2026-05-21 15:16:31 -04:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 524b191ccc | |||
| 075f7d507b | |||
| c4df4d7852 | |||
| 958f270f0d | |||
| 9f699fdfc3 | |||
| 00da7b88a1 | |||
| 144a57ddff | |||
| 1bd2d474d7 | |||
| b33874ef12 | |||
| dbaf4b548b | |||
| 7d58d5be12 | |||
| 42fe86d24c | |||
| eeb55c279b | |||
| 5c159d70a7 | |||
| 44ae0fa7ed | |||
| f782782662 | |||
| 4436cab827 | |||
| 74789ad1c4 | |||
| 7877097b3f | |||
| fb84c1cf61 | |||
| 940a1d4ab8 | |||
| fae25dbe65 | |||
| 8dd0d7f34c | |||
| 9b78f2c0ba | |||
| 67cedfef17 | |||
| c9c2322b9d | |||
| 389356149a | |||
| 4812a2e2d8 | |||
| 8f01d06927 | |||
| a2ff075e9a | |||
| d8b39906f9 | |||
| b36911a16b | |||
| b074ee202e | |||
| 78bb6cf926 | |||
| c980f5fc19 | |||
| a26d9e05ba | |||
| c862163204 | |||
| 5fb8f9bf1a | |||
| 0eaa2c3419 | |||
| 554e7b28a2 | |||
| 167aad7ac2 |
+1
-1
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
+3
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -58,6 +58,6 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.1"
|
||||
"node": "24.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -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
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
+3
-3
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+1
-1
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.2"
|
||||
version = "2.6.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+107
-107
@@ -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": {
|
||||
|
||||
Generated
+1060
-1095
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
+6
-7
@@ -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")/..")"
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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}] `);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -42,6 +42,10 @@ export class EnvDto {
|
||||
@Optional()
|
||||
IMMICH_CONFIG_FILE?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
IMMICH_HELMET_FILE?: string;
|
||||
|
||||
@IsEnum(ImmichEnvironment)
|
||||
@Optional()
|
||||
IMMICH_ENV?: ImmichEnvironment;
|
||||
|
||||
@@ -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), '[]')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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, '!=', '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
24.13.1
|
||||
24.14.0
|
||||
|
||||
+4
-4
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
+2
-2
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -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')} />
|
||||
|
||||
+28
-26
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user