mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 13:15:22 -04:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a69374aa17 | |||
| 15c15bd543 | |||
| 10e754e1aa | |||
| b9282b27e5 | |||
| 146a076324 | |||
| 4c70afc8f4 | |||
| 80db413d69 | |||
| d8d532e7ca |
+1
-1
@@ -1 +1 @@
|
||||
24.14.0
|
||||
24.13.1
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
|
||||
run: gh pr edit "$PR_NUMBER" --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" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
|
||||
run: gh pr edit "$PR_NUMBER" --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" --repo "${{ github.repository }}" --json labels \
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --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@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -189,6 +189,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
@@ -566,7 +566,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
mobile-unit-tests:
|
||||
|
||||
@@ -68,6 +68,6 @@ jobs:
|
||||
permissions: {}
|
||||
if: always()
|
||||
steps:
|
||||
- uses: immich-app/devtools/actions/success-check@53bb77345ee9f953f93bd6fd9980f07a2f24965e # success-check-action-v0.0.5
|
||||
- uses: immich-app/devtools/actions/success-check@68f10eb389bb02a3cf9d1156111964c549eb421b # 0.0.4
|
||||
with:
|
||||
needs: ${{ toJSON(needs) }}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.14.0
|
||||
24.13.1
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"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.12.0",
|
||||
"@types/node": "^24.11.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.14.0"
|
||||
"node": "24.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@ 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.4.1-ubuntu@sha256:1a20dea76a2778773df17dbc365db86b1a4f2d57772b8590b6311038a3acb1db
|
||||
image: grafana/grafana:12.3.2-ubuntu@sha256:6cca4b429a1dc0d37d401dee54825c12d40056c3c6f3f56e3f0d6318ce77749b
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.14.0
|
||||
24.13.1
|
||||
|
||||
@@ -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://tiles.immich.cloud/v1/style/light.json)
|
||||
and [dark theme](https://tiles.immich.cloud/v1/style/dark.json)
|
||||
`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)
|
||||
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,23 +29,22 @@ 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 |
|
||||
| `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 |
|
||||
| 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 |
|
||||
|
||||
\*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.14.0"
|
||||
"node": "24.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.3",
|
||||
"url": "https://docs.v2.6.3.archive.immich.app"
|
||||
"label": "v2.6.2",
|
||||
"url": "https://docs.v2.6.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.14.0
|
||||
24.13.1
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"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.12.0",
|
||||
"@types/node": "^24.11.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.14.0"
|
||||
"node": "24.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ describe('/admin/database-backups', () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
admin = await utils.adminSetup();
|
||||
await utils.resetBackups(admin.accessToken);
|
||||
});
|
||||
|
||||
@@ -96,9 +94,7 @@ describe('/admin/database-backups', () => {
|
||||
({ status, body }) => status === 200 && !body.maintenanceMode,
|
||||
);
|
||||
|
||||
admin = await utils.adminSetup({
|
||||
onboarding: false,
|
||||
});
|
||||
admin = await utils.adminSetup();
|
||||
});
|
||||
|
||||
it.sequential('should not work when the server is configured', async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LoginResponseDto } from '@immich/sdk';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { testAssetDir, utils } from 'src/utils';
|
||||
import { test } from '@playwright/test';
|
||||
import { utils } from 'src/utils';
|
||||
|
||||
test.describe('Album', () => {
|
||||
let admin: LoginResponseDto;
|
||||
@@ -23,41 +22,4 @@ 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();
|
||||
});
|
||||
});
|
||||
|
||||
+1
-3
@@ -866,7 +866,6 @@
|
||||
"crop_aspect_ratio_fixed": "Fixed",
|
||||
"crop_aspect_ratio_free": "Free",
|
||||
"crop_aspect_ratio_original": "Original",
|
||||
"crop_aspect_ratio_square": "Square",
|
||||
"curated_object_page_title": "Things",
|
||||
"current_device": "Current device",
|
||||
"current_pin_code": "Current PIN code",
|
||||
@@ -881,7 +880,7 @@
|
||||
"daily_title_text_date": "E, MMM dd",
|
||||
"daily_title_text_date_year": "E, MMM dd, yyyy",
|
||||
"dark": "Dark",
|
||||
"dark_theme": "Switch to dark theme",
|
||||
"dark_theme": "Toggle dark theme",
|
||||
"date": "Date",
|
||||
"date_after": "Date after",
|
||||
"date_and_time": "Date and Time",
|
||||
@@ -1388,7 +1387,6 @@
|
||||
"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.3",
|
||||
"version": "2.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.3"
|
||||
version = "2.6.2"
|
||||
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.3"
|
||||
version = "2.6.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -14,9 +14,9 @@ config_roots = [
|
||||
]
|
||||
|
||||
[tools]
|
||||
node = "24.14.0"
|
||||
node = "24.13.1"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.32.1"
|
||||
pnpm = "10.30.3"
|
||||
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" => 3041,
|
||||
"android.injected.version.name" => "2.6.3",
|
||||
"android.injected.version.code" => 3040,
|
||||
"android.injected.version.name" => "2.6.2",
|
||||
}
|
||||
)
|
||||
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.3</string>
|
||||
<string>2.6.2</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.3
|
||||
- API version: 2.6.2
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
Generated
+12
-3
@@ -1004,10 +1004,13 @@ class AssetsApi {
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [bool] edited:
|
||||
/// Return edited asset if available
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> playAssetVideoWithHttpInfo(String id, { String? key, String? slug, }) async {
|
||||
Future<Response> playAssetVideoWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/video/playback'
|
||||
.replaceAll('{id}', id);
|
||||
@@ -1019,6 +1022,9 @@ class AssetsApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (edited != null) {
|
||||
queryParams.addAll(_queryParams('', 'edited', edited));
|
||||
}
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
@@ -1048,11 +1054,14 @@ class AssetsApi {
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [bool] edited:
|
||||
/// Return edited asset if available
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<MultipartFile?> playAssetVideo(String id, { String? key, String? slug, }) async {
|
||||
final response = await playAssetVideoWithHttpInfo(id, key: key, slug: slug, );
|
||||
Future<MultipartFile?> playAssetVideo(String id, { bool? edited, String? key, String? slug, }) async {
|
||||
final response = await playAssetVideoWithHttpInfo(id, edited: edited, key: key, slug: slug, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -29,7 +29,6 @@ class JobName {
|
||||
static const assetDetectFaces = JobName._(r'AssetDetectFaces');
|
||||
static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll');
|
||||
static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates');
|
||||
static const assetEditThumbnailGeneration = JobName._(r'AssetEditThumbnailGeneration');
|
||||
static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll');
|
||||
static const assetEncodeVideo = JobName._(r'AssetEncodeVideo');
|
||||
static const assetEmptyTrash = JobName._(r'AssetEmptyTrash');
|
||||
@@ -38,6 +37,7 @@ class JobName {
|
||||
static const assetFileMigration = JobName._(r'AssetFileMigration');
|
||||
static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll');
|
||||
static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails');
|
||||
static const assetProcessEdit = JobName._(r'AssetProcessEdit');
|
||||
static const auditLogCleanup = JobName._(r'AuditLogCleanup');
|
||||
static const auditTableCleanup = JobName._(r'AuditTableCleanup');
|
||||
static const databaseBackup = JobName._(r'DatabaseBackup');
|
||||
@@ -88,7 +88,6 @@ class JobName {
|
||||
assetDetectFaces,
|
||||
assetDetectDuplicatesQueueAll,
|
||||
assetDetectDuplicates,
|
||||
assetEditThumbnailGeneration,
|
||||
assetEncodeVideoQueueAll,
|
||||
assetEncodeVideo,
|
||||
assetEmptyTrash,
|
||||
@@ -97,6 +96,7 @@ class JobName {
|
||||
assetFileMigration,
|
||||
assetGenerateThumbnailsQueueAll,
|
||||
assetGenerateThumbnails,
|
||||
assetProcessEdit,
|
||||
auditLogCleanup,
|
||||
auditTableCleanup,
|
||||
databaseBackup,
|
||||
@@ -182,7 +182,6 @@ class JobNameTypeTransformer {
|
||||
case r'AssetDetectFaces': return JobName.assetDetectFaces;
|
||||
case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll;
|
||||
case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates;
|
||||
case r'AssetEditThumbnailGeneration': return JobName.assetEditThumbnailGeneration;
|
||||
case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll;
|
||||
case r'AssetEncodeVideo': return JobName.assetEncodeVideo;
|
||||
case r'AssetEmptyTrash': return JobName.assetEmptyTrash;
|
||||
@@ -191,6 +190,7 @@ class JobNameTypeTransformer {
|
||||
case r'AssetFileMigration': return JobName.assetFileMigration;
|
||||
case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll;
|
||||
case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails;
|
||||
case r'AssetProcessEdit': return JobName.assetProcessEdit;
|
||||
case r'AuditLogCleanup': return JobName.auditLogCleanup;
|
||||
case r'AuditTableCleanup': return JobName.auditTableCleanup;
|
||||
case r'DatabaseBackup': return JobName.databaseBackup;
|
||||
|
||||
+1
-1
@@ -379,7 +379,7 @@ class MetadataSearchDto {
|
||||
///
|
||||
bool? withExif;
|
||||
|
||||
/// Include people data in response
|
||||
/// Include assets with people
|
||||
///
|
||||
/// 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 people data in response
|
||||
/// Include assets with people
|
||||
///
|
||||
/// 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.3+3041
|
||||
version: 2.6.2+3040
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -4402,6 +4402,16 @@
|
||||
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
||||
"operationId": "playAssetVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "edited",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Return edited asset if available",
|
||||
"schema": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
@@ -15166,7 +15176,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
@@ -18144,7 +18154,6 @@
|
||||
"AssetDetectFaces",
|
||||
"AssetDetectDuplicatesQueueAll",
|
||||
"AssetDetectDuplicates",
|
||||
"AssetEditThumbnailGeneration",
|
||||
"AssetEncodeVideoQueueAll",
|
||||
"AssetEncodeVideo",
|
||||
"AssetEmptyTrash",
|
||||
@@ -18153,6 +18162,7 @@
|
||||
"AssetFileMigration",
|
||||
"AssetGenerateThumbnailsQueueAll",
|
||||
"AssetGenerateThumbnails",
|
||||
"AssetProcessEdit",
|
||||
"AuditLogCleanup",
|
||||
"AuditTableCleanup",
|
||||
"DatabaseBackup",
|
||||
@@ -19129,7 +19139,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"withPeople": {
|
||||
"description": "Include people data in response",
|
||||
"description": "Include assets with people",
|
||||
"type": "boolean"
|
||||
},
|
||||
"withStacked": {
|
||||
@@ -20868,7 +20878,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"withPeople": {
|
||||
"description": "Include people data in response",
|
||||
"description": "Include assets with people",
|
||||
"type": "boolean"
|
||||
},
|
||||
"withStacked": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.0
|
||||
24.13.1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"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.12.0",
|
||||
"@types/node": "^24.11.0",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
@@ -28,6 +28,6 @@
|
||||
"directory": "open-api/typescript-sdk"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.14.0"
|
||||
"node": "24.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.3
|
||||
* 2.6.2
|
||||
* 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 people data in response */
|
||||
/** Include assets with people */
|
||||
withPeople?: boolean;
|
||||
/** Include stacked assets */
|
||||
withStacked?: boolean;
|
||||
@@ -1855,7 +1855,7 @@ export type RandomSearchDto = {
|
||||
withDeleted?: boolean;
|
||||
/** Include EXIF data in response */
|
||||
withExif?: boolean;
|
||||
/** Include people data in response */
|
||||
/** Include assets with people */
|
||||
withPeople?: boolean;
|
||||
/** Include stacked assets */
|
||||
withStacked?: boolean;
|
||||
@@ -4316,7 +4316,8 @@ export function viewAsset({ edited, id, key, size, slug }: {
|
||||
/**
|
||||
* Play asset video
|
||||
*/
|
||||
export function playAssetVideo({ id, key, slug }: {
|
||||
export function playAssetVideo({ edited, id, key, slug }: {
|
||||
edited?: boolean;
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
@@ -4325,6 +4326,7 @@ export function playAssetVideo({ id, key, slug }: {
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({
|
||||
edited,
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
@@ -7164,7 +7166,6 @@ export enum JobName {
|
||||
AssetDetectFaces = "AssetDetectFaces",
|
||||
AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll",
|
||||
AssetDetectDuplicates = "AssetDetectDuplicates",
|
||||
AssetEditThumbnailGeneration = "AssetEditThumbnailGeneration",
|
||||
AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll",
|
||||
AssetEncodeVideo = "AssetEncodeVideo",
|
||||
AssetEmptyTrash = "AssetEmptyTrash",
|
||||
@@ -7173,6 +7174,7 @@ export enum JobName {
|
||||
AssetFileMigration = "AssetFileMigration",
|
||||
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
|
||||
AssetGenerateThumbnails = "AssetGenerateThumbnails",
|
||||
AssetProcessEdit = "AssetProcessEdit",
|
||||
AuditLogCleanup = "AuditLogCleanup",
|
||||
AuditTableCleanup = "AuditTableCleanup",
|
||||
DatabaseBackup = "DatabaseBackup",
|
||||
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
Generated
+107
-107
@@ -15,9 +15,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -32,9 +32,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -49,9 +49,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -66,9 +66,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -83,9 +83,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -100,9 +100,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -117,9 +117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"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==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -134,9 +134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -151,9 +151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"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==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -168,9 +168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -185,9 +185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -202,9 +202,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -219,9 +219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"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==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
@@ -236,9 +236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -253,9 +253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -270,9 +270,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"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==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -287,9 +287,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -304,9 +304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -321,9 +321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -338,9 +338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -355,9 +355,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -372,9 +372,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -389,9 +389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -406,9 +406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -423,9 +423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -440,9 +440,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -467,9 +467,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
@@ -480,32 +480,32 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@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"
|
||||
"@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"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
Generated
+1096
-1061
File diff suppressed because it is too large
Load Diff
@@ -27,10 +27,6 @@
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["ghcr.io/immich-app/base-server-*"],
|
||||
"maxMajorIncrement": 0
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["ruby"],
|
||||
"groupName": "ruby",
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.14.0
|
||||
24.13.1
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
cd plugins && mise run build
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202603251709@sha256:17de30977ff87aa06758a56ad7f10d6b5c97bf9dab76e4ec4177a2a8d1b2b5f3
|
||||
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
|
||||
+7
-6
@@ -15,12 +15,13 @@ log_message() {
|
||||
|
||||
log_message "Initializing Immich $IMMICH_SOURCE_REF"
|
||||
|
||||
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
|
||||
# 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
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/usr/lib/jellyfin-ffmpeg/lib"
|
||||
SERVER_HOME="$(readlink -f "$(dirname "$0")/..")"
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"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'"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-10
@@ -1,15 +1,10 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"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 .",
|
||||
@@ -82,13 +77,12 @@
|
||||
"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.14",
|
||||
"kysely": "0.28.11",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
@@ -142,7 +136,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/node": "^24.11.0",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
@@ -174,7 +168,7 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.14.0"
|
||||
"node": "24.13.1"
|
||||
},
|
||||
"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,10 +2,9 @@ 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 { IMMICH_SERVER_START, excludePaths, serverVersion } from 'src/constants';
|
||||
import { excludePaths, serverVersion } from 'src/constants';
|
||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
@@ -40,7 +39,7 @@ export async function configureExpress(
|
||||
},
|
||||
) {
|
||||
const configRepository = app.get(ConfigRepository);
|
||||
const { environment, host, port, helmet, resourcePaths, network } = configRepository.getEnv();
|
||||
const { environment, host, port, resourcePaths, network } = configRepository.getEnv();
|
||||
|
||||
const logger = await app.resolve(LoggingRepository);
|
||||
logger.setContext('Bootstrap');
|
||||
@@ -48,12 +47,6 @@ 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' }));
|
||||
|
||||
@@ -90,5 +83,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_START} on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
|
||||
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${environment}] `);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ 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';
|
||||
@@ -112,7 +111,6 @@ export class ApiModule extends BaseModule {}
|
||||
StorageRepository,
|
||||
ProcessRepository,
|
||||
DatabaseRepository,
|
||||
UserRepository,
|
||||
SystemMetadataRepository,
|
||||
AppRepository,
|
||||
MaintenanceHealthRepository,
|
||||
|
||||
@@ -4,8 +4,6 @@ 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',
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
AssetThumbnailOptionsDto,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
@@ -154,7 +155,7 @@ export class AssetMediaController {
|
||||
async viewAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: AssetMediaOptionsDto,
|
||||
@Query() dto: AssetThumbnailOptionsDto,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
@@ -197,9 +198,10 @@ export class AssetMediaController {
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Res() res: Response,
|
||||
@Query() dto: AssetMediaOptionsDto,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id, dto), this.logger);
|
||||
}
|
||||
|
||||
@Post('exist')
|
||||
|
||||
@@ -120,8 +120,8 @@ export class StorageCore {
|
||||
);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity, isEdited: boolean = false) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}${isEdited ? '_edited' : ''}.mp4`);
|
||||
}
|
||||
|
||||
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
|
||||
|
||||
@@ -345,10 +345,8 @@ 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: [
|
||||
assetFiles: [
|
||||
'asset_file.id',
|
||||
'asset_file.path',
|
||||
'asset_file.type',
|
||||
|
||||
@@ -18,13 +18,15 @@ export enum AssetMediaSize {
|
||||
}
|
||||
|
||||
export class AssetMediaOptionsDto {
|
||||
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
|
||||
size?: AssetMediaSize;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export class AssetThumbnailOptionsDto extends AssetMediaOptionsDto {
|
||||
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
|
||||
size?: AssetMediaSize;
|
||||
}
|
||||
|
||||
export enum UploadFieldName {
|
||||
ASSET_DATA = 'assetData',
|
||||
SIDECAR_DATA = 'sidecarData',
|
||||
|
||||
@@ -42,10 +42,6 @@ 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 people data in response' })
|
||||
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
|
||||
withPeople?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -588,7 +588,6 @@ export enum JobName {
|
||||
AssetDetectFaces = 'AssetDetectFaces',
|
||||
AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll',
|
||||
AssetDetectDuplicates = 'AssetDetectDuplicates',
|
||||
AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration',
|
||||
AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll',
|
||||
AssetEncodeVideo = 'AssetEncodeVideo',
|
||||
AssetEmptyTrash = 'AssetEmptyTrash',
|
||||
@@ -597,6 +596,7 @@ export enum JobName {
|
||||
AssetFileMigration = 'AssetFileMigration',
|
||||
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
|
||||
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
|
||||
AssetProcessEdit = 'AssetProcessEdit',
|
||||
|
||||
AuditLogCleanup = 'AuditLogCleanup',
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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 {
|
||||
@@ -21,27 +20,45 @@ export class MaintenanceHealthRepository {
|
||||
stdio: ['ignore', 'pipe', 'ignore', 'ipc'],
|
||||
});
|
||||
|
||||
let output = '';
|
||||
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;
|
||||
|
||||
worker.stdout?.on('data', (data) => {
|
||||
if (worker.exitCode !== null) {
|
||||
if (alive) {
|
||||
return;
|
||||
}
|
||||
|
||||
output += data;
|
||||
|
||||
if (output.includes(IMMICH_SERVER_START)) {
|
||||
resolve();
|
||||
worker.kill('SIGTERM');
|
||||
if (output.includes('Immich Server is listening')) {
|
||||
alive = true;
|
||||
void checkHealth();
|
||||
}
|
||||
});
|
||||
|
||||
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}`));
|
||||
worker.on('exit', reject);
|
||||
worker.on('error', reject);
|
||||
|
||||
setTimeout(() => {
|
||||
if (worker.exitCode === null) {
|
||||
reject('Server health check failed, took too long to start.');
|
||||
worker.kill('SIGTERM');
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
@@ -30,7 +30,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -60,7 +62,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -184,7 +188,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -245,6 +251,55 @@ from
|
||||
where
|
||||
"asset"."id" = $4
|
||||
|
||||
-- AssetJobRepository.getForAssetEditProcessing
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" in ($1, $2, $3, $4)
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits",
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $5
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
"asset"."id",
|
||||
@@ -264,7 +319,6 @@ select
|
||||
"asset"."type",
|
||||
"asset"."width",
|
||||
"asset"."height",
|
||||
"asset"."isEdited",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
@@ -289,7 +343,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -315,7 +371,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -372,7 +430,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -412,7 +472,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -437,11 +499,12 @@ select
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
and "asset_file"."isEdited" = $2
|
||||
) as "previewFile"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
"asset"."id" = $3
|
||||
|
||||
-- AssetJobRepository.getForSyncAssets
|
||||
select
|
||||
@@ -475,7 +538,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -516,7 +581,8 @@ where
|
||||
|
||||
-- AssetJobRepository.streamForVideoConversion
|
||||
select
|
||||
"asset"."id"
|
||||
"asset"."id",
|
||||
"asset"."isEdited"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
@@ -547,17 +613,34 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
) as agg
|
||||
) as "files"
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $1
|
||||
"asset"."id" = $2
|
||||
and "asset"."type" = 'VIDEO'
|
||||
|
||||
-- AssetJobRepository.streamForMetadataExtraction
|
||||
@@ -599,7 +682,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -641,7 +726,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
|
||||
@@ -285,7 +285,9 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited"
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -638,12 +640,13 @@ select
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
and "asset_file"."isEdited" = $2
|
||||
) as "encodedVideoPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
and "asset"."type" = $3
|
||||
"asset"."id" = $3
|
||||
and "asset"."type" = $4
|
||||
|
||||
-- AssetRepository.getForOcr
|
||||
select
|
||||
|
||||
@@ -254,7 +254,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "state" is not null
|
||||
and "state" != $3
|
||||
|
||||
-- SearchRepository.getCities
|
||||
select distinct
|
||||
@@ -267,7 +266,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "city" is not null
|
||||
and "city" != $3
|
||||
|
||||
-- SearchRepository.getCameraMakes
|
||||
select distinct
|
||||
@@ -280,7 +278,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "make" is not null
|
||||
and "make" != $3
|
||||
|
||||
-- SearchRepository.getCameraModels
|
||||
select distinct
|
||||
@@ -293,7 +290,6 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "model" is not null
|
||||
and "model" != $3
|
||||
|
||||
-- SearchRepository.getCameraLensModels
|
||||
select distinct
|
||||
@@ -306,4 +302,3 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "lensModel" is not null
|
||||
and "lensModel" != $3
|
||||
|
||||
@@ -3,64 +3,37 @@
|
||||
-- SharedLinkRepository.get
|
||||
select
|
||||
"shared_link".*,
|
||||
(
|
||||
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
|
||||
coalesce(
|
||||
json_agg("a") filter (
|
||||
where
|
||||
"a"."id" is not null
|
||||
),
|
||||
'[]'
|
||||
) 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".*,
|
||||
@@ -87,36 +60,7 @@ 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"
|
||||
"asset_exif".*
|
||||
from
|
||||
"asset_exif"
|
||||
where
|
||||
@@ -130,12 +74,7 @@ from
|
||||
) as "assets" on true
|
||||
inner join lateral (
|
||||
select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
"user".*
|
||||
from
|
||||
"user"
|
||||
where
|
||||
@@ -156,6 +95,9 @@ where
|
||||
"shared_link"."type" = $3
|
||||
or "album"."id" is not null
|
||||
)
|
||||
group by
|
||||
"shared_link"."id",
|
||||
"album".*
|
||||
order by
|
||||
"shared_link"."createdAt" desc
|
||||
|
||||
@@ -192,12 +134,21 @@ from
|
||||
"album"
|
||||
inner join lateral (
|
||||
select
|
||||
"id",
|
||||
"name",
|
||||
"email",
|
||||
"avatarColor",
|
||||
"profileImagePath",
|
||||
"profileChangedAt"
|
||||
"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"
|
||||
from
|
||||
"user"
|
||||
where
|
||||
@@ -316,36 +267,7 @@ 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,6 +582,7 @@ 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
|
||||
|
||||
|
||||
@@ -112,6 +112,26 @@ export class AssetJobRepository {
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForGenerateThumbnailJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select([
|
||||
'asset.id',
|
||||
'asset.visibility',
|
||||
'asset.originalFileName',
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.thumbhash',
|
||||
'asset.type',
|
||||
])
|
||||
.select((eb) => withFiles(eb, [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]))
|
||||
.select(withEdits)
|
||||
.$call(withExifInner)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForAssetEditProcessing(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select([
|
||||
@@ -124,13 +144,12 @@ export class AssetJobRepository {
|
||||
'asset.type',
|
||||
])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(columns.assetFilesForThumbnail)
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
|
||||
).as('files'),
|
||||
withFiles(eb, [
|
||||
AssetFileType.Thumbnail,
|
||||
AssetFileType.Preview,
|
||||
AssetFileType.FullSize,
|
||||
AssetFileType.EncodedVideo,
|
||||
]),
|
||||
)
|
||||
.select(withEdits)
|
||||
.$call(withExifInner)
|
||||
@@ -308,7 +327,7 @@ export class AssetJobRepository {
|
||||
streamForVideoConversion(force?: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id'])
|
||||
.select(['asset.id', 'asset.isEdited'])
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.$if(!force, (qb) =>
|
||||
qb
|
||||
@@ -334,7 +353,8 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
|
||||
.select(withFiles)
|
||||
.select((eb) => withFiles(eb, AssetFileType.EncodedVideo))
|
||||
.select(withEdits)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -1149,12 +1149,12 @@ export class AssetRepository {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForVideo(id: string) {
|
||||
@GenerateSql({ params: [DummyValue.UUID, true] })
|
||||
async getForVideo(id: string, isEdited: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.originalPath'])
|
||||
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
|
||||
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo, isEdited).as('encodedVideoPath'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -5,11 +5,9 @@ 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';
|
||||
@@ -60,10 +58,6 @@ export interface EnvData {
|
||||
config: ClsModuleOptions;
|
||||
};
|
||||
|
||||
helmet: {
|
||||
config?: HelmetOptions;
|
||||
};
|
||||
|
||||
database: {
|
||||
config: DatabaseConnectionParams;
|
||||
skipMigrations: boolean;
|
||||
@@ -149,25 +143,6 @@ 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);
|
||||
@@ -314,10 +289,6 @@ const getEnv = (): EnvData => {
|
||||
vectorExtension,
|
||||
},
|
||||
|
||||
helmet: {
|
||||
config: resolveHelmetFile(dto.IMMICH_HELMET_FILE),
|
||||
},
|
||||
|
||||
licensePublicKey: isProd ? productionKeys : stagingKeys,
|
||||
|
||||
network: {
|
||||
|
||||
@@ -502,7 +502,10 @@ export class SearchRepository {
|
||||
return res.map((row) => row.lensModel!);
|
||||
}
|
||||
|
||||
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
|
||||
private getExifField<K extends 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel'>(
|
||||
field: K,
|
||||
userIds: string[],
|
||||
) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
.select(field)
|
||||
@@ -511,7 +514,6 @@ export class SearchRepository {
|
||||
.where('ownerId', '=', anyUuid(userIds))
|
||||
.where('visibility', '=', AssetVisibility.Timeline)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where(field, 'is not', null)
|
||||
.where(field, '!=', '');
|
||||
.where(field, 'is not', null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
|
||||
import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import _ from 'lodash';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -17,41 +17,6 @@ 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>) {}
|
||||
@@ -61,16 +26,35 @@ export class SharedLinkRepository {
|
||||
return this.db
|
||||
.selectFrom('shared_link')
|
||||
.selectAll('shared_link')
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
withSharedAssets(eb)
|
||||
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
|
||||
.select((eb) => eb.fn.toJson('exifInfo').as('exifInfo')),
|
||||
).as('assets'),
|
||||
.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(),
|
||||
)
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
withSharedLinkAlbum(eb)
|
||||
eb
|
||||
.selectFrom('album')
|
||||
.selectAll('album')
|
||||
.whereRef('album.id', '=', 'shared_link.albumId')
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.leftJoin('album_asset', 'album_asset.albumId', 'album.id')
|
||||
.leftJoinLateral(
|
||||
(eb) =>
|
||||
@@ -79,13 +63,30 @@ export class SharedLinkRepository {
|
||||
.selectAll('asset')
|
||||
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||
.where('asset.deletedAt', 'is', null)
|
||||
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
|
||||
.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(eb.table('exifInfo')).as('exifInfo'))
|
||||
.orderBy('asset.fileCreatedAt', 'asc')
|
||||
.as('assets'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.innerJoinLateral(withAlbumOwner, (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(),
|
||||
)
|
||||
.select((eb) =>
|
||||
eb.fn
|
||||
.coalesce(
|
||||
@@ -103,6 +104,17 @@ 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)
|
||||
@@ -116,13 +128,53 @@ 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) =>
|
||||
withSharedLinkAlbum(eb)
|
||||
.innerJoinLateral(withAlbumOwner, (join) => join.onTrue())
|
||||
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(),
|
||||
)
|
||||
.select((eb) => eb.fn.toJson('owner').as('owner'))
|
||||
.where('album.deletedAt', 'is', null)
|
||||
.as('album'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
@@ -231,7 +283,11 @@ export class SharedLinkRepository {
|
||||
.selectFrom('asset')
|
||||
.whereRef('asset.id', '=', 'shared_link_asset.assetId')
|
||||
.selectAll('asset')
|
||||
.innerJoinLateral(withExifInfo, (join) => join.onTrue())
|
||||
.innerJoinLateral(
|
||||
(eb) =>
|
||||
eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exifInfo'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
.as('assets'),
|
||||
(join) => join.onTrue(),
|
||||
)
|
||||
|
||||
@@ -487,6 +487,7 @@ class AssetFaceSync extends BaseSync {
|
||||
])
|
||||
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
|
||||
.where('asset.ownerId', '=', options.userId)
|
||||
.where('asset_face.isVisible', '=', true)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -695,7 +695,9 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id', { edited: true })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
@@ -706,7 +708,9 @@ describe(AssetMediaService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the encoded video path if available', async () => {
|
||||
@@ -719,7 +723,7 @@ describe(AssetMediaService.name, () => {
|
||||
encodedVideoPath: asset.files[0].path,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/path/to/encoded/video.mp4',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
@@ -736,7 +740,7 @@ describe(AssetMediaService.name, () => {
|
||||
encodedVideoPath: null,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
AssetThumbnailOptionsDto,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
@@ -222,7 +223,7 @@ export class AssetMediaService extends BaseService {
|
||||
async viewThumbnail(
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaOptionsDto,
|
||||
dto: AssetThumbnailOptionsDto,
|
||||
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
@@ -266,10 +267,10 @@ export class AssetMediaService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
async playbackVideo(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getForVideo(id);
|
||||
const asset = await this.assetRepository.getForVideo(id, dto.edited ?? false);
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found or asset is not a video');
|
||||
@@ -356,7 +357,6 @@ 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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
} from 'src/utils/asset.util';
|
||||
import { updateLockedColumns } from 'src/utils/database';
|
||||
import { extractTimeZone } from 'src/utils/date';
|
||||
import { scaleEdits } from 'src/utils/editor';
|
||||
import { transformOcrBoundingBox } from 'src/utils/transform';
|
||||
|
||||
@Injectable()
|
||||
@@ -565,10 +566,6 @@ export class AssetService extends BaseService {
|
||||
throw new BadRequestException('Only images can be edited');
|
||||
}
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
throw new BadRequestException('Editing live photos is not supported');
|
||||
}
|
||||
|
||||
if (isPanorama(asset)) {
|
||||
throw new BadRequestException('Editing panorama images is not supported');
|
||||
}
|
||||
@@ -609,7 +606,28 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
const newEdits = await this.assetEditRepository.replaceAll(id, edits);
|
||||
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
|
||||
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } });
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
const liveAsset = await this.assetRepository.getForEdit(asset.livePhotoVideoId);
|
||||
if (!liveAsset) {
|
||||
throw new BadRequestException('Live photo video not found');
|
||||
}
|
||||
|
||||
const { width: liveWidth, height: liveHeight } = getDimensions(liveAsset);
|
||||
|
||||
const scaledEdits = scaleEdits(
|
||||
edits,
|
||||
{ width: liveWidth, height: liveHeight },
|
||||
{ width: assetWidth, height: assetHeight },
|
||||
);
|
||||
|
||||
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, scaledEdits);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.AssetProcessEdit,
|
||||
data: { id: asset.livePhotoVideoId },
|
||||
});
|
||||
}
|
||||
|
||||
// Return the asset and its applied edits
|
||||
return {
|
||||
@@ -627,6 +645,14 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
await this.assetEditRepository.replaceAll(id, []);
|
||||
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
|
||||
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } });
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, []);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.AssetProcessEdit,
|
||||
data: { id: asset.livePhotoVideoId },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ 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,
|
||||
@@ -188,7 +187,6 @@ 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,
|
||||
@@ -402,7 +400,6 @@ 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,
|
||||
@@ -477,7 +474,6 @@ 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,
|
||||
@@ -540,7 +536,6 @@ 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,
|
||||
@@ -668,7 +663,6 @@ 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,
|
||||
@@ -684,8 +678,6 @@ 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(() => {
|
||||
@@ -748,8 +740,6 @@ 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(() => {
|
||||
@@ -844,24 +834,7 @@ 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();
|
||||
@@ -873,7 +846,6 @@ 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,7 +20,6 @@ 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,
|
||||
@@ -41,7 +40,6 @@ 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()
|
||||
@@ -407,14 +405,7 @@ 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);
|
||||
|
||||
@@ -95,8 +95,7 @@ export class JobService extends BaseService {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case JobName.AssetEditThumbnailGeneration: {
|
||||
case JobName.AssetProcessEdit: {
|
||||
const asset = await this.assetRepository.getById(item.data.id);
|
||||
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.AssetEditThumbnailGeneration,
|
||||
name: JobName.AssetProcessEdit,
|
||||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
@@ -273,7 +273,7 @@ describe(MediaService.name, () => {
|
||||
data: { id: asset.id },
|
||||
},
|
||||
{
|
||||
name: JobName.AssetEditThumbnailGeneration,
|
||||
name: JobName.AssetProcessEdit,
|
||||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
@@ -1321,9 +1321,101 @@ describe(MediaService.name, () => {
|
||||
expect.stringContaining('fullsize.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate edited video thumbnails when asset has edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should generate both original and edited thumbnails (2 original + 2 edited transcodes)
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(4);
|
||||
|
||||
// should upsert files for both original and edited
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: AssetFileType.Preview, isEdited: false }),
|
||||
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: false }),
|
||||
expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
|
||||
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not generate edited video thumbnails when asset has no edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.generateThumbhash.mockResolvedValue(Buffer.from('thumbhash'));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should only generate original thumbnails (2 transcodes for preview + thumbnail)
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.not.arrayContaining([expect.objectContaining({ isEdited: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use edited thumbhash when asset has edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop })
|
||||
.build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const originalThumbhash = Buffer.from('original thumbhash');
|
||||
const editedThumbhash = Buffer.from('edited thumbhash');
|
||||
mocks.media.generateThumbhash.mockResolvedValueOnce(originalThumbhash).mockResolvedValueOnce(editedThumbhash);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should use the edited thumbhash (second call) for the asset update
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: editedThumbhash }));
|
||||
});
|
||||
|
||||
it('should generate edited image thumbnails with edits applied', async () => {
|
||||
const asset = AssetFactory.from()
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 100, y: 100 } })
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should generate original (2) + edited (3 with fullsize) thumbnails
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
edits: [
|
||||
expect.objectContaining({
|
||||
action: 'crop',
|
||||
parameters: { height: 500, width: 500, x: 100, y: 100 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.stringContaining('edited'),
|
||||
);
|
||||
|
||||
// should upsert both original and edited files
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ isEdited: false }),
|
||||
expect.objectContaining({ isEdited: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetEditThumbnailGeneration', () => {
|
||||
describe('handleAssetEditProcessing', () => {
|
||||
let rawInfo: RawImageInfo;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1340,14 +1432,6 @@ describe(MediaService.name, () => {
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
|
||||
});
|
||||
|
||||
it('should skip videos', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should upsert 3 edited files for edit jobs', async () => {
|
||||
const asset = AssetFactory.from()
|
||||
.exif()
|
||||
@@ -1359,13 +1443,13 @@ describe(MediaService.name, () => {
|
||||
])
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
@@ -1381,11 +1465,11 @@ describe(MediaService.name, () => {
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } })
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
@@ -1409,9 +1493,9 @@ describe(MediaService.name, () => {
|
||||
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
const status = await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
@@ -1427,11 +1511,11 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should generate all 3 edited files if an asset has edits', async () => {
|
||||
const asset = AssetFactory.from().exif().edit().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
@@ -1453,26 +1537,147 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should generate the original thumbhash if no edits exist', async () => {
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' });
|
||||
await sut.handleAssetEditProcessing({ id: asset.id, source: 'upload' });
|
||||
|
||||
expect(mocks.media.generateThumbhash).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply thumbhash if job source is edit and edits exist', async () => {
|
||||
const asset = AssetFactory.from().exif().edit().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = factory.buffer();
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
|
||||
});
|
||||
|
||||
it('should return failed if asset not found', async () => {
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(undefined as never);
|
||||
const status = await sut.handleAssetEditProcessing({ id: 'non-existent' });
|
||||
expect(status).toBe(JobStatus.Failed);
|
||||
});
|
||||
|
||||
it('should transcode edited video and generate thumbnails', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.files([
|
||||
{ type: AssetFileType.Preview, isEdited: false },
|
||||
{ type: AssetFileType.EncodedVideo, isEdited: true },
|
||||
{ type: AssetFileType.Preview, isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
// should transcode the video with hw accel disabled
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/video.mp4',
|
||||
expect.stringContaining('edited'),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
|
||||
// should generate edited thumbnails (preview + thumbnail via transcode)
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(3); // 1 video + 2 thumbnails
|
||||
|
||||
// should update thumbhash
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
|
||||
});
|
||||
|
||||
it('should clean up edited video files when asset has no edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.files([
|
||||
{ type: AssetFileType.EncodedVideo, path: 'edited_video.mp4', isEdited: true },
|
||||
{ type: AssetFileType.Preview, path: 'edited_preview.jpg', isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, path: 'edited_thumbnail.webp', isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
// should not transcode since there are no edits
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
|
||||
// should delete old edited files
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: {
|
||||
files: expect.arrayContaining(['edited_video.mp4']),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip thumbnail generation for hidden video assets (live photo video portions)', async () => {
|
||||
const asset = AssetFactory.from({
|
||||
type: AssetType.Video,
|
||||
originalPath: '/original/video.mp4',
|
||||
visibility: AssetVisibility.Hidden,
|
||||
})
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop })
|
||||
.files([{ type: AssetFileType.Preview, isEdited: false }])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.media.generateThumbhash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use original thumbhash when video has no edits but is visible', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.files([{ type: AssetFileType.Preview, path: '/thumbs/preview.jpg', isEdited: false }])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = factory.buffer();
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.media.generateThumbhash).toHaveBeenCalledWith('/thumbs/preview.jpg', expect.any(Object));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: asset.id, thumbhash: thumbhashBuffer }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dimensions from transcoded video edit', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 800, x: 100, y: 100 } })
|
||||
.files([{ type: AssetFileType.Preview, isEdited: false }])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
// should update asset dimensions
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: asset.id, width: 800, height: 500 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGeneratePersonThumbnail', () => {
|
||||
@@ -1974,7 +2179,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
|
||||
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2228,7 +2433,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } });
|
||||
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2626,14 +2831,14 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 },
|
||||
});
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if hwaccel option is invalid', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2920,7 +3125,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } });
|
||||
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3330,7 +3535,7 @@ describe(MediaService.name, () => {
|
||||
sut.videoInterfaces = { dri: [], mali: true };
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } });
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3605,6 +3810,95 @@ describe(MediaService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should also transcode edited version when asset has edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
// should be called for both original and edited
|
||||
expect(mocks.media.probe).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
expect.stringContaining('edited'),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transcode edited version when asset has no edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
// probe is called for both original and edit attempt, but only original is transcoded
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ isEdited: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable hardware acceleration for edited video transcoding', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, transcode: TranscodePolicy.All },
|
||||
});
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
// the edited transcode call should NOT have hw accel options
|
||||
const transcodeCalls = mocks.media.transcode.mock.calls;
|
||||
const editedCall = transcodeCalls.find((call) => (call[1] as string).includes('edited'));
|
||||
expect(editedCall).toBeDefined();
|
||||
// hw accel typically adds device-specific input options; for edited, should be software only
|
||||
expect(editedCall![2].inputOptions).not.toEqual(expect.arrayContaining([expect.stringContaining('qsv')]));
|
||||
});
|
||||
|
||||
it('should upsert both original and edited encoded video files', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: false }),
|
||||
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up edited encoded video when edits are removed', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/edited_video.mp4', isEdited: true })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: {
|
||||
files: expect.arrayContaining(['/encoded/edited_video.mp4']),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSRGB', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
VideoInterfaces,
|
||||
VideoStreamInfo,
|
||||
} from 'src/types';
|
||||
import { getAssetFile, getDimensions } from 'src/utils/asset.util';
|
||||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -56,6 +56,13 @@ interface UpsertFileOptions {
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
type VideoConversionAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForVideoConversion']>>>;
|
||||
|
||||
type ThumbnailGenerationResult = {
|
||||
files: UpsertFileOptions[];
|
||||
thumbhash: Buffer;
|
||||
fullsizeDimensions: ImageDimensions;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MediaService extends BaseService {
|
||||
@@ -84,7 +91,7 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
if (asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
|
||||
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
@@ -168,9 +175,9 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
|
||||
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
@OnJob({ name: JobName.AssetProcessEdit, queue: QueueName.Editor })
|
||||
async handleAssetEditProcessing({ id }: JobOf<JobName.AssetProcessEdit>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForAssetEditProcessing(id);
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
|
||||
if (!asset) {
|
||||
@@ -178,7 +185,25 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const generated = await this.generateEditedThumbnails(asset, config);
|
||||
switch (asset.type) {
|
||||
case AssetType.Image: {
|
||||
await this.handleImageEdit(asset, config);
|
||||
break;
|
||||
}
|
||||
case AssetType.Video: {
|
||||
await this.handleVideoEdit(asset, config);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
}
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private async handleImageEdit(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
const generated = await this.generateEditedImageThumbnails(asset, config);
|
||||
await this.syncFiles(
|
||||
asset.files.filter((file) => file.isEdited),
|
||||
generated?.files ?? [],
|
||||
@@ -203,8 +228,51 @@ export class MediaService extends BaseService {
|
||||
|
||||
const fullsizeDimensions = generated?.fullsizeDimensions ?? getDimensions(asset.exifInfo!);
|
||||
await this.assetRepository.update({ id: asset.id, ...fullsizeDimensions });
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
private async handleVideoEdit(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
// transcode edited video
|
||||
const generatedVideo = asset.edits.length > 0 ? await this.transcodeVideo(asset, config.ffmpeg, true) : undefined;
|
||||
|
||||
await this.syncFiles(
|
||||
asset.files.filter((file) => file.isEdited && file.type === AssetFileType.EncodedVideo),
|
||||
generatedVideo ? [generatedVideo.file] : [],
|
||||
);
|
||||
|
||||
// update asset dimensions
|
||||
const newDimensions = generatedVideo?.dimensions ?? getDimensions(asset.exifInfo!);
|
||||
await this.assetRepository.update({ id: asset.id, ...newDimensions });
|
||||
|
||||
// if the asset is hidden, we dont need to update the thumbhash or thumbnails
|
||||
if (asset.visibility === AssetVisibility.Hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editedThumbnails = await this.generateEditedVideoThumbnails(asset, config);
|
||||
await this.syncFiles(
|
||||
asset.files.filter((file) => file.isEdited && file.type !== AssetFileType.EncodedVideo),
|
||||
editedThumbnails?.files ?? [],
|
||||
);
|
||||
|
||||
let thumbhash: Buffer | undefined = editedThumbnails?.thumbhash;
|
||||
if (!thumbhash) {
|
||||
const previewFile = asset.files.find((file) => file.type === AssetFileType.Preview && !file.isEdited);
|
||||
|
||||
if (!previewFile) {
|
||||
this.logger.warn(`Failed to generate thumbhash for asset ${asset.id}: missing preview file`);
|
||||
return;
|
||||
}
|
||||
|
||||
thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
|
||||
colorspace: config.image.colorspace,
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
// update asset table info
|
||||
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -217,31 +285,34 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
let generated: ThumbnailGenerationResult;
|
||||
let generatedEdited: ThumbnailGenerationResult | undefined;
|
||||
|
||||
if (asset.visibility === AssetVisibility.Hidden) {
|
||||
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
|
||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateVideoThumbnails(asset, config);
|
||||
generatedEdited = await this.generateEditedVideoThumbnails(asset, config);
|
||||
} else if (asset.type === AssetType.Image) {
|
||||
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateImageThumbnails(asset, config);
|
||||
generatedEdited = await this.generateEditedImageThumbnails(asset, config);
|
||||
} else {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const editedGenerated = await this.generateEditedThumbnails(asset, config);
|
||||
if (editedGenerated) {
|
||||
generated.files.push(...editedGenerated.files);
|
||||
if (generatedEdited) {
|
||||
generated.files.push(...generatedEdited.files);
|
||||
}
|
||||
|
||||
await this.syncFiles(asset.files, generated.files);
|
||||
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
|
||||
|
||||
const thumbhash = generatedEdited?.thumbhash || generated.thumbhash;
|
||||
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||
}
|
||||
@@ -507,20 +578,21 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(
|
||||
asset: ThumbnailPathEntity & { originalPath: string },
|
||||
asset: ThumbnailPathEntity & { originalPath: string; edits: AssetEditActionItem[] },
|
||||
{ ffmpeg, image }: SystemConfig,
|
||||
useEdits: boolean = false,
|
||||
) {
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
format: image.preview.format,
|
||||
isEdited: false,
|
||||
isEdited: useEdits,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
});
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: false,
|
||||
isEdited: useEdits,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
});
|
||||
@@ -533,14 +605,27 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
|
||||
let edits: AssetEditActionItem[] | undefined;
|
||||
if (useEdits) {
|
||||
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
|
||||
edits = asset.edits;
|
||||
}
|
||||
|
||||
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
||||
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
||||
const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, mainVideoStream, mainAudioStream, format);
|
||||
const previewOptions = previewConfig.getCommand(
|
||||
TranscodeTarget.Video,
|
||||
mainVideoStream,
|
||||
mainAudioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
const thumbnailOptions = thumbnailConfig.getCommand(
|
||||
TranscodeTarget.Video,
|
||||
mainVideoStream,
|
||||
mainAudioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
|
||||
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
|
||||
@@ -551,73 +636,69 @@ export class MediaService extends BaseService {
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
});
|
||||
|
||||
let fullsizeDimensions = { width: mainVideoStream.width, height: mainVideoStream.height };
|
||||
if (useEdits) {
|
||||
fullsizeDimensions = getOutputDimensions(asset.edits, fullsizeDimensions);
|
||||
}
|
||||
|
||||
return {
|
||||
files: [previewFile, thumbnailFile],
|
||||
thumbhash,
|
||||
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
|
||||
fullsizeDimensions,
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
|
||||
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
|
||||
let queue: { name: JobName.AssetEncodeVideo; data: { id: string } }[] = [];
|
||||
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
|
||||
queue.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
|
||||
|
||||
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(queue);
|
||||
queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(queue);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
|
||||
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForVideoConversion(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
private async transcodeVideo(
|
||||
asset: VideoConversionAsset,
|
||||
ffmpeg: SystemConfigFFmpegDto,
|
||||
useEdits: boolean = false,
|
||||
): Promise<{ file: UpsertFileOptions; dimensions: { width: number; height: number } } | undefined> {
|
||||
const input = asset.originalPath;
|
||||
const output = StorageCore.getEncodedVideoPath(asset);
|
||||
const output = StorageCore.getEncodedVideoPath(asset, useEdits);
|
||||
this.storageCore.ensureFolders(output);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
|
||||
countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs
|
||||
countFrames: this.logger.isLevelEnabled(LogLevel.Debug),
|
||||
});
|
||||
const videoStream = this.getMainStream(videoStreams);
|
||||
const audioStream = this.getMainStream(audioStreams);
|
||||
if (!videoStream || !format.formatName) {
|
||||
return JobStatus.Failed;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!videoStream.height || !videoStream.width) {
|
||||
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
|
||||
return JobStatus.Failed;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let { ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
||||
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
||||
const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||
if (encodedVideo) {
|
||||
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } });
|
||||
await this.assetRepository.deleteFiles([encodedVideo]);
|
||||
} else {
|
||||
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
||||
let target: TranscodeTarget;
|
||||
let edits: AssetEditActionItem[] | undefined;
|
||||
|
||||
if (useEdits) {
|
||||
if (asset.edits.length === 0) {
|
||||
this.logger.verbose(`Asset ${asset.id} has no edits, skipping edited version transcoding`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JobStatus.Skipped;
|
||||
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
|
||||
target = TranscodeTarget.All;
|
||||
edits = asset.edits;
|
||||
} else {
|
||||
target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
||||
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
||||
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
|
||||
target,
|
||||
videoStream,
|
||||
audioStream,
|
||||
useEdits ? undefined : format,
|
||||
edits,
|
||||
);
|
||||
|
||||
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
|
||||
this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
|
||||
} else {
|
||||
@@ -631,7 +712,7 @@ export class MediaService extends BaseService {
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error occurred during transcoding: ${error.message}`);
|
||||
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
|
||||
return JobStatus.Failed;
|
||||
throw error;
|
||||
}
|
||||
|
||||
let partialFallbackSuccess = false;
|
||||
@@ -639,7 +720,13 @@ export class MediaService extends BaseService {
|
||||
try {
|
||||
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
|
||||
ffmpeg = { ...ffmpeg, accelDecode: false };
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
|
||||
target,
|
||||
videoStream,
|
||||
audioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
await this.mediaRepository.transcode(input, output, command);
|
||||
partialFallbackSuccess = true;
|
||||
} catch (error: any) {
|
||||
@@ -650,19 +737,87 @@ export class MediaService extends BaseService {
|
||||
if (!partialFallbackSuccess) {
|
||||
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
|
||||
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
|
||||
target,
|
||||
videoStream,
|
||||
audioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
await this.mediaRepository.transcode(input, output, command);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully encoded ${asset.id}`);
|
||||
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.EncodedVideo,
|
||||
path: output,
|
||||
isEdited: false,
|
||||
});
|
||||
let finalDimensions = { width: videoStream.width, height: videoStream.height };
|
||||
if (useEdits) {
|
||||
finalDimensions = getOutputDimensions(asset.edits, finalDimensions);
|
||||
}
|
||||
|
||||
return {
|
||||
dimensions: finalDimensions,
|
||||
file: {
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.EncodedVideo,
|
||||
path: output,
|
||||
isEdited: useEdits,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
|
||||
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
|
||||
let jobs: JobItem[] = [];
|
||||
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
|
||||
if (force || !asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
|
||||
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForVideoConversion(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const { ffmpeg } = await this.getConfig({ withCache: true });
|
||||
|
||||
const files: UpsertFileOptions[] = [];
|
||||
try {
|
||||
const generated = await this.transcodeVideo(asset, ffmpeg);
|
||||
if (generated?.file) {
|
||||
files.push(generated.file);
|
||||
}
|
||||
|
||||
const editedGenerated = await this.transcodeVideo(asset, ffmpeg, true);
|
||||
if (editedGenerated) {
|
||||
files.push(editedGenerated.file);
|
||||
}
|
||||
} catch {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
await this.syncFiles(asset.files, files);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -874,13 +1029,29 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
private async generateEditedImageThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
|
||||
await this.updateMLVisibilities(asset);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private async generateEditedVideoThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
if (asset.type !== AssetType.Video || (asset.files.length === 0 && asset.edits.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generated = asset.edits.length > 0 ? await this.generateVideoThumbnails(asset, config, true) : undefined;
|
||||
await this.updateMLVisibilities(asset);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private async updateMLVisibilities(asset: ThumbnailAsset) {
|
||||
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
const cropBox = crop
|
||||
? {
|
||||
@@ -900,8 +1071,6 @@ export class MediaService extends BaseService {
|
||||
|
||||
const ocrStatuses = checkOcrVisibility(ocrData, originalDimensions, cropBox);
|
||||
await this.ocrRepository.updateOcrVisibilities(asset.id, ocrStatuses.visible, ocrStatuses.hidden);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) {
|
||||
|
||||
@@ -1641,32 +1641,12 @@ describe(MetadataService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should overwrite existing width/height for unedited assets', async () => {
|
||||
const asset = AssetFactory.create({ width: 1920, height: 1080, isEdited: false });
|
||||
it('should not overwrite existing width/height if they already exist', async () => {
|
||||
const asset = AssetFactory.create({ width: 1920, height: 1080 });
|
||||
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,9 +327,10 @@ export class MetadataService extends BaseService {
|
||||
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
|
||||
fileModifiedAt: stats.mtime,
|
||||
|
||||
// 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,
|
||||
// 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,
|
||||
}),
|
||||
async () => {
|
||||
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
|
||||
|
||||
+3
-1
@@ -130,6 +130,7 @@ export interface TranscodeCommand {
|
||||
progress: {
|
||||
frameCount: number;
|
||||
percentInterval: number;
|
||||
callback: (percent: number, frame: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,6 +152,7 @@ export interface VideoCodecSWConfig {
|
||||
videoStream: VideoStreamInfo,
|
||||
audioStream: AudioStreamInfo,
|
||||
format?: VideoFormat,
|
||||
edits?: AssetEditActionItem[],
|
||||
): TranscodeCommand;
|
||||
}
|
||||
|
||||
@@ -389,7 +391,7 @@ export type JobItem =
|
||||
| { name: JobName.WorkflowRun; data: IWorkflowJob }
|
||||
|
||||
// Editor
|
||||
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
|
||||
| { name: JobName.AssetProcessEdit; data: IEntityJob };
|
||||
|
||||
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
|
||||
|
||||
|
||||
@@ -116,22 +116,24 @@ export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withHidden?: boole
|
||||
).as('faces');
|
||||
}
|
||||
|
||||
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
|
||||
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType | AssetFileType[]) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(columns.assetFiles)
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)),
|
||||
.$if(!!type && typeof type === 'string', (qb) => qb.where('asset_file.type', '=', type!))
|
||||
.$if(!!type && Array.isArray(type), (qb) => qb.where('asset_file.type', 'in', type as AssetFileType[])),
|
||||
).as('files');
|
||||
}
|
||||
|
||||
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
|
||||
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType, isEdited = false) {
|
||||
return eb
|
||||
.selectFrom('asset_file')
|
||||
.select('asset_file.path')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', type);
|
||||
.where('asset_file.type', '=', type)
|
||||
.where('asset_file.isEdited', '=', isEdited);
|
||||
}
|
||||
|
||||
export function withFacesAndPeople(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AssetFace } from 'src/database';
|
||||
import { AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ImageDimensions } from 'src/types';
|
||||
|
||||
@@ -31,6 +32,15 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
|
||||
};
|
||||
};
|
||||
|
||||
const scaleCrop = (crop: CropParameters, target: ImageDimensions, source: ImageDimensions) => {
|
||||
return {
|
||||
width: Math.round((crop.width / source.width) * target.width),
|
||||
height: Math.round((crop.height / source.height) * target.height),
|
||||
x: Math.round((crop.x / source.width) * target.width),
|
||||
y: Math.round((crop.y / source.height) * target.height),
|
||||
};
|
||||
};
|
||||
|
||||
export const checkFaceVisibility = (
|
||||
faces: AssetFace[],
|
||||
originalAssetDimensions: ImageDimensions,
|
||||
@@ -105,3 +115,20 @@ export const checkOcrVisibility = (
|
||||
hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr),
|
||||
};
|
||||
};
|
||||
|
||||
export const scaleEdits = (
|
||||
edits: AssetEditActionItem[],
|
||||
target: ImageDimensions,
|
||||
source: ImageDimensions,
|
||||
): AssetEditActionItem[] => {
|
||||
return edits.map((edit) => {
|
||||
if (edit.action === 'crop') {
|
||||
return {
|
||||
...edit,
|
||||
parameters: scaleCrop(edit.parameters as CropParameters, target, source),
|
||||
} as AssetEditActionItem;
|
||||
}
|
||||
|
||||
return edit;
|
||||
});
|
||||
};
|
||||
|
||||
+95
-12
@@ -1,4 +1,12 @@
|
||||
import { AUDIO_ENCODER } from 'src/constants';
|
||||
import {
|
||||
AssetEditAction,
|
||||
AssetEditActionItem,
|
||||
CropParameters,
|
||||
MirrorAxis,
|
||||
MirrorParameters,
|
||||
RotateParameters,
|
||||
} from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
|
||||
import {
|
||||
@@ -88,15 +96,26 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
videoStream: VideoStreamInfo,
|
||||
audioStream?: AudioStreamInfo,
|
||||
format?: VideoFormat,
|
||||
edits: AssetEditActionItem[] = [],
|
||||
) {
|
||||
const inputOptions = this.getBaseInputOptions(videoStream, format);
|
||||
|
||||
if (edits.length > 0) {
|
||||
// turns out MOV files can have cropping metadata that ffmpeg automatically applies when decoding
|
||||
// this means that the video streams dimensions can just be wrong once it hits the filter pipeline
|
||||
// https://github.com/FFmpeg/FFmpeg/blob/f40fcf802472227851e0b8eeba40b9e6b3b8a3a1/libavutil/frame.h#L1021
|
||||
inputOptions.push('-apply_cropping 0');
|
||||
}
|
||||
|
||||
const options = {
|
||||
inputOptions: this.getBaseInputOptions(videoStream, format),
|
||||
inputOptions,
|
||||
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
|
||||
twoPass: this.eligibleForTwoPass(),
|
||||
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
|
||||
} as TranscodeCommand;
|
||||
|
||||
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
|
||||
const filters = this.getFilterOptions(videoStream);
|
||||
const filters = this.getFilterOptions(videoStream, edits);
|
||||
if (filters.length > 0) {
|
||||
options.outputOptions.push(`-vf ${filters.join(',')}`);
|
||||
}
|
||||
@@ -156,10 +175,46 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return options;
|
||||
}
|
||||
|
||||
getFilterOptions(videoStream: VideoStreamInfo) {
|
||||
getEditOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[]) {
|
||||
const options = [];
|
||||
if (this.shouldScale(videoStream)) {
|
||||
options.push(`scale=${this.getScaling(videoStream)}`);
|
||||
let currentDimensions = { width: videoStream.width, height: videoStream.height };
|
||||
|
||||
// Apply CPU edit operations before hwupload
|
||||
for (const edit of edits) {
|
||||
switch (edit.action) {
|
||||
case AssetEditAction.Crop: {
|
||||
options.push(this.getCropOperation(edit.parameters));
|
||||
currentDimensions = { width: edit.parameters.width, height: edit.parameters.height };
|
||||
break;
|
||||
}
|
||||
case AssetEditAction.Rotate: {
|
||||
const rotateFilter = this.getRotateOperation(edit.parameters);
|
||||
if (rotateFilter) {
|
||||
options.push(rotateFilter);
|
||||
if (Math.abs(edit.parameters.angle) === 90 || Math.abs(edit.parameters.angle) === 270) {
|
||||
currentDimensions = { width: currentDimensions.height, height: currentDimensions.width };
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AssetEditAction.Mirror: {
|
||||
options.push(this.getMirrorOperation(edit.parameters));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { options, currentDimensions };
|
||||
}
|
||||
|
||||
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []) {
|
||||
const options = [];
|
||||
const { options: editOptions, currentDimensions } = this.getEditOptions(videoStream, edits);
|
||||
options.push(...editOptions);
|
||||
|
||||
// Apply scaling based on current dimensions after edits
|
||||
if (this.shouldScale(videoStream, currentDimensions)) {
|
||||
options.push(`scale=${this.getScaling(videoStream, 2, currentDimensions)}`);
|
||||
}
|
||||
|
||||
const tonemapOptions = this.getToneMapping(videoStream);
|
||||
@@ -238,9 +293,10 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return target;
|
||||
}
|
||||
|
||||
shouldScale(videoStream: VideoStreamInfo) {
|
||||
const oddDimensions = videoStream.height % 2 !== 0 || videoStream.width % 2 !== 0;
|
||||
const largerThanTarget = Math.min(videoStream.height, videoStream.width) > this.getTargetResolution(videoStream);
|
||||
shouldScale(videoStream: VideoStreamInfo, currentDimensions?: { width: number; height: number }) {
|
||||
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
|
||||
const oddDimensions = dims.height % 2 !== 0 || dims.width % 2 !== 0;
|
||||
const largerThanTarget = Math.min(dims.height, dims.width) > this.getTargetResolution(videoStream);
|
||||
return oddDimensions || largerThanTarget;
|
||||
}
|
||||
|
||||
@@ -248,9 +304,11 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled;
|
||||
}
|
||||
|
||||
getScaling(videoStream: VideoStreamInfo, mult = 2) {
|
||||
getScaling(videoStream: VideoStreamInfo, mult = 2, currentDimensions?: { width: number; height: number }) {
|
||||
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
|
||||
const targetResolution = this.getTargetResolution(videoStream);
|
||||
return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
||||
const isVertical = dims.height > dims.width || this.isVideoRotated(videoStream);
|
||||
return isVertical ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
||||
}
|
||||
|
||||
getSize(videoStream: VideoStreamInfo) {
|
||||
@@ -329,6 +387,31 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
useCQP() {
|
||||
return this.config.cqMode === CQMode.Cqp;
|
||||
}
|
||||
|
||||
// Edit operations (software filters)
|
||||
getCropOperation({ x, y, width, height }: CropParameters): string {
|
||||
return `crop=${width}:${height}:${x}:${y}`;
|
||||
}
|
||||
|
||||
getRotateOperation({ angle }: RotateParameters): string {
|
||||
switch (angle) {
|
||||
case 90: {
|
||||
return 'transpose=1'; // 90° clockwise
|
||||
}
|
||||
case 180: {
|
||||
return 'hflip,vflip'; // 180°
|
||||
}
|
||||
case 270: {
|
||||
return 'transpose=2'; // 90° counter-clockwise (270° clockwise)
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
getMirrorOperation({ axis }: MirrorParameters): string {
|
||||
return axis === MirrorAxis.Horizontal ? 'hflip' : 'vflip';
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
|
||||
@@ -423,14 +506,14 @@ export class ThumbnailConfig extends BaseConfig {
|
||||
return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
|
||||
}
|
||||
|
||||
getFilterOptions(videoStream: VideoStreamInfo): string[] {
|
||||
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []): string[] {
|
||||
return [
|
||||
'fps=12:start_time=0:eof_action=pass:round=down',
|
||||
'thumbnail=12',
|
||||
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
|
||||
'trim=end_frame=2',
|
||||
'reverse',
|
||||
...super.getFilterOptions(videoStream),
|
||||
...super.getFilterOptions(videoStream, edits),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,6 @@ 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,5 +1,4 @@
|
||||
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';
|
||||
@@ -109,25 +108,4 @@ 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,43 +372,6 @@ 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,10 +35,6 @@ const envData: EnvData = {
|
||||
vectorExtension: DatabaseExtension.Vectors,
|
||||
},
|
||||
|
||||
helmet: {
|
||||
config: {},
|
||||
},
|
||||
|
||||
licensePublicKey: {
|
||||
client: 'client-public-key',
|
||||
server: 'server-public-key',
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.14.0
|
||||
24.13.1
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.3",
|
||||
"version": "2.6.2",
|
||||
"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.69.0",
|
||||
"@immich/ui": "^0.65.3",
|
||||
"@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.54.1",
|
||||
"svelte": "5.53.13",
|
||||
"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.14.0"
|
||||
"node": "24.13.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<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,8 +1,7 @@
|
||||
<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 { navigate } from '$lib/utils/navigation';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk';
|
||||
import { IconButton, modalManager } from '@immich/ui';
|
||||
import { mdiMapOutline } from '@mdi/js';
|
||||
@@ -15,8 +14,8 @@
|
||||
|
||||
let { album }: Props = $props();
|
||||
let abortController: AbortController;
|
||||
let { setAssetId } = assetViewingStore;
|
||||
|
||||
let returnToMap = $state(false);
|
||||
let mapMarkers: MapMarkerResponseDto[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
@@ -25,14 +24,7 @@
|
||||
|
||||
onDestroy(() => {
|
||||
abortController?.abort();
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!assetViewerManager.isViewing && returnToMap) {
|
||||
returnToMap = false;
|
||||
void onClick();
|
||||
}
|
||||
assetViewingStore.showAssetViewer(false);
|
||||
});
|
||||
|
||||
async function loadMapMarkers() {
|
||||
@@ -60,15 +52,13 @@
|
||||
return markers;
|
||||
}
|
||||
|
||||
const onClick = async () => {
|
||||
async function openMap() {
|
||||
const assetIds = await modalManager.show(MapModal, { mapMarkers });
|
||||
|
||||
if (assetIds) {
|
||||
await navigate({ targetRoute: 'current', assetId: assetIds[0] });
|
||||
returnToMap = true;
|
||||
} else {
|
||||
returnToMap = false;
|
||||
await setAssetId(assetIds[0]);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<IconButton
|
||||
@@ -76,6 +66,6 @@
|
||||
shape="round"
|
||||
color="secondary"
|
||||
icon={mdiMapOutline}
|
||||
onclick={onClick}
|
||||
onclick={openMap}
|
||||
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,6 +34,7 @@
|
||||
|
||||
const album = sharedLink.album as AlbumResponseDto;
|
||||
|
||||
let { isViewing: showAssetViewer, setAssetId } = assetViewingStore;
|
||||
let { slideshowState, slideshowNavigation } = slideshowStore;
|
||||
|
||||
const options = $derived({ albumId: album.id, order: album.order });
|
||||
@@ -54,9 +55,7 @@
|
||||
? await timelineManager.getRandomAsset()
|
||||
: timelineManager.months[0]?.dayGroups[0]?.viewerAssets[0]?.asset;
|
||||
if (asset) {
|
||||
handlePromiseError(
|
||||
assetViewerManager.setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)),
|
||||
);
|
||||
handlePromiseError(setAssetId(asset.id).then(() => ($slideshowState = SlideshowState.PlaySlideshow)));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -67,7 +66,7 @@
|
||||
use:shortcut={{
|
||||
shortcut: { key: 'Escape' },
|
||||
onShortcut: () => {
|
||||
if (!assetViewerManager.isViewing && assetInteraction.selectionActive) {
|
||||
if (!$showAssetViewer && assetInteraction.selectionActive) {
|
||||
cancelMultiselect(assetInteraction);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
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';
|
||||
@@ -16,10 +15,8 @@
|
||||
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';
|
||||
@@ -39,6 +36,8 @@
|
||||
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,
|
||||
@@ -61,7 +60,6 @@
|
||||
onUndoDelete?: OnUndoDelete;
|
||||
onPlaySlideshow: () => void;
|
||||
onClose?: () => void;
|
||||
onRemoveFromAlbum?: (assetIds: string[]) => void;
|
||||
playOriginalVideo: boolean;
|
||||
setPlayOriginalVideo: (value: boolean) => void;
|
||||
}
|
||||
@@ -77,13 +75,11 @@
|
||||
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);
|
||||
|
||||
@@ -124,10 +120,10 @@
|
||||
<ActionButton action={Cast} />
|
||||
<ActionButton action={Actions.Share} />
|
||||
<ActionButton action={Actions.Offline} />
|
||||
<ActionButton action={Actions.ZoomIn} />
|
||||
<ActionButton action={Actions.ZoomOut} />
|
||||
<ActionButton action={Actions.PlayMotionPhoto} />
|
||||
<ActionButton action={Actions.StopMotionPhoto} />
|
||||
<ActionButton action={Actions.ZoomIn} />
|
||||
<ActionButton action={Actions.ZoomOut} />
|
||||
<ActionButton action={Actions.Copy} />
|
||||
<ActionButton action={Actions.SharedLinkDownload} />
|
||||
<ActionButton action={Actions.Info} />
|
||||
@@ -158,9 +154,6 @@
|
||||
{/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,6 +1,5 @@
|
||||
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';
|
||||
@@ -42,7 +41,6 @@ describe('AssetViewer', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
slideshowStore.slideshowState.set(SlideshowState.None);
|
||||
resetSavedUser();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
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';
|
||||
@@ -70,7 +71,6 @@
|
||||
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);
|
||||
assetViewerManager.setAsset(refreshedAsset);
|
||||
assetViewingStore.setAsset(refreshedAsset);
|
||||
}
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
@@ -239,7 +239,7 @@
|
||||
}
|
||||
|
||||
if ($slideshowRepeat && slideshowStartAssetId) {
|
||||
await assetViewerManager.setAssetId(slideshowStartAssetId);
|
||||
await setAssetId(slideshowStartAssetId);
|
||||
$restartSlideshowProgress = true;
|
||||
return;
|
||||
}
|
||||
@@ -255,7 +255,7 @@
|
||||
let assetViewerHtmlElement = $state<HTMLElement>();
|
||||
|
||||
const slideshowHistory = new SlideshowHistory((asset) => {
|
||||
handlePromiseError(assetViewerManager.setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
|
||||
handlePromiseError(setAssetId(asset.id).then(() => ($restartSlideshowProgress = true)));
|
||||
});
|
||||
|
||||
const handleVideoStarted = () => {
|
||||
@@ -478,7 +478,6 @@
|
||||
{onUndoDelete}
|
||||
onPlaySlideshow={() => ($slideshowState = SlideshowState.PlaySlideshow)}
|
||||
onClose={onClose ? () => onClose(asset) : undefined}
|
||||
{onRemoveFromAlbum}
|
||||
{playOriginalVideo}
|
||||
{setPlayOriginalVideo}
|
||||
/>
|
||||
@@ -486,7 +485,7 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState != SlideshowState.None}
|
||||
<div class="absolute inset-s-0 top-0 flex w-full justify-start">
|
||||
<div class="absolute w-full flex justify-center">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
assetType={previewStackedAsset?.type ?? asset.type}
|
||||
@@ -581,16 +580,17 @@
|
||||
<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',
|
||||
showDetailPanel ? 'w-90' : 'w-100',
|
||||
]}
|
||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
translate="yes"
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
<div class="w-90 h-full">
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
<div class="w-100 h-full">
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<script lang="ts">
|
||||
import GeolocationPointPickerModal from '$lib/modals/GeolocationPointPickerModal.svelte';
|
||||
import ChangeLocation from '$lib/components/shared-components/change-location.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { updateAsset, type AssetResponseDto } from '@immich/sdk';
|
||||
import { Icon, modalManager } from '@immich/ui';
|
||||
import { Icon } from '@immich/ui';
|
||||
import { mdiMapMarkerOutline, mdiPencil } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
interface Props {
|
||||
isOwner: boolean;
|
||||
asset: AssetResponseDto;
|
||||
};
|
||||
}
|
||||
|
||||
let { isOwner, asset = $bindable() }: Props = $props();
|
||||
|
||||
const onAction = async () => {
|
||||
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
|
||||
let isShowChangeLocation = $state(false);
|
||||
|
||||
const onClose = async (point?: { lng: number; lat: number }) => {
|
||||
isShowChangeLocation = false;
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
@@ -34,7 +38,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
|
||||
onclick={isOwner ? onAction : undefined}
|
||||
onclick={() => (isOwner ? (isShowChangeLocation = true) : null)}
|
||||
title={isOwner ? $t('edit_location') : ''}
|
||||
class:hover:text-primary={isOwner}
|
||||
>
|
||||
@@ -68,11 +72,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"
|
||||
onclick={onAction}
|
||||
onclick={() => (isShowChangeLocation = true)}
|
||||
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">
|
||||
@@ -80,3 +85,9 @@
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if isShowChangeLocation}
|
||||
<Portal>
|
||||
<ChangeLocation {asset} {onClose} />
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{ label: '2:3', value: '2:3', width: 16, height: 24 },
|
||||
{ label: '16:9', value: '16:9', width: 24, height: 14 },
|
||||
{ label: '9:16', value: '9:16', width: 14, height: 24 },
|
||||
{ label: $t('crop_aspect_ratio_square'), value: '1:1', width: 20, height: 20 },
|
||||
{ label: 'Square', value: '1:1', width: 20, height: 20 },
|
||||
];
|
||||
|
||||
let isRotated = $derived(transformManager.normalizedRotation % 180 !== 0);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { shortcut } from '$lib/actions/shortcut';
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
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, tick } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
@@ -27,7 +27,6 @@
|
||||
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[]>([]);
|
||||
|
||||
@@ -82,8 +81,6 @@
|
||||
onMount(async () => {
|
||||
setupCanvas();
|
||||
await getPeople();
|
||||
await tick();
|
||||
searchInputEl?.focus();
|
||||
});
|
||||
|
||||
const imageContentMetrics = $derived.by(() => {
|
||||
@@ -224,15 +221,12 @@
|
||||
|
||||
$effect(() => {
|
||||
const rect = faceRect;
|
||||
const cvs = canvas;
|
||||
if (rect && cvs) {
|
||||
if (rect) {
|
||||
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());
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -287,7 +281,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
await assetViewerManager.setAssetId(assetId);
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
@@ -296,7 +290,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel, ignoreInputFields: false }} />
|
||||
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel }} />
|
||||
|
||||
<div
|
||||
id="face-editor-data"
|
||||
@@ -316,7 +310,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} bind:ref={searchInputEl} size="tiny" />
|
||||
<Input placeholder={$t('search_people')} bind:value={searchTerm} size="tiny" />
|
||||
</div>
|
||||
|
||||
<div bind:this={scrollableListEl} class="h-62.5 overflow-y-auto mt-2">
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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';
|
||||
@@ -178,7 +179,7 @@
|
||||
|
||||
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||
|
||||
await assetViewerManager.setAssetId(assetId);
|
||||
await assetViewingStore.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, $t('error_delete_face'));
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user