Compare commits

...

26 Commits

Author SHA1 Message Date
mertalev d846f7fc7f always use hw filters when hwa is enabled 2024-11-22 03:00:30 -05:00
mertalev efb4394c7b formatting 2024-11-22 01:51:28 -05:00
mertalev e2188867a6 fix format, adjust log message 2024-11-22 01:46:34 -05:00
San 5e32cc7bd5 Merge branch 'main' into main 2024-11-05 20:57:34 +08:00
renovate[bot] 1d55b5bfc0 chore(deps): update dependency @types/node to ^22.8.5 (#13923)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-05 06:48:01 -05:00
renovate[bot] 60afd7b400 chore(deps): update node (#13918)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-04 19:52:23 -05:00
renovate[bot] 3f99ef90ec fix(deps): update machine-learning (#13919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-04 19:50:17 -05:00
Pranay Pandey 380fc06979 fix: remove duplicateIds on unique assets (#13752) 2024-11-04 10:03:03 -05:00
renovate[bot] d34d92dca3 fix(deps): update dependency exiftool-vendored to v28.7.0 (#13790)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-04 12:49:21 +00:00
San 10f8e11db1 fix unit test 2024-11-04 15:21:54 +08:00
San 18b93ddc73 if hw decoding failed with hw dec config enabled, try sw dec+hw enc first, then full sw dec+enc 2024-11-04 15:20:15 +08:00
San 88ca1f31ad fallback to software decoding if is hdr video 2024-11-03 19:50:16 +08:00
Daniel Dietzler 3551407d95 fix: healthcheck if custom host is set (#13887) 2024-11-02 22:46:16 -04:00
cfitzw 6bfc20ef95 feat: render asset path (#13873)
render asset path
2024-11-02 13:27:40 -05:00
Daniel Dietzler eadcbd52fb chore: svelte 5 🎉 (#13738)
chore: svelte 5

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>
2024-11-02 10:49:07 -05:00
Jason Rasmussen fed882a28a refactor(server): immich worker (#13871)
refactor(server): immich work
2024-11-01 17:19:36 -04:00
San c30ef4dfd6 Merge branch 'main' into main 2024-11-01 23:06:53 +08:00
Carsten Otto cdabd08139 fix(server): wrong image dimensions for RAW files (RAF, CR2) (also fixes face preview) (#13377) 2024-11-01 10:34:34 -04:00
Yashraj Jain b95bc32310 fix(mobile): do not removed not backup asset when selecting the correspond options (#13256)
* fixed the local ids selecting issue

* code: updated impl inside deleteLocalOnlyAssets

* fix: used png instead of jpg to maintain picture quality

* Revert "fix: used png instead of jpg to maintain picture quality"

This reverts commit 04f2ed54e4.

* fix: update logic from code-review perspective

* refractor (mobile) : Dart fix applied

* fix (mobile) : Updated multi grid as per requirement

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2024-11-01 09:03:03 -05:00
San e851a9b099 Use hw decoding, sw tone-mapping on HDR files using RKMPP w/o OpenCL 2024-11-01 15:35:46 +08:00
San e46db37e44 Set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file 2024-11-01 12:58:59 +08:00
Mert b9096f3e99 feat(server): use tonemapx for software tone-mapping (#13785) 2024-10-31 20:48:23 -04:00
Alex 5ac236d6fd chore(mobile): update flutter_web_auth (#13863) 2024-10-31 17:35:01 -05:00
renovate[bot] 458f2acf42 chore(deps): update flutter (#13455)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-31 17:53:26 +00:00
Jason Rasmussen 5b2cd704d3 refactor(server): job discovery (#13838)
refactor(server): job discorvery
2024-10-31 13:42:58 -04:00
Alan Grainger 0130052de5 chore(docs): Add Immich Public Proxy to the Community Projects list (#13836)
Update community-projects.tsx

Add Immich Public Proxy to Community Projects
2024-10-31 17:24:11 +00:00
135 changed files with 1222 additions and 1069 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff AS core FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS core
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
+5 -5
View File
@@ -24,7 +24,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
@@ -59,7 +59,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -1378,9 +1378,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.8.5", "version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -20,7 +20,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
-1
View File
@@ -26,7 +26,6 @@ The default configuration looks like this:
"bframes": -1, "bframes": -1,
"refs": 0, "refs": 0,
"gopSize": 0, "gopSize": 0,
"npl": 0,
"temporalAQ": false, "temporalAQ": false,
"cqMode": "auto", "cqMode": "auto",
"twoPass": false, "twoPass": false,
@@ -83,6 +83,12 @@ const projects: CommunityProjectProps[] = [
description: 'Power tools for organizing your immich library.', description: 'Power tools for organizing your immich library.',
url: 'https://github.com/varun-raj/immich-power-tools', url: 'https://github.com/varun-raj/immich-power-tools',
}, },
{
title: 'Immich Public Proxy',
description:
'Share your Immich photos and albums in a safe way without exposing your Immich instance to the public.',
url: 'https://github.com/alangrainger/immich-public-proxy',
},
]; ];
function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element { function CommunityProject({ title, description, url }: CommunityProjectProps): JSX.Element {
+17 -17
View File
@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
@@ -64,7 +64,7 @@
"@types/cli-progress": "^3.11.0", "@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5", "@vitest/coverage-v8": "^2.0.5",
@@ -99,7 +99,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -1613,9 +1613,9 @@
"dev": true "dev": true
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.8.5", "version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3294,9 +3294,9 @@
} }
}, },
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "28.6.0", "version": "28.7.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==", "integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3307,14 +3307,14 @@
"luxon": "^3.5.0" "luxon": "^3.5.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"exiftool-vendored.exe": "12.97.0", "exiftool-vendored.exe": "12.99.0",
"exiftool-vendored.pl": "12.97.0" "exiftool-vendored.pl": "12.99.0"
} }
}, },
"node_modules/exiftool-vendored.exe": { "node_modules/exiftool-vendored.exe": {
"version": "12.97.0", "version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==", "integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@@ -3323,9 +3323,9 @@
] ]
}, },
"node_modules/exiftool-vendored.pl": { "node_modules/exiftool-vendored.pl": {
"version": "12.97.0", "version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==", "integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
+1 -1
View File
@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk", "@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1", "@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"@types/oidc-provider": "^8.5.1", "@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0", "@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4", "@types/pngjs": "^6.0.4",
+72
View File
@@ -1148,6 +1148,78 @@ describe('/asset', () => {
}, },
}, },
}, },
{
input: 'formats/raw/Canon/PowerShot_G12.CR2',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'PowerShot_G12.CR2',
fileCreatedAt: '2015-12-27T09:55:40.000Z',
exifInfo: {
make: 'Canon',
model: 'Canon PowerShot G12',
exifImageHeight: 2736,
exifImageWidth: 3648,
exposureTime: '1/1000',
fNumber: 4,
focalLength: 18.098,
iso: 80,
lensModel: null,
fileSizeInByte: 11_113_617,
dateTimeOriginal: '2015-12-27T09:55:40.000Z',
latitude: null,
longitude: null,
orientation: '1',
},
},
},
{
input: 'formats/raw/Fujifilm/X100V_compressed.RAF',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'X100V_compressed.RAF',
fileCreatedAt: '2024-10-12T21:01:01.000Z',
exifInfo: {
make: 'FUJIFILM',
model: 'X100V',
exifImageHeight: 4160,
exifImageWidth: 6240,
exposureTime: '1/4000',
fNumber: 16,
focalLength: 23,
iso: 160,
lensModel: null,
fileSizeInByte: 13_551_312,
dateTimeOriginal: '2024-10-12T21:01:01.000Z',
latitude: null,
longitude: null,
orientation: '6',
},
},
},
{
input: 'formats/raw/Ricoh/GR3/Ricoh_GR3-450.DNG',
expected: {
type: AssetTypeEnum.Image,
originalFileName: 'Ricoh_GR3-450.DNG',
fileCreatedAt: '2024-06-08T13:48:39.000Z',
exifInfo: {
dateTimeOriginal: '2024-06-08T13:48:39.000Z',
exifImageHeight: 4064,
exifImageWidth: 6112,
exposureTime: '1/400',
fNumber: 5,
fileSizeInByte: 31_175_472,
focalLength: 18.3,
iso: 100,
latitude: 36.613_24,
lensModel: 'GR LENS 18.3mm F2.8',
longitude: -121.897_85,
make: 'RICOH IMAGING COMPANY, LTD.',
model: 'RICOH GR III',
orientation: '1',
},
},
},
]; ];
it(`should upload and generate a thumbnail for different file types`, async () => { it(`should upload and generate a thumbnail for different file types`, async () => {
-2
View File
@@ -305,8 +305,6 @@
"transcoding_threads_description": "Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0.", "transcoding_threads_description": "Higher values lead to faster encoding, but leave less room for the server to process other tasks while active. This value should not be more than the number of CPU cores. Maximizes utilization if set to 0.",
"transcoding_tone_mapping": "Tone-mapping", "transcoding_tone_mapping": "Tone-mapping",
"transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.", "transcoding_tone_mapping_description": "Attempts to preserve the appearance of HDR videos when converted to SDR. Each algorithm makes different tradeoffs for color, detail and brightness. Hable preserves detail, Mobius preserves color, and Reinhard preserves brightness.",
"transcoding_tone_mapping_npl": "Tone-mapping NPL",
"transcoding_tone_mapping_npl_description": "Colors will be adjusted to look normal for a display of this brightness. Counter-intuitively, lower values increase the brightness of the video and vice versa since it compensates for the brightness of the display. 0 sets this value automatically.",
"transcoding_transcode_policy": "Transcode policy", "transcoding_transcode_policy": "Transcode policy",
"transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).", "transcoding_transcode_policy_description": "Policy for when a video should be transcoded. HDR videos will always be transcoded (except if transcoding is disabled).",
"transcoding_two_pass_encoding": "Two-pass encoding", "transcoding_two_pass_encoding": "Two-pass encoding",
+9 -9
View File
@@ -946,13 +946,13 @@ tqdm = ["tqdm"]
[[package]] [[package]]
name = "ftfy" name = "ftfy"
version = "6.3.0" version = "6.3.1"
description = "Fixes mojibake and other problems with Unicode, after the fact" description = "Fixes mojibake and other problems with Unicode, after the fact"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "ftfy-6.3.0-py3-none-any.whl", hash = "sha256:17aca296801f44142e3ff2c16f93fbf6a87609ebb3704a9a41dd5d4903396caf"}, {file = "ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083"},
{file = "ftfy-6.3.0.tar.gz", hash = "sha256:1c7d6418e72b25a7760feb150acf574b86924dbb2e95b32c0b3abbd1ba3d7ad6"}, {file = "ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec"},
] ]
[package.dependencies] [package.dependencies]
@@ -1609,13 +1609,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]] [[package]]
name = "locust" name = "locust"
version = "2.32.0" version = "2.32.1"
description = "Developer-friendly load testing framework" description = "Developer-friendly load testing framework"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
files = [ files = [
{file = "locust-2.32.0-py3-none-any.whl", hash = "sha256:e004514332b8631ca91382d11d224baee4ced040c5f5c8b2233800ebcbc73c0e"}, {file = "locust-2.32.1-py3-none-any.whl", hash = "sha256:3fb5548b4f2b6477fa5229ee55ac3dddbae56e86c3430bf2ba3fee358eb7e7bb"},
{file = "locust-2.32.0.tar.gz", hash = "sha256:d8f7f5d9d4e801b2e7b0ee3f31109333673da744ccedf85e7da0151f2d263dd9"}, {file = "locust-2.32.1.tar.gz", hash = "sha256:8c3b1094dbf20860fd2f6e26b68f0c6064dc28054f4462664389d102fce1448b"},
] ]
[package.dependencies] [package.dependencies]
@@ -2749,13 +2749,13 @@ cli = ["click (>=5.0)"]
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.12" version = "0.0.17"
description = "A streaming multipart parser for Python" description = "A streaming multipart parser for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf"}, {file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"},
{file = "python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb"}, {file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"},
] ]
[[package]] [[package]]
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"flutter": "3.24.3" "flutter": "3.24.4"
} }
+2 -2
View File
@@ -48,7 +48,7 @@ PODS:
- flutter_udid (0.0.1): - flutter_udid (0.0.1):
- Flutter - Flutter
- SAMKeychain - SAMKeychain
- flutter_web_auth (0.5.0): - flutter_web_auth (0.6.0):
- Flutter - Flutter
- fluttertoast (0.0.2): - fluttertoast (0.0.2):
- Flutter - Flutter
@@ -202,7 +202,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086 flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778 flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04 flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d flutter_web_auth: acc15a8fd7bba796a933c724a6dffc3d00f07c27
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450 geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1 image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
+18 -4
View File
@@ -84,34 +84,48 @@ class AssetNotifier extends StateNotifier<bool> {
_deleteInProgress = true; _deleteInProgress = true;
state = true; state = true;
try { try {
// Filter the assets based on the backed-up status
final assets = onlyBackedUp final assets = onlyBackedUp
? deleteAssets.where((e) => e.storage == AssetState.merged) ? deleteAssets.where((e) => e.storage == AssetState.merged)
: deleteAssets; : deleteAssets;
if (assets.isEmpty) {
return false; // No assets to delete
}
// Proceed with local deletion of the filtered assets
final localDeleted = await _deleteLocalAssets(assets); final localDeleted = await _deleteLocalAssets(assets);
if (localDeleted.isNotEmpty) { if (localDeleted.isNotEmpty) {
final localOnlyIds = deleteAssets final localOnlyIds = assets
.where((e) => e.storage == AssetState.local) .where((e) => e.storage == AssetState.local)
.map((e) => e.id) .map((e) => e.id)
.toList(); .toList();
// Update merged assets to remote only
// Update merged assets to remote-only
final mergedAssets = final mergedAssets =
deleteAssets.where((e) => e.storage == AssetState.merged).map((e) { assets.where((e) => e.storage == AssetState.merged).map((e) {
e.localId = null; e.localId = null;
return e; return e;
}).toList(); }).toList();
// Update the local database
await _db.writeTxn(() async { await _db.writeTxn(() async {
if (mergedAssets.isNotEmpty) { if (mergedAssets.isNotEmpty) {
await _db.assets.putAll(mergedAssets); await _db.assets
.putAll(mergedAssets); // Use the filtered merged assets
} }
await _db.exifInfos.deleteAll(localOnlyIds); await _db.exifInfos.deleteAll(localOnlyIds);
await _db.assets.deleteAll(localOnlyIds); await _db.assets.deleteAll(localOnlyIds);
}); });
return true; return true;
} }
} finally { } finally {
_deleteInProgress = false; _deleteInProgress = false;
state = false; state = false;
} }
return false; return false;
} }
@@ -203,18 +203,30 @@ class MultiselectGrid extends HookConsumerWidget {
void onDeleteLocal(bool onlyBackedUp) async { void onDeleteLocal(bool onlyBackedUp) async {
processing.value = true; processing.value = true;
try { try {
// Select only the local assets from the selection
final localIds = selection.value.where((a) => a.isLocal).toList(); final localIds = selection.value.where((a) => a.isLocal).toList();
// Delete only the backed-up assets if 'onlyBackedUp' is true
final isDeleted = await ref final isDeleted = await ref
.read(assetProvider.notifier) .read(assetProvider.notifier)
.deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp); .deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp);
if (isDeleted) { if (isDeleted) {
// Show a toast with the correct number of deleted assets
final deletedCount = localIds
.where(
(e) => !onlyBackedUp || e.isRemote,
) // Only count backed-up assets
.length;
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: 'assets_removed_permanently_from_device' msg: 'assets_removed_permanently_from_device'
.tr(args: ["${localIds.length}"]), .tr(args: ["$deletedCount"]),
gravity: ToastGravity.BOTTOM, gravity: ToastGravity.BOTTOM,
); );
// Reset the selection
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
} }
} finally { } finally {
+1 -10
View File
@@ -23,7 +23,6 @@ class SystemConfigFFmpegDto {
required this.crf, required this.crf,
required this.gopSize, required this.gopSize,
required this.maxBitrate, required this.maxBitrate,
required this.npl,
required this.preferredHwDevice, required this.preferredHwDevice,
required this.preset, required this.preset,
required this.refs, required this.refs,
@@ -62,9 +61,6 @@ class SystemConfigFFmpegDto {
String maxBitrate; String maxBitrate;
/// Minimum value: 0
int npl;
String preferredHwDevice; String preferredHwDevice;
String preset; String preset;
@@ -102,7 +98,6 @@ class SystemConfigFFmpegDto {
other.crf == crf && other.crf == crf &&
other.gopSize == gopSize && other.gopSize == gopSize &&
other.maxBitrate == maxBitrate && other.maxBitrate == maxBitrate &&
other.npl == npl &&
other.preferredHwDevice == preferredHwDevice && other.preferredHwDevice == preferredHwDevice &&
other.preset == preset && other.preset == preset &&
other.refs == refs && other.refs == refs &&
@@ -128,7 +123,6 @@ class SystemConfigFFmpegDto {
(crf.hashCode) + (crf.hashCode) +
(gopSize.hashCode) + (gopSize.hashCode) +
(maxBitrate.hashCode) + (maxBitrate.hashCode) +
(npl.hashCode) +
(preferredHwDevice.hashCode) + (preferredHwDevice.hashCode) +
(preset.hashCode) + (preset.hashCode) +
(refs.hashCode) + (refs.hashCode) +
@@ -142,7 +136,7 @@ class SystemConfigFFmpegDto {
(twoPass.hashCode); (twoPass.hashCode);
@override @override
String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, npl=$npl, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]'; String toString() => 'SystemConfigFFmpegDto[accel=$accel, accelDecode=$accelDecode, acceptedAudioCodecs=$acceptedAudioCodecs, acceptedContainers=$acceptedContainers, acceptedVideoCodecs=$acceptedVideoCodecs, bframes=$bframes, cqMode=$cqMode, crf=$crf, gopSize=$gopSize, maxBitrate=$maxBitrate, preferredHwDevice=$preferredHwDevice, preset=$preset, refs=$refs, targetAudioCodec=$targetAudioCodec, targetResolution=$targetResolution, targetVideoCodec=$targetVideoCodec, temporalAQ=$temporalAQ, threads=$threads, tonemap=$tonemap, transcode=$transcode, twoPass=$twoPass]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@@ -156,7 +150,6 @@ class SystemConfigFFmpegDto {
json[r'crf'] = this.crf; json[r'crf'] = this.crf;
json[r'gopSize'] = this.gopSize; json[r'gopSize'] = this.gopSize;
json[r'maxBitrate'] = this.maxBitrate; json[r'maxBitrate'] = this.maxBitrate;
json[r'npl'] = this.npl;
json[r'preferredHwDevice'] = this.preferredHwDevice; json[r'preferredHwDevice'] = this.preferredHwDevice;
json[r'preset'] = this.preset; json[r'preset'] = this.preset;
json[r'refs'] = this.refs; json[r'refs'] = this.refs;
@@ -190,7 +183,6 @@ class SystemConfigFFmpegDto {
crf: mapValueOfType<int>(json, r'crf')!, crf: mapValueOfType<int>(json, r'crf')!,
gopSize: mapValueOfType<int>(json, r'gopSize')!, gopSize: mapValueOfType<int>(json, r'gopSize')!,
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!, maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
npl: mapValueOfType<int>(json, r'npl')!,
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!, preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
preset: mapValueOfType<String>(json, r'preset')!, preset: mapValueOfType<String>(json, r'preset')!,
refs: mapValueOfType<int>(json, r'refs')!, refs: mapValueOfType<int>(json, r'refs')!,
@@ -259,7 +251,6 @@ class SystemConfigFFmpegDto {
'crf', 'crf',
'gopSize', 'gopSize',
'maxBitrate', 'maxBitrate',
'npl',
'preferredHwDevice', 'preferredHwDevice',
'preset', 'preset',
'refs', 'refs',
+4 -4
View File
@@ -622,10 +622,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_web_auth name: flutter_web_auth
sha256: a69fa8f43b9e4d86ac72176bf747b735e7b977dd7cf215076d95b87cb05affdd sha256: "95e4856e24fb6ac1678f5ff334743b63f782d839ab324543d29ccbd295176209"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.6.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -1861,5 +1861,5 @@ packages:
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.5.0 <4.0.0" dart: ">=3.5.3 <4.0.0"
flutter: ">=3.24.3" flutter: ">=3.24.4"
+2 -2
View File
@@ -6,7 +6,7 @@ version: 1.119.1+164
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'
flutter: 3.24.3 flutter: 3.24.4
dependencies: dependencies:
flutter: flutter:
@@ -42,7 +42,7 @@ dependencies:
path_provider: ^2.1.2 path_provider: ^2.1.2
collection: ^1.18.0 collection: ^1.18.0
http_parser: ^4.0.2 http_parser: ^4.0.2
flutter_web_auth: ^0.5.0 flutter_web_auth: ^0.6.0
easy_image_viewer: ^1.4.0 easy_image_viewer: ^1.4.0
isar: ^3.1.0+1 isar: ^3.1.0+1
isar_flutter_libs: ^3.1.0+1 isar_flutter_libs: ^3.1.0+1
-5
View File
@@ -11621,10 +11621,6 @@
"maxBitrate": { "maxBitrate": {
"type": "string" "type": "string"
}, },
"npl": {
"minimum": 0,
"type": "integer"
},
"preferredHwDevice": { "preferredHwDevice": {
"type": "string" "type": "string"
}, },
@@ -11673,7 +11669,6 @@
"crf", "crf",
"gopSize", "gopSize",
"maxBitrate", "maxBitrate",
"npl",
"preferredHwDevice", "preferredHwDevice",
"preset", "preset",
"refs", "refs",
+4 -4
View File
@@ -12,7 +12,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -22,9 +22,9 @@
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g==" "integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.8.5", "version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
}, },
"repository": { "repository": {
@@ -1104,7 +1104,6 @@ export type SystemConfigFFmpegDto = {
crf: number; crf: number;
gopSize: number; gopSize: number;
maxBitrate: string; maxBitrate: string;
npl: number;
preferredHwDevice: string; preferredHwDevice: string;
preset: string; preset: string;
refs: number; refs: number;
+1 -1
View File
@@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build # web build
FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff AS web FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS web
WORKDIR /usr/src/open-api/typescript-sdk WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./ COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
+29 -29
View File
@@ -83,7 +83,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
@@ -5110,9 +5110,9 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.8.5", "version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dependencies": { "dependencies": {
"undici-types": "~6.19.8" "undici-types": "~6.19.8"
} }
@@ -8236,9 +8236,9 @@
} }
}, },
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "28.6.0", "version": "28.7.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==", "integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
"dependencies": { "dependencies": {
"@photostructure/tz-lookup": "^11.0.0", "@photostructure/tz-lookup": "^11.0.0",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
@@ -8247,23 +8247,23 @@
"luxon": "^3.5.0" "luxon": "^3.5.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"exiftool-vendored.exe": "12.97.0", "exiftool-vendored.exe": "12.99.0",
"exiftool-vendored.pl": "12.97.0" "exiftool-vendored.pl": "12.99.0"
} }
}, },
"node_modules/exiftool-vendored.exe": { "node_modules/exiftool-vendored.exe": {
"version": "12.97.0", "version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==", "integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
] ]
}, },
"node_modules/exiftool-vendored.pl": { "node_modules/exiftool-vendored.pl": {
"version": "12.97.0", "version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==", "integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
"optional": true, "optional": true,
"os": [ "os": [
"!win32" "!win32"
@@ -18258,9 +18258,9 @@
} }
}, },
"@types/node": { "@types/node": {
"version": "22.8.5", "version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==", "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"requires": { "requires": {
"undici-types": "~6.19.8" "undici-types": "~6.19.8"
} }
@@ -20579,29 +20579,29 @@
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==" "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
}, },
"exiftool-vendored": { "exiftool-vendored": {
"version": "28.6.0", "version": "28.7.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==", "integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
"requires": { "requires": {
"@photostructure/tz-lookup": "^11.0.0", "@photostructure/tz-lookup": "^11.0.0",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"batch-cluster": "^13.0.0", "batch-cluster": "^13.0.0",
"exiftool-vendored.exe": "12.97.0", "exiftool-vendored.exe": "12.99.0",
"exiftool-vendored.pl": "12.97.0", "exiftool-vendored.pl": "12.99.0",
"he": "^1.2.0", "he": "^1.2.0",
"luxon": "^3.5.0" "luxon": "^3.5.0"
} }
}, },
"exiftool-vendored.exe": { "exiftool-vendored.exe": {
"version": "12.97.0", "version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==", "integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
"optional": true "optional": true
}, },
"exiftool-vendored.pl": { "exiftool-vendored.pl": {
"version": "12.97.0", "version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==", "integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
"optional": true "optional": true
}, },
"express": { "express": {
+1 -1
View File
@@ -108,7 +108,7 @@
"@types/lodash": "^4.14.197", "@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1", "@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/node": "^22.8.1", "@types/node": "^22.8.5",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0", "@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
+15 -19
View File
@@ -6,10 +6,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { OpenTelemetryModule } from 'nestjs-otel'; import { OpenTelemetryModule } from 'nestjs-otel';
import { commands } from 'src/commands'; import { commands } from 'src/commands';
import { IWorker } from 'src/constants';
import { controllers } from 'src/controllers'; import { controllers } from 'src/controllers';
import { entities } from 'src/entities'; import { entities } from 'src/entities';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { IEventRepository } from 'src/interfaces/event.interface'; import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface'; import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
import { AuthGuard } from 'src/middleware/auth.guard'; import { AuthGuard } from 'src/middleware/auth.guard';
@@ -56,23 +58,25 @@ const imports = [
TypeOrmModule.forFeature(entities), TypeOrmModule.forFeature(entities),
]; ];
abstract class BaseModule implements OnModuleInit, OnModuleDestroy { class BaseModule implements OnModuleInit, OnModuleDestroy {
private get worker() {
return this.getWorker();
}
constructor( constructor(
@Inject(IWorker) private worker: ImmichWorker,
@Inject(ILoggerRepository) logger: ILoggerRepository, @Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ITelemetryRepository) private telemetryRepository: ITelemetryRepository, @Inject(ITelemetryRepository) private telemetryRepository: ITelemetryRepository,
) { ) {
logger.setAppName(this.worker); logger.setAppName(this.worker);
} }
abstract getWorker(): ImmichWorker;
async onModuleInit() { async onModuleInit() {
this.telemetryRepository.setup({ repositories: repositories.map(({ useClass }) => useClass) }); this.telemetryRepository.setup({ repositories: repositories.map(({ useClass }) => useClass) });
this.jobRepository.setup({ services });
if (this.worker === ImmichWorker.MICROSERVICES) {
this.jobRepository.startWorkers();
}
this.eventRepository.setup({ services }); this.eventRepository.setup({ services });
await this.eventRepository.emit('app.bootstrap', this.worker); await this.eventRepository.emit('app.bootstrap', this.worker);
} }
@@ -86,23 +90,15 @@ abstract class BaseModule implements OnModuleInit, OnModuleDestroy {
@Module({ @Module({
imports: [...imports, ScheduleModule.forRoot()], imports: [...imports, ScheduleModule.forRoot()],
controllers: [...controllers], controllers: [...controllers],
providers: [...common, ...middleware], providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }],
}) })
export class ApiModule extends BaseModule { export class ApiModule extends BaseModule {}
getWorker() {
return ImmichWorker.API;
}
}
@Module({ @Module({
imports: [...imports], imports: [...imports],
providers: [...common, SchedulerRegistry], providers: [...common, { provide: IWorker, useValue: ImmichWorker.MICROSERVICES }, SchedulerRegistry],
}) })
export class MicroservicesModule extends BaseModule { export class MicroservicesModule extends BaseModule {}
getWorker() {
return ImmichWorker.MICROSERVICES;
}
}
@Module({ @Module({
imports: [...imports], imports: [...imports],
+2 -2
View File
@@ -3,7 +3,7 @@ import { ImmichWorker } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
const main = async () => { const main = async () => {
const { workers, port } = new ConfigRepository().getEnv(); const { host, workers, port } = new ConfigRepository().getEnv();
if (!workers.includes(ImmichWorker.API)) { if (!workers.includes(ImmichWorker.API)) {
process.exit(); process.exit();
} }
@@ -11,7 +11,7 @@ const main = async () => {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000); const timeout = setTimeout(() => controller.abort(), 2000);
try { try {
const response = await fetch(`http://localhost:${port}/api/server/ping`, { const response = await fetch(`http://${host || 'localhost'}:${port}/api/server/ping`, {
signal: controller.signal, signal: controller.signal,
}); });
-2
View File
@@ -36,7 +36,6 @@ export interface SystemConfig {
bframes: number; bframes: number;
refs: number; refs: number;
gopSize: number; gopSize: number;
npl: number;
temporalAQ: boolean; temporalAQ: boolean;
cqMode: CQMode; cqMode: CQMode;
twoPass: boolean; twoPass: boolean;
@@ -178,7 +177,6 @@ export const defaults = Object.freeze<SystemConfig>({
bframes: -1, bframes: -1,
refs: 0, refs: 0,
gopSize: 0, gopSize: 0,
npl: 0,
temporalAQ: false, temporalAQ: false,
cqMode: CQMode.AUTO, cqMode: CQMode.AUTO,
twoPass: false, twoPass: false,
+2
View File
@@ -13,6 +13,8 @@ export const ADDED_IN_PREFIX = 'This property was added in ';
export const SALT_ROUNDS = 10; export const SALT_ROUNDS = 10;
export const IWorker = 'IWorker';
const { version } = JSON.parse(readFileSync('./package.json', 'utf8')); const { version } = JSON.parse(readFileSync('./package.json', 'utf8'));
export const serverVersion = new SemVer(version); export const serverVersion = new SemVer(version);
+7
View File
@@ -4,6 +4,7 @@ import _ from 'lodash';
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
import { MetadataKey } from 'src/enum'; import { MetadataKey } from 'src/enum';
import { EmitEvent } from 'src/interfaces/event.interface'; import { EmitEvent } from 'src/interfaces/event.interface';
import { JobName, QueueName } from 'src/interfaces/job.interface';
import { setUnion } from 'src/utils/set'; import { setUnion } from 'src/utils/set';
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
@@ -122,6 +123,12 @@ export type EventConfig = {
}; };
export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config); export const OnEvent = (config: EventConfig) => SetMetadata(MetadataKey.EVENT_CONFIG, config);
export type JobConfig = {
name: JobName;
queue: QueueName;
};
export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JOB_CONFIG, config);
type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleRelease = 'NEXT_RELEASE' | string;
type LifecycleMetadata = { type LifecycleMetadata = {
addedAt?: LifecycleRelease; addedAt?: LifecycleRelease;
-6
View File
@@ -134,12 +134,6 @@ export class SystemConfigFFmpegDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
gopSize!: number; gopSize!: number;
@IsInt()
@Min(0)
@Type(() => Number)
@ApiProperty({ type: 'integer' })
npl!: number;
@ValidateBoolean() @ValidateBoolean()
temporalAQ!: boolean; temporalAQ!: boolean;
+1
View File
@@ -335,6 +335,7 @@ export enum MetadataKey {
SHARED_ROUTE = 'shared_route', SHARED_ROUTE = 'shared_route',
API_KEY_SECURITY = 'api_key', API_KEY_SECURITY = 'api_key',
EVENT_CONFIG = 'event_config', EVENT_CONFIG = 'event_config',
JOB_CONFIG = 'job_config',
TELEMETRY_ENABLED = 'telemetry_enabled', TELEMETRY_ENABLED = 'telemetry_enabled',
} }
+3
View File
@@ -3,6 +3,7 @@ import { SystemConfig } from 'src/config';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { JobItem, QueueName } from 'src/interfaces/job.interface';
export const IEventRepository = 'IEventRepository'; export const IEventRepository = 'IEventRepository';
@@ -38,6 +39,8 @@ type EventMap = {
'assets.delete': [{ assetIds: string[]; userId: string }]; 'assets.delete': [{ assetIds: string[]; userId: string }];
'assets.restore': [{ assetIds: string[]; userId: string }]; 'assets.restore': [{ assetIds: string[]; userId: string }];
'job.start': [QueueName, JobItem];
// session events // session events
'session.delete': [{ sessionId: string }]; 'session.delete': [{ sessionId: string }];
+9 -7
View File
@@ -1,3 +1,4 @@
import { ClassConstructor } from 'class-transformer';
import { EmailImageAttachment } from 'src/interfaces/notification.interface'; import { EmailImageAttachment } from 'src/interfaces/notification.interface';
export enum QueueName { export enum QueueName {
@@ -238,8 +239,8 @@ export type JobItem =
// Migration // Migration
| { name: JobName.QUEUE_MIGRATION; data?: IBaseJob } | { name: JobName.QUEUE_MIGRATION; data?: IBaseJob }
| { name: JobName.MIGRATE_ASSET; data?: IEntityJob } | { name: JobName.MIGRATE_ASSET; data: IEntityJob }
| { name: JobName.MIGRATE_PERSON; data?: IEntityJob } | { name: JobName.MIGRATE_PERSON; data: IEntityJob }
// Metadata Extraction // Metadata Extraction
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob } | { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
@@ -286,7 +287,7 @@ export type JobItem =
| { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob } | { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_SYNC_FILES; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob } | { name: JobName.LIBRARY_QUEUE_SYNC_ASSETS; data: IEntityJob }
| { name: JobName.LIBRARY_SYNC_ASSET; data: IEntityJob } | { name: JobName.LIBRARY_SYNC_ASSET; data: ILibraryAssetJob }
| { name: JobName.LIBRARY_DELETE; data: IEntityJob } | { name: JobName.LIBRARY_DELETE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob } | { name: JobName.LIBRARY_QUEUE_SYNC_ALL; data?: IBaseJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob } | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
@@ -305,14 +306,15 @@ export enum JobStatus {
FAILED = 'failed', FAILED = 'failed',
SKIPPED = 'skipped', SKIPPED = 'skipped',
} }
export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] };
export type JobHandler<T = any> = (data: T) => Promise<JobStatus>; export type JobOf<T extends JobName> = Jobs[T];
export type JobItemHandler = (item: JobItem) => Promise<void>;
export const IJobRepository = 'IJobRepository'; export const IJobRepository = 'IJobRepository';
export interface IJobRepository { export interface IJobRepository {
addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void; setup(options: { services: ClassConstructor<unknown>[] }): void;
startWorkers(): void;
run(job: JobItem): Promise<JobStatus>;
addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void; addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void;
updateCronJob(name: string, expression?: string, start?: boolean): void; updateCronJob(name: string, expression?: string, start?: boolean): void;
setConcurrency(queueName: QueueName, concurrency: number): void; setConcurrency(queueName: QueueName, concurrency: number): void;
+1
View File
@@ -59,6 +59,7 @@ export interface VideoStreamInfo {
frameCount: number; frameCount: number;
isHDR: boolean; isHDR: boolean;
bitrate: number; bitrate: number;
pixelFormat: string;
} }
export interface AudioStreamInfo { export interface AudioStreamInfo {
@@ -0,0 +1,12 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemoveNplFromSystemConfig1730227312171 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
update system_metadata
set value = value #- '{ffmpeg,npl}'
where key = 'system-config' and value->'ffmpeg'->'npl' is not null`);
}
public async down(): Promise<void> {}
}
+96 -94
View File
@@ -1,124 +1,122 @@
import { getQueueToken } from '@nestjs/bullmq'; import { getQueueToken } from '@nestjs/bullmq';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef, Reflector } from '@nestjs/core';
import { SchedulerRegistry } from '@nestjs/schedule'; import { SchedulerRegistry } from '@nestjs/schedule';
import { Job, JobsOptions, Processor, Queue, Worker, WorkerOptions } from 'bullmq'; import { JobsOptions, Queue, Worker } from 'bullmq';
import { ClassConstructor } from 'class-transformer';
import { CronJob, CronTime } from 'cron'; import { CronJob, CronTime } from 'cron';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { JobConfig } from 'src/decorators';
import { MetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface'; import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { import {
IEntityJob, IEntityJob,
IJobRepository, IJobRepository,
JobCounts, JobCounts,
JobItem, JobItem,
JobName, JobName,
JobOf,
JobStatus,
QueueCleanType, QueueCleanType,
QueueName, QueueName,
QueueStatus, QueueStatus,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = { type JobMapItem = {
// misc jobName: JobName;
[JobName.ASSET_DELETION]: QueueName.BACKGROUND_TASK, queueName: QueueName;
[JobName.ASSET_DELETION_CHECK]: QueueName.BACKGROUND_TASK, handler: (job: JobOf<any>) => Promise<JobStatus>;
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK, label: string;
[JobName.USER_DELETION]: QueueName.BACKGROUND_TASK,
[JobName.DELETE_FILES]: QueueName.BACKGROUND_TASK,
[JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK,
[JobName.CLEAN_OLD_SESSION_TOKENS]: QueueName.BACKGROUND_TASK,
[JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
[JobName.USER_SYNC_USAGE]: QueueName.BACKGROUND_TASK,
// backups
[JobName.BACKUP_DATABASE]: QueueName.BACKUP_DATABASE,
// conversion
[JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,
[JobName.VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,
// thumbnails
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// tags
[JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK,
// metadata
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.LINK_LIVE_PHOTOS]: QueueName.METADATA_EXTRACTION,
// storage template
[JobName.STORAGE_TEMPLATE_MIGRATION]: QueueName.STORAGE_TEMPLATE_MIGRATION,
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: QueueName.STORAGE_TEMPLATE_MIGRATION,
// migration
[JobName.QUEUE_MIGRATION]: QueueName.MIGRATION,
[JobName.MIGRATE_ASSET]: QueueName.MIGRATION,
[JobName.MIGRATE_PERSON]: QueueName.MIGRATION,
// facial recognition
[JobName.QUEUE_FACE_DETECTION]: QueueName.FACE_DETECTION,
[JobName.FACE_DETECTION]: QueueName.FACE_DETECTION,
[JobName.QUEUE_FACIAL_RECOGNITION]: QueueName.FACIAL_RECOGNITION,
[JobName.FACIAL_RECOGNITION]: QueueName.FACIAL_RECOGNITION,
// smart search
[JobName.QUEUE_SMART_SEARCH]: QueueName.SMART_SEARCH,
[JobName.SMART_SEARCH]: QueueName.SMART_SEARCH,
// duplicate detection
[JobName.QUEUE_DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION,
[JobName.DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION,
// XMP sidecars
[JobName.QUEUE_SIDECAR]: QueueName.SIDECAR,
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
[JobName.SIDECAR_SYNC]: QueueName.SIDECAR,
[JobName.SIDECAR_WRITE]: QueueName.SIDECAR,
// Library management
[JobName.LIBRARY_SYNC_FILE]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_FILES]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_ASSETS]: QueueName.LIBRARY,
[JobName.LIBRARY_DELETE]: QueueName.LIBRARY,
[JobName.LIBRARY_SYNC_ASSET]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_SYNC_ALL]: QueueName.LIBRARY,
[JobName.LIBRARY_QUEUE_CLEANUP]: QueueName.LIBRARY,
// Notification
[JobName.SEND_EMAIL]: QueueName.NOTIFICATION,
[JobName.NOTIFY_ALBUM_INVITE]: QueueName.NOTIFICATION,
[JobName.NOTIFY_ALBUM_UPDATE]: QueueName.NOTIFICATION,
[JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION,
// Version check
[JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK,
// Trash
[JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK,
}; };
@Injectable() @Injectable()
export class JobRepository implements IJobRepository { export class JobRepository implements IJobRepository {
private workers: Partial<Record<QueueName, Worker>> = {}; private workers: Partial<Record<QueueName, Worker>> = {};
private handlers: Partial<Record<JobName, JobMapItem>> = {};
constructor( constructor(
private moduleReference: ModuleRef, private moduleRef: ModuleRef,
private schedulerReqistry: SchedulerRegistry, private schedulerRegistry: SchedulerRegistry,
@Inject(IConfigRepository) private configRepository: IConfigRepository, @Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(JobRepository.name); this.logger.setContext(JobRepository.name);
} }
addHandler(queueName: QueueName, concurrency: number, handler: (item: JobItem) => Promise<void>) { setup({ services }: { services: ClassConstructor<unknown>[] }) {
const reflector = this.moduleRef.get(Reflector, { strict: false });
// discovery
for (const Service of services) {
const instance = this.moduleRef.get<any>(Service);
for (const methodName of getMethodNames(instance)) {
const handler = instance[methodName];
const config = reflector.get<JobConfig>(MetadataKey.JOB_CONFIG, handler);
if (!config) {
continue;
}
const { name: jobName, queue: queueName } = config;
const label = `${Service.name}.${handler.name}`;
// one handler per job
if (this.handlers[jobName]) {
const jobKey = getKeyByValue(JobName, jobName);
const errorMessage = `Failed to add job handler for ${label}`;
this.logger.error(
`${errorMessage}. JobName.${jobKey} is already handled by ${this.handlers[jobName].label}.`,
);
throw new ImmichStartupError(errorMessage);
}
this.handlers[jobName] = {
label,
jobName,
queueName,
handler: handler.bind(instance),
};
this.logger.verbose(`Added job handler: ${jobName} => ${label}`);
}
}
// no missing handlers
for (const [jobKey, jobName] of Object.entries(JobName)) {
const item = this.handlers[jobName];
if (!item) {
const errorMessage = `Failed to find job handler for Job.${jobKey} ("${jobName}")`;
this.logger.error(
`${errorMessage}. Make sure to add the @OnJob({ name: JobName.${jobKey}, queue: QueueName.XYZ }) decorator for the new job.`,
);
throw new ImmichStartupError(errorMessage);
}
}
}
startWorkers() {
const { bull } = this.configRepository.getEnv(); const { bull } = this.configRepository.getEnv();
const workerHandler: Processor = async (job: Job) => handler(job as JobItem); for (const queueName of Object.values(QueueName)) {
const workerOptions: WorkerOptions = { ...bull.config, concurrency }; this.logger.debug(`Starting worker for queue: ${queueName}`);
this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions); this.workers[queueName] = new Worker(
queueName,
(job) => this.eventRepository.emit('job.start', queueName, job as JobItem),
{ ...bull.config, concurrency: 1 },
);
}
}
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
if (!item) {
this.logger.warn(`Skipping unknown job: "${name}"`);
return JobStatus.SKIPPED;
}
return item.handler(data);
} }
addCronJob(name: string, expression: string, onTick: () => void, start = true): void { addCronJob(name: string, expression: string, onTick: () => void, start = true): void {
@@ -141,11 +139,11 @@ export class JobRepository implements IJobRepository {
true, true,
); );
this.schedulerReqistry.addCronJob(name, job); this.schedulerRegistry.addCronJob(name, job);
} }
updateCronJob(name: string, expression?: string, start?: boolean): void { updateCronJob(name: string, expression?: string, start?: boolean): void {
const job = this.schedulerReqistry.getCronJob(name); const job = this.schedulerRegistry.getCronJob(name);
if (expression) { if (expression) {
job.setTime(new CronTime(expression)); job.setTime(new CronTime(expression));
} }
@@ -204,6 +202,10 @@ export class JobRepository implements IJobRepository {
) as unknown as Promise<JobCounts>; ) as unknown as Promise<JobCounts>;
} }
private getQueueName(name: JobName) {
return (this.handlers[name] as JobMapItem).queueName;
}
async queueAll(items: JobItem[]): Promise<void> { async queueAll(items: JobItem[]): Promise<void> {
if (items.length === 0) { if (items.length === 0) {
return; return;
@@ -212,7 +214,7 @@ export class JobRepository implements IJobRepository {
const promises = []; const promises = [];
const itemsByQueue = {} as Record<string, (JobItem & { data: any; options: JobsOptions | undefined })[]>; const itemsByQueue = {} as Record<string, (JobItem & { data: any; options: JobsOptions | undefined })[]>;
for (const item of items) { for (const item of items) {
const queueName = JOBS_TO_QUEUE[item.name]; const queueName = this.getQueueName(item.name);
const job = { const job = {
name: item.name, name: item.name,
data: item.data || {}, data: item.data || {},
@@ -273,11 +275,11 @@ export class JobRepository implements IJobRepository {
} }
private getQueue(queue: QueueName): Queue { private getQueue(queue: QueueName): Queue {
return this.moduleReference.get<Queue>(getQueueToken(queue), { strict: false }); return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
} }
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> { public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> {
const existingJob = await this.getQueue(JOBS_TO_QUEUE[name]).getJob(jobId); const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId);
if (!existingJob) { if (!existingJob) {
return; return;
} }
@@ -126,6 +126,7 @@ export class MediaRepository implements IMediaRepository {
rotation: this.parseInt(stream.rotation), rotation: this.parseInt(stream.rotation),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: this.parseInt(stream.bit_rate), bitrate: this.parseInt(stream.bit_rate),
pixelFormat: stream.pix_fmt || 'yuv420p',
})), })),
audioStreams: results.streams audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio') .filter((stream) => stream.codec_type === 'audio')
+6 -2
View File
@@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import _ from 'lodash'; import _ from 'lodash';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { OnJob } from 'src/decorators';
import { import {
AssetResponseDto, AssetResponseDto,
MemoryLaneResponseDto, MemoryLaneResponseDto,
@@ -21,12 +22,13 @@ import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, Permission } from 'src/enum'; import { AssetStatus, Permission } from 'src/enum';
import { import {
IAssetDeleteJob,
ISidecarWriteJob, ISidecarWriteJob,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobItem, JobItem,
JobName, JobName,
JobOf,
JobStatus, JobStatus,
QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util'; import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUnlink } from 'src/utils/asset.util';
@@ -186,6 +188,7 @@ export class AssetService extends BaseService {
await this.assetRepository.updateAll(ids, options); await this.assetRepository.updateAll(ids, options);
} }
@OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK })
async handleAssetDeletionCheck(): Promise<JobStatus> { async handleAssetDeletionCheck(): Promise<JobStatus> {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
const trashedDays = config.trash.enabled ? config.trash.days : 0; const trashedDays = config.trash.enabled ? config.trash.days : 0;
@@ -211,7 +214,8 @@ export class AssetService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleAssetDeletion(job: IAssetDeleteJob): Promise<JobStatus> { @OnJob({ name: JobName.ASSET_DELETION, queue: QueueName.BACKGROUND_TASK })
async handleAssetDeletion(job: JobOf<JobName.ASSET_DELETION>): Promise<JobStatus> {
const { id, deleteOnDisk } = job; const { id, deleteOnDisk } = job;
const asset = await this.assetRepository.getById(id, { const asset = await this.assetRepository.getById(id, {
+3 -1
View File
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { import {
AuditDeletesDto, AuditDeletesDto,
AuditDeletesResponseDto, AuditDeletesResponseDto,
@@ -21,13 +22,14 @@ import {
StorageFolder, StorageFolder,
UserPathType, UserPathType,
} from 'src/enum'; } from 'src/enum';
import { JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { JobName, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class AuditService extends BaseService { export class AuditService extends BaseService {
@OnJob({ name: JobName.CLEAN_OLD_AUDIT_LOGS, queue: QueueName.BACKGROUND_TASK })
async handleCleanup(): Promise<JobStatus> { async handleCleanup(): Promise<JobStatus> {
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
+3 -2
View File
@@ -1,11 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { default as path } from 'node:path'; import { default as path } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { ImmichWorker, StorageFolder } from 'src/enum'; import { ImmichWorker, StorageFolder } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
import { validateCronExpression } from 'src/validation'; import { validateCronExpression } from 'src/validation';
@@ -75,6 +75,7 @@ export class BackupService extends BaseService {
this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`); this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`);
} }
@OnJob({ name: JobName.BACKUP_DATABASE, queue: QueueName.BACKUP_DATABASE })
async handleBackupDatabase(): Promise<JobStatus> { async handleBackupDatabase(): Promise<JobStatus> {
this.logger.debug(`Database Backup Started`); this.logger.debug(`Database Backup Started`);
+4 -2
View File
@@ -1,9 +1,10 @@
import { BadRequestException, Inject } from '@nestjs/common'; import { BadRequestException, Inject, Optional } from '@nestjs/common';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants'; import { IWorker, SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { ImmichWorker } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface';
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface'; import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
@@ -49,6 +50,7 @@ export class BaseService {
protected storageCore: StorageCore; protected storageCore: StorageCore;
constructor( constructor(
@Inject(IWorker) @Optional() protected worker: ImmichWorker | undefined,
@Inject(ILoggerRepository) protected logger: ILoggerRepository, @Inject(ILoggerRepository) protected logger: ILoggerRepository,
@Inject(IAccessRepository) protected accessRepository: IAccessRepository, @Inject(IAccessRepository) protected accessRepository: IAccessRepository,
@Inject(IActivityRepository) protected activityRepository: IActivityRepository, @Inject(IActivityRepository) protected activityRepository: IActivityRepository,
+14 -2
View File
@@ -31,11 +31,23 @@ describe(SearchService.name, () => {
describe('getDuplicates', () => { describe('getDuplicates', () => {
it('should get duplicates', async () => { it('should get duplicates', async () => {
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]); assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe, assetStub.hasDupe]);
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([ await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
{ duplicateId: assetStub.hasDupe.duplicateId, assets: [expect.objectContaining({ id: assetStub.hasDupe.id })] }, {
duplicateId: assetStub.hasDupe.duplicateId,
assets: [
expect.objectContaining({ id: assetStub.hasDupe.id }),
expect.objectContaining({ id: assetStub.hasDupe.id }),
],
},
]); ]);
}); });
it('should update assets with duplicateId', async () => {
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]);
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([]);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasDupe.id], { duplicateId: null });
});
}); });
describe('handleQueueSearchDuplicates', () => { describe('handleQueueSearchDuplicates', () => {
+24 -5
View File
@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { OnJob } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto'; import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { WithoutProperty } from 'src/interfaces/asset.interface';
import { IBaseJob, IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { AssetDuplicateResult } from 'src/interfaces/search.interface'; import { AssetDuplicateResult } from 'src/interfaces/search.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
@@ -15,11 +16,28 @@ import { usePagination } from 'src/utils/pagination';
export class DuplicateService extends BaseService { export class DuplicateService extends BaseService {
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> { async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
const uniqueAssetIds: string[] = [];
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))); const duplicates = mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))).filter(
(duplicate) => {
if (duplicate.assets.length === 1) {
uniqueAssetIds.push(duplicate.assets[0].id);
return false;
}
return true;
},
);
if (uniqueAssetIds.length > 0) {
try {
await this.assetRepository.updateAll(uniqueAssetIds, { duplicateId: null });
} catch (error: any) {
this.logger.error(`Failed to remove duplicateId from assets: ${error.message}`);
}
}
return duplicates;
} }
async handleQueueSearchDuplicates({ force }: IBaseJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION })
async handleQueueSearchDuplicates({ force }: JobOf<JobName.QUEUE_DUPLICATE_DETECTION>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false }); const { machineLearning } = await this.getConfig({ withCache: false });
if (!isDuplicateDetectionEnabled(machineLearning)) { if (!isDuplicateDetectionEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -40,7 +58,8 @@ export class DuplicateService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleSearchDuplicates({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION })
async handleSearchDuplicates({ id }: JobOf<JobName.DUPLICATE_DETECTION>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true }); const { machineLearning } = await this.getConfig({ withCache: true });
if (!isDuplicateDetectionEnabled(machineLearning)) { if (!isDuplicateDetectionEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
-2
View File
@@ -17,7 +17,6 @@ import { MapService } from 'src/services/map.service';
import { MediaService } from 'src/services/media.service'; import { MediaService } from 'src/services/media.service';
import { MemoryService } from 'src/services/memory.service'; import { MemoryService } from 'src/services/memory.service';
import { MetadataService } from 'src/services/metadata.service'; import { MetadataService } from 'src/services/metadata.service';
import { MicroservicesService } from 'src/services/microservices.service';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
import { PartnerService } from 'src/services/partner.service'; import { PartnerService } from 'src/services/partner.service';
import { PersonService } from 'src/services/person.service'; import { PersonService } from 'src/services/person.service';
@@ -60,7 +59,6 @@ export const services = [
MediaService, MediaService,
MemoryService, MemoryService,
MetadataService, MetadataService,
MicroservicesService,
NotificationService, NotificationService,
PartnerService, PartnerService,
PersonService, PersonService,
+28 -31
View File
@@ -2,37 +2,25 @@ import { BadRequestException } from '@nestjs/common';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface';
import { import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
IJobRepository, import { ILoggerRepository } from 'src/interfaces/logger.interface';
JobCommand, import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
JobHandler,
JobItem,
JobName,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { newTestService } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked, vitest } from 'vitest'; import { Mocked } from 'vitest';
const makeMockHandlers = (status: JobStatus) => {
const mock = vitest.fn().mockResolvedValue(status);
return Object.fromEntries(Object.values(JobName).map((jobName) => [jobName, mock])) as unknown as Record<
JobName,
JobHandler
>;
};
describe(JobService.name, () => { describe(JobService.name, () => {
let sut: JobService; let sut: JobService;
let assetMock: Mocked<IAssetRepository>; let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>; let jobMock: Mocked<IJobRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let loggerMock: Mocked<ILoggerRepository>;
let telemetryMock: Mocked<ITelemetryRepository>;
beforeEach(() => { beforeEach(() => {
({ sut, assetMock, jobMock, systemMock } = newTestService(JobService)); ({ sut, assetMock, jobMock, loggerMock, telemetryMock } = newTestService(JobService, {
worker: ImmichWorker.MICROSERVICES,
}));
}); });
it('should work', () => { it('should work', () => {
@@ -41,7 +29,6 @@ describe(JobService.name, () => {
describe('onConfigUpdate', () => { describe('onConfigUpdate', () => {
it('should update concurrency', () => { it('should update concurrency', () => {
sut.onBootstrap(ImmichWorker.MICROSERVICES);
sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults });
expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15); expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15);
@@ -225,11 +212,19 @@ describe(JobService.name, () => {
}); });
}); });
describe('init', () => { describe('onJobStart', () => {
it('should register a handler for each queue', async () => { it('should process a successful job', async () => {
await sut.init(makeMockHandlers(JobStatus.SUCCESS)); jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
expect(systemMock.get).toHaveBeenCalled();
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); await sut.onJobStart(QueueName.BACKGROUND_TASK, {
name: JobName.DELETE_FILES,
data: { files: ['path/to/file'] },
});
expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', 1);
expect(telemetryMock.jobs.addToGauge).toHaveBeenCalledWith('immich.queues.background_task.active', -1);
expect(telemetryMock.jobs.addToCounter).toHaveBeenCalledWith('immich.jobs.delete_files.success', 1);
expect(loggerMock.error).not.toHaveBeenCalled();
}); });
const tests: Array<{ item: JobItem; jobs: JobName[] }> = [ const tests: Array<{ item: JobItem; jobs: JobName[] }> = [
@@ -297,8 +292,9 @@ describe(JobService.name, () => {
} }
} }
await sut.init(makeMockHandlers(JobStatus.SUCCESS)); jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
await jobMock.addHandler.mock.calls[0][2](item);
await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
if (jobs.length > 1) { if (jobs.length > 1) {
expect(jobMock.queueAll).toHaveBeenCalledWith( expect(jobMock.queueAll).toHaveBeenCalledWith(
@@ -313,8 +309,9 @@ describe(JobService.name, () => {
}); });
it(`should not queue any jobs when ${item.name} fails`, async () => { it(`should not queue any jobs when ${item.name} fails`, async () => {
await sut.init(makeMockHandlers(JobStatus.FAILED)); jobMock.run.mockResolvedValue(JobStatus.FAILED);
await jobMock.addHandler.mock.calls[0][2](item);
await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
}); });
+17 -45
View File
@@ -4,11 +4,10 @@ import { OnEvent } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto'; import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ImmichWorker, ManualJobName } from 'src/enum'; import { AssetType, ImmichWorker, ManualJobName } from 'src/enum';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf, ArgsOf } from 'src/interfaces/event.interface';
import { import {
ConcurrentQueueName, ConcurrentQueueName,
JobCommand, JobCommand,
JobHandler,
JobItem, JobItem,
JobName, JobName,
JobStatus, JobStatus,
@@ -39,16 +38,9 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
@Injectable() @Injectable()
export class JobService extends BaseService { export class JobService extends BaseService {
private isMicroservices = false;
@OnEvent({ name: 'app.bootstrap' })
onBootstrap(app: ArgOf<'app.bootstrap'>) {
this.isMicroservices = app === ImmichWorker.MICROSERVICES;
}
@OnEvent({ name: 'config.update', server: true }) @OnEvent({ name: 'config.update', server: true })
onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) { onConfigUpdate({ newConfig: config }: ArgOf<'config.update'>) {
if (!oldConfig || !this.isMicroservices) { if (this.worker !== ImmichWorker.MICROSERVICES) {
return; return;
} }
@@ -177,41 +169,21 @@ export class JobService extends BaseService {
} }
} }
async init(jobHandlers: Record<JobName, JobHandler>) { @OnEvent({ name: 'job.start' })
const config = await this.getConfig({ withCache: false }); async onJobStart(...[queueName, job]: ArgsOf<'job.start'>) {
for (const queueName of Object.values(QueueName)) { const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
let concurrency = 1; this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
try {
if (this.isConcurrentQueue(queueName)) { const status = await this.jobRepository.run(job);
concurrency = config.job[queueName].concurrency; const jobMetric = `immich.jobs.${job.name.replaceAll('-', '_')}.${status}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
await this.onDone(job);
} }
} catch (error: Error | any) {
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`); this.logger.error(`Unable to run job handler (${queueName}/${job.name}): ${error}`, error?.stack, job.data);
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => { } finally {
const { name, data } = item; this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
const handler = jobHandlers[name];
if (!handler) {
this.logger.warn(`Skipping unknown job: "${name}"`);
return;
}
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
try {
const status = await handler(data);
const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`;
this.telemetryRepository.jobs.addToCounter(jobMetric, 1);
if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
await this.onDone(item);
}
} catch (error: Error | any) {
this.logger.error(`Unable to run job handler (${queueName}/${name}): ${error}`, error?.stack, data);
} finally {
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
}
});
} }
} }
+14 -14
View File
@@ -3,7 +3,7 @@ import { R_OK } from 'node:constants';
import path, { basename, isAbsolute, parse } from 'node:path'; import path, { basename, isAbsolute, parse } from 'node:path';
import picomatch from 'picomatch'; import picomatch from 'picomatch';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { import {
CreateLibraryDto, CreateLibraryDto,
LibraryResponseDto, LibraryResponseDto,
@@ -19,14 +19,7 @@ import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType, ImmichWorker } from 'src/enum'; import { AssetType, ImmichWorker } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import { JobName, JobOf, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
IEntityJob,
ILibraryAssetJob,
ILibraryFileJob,
JobName,
JOBS_LIBRARY_PAGINATION_SIZE,
JobStatus,
} from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
@@ -223,6 +216,7 @@ export class LibraryService extends BaseService {
return libraries.map((library) => mapLibrary(library)); return libraries.map((library) => mapLibrary(library));
} }
@OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY })
async handleQueueCleanup(): Promise<JobStatus> { async handleQueueCleanup(): Promise<JobStatus> {
this.logger.debug('Cleaning up any pending library deletions'); this.logger.debug('Cleaning up any pending library deletions');
const pendingDeletion = await this.libraryRepository.getAllDeleted(); const pendingDeletion = await this.libraryRepository.getAllDeleted();
@@ -340,7 +334,8 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } });
} }
async handleDeleteLibrary(job: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.LIBRARY_DELETE, queue: QueueName.LIBRARY })
async handleDeleteLibrary(job: JobOf<JobName.LIBRARY_DELETE>): Promise<JobStatus> {
const libraryId = job.id; const libraryId = job.id;
const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
@@ -374,7 +369,8 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleSyncFile(job: ILibraryFileJob): Promise<JobStatus> { @OnJob({ name: JobName.LIBRARY_SYNC_FILE, queue: QueueName.LIBRARY })
async handleSyncFile(job: JobOf<JobName.LIBRARY_SYNC_FILE>): Promise<JobStatus> {
// Only needs to handle new assets // Only needs to handle new assets
const assetPath = path.normalize(job.assetPath); const assetPath = path.normalize(job.assetPath);
@@ -458,6 +454,7 @@ export class LibraryService extends BaseService {
await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } }); await this.jobRepository.queue({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, data: { id } });
} }
@OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY })
async handleQueueSyncAll(): Promise<JobStatus> { async handleQueueSyncAll(): Promise<JobStatus> {
this.logger.debug(`Refreshing all external libraries`); this.logger.debug(`Refreshing all external libraries`);
@@ -483,7 +480,8 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleSyncAsset(job: ILibraryAssetJob): Promise<JobStatus> { @OnJob({ name: JobName.LIBRARY_SYNC_ASSET, queue: QueueName.LIBRARY })
async handleSyncAsset(job: JobOf<JobName.LIBRARY_SYNC_ASSET>): Promise<JobStatus> {
const asset = await this.assetRepository.getById(job.id); const asset = await this.assetRepository.getById(job.id);
if (!asset) { if (!asset) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -538,7 +536,8 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleQueueSyncFiles(job: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_FILES, queue: QueueName.LIBRARY })
async handleQueueSyncFiles(job: JobOf<JobName.LIBRARY_QUEUE_SYNC_FILES>): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id); const library = await this.libraryRepository.get(job.id);
if (!library) { if (!library) {
this.logger.debug(`Library ${job.id} not found, skipping refresh`); this.logger.debug(`Library ${job.id} not found, skipping refresh`);
@@ -589,7 +588,8 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleQueueSyncAssets(job: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ASSETS, queue: QueueName.LIBRARY })
async handleQueueSyncAssets(job: JobOf<JobName.LIBRARY_QUEUE_SYNC_ASSETS>): Promise<JobStatus> {
const library = await this.libraryRepository.get(job.id); const library = await this.libraryRepository.get(job.id);
if (!library) { if (!library) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
+110 -28
View File
@@ -9,7 +9,6 @@ import {
AudioCodec, AudioCodec,
Colorspace, Colorspace,
ImageFormat, ImageFormat,
ToneMapping,
TranscodeHWAccel, TranscodeHWAccel,
TranscodePolicy, TranscodePolicy,
VideoCodec, VideoCodec,
@@ -410,7 +409,7 @@ describe(MediaService.name, () => {
'-frames:v 1', '-frames:v 1',
'-update 1', '-update 1',
'-v verbose', '-v verbose',
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_color_matrix=bt601:out_range=pc,format=yuv420p`, String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,scale=-2:1440:flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc`,
], ],
twoPass: false, twoPass: false,
}), }),
@@ -445,7 +444,7 @@ describe(MediaService.name, () => {
'-frames:v 1', '-frames:v 1',
'-update 1', '-update 1',
'-v verbose', '-v verbose',
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=601:m=470bg:range=pc,format=yuv420p`, String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`,
], ],
twoPass: false, twoPass: false,
}), }),
@@ -482,7 +481,7 @@ describe(MediaService.name, () => {
'-frames:v 1', '-frames:v 1',
'-update 1', '-update 1',
'-v verbose', '-v verbose',
String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=601:m=470bg:range=pc,format=yuv420p`, String.raw`-vf fps=12:eof_action=pass:round=down,thumbnail=12,select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20),trim=end_frame=2,reverse,tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p`,
], ],
twoPass: false, twoPass: false,
}), }),
@@ -1328,7 +1327,7 @@ describe(MediaService.name, () => {
'-map 0:0', '-map 0:0',
'-map 0:1', '-map 0:1',
'-v verbose', '-v verbose',
'-vf scale=-2:720,format=yuv420p', '-vf scale=-2:720',
'-preset 12', '-preset 12',
'-crf 23', '-crf 23',
]), ]),
@@ -1454,7 +1453,7 @@ describe(MediaService.name, () => {
'-map 0:1', '-map 0:1',
'-g 256', '-g 256',
'-v verbose', '-v verbose',
'-vf format=nv12,hwupload_cuda,scale_cuda=-2:720', '-vf hwupload_cuda,scale_cuda=-2:720:format=nv12',
'-preset p1', '-preset p1',
'-cq:v 23', '-cq:v 23',
]), ]),
@@ -1586,7 +1585,7 @@ describe(MediaService.name, () => {
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']), inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
expect.stringContaining( expect.stringContaining(
'tonemap_cuda=desat=0:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709:format=nv12', 'tonemap_cuda=desat=0:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:tonemap_mode=lum:transfer=bt709:peak=100:format=nv12',
), ),
]), ]),
twoPass: false, twoPass: false,
@@ -1594,6 +1593,24 @@ describe(MediaService.name, () => {
); );
}); });
it('should set format to nv12 for nvenc if input is not yuv420p', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.NVENC, accelDecode: true },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]),
twoPass: false,
}),
);
});
it('should set options for qsv', async () => { it('should set options for qsv', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
@@ -1616,7 +1633,7 @@ describe(MediaService.name, () => {
'-refs 5', '-refs 5',
'-g 256', '-g 256',
'-v verbose', '-v verbose',
'-vf format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720:mode=hq', '-vf hwupload=extra_hw_frames=64,scale_qsv=-1:720:mode=hq:format=nv12',
'-preset 7', '-preset 7',
'-global_quality:v 23', '-global_quality:v 23',
'-maxrate 10000k', '-maxrate 10000k',
@@ -1748,7 +1765,7 @@ describe(MediaService.name, () => {
]), ]),
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
expect.stringContaining( expect.stringContaining(
'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=qsv:reverse=1,format=qsv', 'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:transfer=bt709:range=pc:tonemap=hable:tonemap_mode=lum:peak=100,hwmap=derive_device=qsv:reverse=1,format=qsv',
), ),
]), ]),
twoPass: false, twoPass: false,
@@ -1776,6 +1793,32 @@ describe(MediaService.name, () => {
); );
}); });
it('should set format to nv12 for qsv if input is not yuv420p', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.QSV, accelDecode: true },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining([
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-threads 1',
]),
outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]),
twoPass: false,
}),
);
});
it('should set options for vaapi', async () => { it('should set options for vaapi', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
@@ -1799,7 +1842,7 @@ describe(MediaService.name, () => {
'-map 0:1', '-map 0:1',
'-g 256', '-g 256',
'-v verbose', '-v verbose',
'-vf format=nv12,hwupload,scale_vaapi=-2:720:mode=hq:out_range=pc', '-vf hwupload=extra_hw_frames=64,scale_vaapi=-2:720:mode=hq:out_range=pc:format=nv12',
'-compression_level 7', '-compression_level 7',
'-rc_mode 1', '-rc_mode 1',
]), ]),
@@ -1970,7 +2013,7 @@ describe(MediaService.name, () => {
); );
}); });
it('should use hardware tone-mapping for qsv if hardware decoding is enabled and should tone map', async () => { it('should use hardware tone-mapping for vaapi if hardware decoding is enabled and should tone map', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
systemMock.get.mockResolvedValue({ systemMock.get.mockResolvedValue({
@@ -1987,7 +2030,7 @@ describe(MediaService.name, () => {
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']), inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']),
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
expect.stringContaining( expect.stringContaining(
'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:range=pc:tonemap=hable:transfer=bt709,hwmap=derive_device=vaapi:reverse=1,format=vaapi', 'hwmap=derive_device=opencl,tonemap_opencl=desat=0:format=nv12:matrix=bt709:primaries=bt709:transfer=bt709:range=pc:tonemap=hable:tonemap_mode=lum:peak=100,hwmap=derive_device=vaapi:reverse=1,format=vaapi',
), ),
]), ]),
twoPass: false, twoPass: false,
@@ -1995,6 +2038,27 @@ describe(MediaService.name, () => {
); );
}); });
it('should set format to nv12 for vaapi if input is not yuv420p', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.VAAPI, accelDecode: true },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']),
outputOptions: expect.arrayContaining([expect.stringContaining('format=nv12')]),
twoPass: false,
}),
);
});
it('should use preferred device for vaapi when hardware decoding', async () => { it('should use preferred device for vaapi when hardware decoding', async () => {
storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']); storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
@@ -2069,7 +2133,7 @@ describe(MediaService.name, () => {
'-map 0:1', '-map 0:1',
'-g 256', '-g 256',
'-v verbose', '-v verbose',
'-vf scale_rkrga=-2:720:format=nv12:afbc=1', '-vf scale_rkrga=-2:720:format=nv12:afbc=1:async_depth=4',
'-level 51', '-level 51',
'-rc_mode CQP', '-rc_mode CQP',
'-qp_init 23', '-qp_init 23',
@@ -2140,7 +2204,7 @@ describe(MediaService.name, () => {
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']), inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
expect.stringContaining( expect.stringContaining(
'scale_rkrga=-2:720:format=p010:afbc=1,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime', 'scale_rkrga=-2:720:format=p010:afbc=1:async_depth=4,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0:tonemap_mode=lum:peak=100,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime',
), ),
]), ]),
twoPass: false, twoPass: false,
@@ -2148,6 +2212,28 @@ describe(MediaService.name, () => {
); );
}); });
it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([
expect.stringContaining('scale_rkrga=-2:720:format=nv12:afbc=1:async_depth=4'),
]),
twoPass: false,
}),
);
});
it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => { it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats); storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
@@ -2164,7 +2250,7 @@ describe(MediaService.name, () => {
inputOptions: [], inputOptions: [],
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
expect.stringContaining( expect.stringContaining(
'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', 'tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
), ),
]), ]),
twoPass: false, twoPass: false,
@@ -2172,7 +2258,7 @@ describe(MediaService.name, () => {
); );
}); });
it('should use software decoding and tone-mapping if opencl is not available', async () => { it('should use software tone-mapping if opencl is not available', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']); storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats); storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
@@ -2185,10 +2271,10 @@ describe(MediaService.name, () => {
'/original/path.ext', '/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4', 'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({ expect.objectContaining({
inputOptions: [], inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
expect.stringContaining( expect.stringContaining(
'zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', 'tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
), ),
]), ]),
twoPass: false, twoPass: false,
@@ -2209,7 +2295,7 @@ describe(MediaService.name, () => {
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
'-c:v h264', '-c:v h264',
'-c:a copy', '-c:a copy',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', '-vf tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
]), ]),
twoPass: false, twoPass: false,
}), }),
@@ -2229,16 +2315,16 @@ describe(MediaService.name, () => {
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining([
'-c:v h264', '-c:v h264',
'-c:a copy', '-c:a copy',
'-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p', '-vf tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',
]), ]),
twoPass: false, twoPass: false,
}), }),
); );
}); });
it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => { it('should transcode when policy is required and video is not yuv420p', async () => {
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } }); systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id }); await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith( expect(mediaMock.transcode).toHaveBeenCalledWith(
@@ -2246,11 +2332,7 @@ describe(MediaService.name, () => {
'upload/encoded-video/user-id/as/se/asset-id.mp4', 'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({ expect.objectContaining({
inputOptions: expect.any(Array), inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([ outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy', '-vf format=yuv420p']),
'-c:v h264',
'-c:a copy',
'-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=709:t=709:m=709:range=pc,format=yuv420p',
]),
twoPass: false, twoPass: false,
}), }),
); );
+39 -15
View File
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { dirname } from 'node:path'; import { dirname } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { import {
@@ -19,11 +20,10 @@ import {
} from 'src/enum'; } from 'src/enum';
import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface'; import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import { import {
IBaseJob,
IEntityJob,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobItem, JobItem,
JobName, JobName,
JobOf,
JobStatus, JobStatus,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
@@ -39,7 +39,8 @@ export class MediaService extends BaseService {
private maliOpenCL?: boolean; private maliOpenCL?: boolean;
private devices?: string[]; private devices?: string[];
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination, { ? this.assetRepository.getAll(pagination, {
@@ -90,6 +91,7 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
async handleQueueMigration(): Promise<JobStatus> { async handleQueueMigration(): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination), this.assetRepository.getAll(pagination),
@@ -120,7 +122,8 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleAssetMigration({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION })
async handleAssetMigration({ id }: JobOf<JobName.MIGRATE_ASSET>): Promise<JobStatus> {
const { image } = await this.getConfig({ withCache: true }); const { image } = await this.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id], { files: true }); const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) { if (!asset) {
@@ -134,7 +137,8 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleGenerateThumbnails({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
async handleGenerateThumbnails({ id }: JobOf<JobName.GENERATE_THUMBNAILS>): Promise<JobStatus> {
const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true }); const asset = await this.assetRepository.getById(id, { exifInfo: true, files: true });
if (!asset) { if (!asset) {
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`); this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
@@ -257,7 +261,8 @@ export class MediaService extends BaseService {
return { previewPath, thumbnailPath, thumbhash }; return { previewPath, thumbnailPath, thumbhash };
} }
async handleQueueVideoConversion(job: IBaseJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION })
async handleQueueVideoConversion(job: JobOf<JobName.QUEUE_VIDEO_CONVERSION>): Promise<JobStatus> {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
@@ -275,7 +280,8 @@ export class MediaService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleVideoConversion({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.VIDEO_CONVERSION, queue: QueueName.VIDEO_CONVERSION })
async handleVideoConversion({ id }: JobOf<JobName.VIDEO_CONVERSION>): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || asset.type !== AssetType.VIDEO) { if (!asset || asset.type !== AssetType.VIDEO) {
return JobStatus.FAILED; return JobStatus.FAILED;
@@ -323,9 +329,11 @@ export class MediaService extends BaseService {
} }
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
this.logger.log(`Encoding video ${asset.id} without hardware acceleration`); this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
} else { } else {
this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`); this.logger.log(
`Transcoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and${ffmpeg.accelDecode ? '' : ' software'} decoding`,
);
} }
try { try {
@@ -335,10 +343,26 @@ export class MediaService extends BaseService {
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED }); let partialFallbackSuccess = false;
command = config.getCommand(target, mainVideoStream, mainAudioStream); if (ffmpeg.accelDecode) {
await this.mediaRepository.transcode(input, output, command); try {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
const config = BaseConfig.create({ ...ffmpeg, accelDecode: false });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
partialFallbackSuccess = true;
} catch (error: any) {
this.logger.error(`Error occurred during transcoding: ${error.message}`);
}
}
if (!partialFallbackSuccess) {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
}
} }
this.logger.log(`Successfully encoded ${asset.id}`); this.logger.log(`Successfully encoded ${asset.id}`);
@@ -407,7 +431,7 @@ export class MediaService extends BaseService {
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate); const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec); const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
const isRequired = !isTargetVideoCodec || stream.isHDR; const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p');
switch (ffmpegConfig.transcode) { switch (ffmpegConfig.transcode) {
case TranscodePolicy.DISABLED: { case TranscodePolicy.DISABLED: {
@@ -496,7 +520,7 @@ export class MediaService extends BaseService {
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch { } catch {
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding'); this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
this.maliOpenCL = false; this.maliOpenCL = false;
} }
} }
+33 -19
View File
@@ -7,7 +7,7 @@ import { constants } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity'; import { ExifEntity } from 'src/entities/exif.entity';
@@ -16,15 +16,7 @@ import { AssetType, ImmichWorker, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
IBaseJob,
IEntityJob,
ISidecarWriteJob,
JobName,
JOBS_ASSET_PAGINATION_SIZE,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { ReverseGeocodeResult } from 'src/interfaces/map.interface'; import { ReverseGeocodeResult } from 'src/interfaces/map.interface';
import { ImmichTags } from 'src/interfaces/metadata.interface'; import { ImmichTags } from 'src/interfaces/metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@@ -124,7 +116,8 @@ export class MetadataService extends BaseService {
} }
} }
async handleLivePhotoLinking(job: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.LINK_LIVE_PHOTOS, queue: QueueName.METADATA_EXTRACTION })
async handleLivePhotoLinking(job: JobOf<JobName.LINK_LIVE_PHOTOS>): Promise<JobStatus> {
const { id } = job; const { id } = job;
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
if (!asset?.exifInfo) { if (!asset?.exifInfo) {
@@ -159,7 +152,8 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleQueueMetadataExtraction(job: IBaseJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleQueueMetadataExtraction(job: JobOf<JobName.QUEUE_METADATA_EXTRACTION>): Promise<JobStatus> {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
@@ -176,7 +170,8 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleMetadataExtraction({ id }: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true }); const { metadata, reverseGeocoding } = await this.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } }); const [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } });
if (!asset) { if (!asset) {
@@ -192,6 +187,8 @@ export class MetadataService extends BaseService {
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
const { width, height } = this.getImageDimensions(exifTags);
const exifData: Partial<ExifEntity> = { const exifData: Partial<ExifEntity> = {
assetId: asset.id, assetId: asset.id,
@@ -209,8 +206,8 @@ export class MetadataService extends BaseService {
// image/file // image/file
fileSizeInByte: stats.size, fileSizeInByte: stats.size,
exifImageHeight: validate(exifTags.ImageHeight), exifImageHeight: validate(height),
exifImageWidth: validate(exifTags.ImageWidth), exifImageWidth: validate(width),
orientation: validate(exifTags.Orientation)?.toString() ?? null, orientation: validate(exifTags.Orientation)?.toString() ?? null,
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null, projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
bitsPerSample: this.getBitsPerSample(exifTags), bitsPerSample: this.getBitsPerSample(exifTags),
@@ -260,7 +257,8 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleQueueSidecar(job: IBaseJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR })
async handleQueueSidecar(job: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
@@ -280,11 +278,13 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
handleSidecarSync({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.SIDECAR_SYNC, queue: QueueName.SIDECAR })
handleSidecarSync({ id }: JobOf<JobName.SIDECAR_SYNC>): Promise<JobStatus> {
return this.processSidecar(id, true); return this.processSidecar(id, true);
} }
handleSidecarDiscovery({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.SIDECAR_DISCOVERY, queue: QueueName.SIDECAR })
handleSidecarDiscovery({ id }: JobOf<JobName.SIDECAR_DISCOVERY>): Promise<JobStatus> {
return this.processSidecar(id, false); return this.processSidecar(id, false);
} }
@@ -298,7 +298,8 @@ export class MetadataService extends BaseService {
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id: assetId, tags: true } });
} }
async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> { @OnJob({ name: JobName.SIDECAR_WRITE, queue: QueueName.SIDECAR })
async handleSidecarWrite(job: JobOf<JobName.SIDECAR_WRITE>): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job; const { id, description, dateTimeOriginal, latitude, longitude, rating, tags } = job;
const [asset] = await this.assetRepository.getByIds([id], { tags: true }); const [asset] = await this.assetRepository.getByIds([id], { tags: true });
if (!asset) { if (!asset) {
@@ -334,6 +335,19 @@ export class MetadataService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
private getImageDimensions(exifTags: ImmichTags): { width?: number; height?: number } {
/*
* The "true" values for width and height are a bit hidden, depending on the camera model and file format.
* For RAW images in the CR2 or RAF format, the "ImageSize" value seems to be correct,
* but ImageWidth and ImageHeight are not correct (they contain the dimensions of the preview image).
*/
let [width, height] = exifTags.ImageSize?.split('x').map((dim) => Number.parseInt(dim) || undefined) || [];
if (!width || !height) {
[width, height] = [exifTags.ImageWidth, exifTags.ImageHeight];
}
return { width, height };
}
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> { private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
const mediaTags = await this.metadataRepository.readTags(asset.originalPath); const mediaTags = await this.metadataRepository.readTags(asset.originalPath);
const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {}; const sidecarTags = asset.sidecarPath ? await this.metadataRepository.readTags(asset.sidecarPath) : {};
@@ -1,106 +0,0 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators';
import { ImmichWorker } from 'src/enum';
import { ArgOf } from 'src/interfaces/event.interface';
import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { BackupService } from 'src/services/backup.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { JobService } from 'src/services/job.service';
import { LibraryService } from 'src/services/library.service';
import { MediaService } from 'src/services/media.service';
import { MetadataService } from 'src/services/metadata.service';
import { NotificationService } from 'src/services/notification.service';
import { PersonService } from 'src/services/person.service';
import { SessionService } from 'src/services/session.service';
import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { TagService } from 'src/services/tag.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
@Injectable()
export class MicroservicesService {
constructor(
private auditService: AuditService,
private assetService: AssetService,
private backupService: BackupService,
private jobService: JobService,
private libraryService: LibraryService,
private mediaService: MediaService,
private metadataService: MetadataService,
private notificationService: NotificationService,
private personService: PersonService,
private smartInfoService: SmartInfoService,
private sessionService: SessionService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private tagService: TagService,
private trashService: TrashService,
private userService: UserService,
private duplicateService: DuplicateService,
private versionService: VersionService,
) {}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(app: ArgOf<'app.bootstrap'>) {
if (app !== ImmichWorker.MICROSERVICES) {
return;
}
await this.jobService.init({
[JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data),
[JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(),
[JobName.BACKUP_DATABASE]: () => this.backupService.handleBackupDatabase(),
[JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data),
[JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
[JobName.CLEAN_OLD_SESSION_TOKENS]: () => this.sessionService.handleCleanup(),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(),
[JobName.QUEUE_SMART_SEARCH]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.SMART_SEARCH]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.QUEUE_DUPLICATE_DETECTION]: (data) => this.duplicateService.handleQueueSearchDuplicates(data),
[JobName.DUPLICATE_DETECTION]: (data) => this.duplicateService.handleSearchDuplicates(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
[JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(),
[JobName.MIGRATE_ASSET]: (data) => this.mediaService.handleAssetMigration(data),
[JobName.MIGRATE_PERSON]: (data) => this.personService.handlePersonMigration(data),
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_THUMBNAILS]: (data) => this.mediaService.handleGenerateThumbnails(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataService.handleQueueMetadataExtraction(data),
[JobName.METADATA_EXTRACTION]: (data) => this.metadataService.handleMetadataExtraction(data),
[JobName.LINK_LIVE_PHOTOS]: (data) => this.metadataService.handleLivePhotoLinking(data),
[JobName.QUEUE_FACE_DETECTION]: (data) => this.personService.handleQueueDetectFaces(data),
[JobName.FACE_DETECTION]: (data) => this.personService.handleDetectFaces(data),
[JobName.QUEUE_FACIAL_RECOGNITION]: (data) => this.personService.handleQueueRecognizeFaces(data),
[JobName.FACIAL_RECOGNITION]: (data) => this.personService.handleRecognizeFaces(data),
[JobName.GENERATE_PERSON_THUMBNAIL]: (data) => this.personService.handleGeneratePersonThumbnail(data),
[JobName.PERSON_CLEANUP]: () => this.personService.handlePersonCleanup(),
[JobName.QUEUE_SIDECAR]: (data) => this.metadataService.handleQueueSidecar(data),
[JobName.SIDECAR_DISCOVERY]: (data) => this.metadataService.handleSidecarDiscovery(data),
[JobName.SIDECAR_SYNC]: (data) => this.metadataService.handleSidecarSync(data),
[JobName.SIDECAR_WRITE]: (data) => this.metadataService.handleSidecarWrite(data),
[JobName.LIBRARY_QUEUE_SYNC_ALL]: () => this.libraryService.handleQueueSyncAll(),
[JobName.LIBRARY_QUEUE_SYNC_FILES]: (data) => this.libraryService.handleQueueSyncFiles(data), //Queues all files paths on disk
[JobName.LIBRARY_SYNC_FILE]: (data) => this.libraryService.handleSyncFile(data), //Handles a single path on disk //Watcher calls for new files
[JobName.LIBRARY_QUEUE_SYNC_ASSETS]: (data) => this.libraryService.handleQueueSyncAssets(data), //Queues all library assets
[JobName.LIBRARY_SYNC_ASSET]: (data) => this.libraryService.handleSyncAsset(data), //Handles all library assets // Watcher calls for unlink and changed
[JobName.LIBRARY_DELETE]: (data) => this.libraryService.handleDeleteLibrary(data),
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),
[JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data),
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
[JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(),
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
[JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(),
});
}
}
+11 -8
View File
@@ -1,17 +1,16 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import {
IEmailJob,
IEntityJob, IEntityJob,
INotifyAlbumInviteJob,
INotifyAlbumUpdateJob, INotifyAlbumUpdateJob,
INotifySignupJob,
JobItem, JobItem,
JobName, JobName,
JobOf,
JobStatus, JobStatus,
QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface'; import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@@ -176,7 +175,8 @@ export class NotificationService extends BaseService {
return { messageId }; return { messageId };
} }
async handleUserSignup({ id, tempPassword }: INotifySignupJob) { @OnJob({ name: JobName.NOTIFY_SIGNUP, queue: QueueName.NOTIFICATION })
async handleUserSignup({ id, tempPassword }: JobOf<JobName.NOTIFY_SIGNUP>) {
const user = await this.userRepository.get(id, { withDeleted: false }); const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) { if (!user) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -207,7 +207,8 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleAlbumInvite({ id, recipientId }: INotifyAlbumInviteJob) { @OnJob({ name: JobName.NOTIFY_ALBUM_INVITE, queue: QueueName.NOTIFICATION })
async handleAlbumInvite({ id, recipientId }: JobOf<JobName.NOTIFY_ALBUM_INVITE>) {
const album = await this.albumRepository.getById(id, { withAssets: false }); const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) { if (!album) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -254,7 +255,8 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleAlbumUpdate({ id, recipientIds }: INotifyAlbumUpdateJob) { @OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION })
async handleAlbumUpdate({ id, recipientIds }: JobOf<JobName.NOTIFY_ALBUM_UPDATE>) {
const album = await this.albumRepository.getById(id, { withAssets: false }); const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) { if (!album) {
@@ -312,7 +314,8 @@ export class NotificationService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleSendEmail(data: IEmailJob): Promise<JobStatus> { @OnJob({ name: JobName.SEND_EMAIL, queue: QueueName.NOTIFICATION })
async handleSendEmail(data: JobOf<JobName.SEND_EMAIL>): Promise<JobStatus> {
const { notifications } = await this.getConfig({ withCache: false }); const { notifications } = await this.getConfig({ withCache: false });
if (!notifications.smtp.enabled) { if (!notifications.smtp.enabled) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
+15 -10
View File
@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { FACE_THUMBNAIL_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
@@ -33,13 +34,10 @@ import {
} from 'src/enum'; } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { WithoutProperty } from 'src/interfaces/asset.interface';
import { import {
IBaseJob,
IDeferrableJob,
IEntityJob,
INightlyJob,
JOBS_ASSET_PAGINATION_SIZE, JOBS_ASSET_PAGINATION_SIZE,
JobItem, JobItem,
JobName, JobName,
JobOf,
JobStatus, JobStatus,
QueueName, QueueName,
} from 'src/interfaces/job.interface'; } from 'src/interfaces/job.interface';
@@ -231,13 +229,15 @@ export class PersonService extends BaseService {
this.logger.debug(`Deleted ${people.length} people`); this.logger.debug(`Deleted ${people.length} people`);
} }
@OnJob({ name: JobName.PERSON_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async handlePersonCleanup(): Promise<JobStatus> { async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.personRepository.getAllWithoutFaces(); const people = await this.personRepository.getAllWithoutFaces();
await this.delete(people); await this.delete(people);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleQueueDetectFaces({ force }: IBaseJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_FACE_DETECTION, queue: QueueName.FACE_DETECTION })
async handleQueueDetectFaces({ force }: JobOf<JobName.QUEUE_FACE_DETECTION>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false }); const { machineLearning } = await this.getConfig({ withCache: false });
if (!isFacialRecognitionEnabled(machineLearning)) { if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -272,7 +272,8 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleDetectFaces({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.FACE_DETECTION, queue: QueueName.FACE_DETECTION })
async handleDetectFaces({ id }: JobOf<JobName.FACE_DETECTION>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true }); const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) { if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -376,7 +377,8 @@ export class PersonService extends BaseService {
return intersection / union; return intersection / union;
} }
async handleQueueRecognizeFaces({ force, nightly }: INightlyJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION })
async handleQueueRecognizeFaces({ force, nightly }: JobOf<JobName.QUEUE_FACIAL_RECOGNITION>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false }); const { machineLearning } = await this.getConfig({ withCache: false });
if (!isFacialRecognitionEnabled(machineLearning)) { if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -426,7 +428,8 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise<JobStatus> { @OnJob({ name: JobName.FACIAL_RECOGNITION, queue: QueueName.FACIAL_RECOGNITION })
async handleRecognizeFaces({ id, deferred }: JobOf<JobName.FACIAL_RECOGNITION>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true }); const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) { if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -509,7 +512,8 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handlePersonMigration({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.MIGRATE_PERSON, queue: QueueName.MIGRATION })
async handlePersonMigration({ id }: JobOf<JobName.MIGRATE_PERSON>): Promise<JobStatus> {
const person = await this.personRepository.getById(id); const person = await this.personRepository.getById(id);
if (!person) { if (!person) {
return JobStatus.FAILED; return JobStatus.FAILED;
@@ -520,7 +524,8 @@ export class PersonService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.GENERATE_PERSON_THUMBNAIL, queue: QueueName.THUMBNAIL_GENERATION })
async handleGeneratePersonThumbnail(data: JobOf<JobName.GENERATE_PERSON_THUMBNAIL>): Promise<JobStatus> {
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true }); const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) { if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
+4 -2
View File
@@ -1,14 +1,16 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto'; import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@Injectable() @Injectable()
export class SessionService extends BaseService { export class SessionService extends BaseService {
async handleCleanup() { @OnJob({ name: JobName.CLEAN_OLD_SESSION_TOKENS, queue: QueueName.BACKGROUND_TASK })
async handleCleanup(): Promise<JobStatus> {
const sessions = await this.sessionRepository.search({ const sessions = await this.sessionRepository.search({
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(), updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
}); });
+6 -11
View File
@@ -1,18 +1,11 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { ImmichWorker } from 'src/enum'; import { ImmichWorker } from 'src/enum';
import { WithoutProperty } from 'src/interfaces/asset.interface'; import { WithoutProperty } from 'src/interfaces/asset.interface';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
IBaseJob,
IEntityJob,
JOBS_ASSET_PAGINATION_SIZE,
JobName,
JobStatus,
QueueName,
} from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
@@ -86,7 +79,8 @@ export class SmartInfoService extends BaseService {
}); });
} }
async handleQueueEncodeClip({ force }: IBaseJob): Promise<JobStatus> { @OnJob({ name: JobName.QUEUE_SMART_SEARCH, queue: QueueName.SMART_SEARCH })
async handleQueueEncodeClip({ force }: JobOf<JobName.QUEUE_SMART_SEARCH>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: false }); const { machineLearning } = await this.getConfig({ withCache: false });
if (!isSmartSearchEnabled(machineLearning)) { if (!isSmartSearchEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -111,7 +105,8 @@ export class SmartInfoService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleEncodeClip({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.SMART_SEARCH, queue: QueueName.SMART_SEARCH })
async handleEncodeClip({ id }: JobOf<JobName.SMART_SEARCH>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true }); const { machineLearning } = await this.getConfig({ withCache: true });
if (!isSmartSearchEnabled(machineLearning)) { if (!isSmartSearchEnabled(machineLearning)) {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
@@ -4,13 +4,13 @@ import { DateTime } from 'luxon';
import path from 'node:path'; import path from 'node:path';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface'; import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getLivePhotoMotionFilename } from 'src/utils/file'; import { getLivePhotoMotionFilename } from 'src/utils/file';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@@ -108,7 +108,8 @@ export class StorageTemplateService extends BaseService {
return { ...storageTokens, presetOptions: storagePresets }; return { ...storageTokens, presetOptions: storagePresets };
} }
async handleMigrationSingle({ id }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
async handleMigrationSingle({ id }: JobOf<JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE>): Promise<JobStatus> {
const config = await this.getConfig({ withCache: true }); const config = await this.getConfig({ withCache: true });
const storageTemplateEnabled = config.storageTemplate.enabled; const storageTemplateEnabled = config.storageTemplate.enabled;
if (!storageTemplateEnabled) { if (!storageTemplateEnabled) {
@@ -137,6 +138,7 @@ export class StorageTemplateService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
async handleMigration(): Promise<JobStatus> { async handleMigration(): Promise<JobStatus> {
this.logger.log('Starting storage template migration'); this.logger.log('Starting storage template migration');
const { storageTemplate } = await this.getConfig({ withCache: true }); const { storageTemplate } = await this.getConfig({ withCache: true });
+2 -1
View File
@@ -3,7 +3,8 @@ import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ImmichStartupError, StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { ImmichStartupError } from 'src/utils/misc';
import { mockEnvData } from 'test/repositories/config.repository.mock'; import { mockEnvData } from 'test/repositories/config.repository.mock';
import { newTestService } from 'test/utils'; import { newTestService } from 'test/utils';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
+5 -6
View File
@@ -1,15 +1,13 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { join } from 'node:path'; import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { SystemFlags } from 'src/entities/system-metadata.entity'; import { SystemFlags } from 'src/entities/system-metadata.entity';
import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ImmichStartupError } from 'src/utils/misc';
export class ImmichStartupError extends Error {}
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`; const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
@@ -66,7 +64,8 @@ export class StorageService extends BaseService {
}); });
} }
async handleDeleteFiles(job: IDeleteFilesJob) { @OnJob({ name: JobName.DELETE_FILES, queue: QueueName.BACKGROUND_TASK })
async handleDeleteFiles(job: JobOf<JobName.DELETE_FILES>): Promise<JobStatus> {
const { files } = job; const { files } = job;
// TODO: one job per file // TODO: one job per file
@@ -65,7 +65,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
bframes: -1, bframes: -1,
refs: 0, refs: 0,
gopSize: 0, gopSize: 0,
npl: 0,
temporalAQ: false, temporalAQ: false,
cqMode: CQMode.AUTO, cqMode: CQMode.AUTO,
twoPass: false, twoPass: false,
+3 -1
View File
@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { OnJob } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
@@ -12,7 +13,7 @@ import {
} from 'src/dtos/tag.dto'; } from 'src/dtos/tag.dto';
import { TagEntity } from 'src/entities/tag.entity'; import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { JobStatus } from 'src/interfaces/job.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { AssetTagItem } from 'src/interfaces/tag.interface'; import { AssetTagItem } from 'src/interfaces/tag.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util'; import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -131,6 +132,7 @@ export class TagService extends BaseService {
return results; return results;
} }
@OnJob({ name: JobName.TAG_CLEANUP, queue: QueueName.BACKGROUND_TASK })
async handleTagCleanup() { async handleTagCleanup() {
await this.tagRepository.deleteEmptyTags(); await this.tagRepository.deleteEmptyTags();
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
+3 -2
View File
@@ -1,9 +1,9 @@
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto'; import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface'; import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@@ -44,6 +44,7 @@ export class TrashService extends BaseService {
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} }); await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
} }
@OnJob({ name: JobName.QUEUE_TRASH_EMPTY, queue: QueueName.BACKGROUND_TASK })
async handleQueueEmptyTrash() { async handleQueueEmptyTrash() {
let count = 0; let count = 0;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
+6 -2
View File
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
@@ -10,7 +11,7 @@ import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUse
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum';
import { IEntityJob, JobName, JobStatus } from 'src/interfaces/job.interface'; import { JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { UserFindOptions } from 'src/interfaces/user.interface'; import { UserFindOptions } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
@@ -163,11 +164,13 @@ export class UserService extends BaseService {
return licenseData; return licenseData;
} }
@OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
async handleUserSyncUsage(): Promise<JobStatus> { async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage(); await this.userRepository.syncUsage();
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@OnJob({ name: JobName.USER_DELETE_CHECK, queue: QueueName.BACKGROUND_TASK })
async handleUserDeleteCheck(): Promise<JobStatus> { async handleUserDeleteCheck(): Promise<JobStatus> {
const users = await this.userRepository.getDeletedUsers(); const users = await this.userRepository.getDeletedUsers();
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
@@ -181,7 +184,8 @@ export class UserService extends BaseService {
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async handleUserDelete({ id, force }: IEntityJob): Promise<JobStatus> { @OnJob({ name: JobName.USER_DELETION, queue: QueueName.BACKGROUND_TASK })
async handleUserDelete({ id, force }: JobOf<JobName.USER_DELETION>): Promise<JobStatus> {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
const user = await this.userRepository.get(id, { withDeleted: true }); const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) { if (!user) {
+3 -2
View File
@@ -2,13 +2,13 @@ import { Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import semver, { SemVer } from 'semver'; import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants'; import { serverVersion } from 'src/constants';
import { OnEvent } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto'; import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity'; import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { ImmichEnvironment, SystemMetadataKey } from 'src/enum'; import { ImmichEnvironment, SystemMetadataKey } from 'src/enum';
import { DatabaseLock } from 'src/interfaces/database.interface'; import { DatabaseLock } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface'; import { ArgOf } from 'src/interfaces/event.interface';
import { JobName, JobStatus } from 'src/interfaces/job.interface'; import { JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => { const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
@@ -48,6 +48,7 @@ export class VersionService extends BaseService {
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
} }
@OnJob({ name: JobName.VERSION_CHECK, queue: QueueName.BACKGROUND_TASK })
async handleVersionCheck(): Promise<JobStatus> { async handleVersionCheck(): Promise<JobStatus> {
try { try {
this.logger.debug('Running version check'); this.logger.debug('Running version check');
+1 -1
View File
@@ -16,7 +16,7 @@ export const logGlobalError = (logger: ILoggerRepository, error: Error) => {
} }
if (error instanceof Error) { if (error instanceof Error) {
logger.error(`Unknown error: ${error}`); logger.error(`Unknown error: ${error}`, error?.stack);
return; return;
} }
}; };
+143 -250
View File
@@ -58,10 +58,9 @@ export class BaseConfig implements VideoCodecSWConfig {
break; break;
} }
case TranscodeHWAccel.RKMPP: { case TranscodeHWAccel.RKMPP: {
handler = handler = config.accelDecode
config.accelDecode && hasMaliOpenCL ? new RkmppHwDecodeConfig(config, devices, hasMaliOpenCL)
? new RkmppHwDecodeConfig(config, devices) : new RkmppSwDecodeConfig(config, devices);
: new RkmppSwDecodeConfig(config, devices);
break; break;
} }
default: { default: {
@@ -149,7 +148,11 @@ export class BaseConfig implements VideoCodecSWConfig {
options.push(`scale=${this.getScaling(videoStream)}`); options.push(`scale=${this.getScaling(videoStream)}`);
} }
options.push(...this.getToneMapping(videoStream), 'format=yuv420p'); options.push(...this.getToneMapping(videoStream));
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push(`format=yuv420p`);
}
return options; return options;
} }
@@ -271,33 +274,20 @@ export class BaseConfig implements VideoCodecSWConfig {
getColors() { getColors() {
return { return {
primaries: '709', primaries: 'bt709',
transfer: '709', transfer: 'bt709',
matrix: '709', matrix: 'bt709',
}; };
} }
getNPL() {
if (this.config.npl <= 0) {
// since hable already outputs a darker image, we use a lower npl value for it
return this.config.tonemap === ToneMapping.HABLE ? 100 : 250;
} else {
return this.config.npl;
}
}
getToneMapping(videoStream: VideoStreamInfo) { getToneMapping(videoStream: VideoStreamInfo) {
if (!this.shouldToneMap(videoStream)) { if (!this.shouldToneMap(videoStream)) {
return []; return [];
} }
const colors = this.getColors(); const { primaries, transfer, matrix } = this.getColors();
const options = `tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=yuv420p`;
return [ return [options];
`zscale=t=linear:npl=${this.getNPL()}`,
`tonemap=${this.config.tonemap}:desat=0`,
`zscale=p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:range=pc`,
];
} }
getAudioCodec(): string { getAudioCodec(): string {
@@ -336,6 +326,32 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
this.devices = this.validateDevices(devices); this.devices = this.validateDevices(devices);
} }
getScalingFilter(videoStream: VideoStreamInfo, format: string) {
return `scale_${this.config.accel}=${this.getScaling(videoStream)}:format=${format}`;
}
getHwDecodeFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
const tonemapOptions = this.getToneMapping(videoStream);
if (this.shouldScale(videoStream) || tonemapOptions.length === 0) {
const format = tonemapOptions.length === 0 ? 'nv12' : 'p010';
options.push(this.getScalingFilter(videoStream, format));
} else if (tonemapOptions.length > 0) {
options.push('format=p010');
}
options.push(...tonemapOptions);
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
return this.config.accelDecode
? this.getHwDecodeFilterOptions(videoStream)
: [`hwupload_${this.config.accel}`, ...this.getHwDecodeFilterOptions(videoStream)];
}
getSupportedCodecs() { getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC]; return [VideoCodec.H264, VideoCodec.HEVC];
} }
@@ -379,6 +395,20 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
return `/dev/dri/${deviceName}`; return `/dev/dri/${deviceName}`;
} }
getInputThreadOptions() {
if (this.config.accelDecode) {
return [`-threads 1`];
}
return [];
}
getOutputThreadOptions() {
if (this.config.accelDecode) {
return [];
}
return super.getOutputThreadOptions();
}
} }
export class ThumbnailConfig extends BaseConfig { export class ThumbnailConfig extends BaseConfig {
@@ -395,19 +425,14 @@ export class ThumbnailConfig extends BaseConfig {
} }
getFilterOptions(videoStream: VideoStreamInfo): string[] { getFilterOptions(videoStream: VideoStreamInfo): string[] {
const options = [ return [
'fps=12:eof_action=pass:round=down', 'fps=12:eof_action=pass:round=down',
'thumbnail=12', 'thumbnail=12',
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`, String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
'trim=end_frame=2', 'trim=end_frame=2',
'reverse', 'reverse',
...super.getFilterOptions(videoStream),
]; ];
if (this.shouldScale(videoStream)) {
options.push(`scale=${this.getScaling(videoStream)}`);
}
options.push(...this.getToneMapping(videoStream), 'format=yuv420p');
return options;
} }
getPresetOptions() { getPresetOptions() {
@@ -423,19 +448,7 @@ export class ThumbnailConfig extends BaseConfig {
} }
getScaling(videoStream: VideoStreamInfo) { getScaling(videoStream: VideoStreamInfo) {
let options = super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int'; return super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc';
if (!this.shouldToneMap(videoStream)) {
options += ':out_color_matrix=bt601:out_range=pc';
}
return options;
}
getColors() {
return {
primaries: '709',
transfer: '601',
matrix: '470bg',
};
} }
} }
@@ -530,15 +543,11 @@ export class AV1Config extends BaseConfig {
} }
} }
export class NvencSwDecodeConfig extends BaseHWConfig { export class NvencConfig extends BaseHWConfig {
getSupportedCodecs() { getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1]; return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1];
} }
getBaseInputOptions() {
return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'];
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) { getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = [ const options = [
// below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding // below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
@@ -557,16 +566,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
return options; return options;
} }
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('format=nv12', 'hwupload_cuda');
if (this.shouldScale(videoStream)) {
options.push(`scale_cuda=${this.getScaling(videoStream)}`);
}
return options;
}
getPresetOptions() { getPresetOptions() {
let presetIndex = this.getPresetIndex(); let presetIndex = this.getPresetIndex();
if (presetIndex < 0) { if (presetIndex < 0) {
@@ -596,10 +595,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
} }
} }
getThreadOptions() {
return [];
}
getRefs() { getRefs() {
const bframes = this.getBFrames(); const bframes = this.getBFrames();
if (bframes > 0 && bframes < 3 && this.config.refs < 3) { if (bframes > 0 && bframes < 3 && this.config.refs < 3) {
@@ -607,68 +602,59 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
} }
return this.config.refs; return this.config.refs;
} }
}
export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
getBaseInputOptions() { getBaseInputOptions() {
if (!this.config.accelDecode) {
return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'];
}
return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()]; return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()];
} }
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
if (this.shouldScale(videoStream)) {
options.push(`scale_cuda=${this.getScaling(videoStream)}`);
}
options.push(...this.getToneMapping(videoStream));
if (options.length > 0) {
options[options.length - 1] += ':format=nv12';
}
return options;
}
getToneMapping(videoStream: VideoStreamInfo) { getToneMapping(videoStream: VideoStreamInfo) {
if (!this.shouldToneMap(videoStream)) { if (!this.shouldToneMap(videoStream)) {
return []; return [];
} }
const colors = this.getColors(); const { matrix, primaries, transfer } = this.getColors();
const tonemapOptions = [ const tonemapOptions = [
'desat=0', 'desat=0',
`matrix=${colors.matrix}`, `matrix=${matrix}`,
`primaries=${colors.primaries}`, `primaries=${primaries}`,
'range=pc', 'range=pc',
`tonemap=${this.config.tonemap}`, `tonemap=${this.config.tonemap}`,
`transfer=${colors.transfer}`, 'tonemap_mode=lum',
`transfer=${transfer}`,
'peak=100',
'format=nv12',
]; ];
return [`tonemap_cuda=${tonemapOptions.join(':')}`]; return [`tonemap_cuda=${tonemapOptions.join(':')}`];
} }
getInputThreadOptions() {
return [`-threads 1`];
}
getOutputThreadOptions() {
return [];
}
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
} }
export class QsvSwDecodeConfig extends BaseHWConfig { export class QsvConfig extends BaseHWConfig {
getBaseInputOptions() { getBaseInputOptions() {
if (this.devices.length === 0) { if (this.devices.length === 0) {
throw new Error('No QSV device found'); throw new Error('No QSV device found');
} }
let qsvString = '';
const hwDevice = this.getPreferredHardwareDevice(); const hwDevice = this.getPreferredHardwareDevice();
if (this.config.accelDecode) {
const options = [
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-noautorotate',
...this.getInputThreadOptions(),
];
if (hwDevice) {
options.push(`-qsv_device ${hwDevice}`);
}
return options;
}
let qsvString = '';
if (hwDevice) { if (hwDevice) {
qsvString = `,child_device=${hwDevice}`; qsvString = `,child_device=${hwDevice}`;
} }
@@ -685,15 +671,6 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
return options; return options;
} }
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('format=nv12', 'hwupload=extra_hw_frames=64');
if (this.shouldScale(videoStream)) {
options.push(`scale_qsv=${this.getScaling(videoStream)}:mode=hq`);
}
return options;
}
getPresetOptions() { getPresetOptions() {
let presetIndex = this.getPresetIndex(); let presetIndex = this.getPresetIndex();
if (presetIndex < 0) { if (presetIndex < 0) {
@@ -739,41 +716,9 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
getScaling(videoStream: VideoStreamInfo): string { getScaling(videoStream: VideoStreamInfo): string {
return super.getScaling(videoStream, 1); return super.getScaling(videoStream, 1);
} }
}
export class QsvHwDecodeConfig extends QsvSwDecodeConfig { getScalingFilter(videoStream: VideoStreamInfo, format: string) {
getBaseInputOptions() { return `scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq:format=${format}`;
if (this.devices.length === 0) {
throw new Error('No QSV device found');
}
const options = [
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-noautorotate',
...this.getInputThreadOptions(),
];
const hwDevice = this.getPreferredHardwareDevice();
if (hwDevice) {
options.push(`-qsv_device ${hwDevice}`);
}
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
if (this.shouldScale(videoStream) || !this.shouldToneMap(videoStream)) {
let scaling = `scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq`;
if (!this.shouldToneMap(videoStream)) {
scaling += ':format=nv12';
}
options.push(scaling);
}
options.push(...this.getToneMapping(videoStream));
return options;
} }
getToneMapping(videoStream: VideoStreamInfo): string[] { getToneMapping(videoStream: VideoStreamInfo): string[] {
@@ -781,15 +726,17 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
return []; return [];
} }
const colors = this.getColors(); const { matrix, primaries, transfer } = this.getColors();
const tonemapOptions = [ const tonemapOptions = [
'desat=0', 'desat=0',
'format=nv12', 'format=nv12',
`matrix=${colors.matrix}`, `matrix=${matrix}`,
`primaries=${colors.primaries}`, `primaries=${primaries}`,
`transfer=${transfer}`,
'range=pc', 'range=pc',
`tonemap=${this.config.tonemap}`, `tonemap=${this.config.tonemap}`,
`transfer=${colors.transfer}`, 'tonemap_mode=lum',
'peak=100',
]; ];
return [ return [
@@ -798,27 +745,27 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
'hwmap=derive_device=qsv:reverse=1,format=qsv', 'hwmap=derive_device=qsv:reverse=1,format=qsv',
]; ];
} }
getInputThreadOptions() {
return [`-threads 1`];
}
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
} }
export class VaapiSwDecodeConfig extends BaseHWConfig { export class VaapiConfig extends BaseHWConfig {
getBaseInputOptions() { getBaseInputOptions() {
if (this.devices.length === 0) { if (this.devices.length === 0) {
throw new Error('No VAAPI device found'); throw new Error('No VAAPI device found');
} }
let hwDevice = this.getPreferredHardwareDevice(); let hwDevice = this.getPreferredHardwareDevice();
if (this.config.accelDecode) {
const options = [
'-hwaccel vaapi',
'-hwaccel_output_format vaapi',
'-noautorotate',
...this.getInputThreadOptions(),
];
if (hwDevice) {
options.push(`-hwaccel_device ${hwDevice}`);
}
}
if (!hwDevice) { if (!hwDevice) {
hwDevice = `/dev/dri/${this.devices[0]}`; hwDevice = `/dev/dri/${this.devices[0]}`;
} }
@@ -826,16 +773,6 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
return [`-init_hw_device vaapi=accel:${hwDevice}`, '-filter_hw_device accel']; return [`-init_hw_device vaapi=accel:${hwDevice}`, '-filter_hw_device accel'];
} }
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('format=nv12', 'hwupload');
if (this.shouldScale(videoStream)) {
options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`);
}
return options;
}
getPresetOptions() { getPresetOptions() {
let presetIndex = this.getPresetIndex(); let presetIndex = this.getPresetIndex();
if (presetIndex < 0) { if (presetIndex < 0) {
@@ -877,40 +814,9 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
useCQP() { useCQP() {
return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9; return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9;
} }
}
export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig { getScalingFilter(videoStream: VideoStreamInfo, format: string) {
getBaseInputOptions() { return `scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc:format=${format}`;
if (this.devices.length === 0) {
throw new Error('No VAAPI device found');
}
const options = [
'-hwaccel vaapi',
'-hwaccel_output_format vaapi',
'-noautorotate',
...this.getInputThreadOptions(),
];
const hwDevice = this.getPreferredHardwareDevice();
if (hwDevice) {
options.push(`-hwaccel_device ${hwDevice}`);
}
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
if (this.shouldScale(videoStream) || !this.shouldToneMap(videoStream)) {
let scaling = `scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`;
if (!this.shouldToneMap(videoStream)) {
scaling += ':format=nv12';
}
options.push(scaling);
}
options.push(...this.getToneMapping(videoStream));
return options;
} }
getToneMapping(videoStream: VideoStreamInfo): string[] { getToneMapping(videoStream: VideoStreamInfo): string[] {
@@ -918,15 +824,17 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
return []; return [];
} }
const colors = this.getColors(); const { matrix, primaries, transfer } = this.getColors();
const tonemapOptions = [ const tonemapOptions = [
'desat=0', 'desat=0',
'format=nv12', 'format=nv12',
`matrix=${colors.matrix}`, `matrix=${matrix}`,
`primaries=${colors.primaries}`, `primaries=${primaries}`,
`transfer=${transfer}`,
'range=pc', 'range=pc',
`tonemap=${this.config.tonemap}`, `tonemap=${this.config.tonemap}`,
`transfer=${colors.transfer}`, 'tonemap_mode=lum',
'peak=100',
]; ];
return [ return [
@@ -935,37 +843,33 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
'hwmap=derive_device=vaapi:reverse=1,format=vaapi', 'hwmap=derive_device=vaapi:reverse=1,format=vaapi',
]; ];
} }
getInputThreadOptions() {
return [`-threads 1`];
}
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
} }
export class RkmppSwDecodeConfig extends BaseHWConfig { export class RkmppConfig extends BaseHWConfig {
protected hasMaliOpenCL: boolean;
constructor( constructor(
protected config: SystemConfigFFmpegDto, protected config: SystemConfigFFmpegDto,
devices: string[] = [], devices: string[] = [],
hasMaliOpenCL = false,
) { ) {
super(config, devices); super(config, devices);
this.hasMaliOpenCL = hasMaliOpenCL;
} }
eligibleForTwoPass(): boolean { eligibleForTwoPass(): boolean {
return false; return false;
} }
getBaseInputOptions(): string[] { getBaseInputOptions() {
if (this.devices.length === 0) { if (this.devices.length === 0) {
throw new Error('No RKMPP device found'); throw new Error('No RKMPP device found');
} }
return [];
if (this.config.accelDecode) {
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
}
return ['-init_hw_device rkmpp=hw', '-filter_hw_device hw'];
} }
getPresetOptions() { getPresetOptions() {
@@ -998,41 +902,30 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
return [VideoCodec.H264, VideoCodec.HEVC]; return [VideoCodec.H264, VideoCodec.HEVC];
} }
getVideoCodec(): string {
return `${this.config.targetVideoCodec}_rkmpp`;
}
}
export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw new Error('No RKMPP device found');
}
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
}
getFilterOptions(videoStream: VideoStreamInfo) { getFilterOptions(videoStream: VideoStreamInfo) {
if (this.shouldToneMap(videoStream)) { if (this.shouldToneMap(videoStream)) {
const colors = this.getColors(); const { primaries, transfer, matrix } = this.getColors();
if (this.hasMaliOpenCL) {
return [
// use RKMPP for scaling, OpenCL for tone mapping
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,
'hwmap=derive_device=opencl:mode=read',
`tonemap_opencl=format=nv12:r=pc:p=${primaries}:t=${transfer}:m=${matrix}:tonemap=${this.config.tonemap}:desat=0:tonemap_mode=lum:peak=100`,
'hwmap=derive_device=rkmpp:mode=write:reverse=1',
'format=drm_prime',
];
}
return [ return [
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`, // use RKMPP for scaling, CPU for tone mapping (only works on RK3588, which supports 10-bit output)
'hwmap=derive_device=opencl:mode=read', `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,
`tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`, 'hwdownload',
'hwmap=derive_device=rkmpp:mode=write:reverse=1', 'format=p010',
'format=drm_prime', `tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=yuv420p`,
'hwupload',
]; ];
} else if (this.shouldScale(videoStream)) { } else if (this.shouldScale(videoStream)) {
return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1:async_depth=4`];
} }
return []; return [];
} }
getColors() {
return {
primaries: 'bt709',
transfer: 'bt709',
matrix: 'bt709',
};
}
} }
+26
View File
@@ -15,6 +15,32 @@ import { CLIP_MODEL_INFO, serverVersion } from 'src/constants';
import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum'; import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
export class ImmichStartupError extends Error {}
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
export const getKeyByValue = (object: Record<string, unknown>, value: unknown) =>
Object.keys(object).find((key) => object[key] === value);
export const getMethodNames = (instance: any) => {
const ctx = Object.getPrototypeOf(instance);
const methods: string[] = [];
for (const property of Object.getOwnPropertyNames(ctx)) {
const descriptor = Object.getOwnPropertyDescriptor(ctx, property);
if (!descriptor || descriptor.get || descriptor.set) {
continue;
}
const handler = instance[property];
if (typeof handler !== 'function') {
continue;
}
methods.push(property);
}
return methods;
};
export const getExternalDomain = (server: SystemConfig['server'], port: number) => export const getExternalDomain = (server: SystemConfig['server'], port: number) =>
server.externalDomain || `http://localhost:${port}`; server.externalDomain || `http://localhost:${port}`;
+1 -2
View File
@@ -13,8 +13,7 @@ import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { ApiService } from 'src/services/api.service'; import { ApiService } from 'src/services/api.service';
import { isStartUpError } from 'src/services/storage.service'; import { isStartUpError, useSwagger } from 'src/utils/misc';
import { useSwagger } from 'src/utils/misc';
async function bootstrap() { async function bootstrap() {
process.title = 'immich-api'; process.title = 'immich-api';
+1 -1
View File
@@ -7,7 +7,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter'; import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository'; import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
import { isStartUpError } from 'src/services/storage.service'; import { isStartUpError } from 'src/utils/misc';
export async function bootstrap() { export async function bootstrap() {
const { telemetry } = new ConfigRepository().getEnv(); const { telemetry } = new ConfigRepository().getEnv();
+25
View File
@@ -17,6 +17,7 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
rotation: 0, rotation: 0,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
]; ];
@@ -43,6 +44,7 @@ export const probeStub = {
rotation: 0, rotation: 0,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
{ {
index: 1, index: 1,
@@ -53,6 +55,7 @@ export const probeStub = {
rotation: 0, rotation: 0,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
], ],
}), }),
@@ -68,6 +71,7 @@ export const probeStub = {
rotation: 0, rotation: 0,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
], ],
}), }),
@@ -83,6 +87,7 @@ export const probeStub = {
rotation: 0, rotation: 0,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
], ],
}), }),
@@ -102,6 +107,23 @@ export const probeStub = {
rotation: 0, rotation: 0,
isHDR: true, isHDR: true,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p10le',
},
],
}),
videoStream10Bit: Object.freeze<VideoInfo>({
...probeStubDefault,
videoStreams: [
{
index: 0,
height: 480,
width: 480,
codecName: 'h264',
frameCount: 100,
rotation: 0,
isHDR: false,
bitrate: 0,
pixelFormat: 'yuv420p10le',
}, },
], ],
}), }),
@@ -117,6 +139,7 @@ export const probeStub = {
rotation: 90, rotation: 90,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
], ],
}), }),
@@ -132,6 +155,7 @@ export const probeStub = {
rotation: 0, rotation: 0,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
], ],
}), }),
@@ -147,6 +171,7 @@ export const probeStub = {
rotation: 0, rotation: 0,
isHDR: false, isHDR: false,
bitrate: 0, bitrate: 0,
pixelFormat: 'yuv420p',
}, },
], ],
}), }),
@@ -3,7 +3,9 @@ import { Mocked, vitest } from 'vitest';
export const newJobRepositoryMock = (): Mocked<IJobRepository> => { export const newJobRepositoryMock = (): Mocked<IJobRepository> => {
return { return {
addHandler: vitest.fn(), setup: vitest.fn(),
startWorkers: vitest.fn(),
run: vitest.fn(),
addCronJob: vitest.fn(), addCronJob: vitest.fn(),
updateCronJob: vitest.fn(), updateCronJob: vitest.fn(),
setConcurrency: vitest.fn(), setConcurrency: vitest.fn(),
+9 -4
View File
@@ -1,6 +1,7 @@
import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
import { ImmichWorker } from 'src/enum';
import { IMetadataRepository } from 'src/interfaces/metadata.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
@@ -44,8 +45,9 @@ import { newViewRepositoryMock } from 'test/repositories/view.repository.mock';
import { Readable } from 'typeorm/platform/PlatformTools'; import { Readable } from 'typeorm/platform/PlatformTools';
import { Mocked, vitest } from 'vitest'; import { Mocked, vitest } from 'vitest';
type RepositoryOverrides = { type Overrides = {
metadataRepository: IMetadataRepository; worker?: ImmichWorker;
metadataRepository?: IMetadataRepository;
}; };
type BaseServiceArgs = ConstructorParameters<typeof BaseService>; type BaseServiceArgs = ConstructorParameters<typeof BaseService>;
type Constructor<Type, Args extends Array<any>> = { type Constructor<Type, Args extends Array<any>> = {
@@ -54,9 +56,11 @@ type Constructor<Type, Args extends Array<any>> = {
export const newTestService = <T extends BaseService>( export const newTestService = <T extends BaseService>(
Service: Constructor<T, BaseServiceArgs>, Service: Constructor<T, BaseServiceArgs>,
overrides?: RepositoryOverrides, overrides?: Overrides,
) => { ) => {
const { metadataRepository } = overrides || {}; const { metadataRepository, worker: workerOverride } = overrides || {};
const worker = workerOverride || ImmichWorker.API;
const accessMock = newAccessRepositoryMock(); const accessMock = newAccessRepositoryMock();
const loggerMock = newLoggerRepositoryMock(); const loggerMock = newLoggerRepositoryMock();
@@ -98,6 +102,7 @@ export const newTestService = <T extends BaseService>(
const viewMock = newViewRepositoryMock(); const viewMock = newViewRepositoryMock();
const sut = new Service( const sut = new Service(
worker,
loggerMock, loggerMock,
accessMock, accessMock,
activityMock, activityMock,
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3
RUN apk add --no-cache tini RUN apk add --no-cache tini
USER node USER node
+121 -129
View File
@@ -23,9 +23,9 @@
"justified-layout": "^4.1.0", "justified-layout": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"socket.io-client": "^4.7.4", "socket.io-client": "~4.7.5",
"svelte-gestures": "^5.0.4", "svelte-gestures": "^5.0.4",
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.1",
"svelte-local-storage-store": "^0.6.4", "svelte-local-storage-store": "^0.6.4",
"svelte-maplibre": "^0.9.13", "svelte-maplibre": "^0.9.13",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"
@@ -35,12 +35,12 @@
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@faker-js/faker": "^9.0.0", "@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/enhanced-img": "^0.3.0",
"@sveltejs/kit": "^2.5.18", "@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.0", "@testing-library/svelte": "^5.2.4",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/dom-to-image": "^2.6.7", "@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4", "@types/justified-layout": "^4.1.4",
@@ -63,7 +63,7 @@
"prettier-plugin-sort-json": "^4.0.0", "prettier-plugin-sort-json": "^4.0.0",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"svelte": "^4.2.19", "svelte": "^5.1.5",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
@@ -80,7 +80,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.8.0", "@types/node": "^22.8.1",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },
@@ -1994,43 +1994,42 @@
} }
}, },
"node_modules/@sveltejs/vite-plugin-svelte": { "node_modules/@sveltejs/vite-plugin-svelte": {
"version": "3.1.2", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.0.tgz",
"integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", "integrity": "sha512-kpVJwF+gNiMEsoHaw+FJL76IYiwBikkxYU83+BpqQLdVMff19KeRKLd2wisS8niNBMJ2omv5gG+iGDDwd8jzag==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
"debug": "^4.3.4", "debug": "^4.3.7",
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"magic-string": "^0.30.10", "magic-string": "^0.30.12",
"svelte-hmr": "^0.16.0", "vitefu": "^1.0.3"
"vitefu": "^0.2.5"
}, },
"engines": { "engines": {
"node": "^18.0.0 || >=20" "node": "^18.0.0 || ^20.0.0 || >=22"
}, },
"peerDependencies": { "peerDependencies": {
"svelte": "^4.0.0 || ^5.0.0-next.0", "svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0" "vite": "^5.0.0"
} }
}, },
"node_modules/@sveltejs/vite-plugin-svelte-inspector": { "node_modules/@sveltejs/vite-plugin-svelte-inspector": {
"version": "2.1.0", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
"integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.3.4" "debug": "^4.3.7"
}, },
"engines": { "engines": {
"node": "^18.0.0 || >=20" "node": "^18.0.0 || ^20.0.0 || >=22"
}, },
"peerDependencies": { "peerDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0", "svelte": "^5.0.0-next.96 || ^5.0.0",
"vite": "^5.0.0" "vite": "^5.0.0"
} }
}, },
@@ -2230,7 +2229,6 @@
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.4.tgz", "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.4.tgz",
"integrity": "sha512-EFdy73+lULQgMJ1WolAymrxWWrPv9DWyDuDFKKlUip2PA/EXuHptzfYOKWljccFWDKhhGOu3dqNmoc2f/h/Ecg==", "integrity": "sha512-EFdy73+lULQgMJ1WolAymrxWWrPv9DWyDuDFKKlUip2PA/EXuHptzfYOKWljccFWDKhhGOu3dqNmoc2f/h/Ecg==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"@testing-library/dom": "^10.0.0" "@testing-library/dom": "^10.0.0"
}, },
@@ -2797,6 +2795,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/acorn-typescript": {
"version": "1.4.13",
"resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz",
"integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==",
"license": "MIT",
"peerDependencies": {
"acorn": ">=8.9.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
@@ -2883,6 +2890,7 @@
"version": "5.3.0", "version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"dependencies": { "dependencies": {
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
@@ -2960,11 +2968,12 @@
} }
}, },
"node_modules/axobject-query": { "node_modules/axobject-query": {
"version": "4.0.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dependencies": { "license": "Apache-2.0",
"dequal": "^2.0.3" "engines": {
"node": ">= 0.4"
} }
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
@@ -3271,18 +3280,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/code-red": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
"integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"@types/estree": "^1.0.1",
"acorn": "^8.10.0",
"estree-walker": "^3.0.3",
"periscopic": "^3.1.0"
}
},
"node_modules/color": { "node_modules/color": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
@@ -3404,18 +3401,6 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-tree": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz",
"integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==",
"dependencies": {
"mdn-data": "2.0.30",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css.escape": { "node_modules/css.escape": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -3505,11 +3490,12 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.6", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">=6.0" "node": ">=6.0"
@@ -3656,22 +3642,23 @@
"dev": true "dev": true
}, },
"node_modules/engine.io-client": { "node_modules/engine.io-client": {
"version": "6.6.1", "version": "6.5.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
"integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==", "integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@socket.io/component-emitter": "~3.1.0", "@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1", "debug": "~4.3.1",
"engine.io-parser": "~5.2.1", "engine.io-parser": "~5.2.1",
"ws": "~8.17.1", "ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1" "xmlhttprequest-ssl": "~2.0.0"
} }
}, },
"node_modules/engine.io-parser": { "node_modules/engine.io-parser": {
"version": "5.2.1", "version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz", "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==", "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
} }
@@ -4151,8 +4138,7 @@
"node_modules/esm-env": { "node_modules/esm-env": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz",
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA=="
"dev": true
}, },
"node_modules/esniff": { "node_modules/esniff": {
"version": "2.0.1", "version": "2.0.1",
@@ -4197,6 +4183,16 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/esrap": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-1.2.2.tgz",
"integrity": "sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15",
"@types/estree": "^1.0.1"
}
},
"node_modules/esrecurse": { "node_modules/esrecurse": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -4222,6 +4218,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"dependencies": { "dependencies": {
"@types/estree": "^1.0.0" "@types/estree": "^1.0.0"
} }
@@ -4962,6 +4959,7 @@
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
"integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
"license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "*" "@types/estree": "*"
} }
@@ -5329,9 +5327,9 @@
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.11", "version": "0.30.12",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
"integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
@@ -5475,11 +5473,6 @@
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
} }
}, },
"node_modules/mdn-data": {
"version": "2.0.30",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz",
"integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA=="
},
"node_modules/memoizee": { "node_modules/memoizee": {
"version": "0.4.17", "version": "0.4.17",
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz",
@@ -5601,9 +5594,10 @@
} }
}, },
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
}, },
"node_modules/murmurhash-js": { "node_modules/murmurhash-js": {
"version": "1.0.0", "version": "1.0.0",
@@ -5943,16 +5937,6 @@
"pbf": "bin/pbf" "pbf": "bin/pbf"
} }
}, },
"node_modules/periscopic": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz",
"integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^3.0.0",
"is-reference": "^3.0.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
@@ -6860,14 +6844,14 @@
} }
}, },
"node_modules/socket.io-client": { "node_modules/socket.io-client": {
"version": "4.8.1", "version": "4.7.5",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", "integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@socket.io/component-emitter": "~3.1.0", "@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2", "debug": "~4.3.2",
"engine.io-client": "~6.6.1", "engine.io-client": "~6.5.2",
"socket.io-parser": "~4.2.4" "socket.io-parser": "~4.2.4"
}, },
"engines": { "engines": {
@@ -6930,6 +6914,7 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -7176,28 +7161,27 @@
} }
}, },
"node_modules/svelte": { "node_modules/svelte": {
"version": "4.2.19", "version": "5.1.5",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.1.5.tgz",
"integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "integrity": "sha512-AyYondx6wS0g8mmBMfwJVnOYYBswjBv6L4bc99awfbET2KozWvVwxe8NSN7fhx7Pgr7pOfOXIv7K8+Impc0OoQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.1", "@ampproject/remapping": "^2.3.0",
"@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/sourcemap-codec": "^1.5.0",
"@jridgewell/trace-mapping": "^0.3.18", "@types/estree": "^1.0.5",
"@types/estree": "^1.0.1", "acorn": "^8.12.1",
"acorn": "^8.9.0", "acorn-typescript": "^1.4.13",
"aria-query": "^5.3.0", "aria-query": "^5.3.1",
"axobject-query": "^4.0.0", "axobject-query": "^4.1.0",
"code-red": "^1.0.3", "esm-env": "^1.0.0",
"css-tree": "^2.3.1", "esrap": "^1.2.2",
"estree-walker": "^3.0.3", "is-reference": "^3.0.2",
"is-reference": "^3.0.1",
"locate-character": "^3.0.0", "locate-character": "^3.0.0",
"magic-string": "^0.30.4", "magic-string": "^0.30.11",
"periscopic": "^3.1.0" "zimmerframe": "^1.1.2"
}, },
"engines": { "engines": {
"node": ">=16" "node": ">=18"
} }
}, },
"node_modules/svelte-check": { "node_modules/svelte-check": {
@@ -7318,18 +7302,6 @@
"integrity": "sha512-kElJnoZrQtlkXE0O/RcKioz9NP0Sxx05j31ohyosNkydo6NOEsZB85mhoaCxOQNjxN+QPumYWfmIUsznYFjihA==", "integrity": "sha512-kElJnoZrQtlkXE0O/RcKioz9NP0Sxx05j31ohyosNkydo6NOEsZB85mhoaCxOQNjxN+QPumYWfmIUsznYFjihA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/svelte-hmr": {
"version": "0.16.0",
"resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz",
"integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==",
"dev": true,
"engines": {
"node": "^12.20 || ^14.13.1 || >= 16"
},
"peerDependencies": {
"svelte": "^3.19.0 || ^4.0.0"
}
},
"node_modules/svelte-i18n": { "node_modules/svelte-i18n": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/svelte-i18n/-/svelte-i18n-4.0.1.tgz", "resolved": "https://registry.npmjs.org/svelte-i18n/-/svelte-i18n-4.0.1.tgz",
@@ -7821,6 +7793,15 @@
"svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1" "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0-next.1"
} }
}, },
"node_modules/svelte/node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
@@ -8420,12 +8401,17 @@
} }
}, },
"node_modules/vitefu": { "node_modules/vitefu": {
"version": "0.2.5", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.3.tgz",
"integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", "integrity": "sha512-iKKfOMBHob2WxEJbqbJjHAkmYgvFDPhuqrO82om83S8RLk+17FtyMBfcyeH8GqD0ihShtkMW/zzJgiA51hCNCQ==",
"dev": true, "dev": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*"
],
"peerDependencies": { "peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0" "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0-beta.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"vite": { "vite": {
@@ -8757,9 +8743,9 @@
"peer": true "peer": true
}, },
"node_modules/xmlhttprequest-ssl": { "node_modules/xmlhttprequest-ssl": {
"version": "2.1.1", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
"integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==", "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
@@ -8820,6 +8806,12 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"license": "MIT"
} }
} }
} }
+8 -8
View File
@@ -8,7 +8,7 @@
"build:stats": "BUILD_STATS=true vite build", "build:stats": "BUILD_STATS=true vite build",
"package": "svelte-kit package", "package": "svelte-kit package",
"preview": "vite preview", "preview": "vite preview",
"check:svelte": "svelte-check --no-tsconfig --fail-on-warnings", "check:svelte": "svelte-check --no-tsconfig --fail-on-warnings --compiler-warnings 'reactive_declaration_non_reactive_property:ignore'",
"check:typescript": "tsc --noEmit", "check:typescript": "tsc --noEmit",
"check:watch": "npm run check:svelte -- --watch", "check:watch": "npm run check:svelte -- --watch",
"check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript", "check:code": "npm run format && npm run lint && npm run check:svelte && npm run check:typescript",
@@ -27,12 +27,12 @@
"@eslint/js": "^9.8.0", "@eslint/js": "^9.8.0",
"@faker-js/faker": "^9.0.0", "@faker-js/faker": "^9.0.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.1", "@sveltejs/adapter-static": "^3.0.5",
"@sveltejs/enhanced-img": "^0.3.0", "@sveltejs/enhanced-img": "^0.3.0",
"@sveltejs/kit": "^2.5.18", "@sveltejs/kit": "^2.7.2",
"@sveltejs/vite-plugin-svelte": "^3.1.2", "@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.0", "@testing-library/svelte": "^5.2.4",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
"@types/dom-to-image": "^2.6.7", "@types/dom-to-image": "^2.6.7",
"@types/justified-layout": "^4.1.4", "@types/justified-layout": "^4.1.4",
@@ -55,7 +55,7 @@
"prettier-plugin-sort-json": "^4.0.0", "prettier-plugin-sort-json": "^4.0.0",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.6",
"rollup-plugin-visualizer": "^5.12.0", "rollup-plugin-visualizer": "^5.12.0",
"svelte": "^4.2.19", "svelte": "^5.1.5",
"svelte-check": "^4.0.0", "svelte-check": "^4.0.0",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"tslib": "^2.6.2", "tslib": "^2.6.2",
@@ -79,9 +79,9 @@
"justified-layout": "^4.1.0", "justified-layout": "^4.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.4.4", "luxon": "^3.4.4",
"socket.io-client": "^4.7.4", "socket.io-client": "~4.7.5",
"svelte-gestures": "^5.0.4", "svelte-gestures": "^5.0.4",
"svelte-i18n": "^4.0.0", "svelte-i18n": "^4.0.1",
"svelte-local-storage-store": "^0.6.4", "svelte-local-storage-store": "^0.6.4",
"svelte-maplibre": "^0.9.13", "svelte-maplibre": "^0.9.13",
"thumbhash": "^0.1.1" "thumbhash": "^0.1.1"
+17
View File
@@ -0,0 +1,17 @@
import { tick } from 'svelte';
import { vi } from 'vitest';
export const getAnimateMock = () =>
vi.fn().mockImplementation(() => {
let onfinish: (() => void) | null = null;
void tick().then(() => onfinish?.());
return {
set onfinish(fn: () => void) {
onfinish = fn;
},
cancel() {
onfinish = null;
},
};
});
@@ -14,14 +14,14 @@
mdiPlay, mdiPlay,
mdiSelectionSearch, mdiSelectionSearch,
} from '@mdi/js'; } from '@mdi/js';
import { type ComponentType } from 'svelte'; import { type Component } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import JobTileButton from './job-tile-button.svelte'; import JobTileButton from './job-tile-button.svelte';
import JobTileStatus from './job-tile-status.svelte'; import JobTileStatus from './job-tile-status.svelte';
export let title: string; export let title: string;
export let subtitle: string | undefined; export let subtitle: string | undefined;
export let description: ComponentType | undefined; export let description: Component | undefined;
export let jobCounts: JobCountsDto; export let jobCounts: JobCountsDto;
export let queueStatus: QueueStatusDto; export let queueStatus: QueueStatusDto;
export let icon: string; export let icon: string;
@@ -19,7 +19,7 @@
mdiTagFaces, mdiTagFaces,
mdiVideo, mdiVideo,
} from '@mdi/js'; } from '@mdi/js';
import type { ComponentType } from 'svelte'; import type { Component } from 'svelte';
import JobTile from './job-tile.svelte'; import JobTile from './job-tile.svelte';
import StorageMigrationDescription from './storage-migration-description.svelte'; import StorageMigrationDescription from './storage-migration-description.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { dialogController } from '$lib/components/shared-components/dialog/dialog';
@@ -30,7 +30,7 @@
interface JobDetails { interface JobDetails {
title: string; title: string;
subtitle?: string; subtitle?: string;
description?: ComponentType; description?: Component;
allText?: string; allText?: string;
refreshText?: string; refreshText?: string;
missingText: string; missingText: string;
@@ -56,6 +56,7 @@
await handleCommand(jobId, dto); await handleCommand(jobId, dto);
}; };
// svelte-ignore reactive_declaration_non_reactive_property
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{ $: jobDetails = <Partial<Record<JobName, JobDetails>>>{
[JobName.ThumbnailGeneration]: { [JobName.ThumbnailGeneration]: {
icon: mdiFileJpgBox, icon: mdiFileJpgBox,
@@ -343,15 +343,6 @@
subtitle={$t('admin.transcoding_advanced_options_description')} subtitle={$t('admin.transcoding_advanced_options_description')}
> >
<div class="ml-4 mt-4 flex flex-col gap-4"> <div class="ml-4 mt-4 flex flex-col gap-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_tone_mapping_npl')}
desc={$t('admin.transcoding_tone_mapping_npl_description')}
bind:value={config.ffmpeg.npl}
isEdited={config.ffmpeg.npl !== savedConfig.ffmpeg.npl}
{disabled}
/>
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.NUMBER} inputType={SettingInputFieldType.NUMBER}
label={$t('admin.transcoding_max_b_frames')} label={$t('admin.transcoding_max_b_frames')}
@@ -87,6 +87,7 @@
} }
} }
// svelte-ignore reactive_declaration_non_reactive_property
$: { $: {
if (selectedGroupOption.id === AlbumGroupBy.None) { if (selectedGroupOption.id === AlbumGroupBy.None) {
groupIcon = mdiFolderRemoveOutline; groupIcon = mdiFolderRemoveOutline;
@@ -96,8 +97,10 @@
} }
} }
// svelte-ignore reactive_declaration_non_reactive_property
$: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin; $: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin;
// svelte-ignore reactive_declaration_non_reactive_property
$: albumFilterNames = ((): Record<AlbumFilter, string> => { $: albumFilterNames = ((): Record<AlbumFilter, string> => {
return { return {
[AlbumFilter.All]: $t('all'), [AlbumFilter.All]: $t('all'),
@@ -106,6 +109,7 @@
}; };
})(); })();
// svelte-ignore reactive_declaration_non_reactive_property
$: albumSortByNames = ((): Record<AlbumSortBy, string> => { $: albumSortByNames = ((): Record<AlbumSortBy, string> => {
return { return {
[AlbumSortBy.Title]: $t('sort_title'), [AlbumSortBy.Title]: $t('sort_title'),
@@ -117,6 +121,7 @@
}; };
})(); })();
// svelte-ignore reactive_declaration_non_reactive_property
$: albumGroupByNames = ((): Record<AlbumGroupBy, string> => { $: albumGroupByNames = ((): Record<AlbumGroupBy, string> => {
return { return {
[AlbumGroupBy.None]: $t('group_no'), [AlbumGroupBy.None]: $t('group_no'),
@@ -135,6 +135,7 @@
let isOpen = false; let isOpen = false;
// Step 1: Filter between Owned and Shared albums, or both. // Step 1: Filter between Owned and Shared albums, or both.
// svelte-ignore reactive_declaration_non_reactive_property
$: { $: {
switch (userSettings.filter) { switch (userSettings.filter) {
case AlbumFilter.Owned: { case AlbumFilter.Owned: {
@@ -13,7 +13,7 @@
$albumViewSettings.sortOrder = option.defaultOrder; $albumViewSettings.sortOrder = option.defaultOrder;
} }
}; };
// svelte-ignore reactive_declaration_non_reactive_property
$: albumSortByNames = ((): Record<AlbumSortBy, string> => { $: albumSortByNames = ((): Record<AlbumSortBy, string> => {
return { return {
[AlbumSortBy.Title]: $t('sort_title'), [AlbumSortBy.Title]: $t('sort_title'),
@@ -293,7 +293,7 @@
class="h-[18px] {disabled class="h-[18px] {disabled
? 'cursor-not-allowed' ? 'cursor-not-allowed'
: ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200" : ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
/> ></textarea>
</div> </div>
{#if isSendingMessage} {#if isSendingMessage}
<div class="flex items-end place-items-center pb-2 ml-0"> <div class="flex items-end place-items-center pb-2 ml-0">
@@ -15,13 +15,31 @@ describe('AssetViewerNavBar component', () => {
showShareButton: false, showShareButton: false,
onZoomImage: () => {}, onZoomImage: () => {},
onCopyImage: () => {}, onCopyImage: () => {},
onAction: () => {},
onRunJob: () => {},
onPlaySlideshow: () => {},
onShowDetail: () => {},
onClose: () => {},
}; };
beforeAll(() => {
Element.prototype.animate = vi.fn().mockImplementation(() => ({
cancel: () => {},
}));
vi.stubGlobal(
'ResizeObserver',
vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() })),
);
});
afterEach(() => { afterEach(() => {
vi.resetAllMocks();
resetSavedUser(); resetSavedUser();
}); });
afterAll(() => {
vi.restoreAllMocks();
});
it('shows back button', () => { it('shows back button', () => {
const asset = assetFactory.build({ isTrashed: false }); const asset = assetFactory.build({ isTrashed: false });
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps }); const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
@@ -61,6 +61,7 @@
const sharedLink = getSharedLink(); const sharedLink = getSharedLink();
$: isOwner = $user && asset.ownerId === $user?.id; $: isOwner = $user && asset.ownerId === $user?.id;
// svelte-ignore reactive_declaration_non_reactive_property
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; $: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
// $: showEditorButton = // $: showEditorButton =
// isOwner && // isOwner &&
@@ -598,7 +598,7 @@
{#if stackedAsset.id == asset.id} {#if stackedAsset.id == asset.id}
<div class="w-full flex place-items-center place-content-center"> <div class="w-full flex place-items-center place-content-center">
<div class="w-2 h-2 bg-white rounded-full flex mt-[2px]" /> <div class="w-2 h-2 bg-white rounded-full flex mt-[2px]"></div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -154,7 +154,7 @@
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700"> <div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
<p> <p>
{#if $user?.isAdmin} {#if $user?.isAdmin}
<p>{$t('admin.asset_offline_description')}</p> {$t('admin.asset_offline_description')}
{:else} {:else}
{$t('asset_offline_description')} {$t('asset_offline_description')}
{/if} {/if}
@@ -345,43 +345,45 @@
</Portal> </Portal>
{/if} {/if}
{#if asset.exifInfo?.fileSizeInByte} <div class="flex gap-4 py-4">
<div class="flex gap-4 py-4"> <div><Icon path={mdiImageOutline} size="24" /></div>
<div><Icon path={mdiImageOutline} size="24" /></div>
<div> <div>
<p class="break-all flex place-items-center gap-2"> <p class="break-all flex place-items-center gap-2">
{asset.originalFileName} {asset.originalFileName}
{#if isOwner} {#if isOwner}
<CircleIconButton <CircleIconButton
icon={mdiInformationOutline} icon={mdiInformationOutline}
title={$t('show_file_location')} title={$t('show_file_location')}
size="16" size="16"
padding="2" padding="2"
on:click={toggleAssetPath} on:click={toggleAssetPath}
/> />
{/if} {/if}
</p>
{#if showAssetPath}
<p class="text-xs opacity-50 break-all pb-2" transition:slide={{ duration: 250 }}>
{asset.originalPath}
</p> </p>
{/if}
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
<div class="flex gap-2 text-sm"> <div class="flex gap-2 text-sm">
{#if asset.exifInfo.exifImageHeight && asset.exifInfo.exifImageWidth} {#if asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth}
{#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} {#if getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)}
<p> <p>
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP {getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
</p> </p>
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if} {/if}
{@const { width, height } = getDimensions(asset.exifInfo)}
<p>{width} x {height}</p>
{/if} {/if}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p> {#if asset.exifInfo?.fileSizeInByte}
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
{/if}
</div> </div>
{#if showAssetPath} {/if}
<p class="text-xs opacity-50 break-all" transition:slide={{ duration: 250 }}>
{asset.originalPath}
</p>
{/if}
</div>
</div> </div>
{/if} </div>
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber} {#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber}
<div class="flex gap-4 py-4"> <div class="flex gap-4 py-4">
@@ -32,7 +32,7 @@
</div> </div>
<div class="flex place-items-center gap-2"> <div class="flex place-items-center gap-2">
<div class="h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700"> <div class="h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`} /> <div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`}></div>
</div> </div>
<p class="min-w-[4em] whitespace-nowrap text-right"> <p class="min-w-[4em] whitespace-nowrap text-right">
<span class="text-immich-primary"> <span class="text-immich-primary">
@@ -23,6 +23,7 @@
export let onClose: () => void; export let onClose: () => void;
let selectedType: string = editTypes[0].name; let selectedType: string = editTypes[0].name;
// svelte-ignore reactive_declaration_non_reactive_property
$: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0]; $: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0];
setTimeout(() => { setTimeout(() => {
@@ -55,4 +55,4 @@
}); });
</script> </script>
<div class="h-full w-full mb-0" bind:this={container} /> <div class="h-full w-full mb-0" bind:this={container}></div>
@@ -1,3 +1,4 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte'; import PhotoViewer from '$lib/components/asset-viewer/photo-viewer.svelte';
import * as utils from '$lib/utils'; import * as utils from '$lib/utils';
import { AssetMediaSize } from '@immich/sdk'; import { AssetMediaSize } from '@immich/sdk';
@@ -24,6 +25,10 @@ describe('PhotoViewer component', () => {
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl'); getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
}); });
beforeEach(() => {
Element.prototype.animate = getAnimateMock();
});
afterEach(() => { afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
@@ -193,7 +193,7 @@
<div <div
class="absolute border-solid border-white border-[3px] rounded-lg" class="absolute border-solid border-white border-[3px] rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;" style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
/> ></div>
{/each} {/each}
</div> </div>
{/if} {/if}
@@ -3,9 +3,9 @@ import { render } from '@testing-library/svelte';
describe('ImageThumbnail component', () => { describe('ImageThumbnail component', () => {
beforeAll(() => { beforeAll(() => {
Object.defineProperty(HTMLImageElement.prototype, 'complete', { Element.prototype.animate = vi.fn().mockImplementation(() => ({
value: true, cancel: () => {},
}); }));
}); });
it('shows thumbhash while image is loading', () => { it('shows thumbhash while image is loading', () => {
@@ -96,5 +96,5 @@
class:rounded-full={circle} class:rounded-full={circle}
draggable="false" draggable="false"
out:fade={{ duration: THUMBHASH_FADE_DURATION }} out:fade={{ duration: THUMBHASH_FADE_DURATION }}
/> ></canvas>
{/if} {/if}
@@ -218,6 +218,7 @@
href={currentUrlReplaceAssetId(asset.id)} href={currentUrlReplaceAssetId(asset.id)}
on:click={(evt) => evt.preventDefault()} on:click={(evt) => evt.preventDefault()}
tabindex={0} tabindex={0}
aria-label="Thumbnail URL"
> >
</a> </a>
{/if} {/if}
@@ -255,12 +256,12 @@
<div <div
class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100" class="absolute z-10 h-full w-full bg-gradient-to-b from-black/25 via-[transparent_25%] opacity-0 transition-opacity group-hover:opacity-100"
class:rounded-xl={selected} class:rounded-xl={selected}
/> ></div>
<!-- Outline on focus --> <!-- Outline on focus -->
<div <div
class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary" class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary"
/> ></div>
<!-- Favorite asset star --> <!-- Favorite asset star -->
{#if !isSharedLink() && asset.isFavorite} {#if !isSharedLink() && asset.isFavorite}
@@ -339,7 +340,7 @@
class="absolute top-0 h-full w-full bg-immich-primary opacity-40" class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
in:fade={{ duration: 100 }} in:fade={{ duration: 100 }}
out:fade={{ duration: 100 }} out:fade={{ duration: 100 }}
/> ></div>
{/if} {/if}
</div> </div>
{/if} {/if}
@@ -113,7 +113,10 @@
}} }}
on:timeupdate={({ currentTarget }) => { on:timeupdate={({ currentTarget }) => {
const remaining = currentTarget.duration - currentTarget.currentTime; const remaining = currentTarget.duration - currentTarget.currentTime;
remainingSeconds = Math.min(Math.ceil(remaining), durationInSeconds); remainingSeconds = Math.min(
Math.ceil(Number.isNaN(remaining) ? Number.POSITIVE_INFINITY : remaining),
durationInSeconds,
);
}} }}
/> ></video>
{/if} {/if}
@@ -102,7 +102,7 @@
{renderedOption.title} {renderedOption.title}
</p> </p>
{:else} {:else}
<div /> <div></div>
<p class="justify-self-start"> <p class="justify-self-start">
{renderedOption.title} {renderedOption.title}
</p> </p>
@@ -28,11 +28,11 @@
{#if disabled} {#if disabled}
<span <span
class="slider slider-disabled cursor-not-allowed border border-transparent before:border before:border-transparent" class="slider slider-disabled cursor-not-allowed border border-transparent before:border before:border-transparent"
/> ></span>
{:else} {:else}
<span <span
class="slider slider-enabled cursor-pointer border-2 border-transparent before:border-2 before:border-transparent peer-focus-visible:outline before:peer-focus-visible:outline peer-focus-visible:dark:outline-gray-200 before:peer-focus-visible:dark:outline-gray-200 peer-focus-visible:outline-gray-600 before:peer-focus-visible:outline-gray-600 peer-focus-visible:dark:border-black before:peer-focus-visible:dark:border-black peer-focus-visible:border-white before:peer-focus-visible:border-white" class="slider slider-enabled cursor-pointer border-2 border-transparent before:border-2 before:border-transparent peer-focus-visible:outline before:peer-focus-visible:outline peer-focus-visible:dark:outline-gray-200 before:peer-focus-visible:dark:outline-gray-200 peer-focus-visible:outline-gray-600 before:peer-focus-visible:outline-gray-600 peer-focus-visible:dark:border-black before:peer-focus-visible:dark:border-black peer-focus-visible:border-white before:peer-focus-visible:border-white"
/> ></span>
{/if} {/if}
</label> </label>
@@ -36,14 +36,14 @@
class:hover:opacity-100={selectable} class:hover:opacity-100={selectable}
class:rounded-full={circle} class:rounded-full={circle}
class:rounded-lg={!circle} class:rounded-lg={!circle}
/> ></div>
{#if selected} {#if selected}
<div <div
class="absolute left-0 top-0 h-full w-full bg-blue-500/80" class="absolute left-0 top-0 h-full w-full bg-blue-500/80"
class:rounded-full={circle} class:rounded-full={circle}
class:rounded-lg={!circle} class:rounded-lg={!circle}
/> ></div>
{/if} {/if}
{#if person.name} {#if person.name}
@@ -44,6 +44,8 @@
return personIsHidden; return personIsHidden;
}; };
// svelte-ignore reactive_declaration_non_reactive_property
// svelte-ignore reactive_declaration_module_script_dependency
$: toggleButtonOptions = ((): Record<ToggleVisibility, { icon: string; label: string }> => { $: toggleButtonOptions = ((): Record<ToggleVisibility, { icon: string; label: string }> => {
return { return {
[ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') }, [ToggleVisibility.HIDE_ALL]: { icon: mdiEyeOff, label: $t('hide_all_people') },

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