Compare commits
26 Commits
share-to-i
...
fix/server
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d846f7fc7f | ||
|
|
efb4394c7b | ||
|
|
e2188867a6 | ||
|
|
5e32cc7bd5 | ||
|
|
1d55b5bfc0 | ||
|
|
60afd7b400 | ||
|
|
3f99ef90ec | ||
|
|
380fc06979 | ||
|
|
d34d92dca3 | ||
|
|
10f8e11db1 | ||
|
|
18b93ddc73 | ||
|
|
88ca1f31ad | ||
|
|
3551407d95 | ||
|
|
6bfc20ef95 | ||
|
|
eadcbd52fb | ||
|
|
fed882a28a | ||
|
|
c30ef4dfd6 | ||
|
|
cdabd08139 | ||
|
|
b95bc32310 | ||
|
|
e851a9b099 | ||
|
|
e46db37e44 | ||
|
|
b9096f3e99 | ||
|
|
5ac236d6fd | ||
|
|
458f2acf42 | ||
|
|
5b2cd704d3 | ||
|
|
0130052de5 |
@@ -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
|
||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||
|
||||
10
cli/package-lock.json
generated
10
cli/package-lock.json
generated
@@ -24,7 +24,7 @@
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@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/parser": "^8.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
@@ -59,7 +59,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@@ -1378,9 +1378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
|
||||
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
|
||||
"version": "22.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
|
||||
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@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/parser": "^8.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
|
||||
@@ -26,7 +26,6 @@ The default configuration looks like this:
|
||||
"bframes": -1,
|
||||
"refs": 0,
|
||||
"gopSize": 0,
|
||||
"npl": 0,
|
||||
"temporalAQ": false,
|
||||
"cqMode": "auto",
|
||||
"twoPass": false,
|
||||
|
||||
@@ -83,6 +83,12 @@ const projects: CommunityProjectProps[] = [
|
||||
description: 'Power tools for organizing your immich library.',
|
||||
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 {
|
||||
|
||||
34
e2e/package-lock.json
generated
34
e2e/package-lock.json
generated
@@ -15,7 +15,7 @@
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"@types/oidc-provider": "^8.5.1",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
@@ -64,7 +64,7 @@
|
||||
"@types/cli-progress": "^3.11.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@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/parser": "^8.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.5",
|
||||
@@ -99,7 +99,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@@ -1613,9 +1613,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
|
||||
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
|
||||
"version": "22.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
|
||||
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3294,9 +3294,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/exiftool-vendored": {
|
||||
"version": "28.6.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz",
|
||||
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==",
|
||||
"version": "28.7.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
|
||||
"integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -3307,14 +3307,14 @@
|
||||
"luxon": "^3.5.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"exiftool-vendored.exe": "12.97.0",
|
||||
"exiftool-vendored.pl": "12.97.0"
|
||||
"exiftool-vendored.exe": "12.99.0",
|
||||
"exiftool-vendored.pl": "12.99.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exiftool-vendored.exe": {
|
||||
"version": "12.97.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz",
|
||||
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==",
|
||||
"version": "12.99.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
|
||||
"integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -3323,9 +3323,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/exiftool-vendored.pl": {
|
||||
"version": "12.97.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz",
|
||||
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==",
|
||||
"version": "12.99.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
|
||||
"integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"@types/oidc-provider": "^8.5.1",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/pngjs": "^6.0.4",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Submodule e2e/test-assets updated: 3e057d2f58...99544a2004
@@ -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_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_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_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",
|
||||
|
||||
18
machine-learning/poetry.lock
generated
18
machine-learning/poetry.lock
generated
@@ -946,13 +946,13 @@ tqdm = ["tqdm"]
|
||||
|
||||
[[package]]
|
||||
name = "ftfy"
|
||||
version = "6.3.0"
|
||||
version = "6.3.1"
|
||||
description = "Fixes mojibake and other problems with Unicode, after the fact"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "ftfy-6.3.0-py3-none-any.whl", hash = "sha256:17aca296801f44142e3ff2c16f93fbf6a87609ebb3704a9a41dd5d4903396caf"},
|
||||
{file = "ftfy-6.3.0.tar.gz", hash = "sha256:1c7d6418e72b25a7760feb150acf574b86924dbb2e95b32c0b3abbd1ba3d7ad6"},
|
||||
{file = "ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083"},
|
||||
{file = "ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -1609,13 +1609,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "locust"
|
||||
version = "2.32.0"
|
||||
version = "2.32.1"
|
||||
description = "Developer-friendly load testing framework"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "locust-2.32.0-py3-none-any.whl", hash = "sha256:e004514332b8631ca91382d11d224baee4ced040c5f5c8b2233800ebcbc73c0e"},
|
||||
{file = "locust-2.32.0.tar.gz", hash = "sha256:d8f7f5d9d4e801b2e7b0ee3f31109333673da744ccedf85e7da0151f2d263dd9"},
|
||||
{file = "locust-2.32.1-py3-none-any.whl", hash = "sha256:3fb5548b4f2b6477fa5229ee55ac3dddbae56e86c3430bf2ba3fee358eb7e7bb"},
|
||||
{file = "locust-2.32.1.tar.gz", hash = "sha256:8c3b1094dbf20860fd2f6e26b68f0c6064dc28054f4462664389d102fce1448b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2749,13 +2749,13 @@ cli = ["click (>=5.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.12"
|
||||
version = "0.0.17"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf"},
|
||||
{file = "python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb"},
|
||||
{file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"},
|
||||
{file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"flutter": "3.24.3"
|
||||
"flutter": "3.24.4"
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ PODS:
|
||||
- flutter_udid (0.0.1):
|
||||
- Flutter
|
||||
- SAMKeychain
|
||||
- flutter_web_auth (0.5.0):
|
||||
- flutter_web_auth (0.6.0):
|
||||
- Flutter
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
@@ -202,7 +202,7 @@ SPEC CHECKSUMS:
|
||||
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
|
||||
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
||||
flutter_web_auth: acc15a8fd7bba796a933c724a6dffc3d00f07c27
|
||||
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
|
||||
geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
|
||||
@@ -84,34 +84,48 @@ class AssetNotifier extends StateNotifier<bool> {
|
||||
_deleteInProgress = true;
|
||||
state = true;
|
||||
try {
|
||||
// Filter the assets based on the backed-up status
|
||||
final assets = onlyBackedUp
|
||||
? deleteAssets.where((e) => e.storage == AssetState.merged)
|
||||
: deleteAssets;
|
||||
|
||||
if (assets.isEmpty) {
|
||||
return false; // No assets to delete
|
||||
}
|
||||
|
||||
// Proceed with local deletion of the filtered assets
|
||||
final localDeleted = await _deleteLocalAssets(assets);
|
||||
|
||||
if (localDeleted.isNotEmpty) {
|
||||
final localOnlyIds = deleteAssets
|
||||
final localOnlyIds = assets
|
||||
.where((e) => e.storage == AssetState.local)
|
||||
.map((e) => e.id)
|
||||
.toList();
|
||||
// Update merged assets to remote only
|
||||
|
||||
// Update merged assets to remote-only
|
||||
final mergedAssets =
|
||||
deleteAssets.where((e) => e.storage == AssetState.merged).map((e) {
|
||||
assets.where((e) => e.storage == AssetState.merged).map((e) {
|
||||
e.localId = null;
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
// Update the local database
|
||||
await _db.writeTxn(() async {
|
||||
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.assets.deleteAll(localOnlyIds);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
} finally {
|
||||
_deleteInProgress = false;
|
||||
state = false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -203,18 +203,30 @@ class MultiselectGrid extends HookConsumerWidget {
|
||||
void onDeleteLocal(bool onlyBackedUp) async {
|
||||
processing.value = true;
|
||||
try {
|
||||
// Select only the local assets from the selection
|
||||
final localIds = selection.value.where((a) => a.isLocal).toList();
|
||||
|
||||
// Delete only the backed-up assets if 'onlyBackedUp' is true
|
||||
final isDeleted = await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp);
|
||||
|
||||
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(
|
||||
context: context,
|
||||
msg: 'assets_removed_permanently_from_device'
|
||||
.tr(args: ["${localIds.length}"]),
|
||||
.tr(args: ["$deletedCount"]),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
|
||||
// Reset the selection
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -23,7 +23,6 @@ class SystemConfigFFmpegDto {
|
||||
required this.crf,
|
||||
required this.gopSize,
|
||||
required this.maxBitrate,
|
||||
required this.npl,
|
||||
required this.preferredHwDevice,
|
||||
required this.preset,
|
||||
required this.refs,
|
||||
@@ -62,9 +61,6 @@ class SystemConfigFFmpegDto {
|
||||
|
||||
String maxBitrate;
|
||||
|
||||
/// Minimum value: 0
|
||||
int npl;
|
||||
|
||||
String preferredHwDevice;
|
||||
|
||||
String preset;
|
||||
@@ -102,7 +98,6 @@ class SystemConfigFFmpegDto {
|
||||
other.crf == crf &&
|
||||
other.gopSize == gopSize &&
|
||||
other.maxBitrate == maxBitrate &&
|
||||
other.npl == npl &&
|
||||
other.preferredHwDevice == preferredHwDevice &&
|
||||
other.preset == preset &&
|
||||
other.refs == refs &&
|
||||
@@ -128,7 +123,6 @@ class SystemConfigFFmpegDto {
|
||||
(crf.hashCode) +
|
||||
(gopSize.hashCode) +
|
||||
(maxBitrate.hashCode) +
|
||||
(npl.hashCode) +
|
||||
(preferredHwDevice.hashCode) +
|
||||
(preset.hashCode) +
|
||||
(refs.hashCode) +
|
||||
@@ -142,7 +136,7 @@ class SystemConfigFFmpegDto {
|
||||
(twoPass.hashCode);
|
||||
|
||||
@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() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -156,7 +150,6 @@ class SystemConfigFFmpegDto {
|
||||
json[r'crf'] = this.crf;
|
||||
json[r'gopSize'] = this.gopSize;
|
||||
json[r'maxBitrate'] = this.maxBitrate;
|
||||
json[r'npl'] = this.npl;
|
||||
json[r'preferredHwDevice'] = this.preferredHwDevice;
|
||||
json[r'preset'] = this.preset;
|
||||
json[r'refs'] = this.refs;
|
||||
@@ -190,7 +183,6 @@ class SystemConfigFFmpegDto {
|
||||
crf: mapValueOfType<int>(json, r'crf')!,
|
||||
gopSize: mapValueOfType<int>(json, r'gopSize')!,
|
||||
maxBitrate: mapValueOfType<String>(json, r'maxBitrate')!,
|
||||
npl: mapValueOfType<int>(json, r'npl')!,
|
||||
preferredHwDevice: mapValueOfType<String>(json, r'preferredHwDevice')!,
|
||||
preset: mapValueOfType<String>(json, r'preset')!,
|
||||
refs: mapValueOfType<int>(json, r'refs')!,
|
||||
@@ -259,7 +251,6 @@ class SystemConfigFFmpegDto {
|
||||
'crf',
|
||||
'gopSize',
|
||||
'maxBitrate',
|
||||
'npl',
|
||||
'preferredHwDevice',
|
||||
'preset',
|
||||
'refs',
|
||||
|
||||
@@ -622,10 +622,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_web_auth
|
||||
sha256: a69fa8f43b9e4d86ac72176bf747b735e7b977dd7cf215076d95b87cb05affdd
|
||||
sha256: "95e4856e24fb6ac1678f5ff334743b63f782d839ab324543d29ccbd295176209"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
version: "0.6.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
@@ -1861,5 +1861,5 @@ packages:
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
sdks:
|
||||
dart: ">=3.5.0 <4.0.0"
|
||||
flutter: ">=3.24.3"
|
||||
dart: ">=3.5.3 <4.0.0"
|
||||
flutter: ">=3.24.4"
|
||||
|
||||
@@ -6,7 +6,7 @@ version: 1.119.1+164
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
flutter: 3.24.3
|
||||
flutter: 3.24.4
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
@@ -42,7 +42,7 @@ dependencies:
|
||||
path_provider: ^2.1.2
|
||||
collection: ^1.18.0
|
||||
http_parser: ^4.0.2
|
||||
flutter_web_auth: ^0.5.0
|
||||
flutter_web_auth: ^0.6.0
|
||||
easy_image_viewer: ^1.4.0
|
||||
isar: ^3.1.0+1
|
||||
isar_flutter_libs: ^3.1.0+1
|
||||
|
||||
@@ -11621,10 +11621,6 @@
|
||||
"maxBitrate": {
|
||||
"type": "string"
|
||||
},
|
||||
"npl": {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"preferredHwDevice": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -11673,7 +11669,6 @@
|
||||
"crf",
|
||||
"gopSize",
|
||||
"maxBitrate",
|
||||
"npl",
|
||||
"preferredHwDevice",
|
||||
"preset",
|
||||
"refs",
|
||||
|
||||
8
open-api/typescript-sdk/package-lock.json
generated
8
open-api/typescript-sdk/package-lock.json
generated
@@ -12,7 +12,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@@ -22,9 +22,9 @@
|
||||
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
|
||||
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
|
||||
"version": "22.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
|
||||
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"repository": {
|
||||
|
||||
@@ -1104,7 +1104,6 @@ export type SystemConfigFFmpegDto = {
|
||||
crf: number;
|
||||
gopSize: number;
|
||||
maxBitrate: string;
|
||||
npl: number;
|
||||
preferredHwDevice: string;
|
||||
preset: string;
|
||||
refs: number;
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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
|
||||
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./
|
||||
|
||||
58
server/package-lock.json
generated
58
server/package-lock.json
generated
@@ -83,7 +83,7 @@
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/picomatch": "^3.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
@@ -5110,9 +5110,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
|
||||
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
|
||||
"version": "22.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
|
||||
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.8"
|
||||
}
|
||||
@@ -8236,9 +8236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/exiftool-vendored": {
|
||||
"version": "28.6.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz",
|
||||
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==",
|
||||
"version": "28.7.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
|
||||
"integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
|
||||
"dependencies": {
|
||||
"@photostructure/tz-lookup": "^11.0.0",
|
||||
"@types/luxon": "^3.4.2",
|
||||
@@ -8247,23 +8247,23 @@
|
||||
"luxon": "^3.5.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"exiftool-vendored.exe": "12.97.0",
|
||||
"exiftool-vendored.pl": "12.97.0"
|
||||
"exiftool-vendored.exe": "12.99.0",
|
||||
"exiftool-vendored.pl": "12.99.0"
|
||||
}
|
||||
},
|
||||
"node_modules/exiftool-vendored.exe": {
|
||||
"version": "12.97.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz",
|
||||
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==",
|
||||
"version": "12.99.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
|
||||
"integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/exiftool-vendored.pl": {
|
||||
"version": "12.97.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz",
|
||||
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==",
|
||||
"version": "12.99.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
|
||||
"integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"!win32"
|
||||
@@ -18258,9 +18258,9 @@
|
||||
}
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "22.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
|
||||
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
|
||||
"version": "22.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
|
||||
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
|
||||
"requires": {
|
||||
"undici-types": "~6.19.8"
|
||||
}
|
||||
@@ -20579,29 +20579,29 @@
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
|
||||
},
|
||||
"exiftool-vendored": {
|
||||
"version": "28.6.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz",
|
||||
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==",
|
||||
"version": "28.7.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
|
||||
"integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
|
||||
"requires": {
|
||||
"@photostructure/tz-lookup": "^11.0.0",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"batch-cluster": "^13.0.0",
|
||||
"exiftool-vendored.exe": "12.97.0",
|
||||
"exiftool-vendored.pl": "12.97.0",
|
||||
"exiftool-vendored.exe": "12.99.0",
|
||||
"exiftool-vendored.pl": "12.99.0",
|
||||
"he": "^1.2.0",
|
||||
"luxon": "^3.5.0"
|
||||
}
|
||||
},
|
||||
"exiftool-vendored.exe": {
|
||||
"version": "12.97.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz",
|
||||
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==",
|
||||
"version": "12.99.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
|
||||
"integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
|
||||
"optional": true
|
||||
},
|
||||
"exiftool-vendored.pl": {
|
||||
"version": "12.97.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz",
|
||||
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==",
|
||||
"version": "12.99.0",
|
||||
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
|
||||
"integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
|
||||
"optional": true
|
||||
},
|
||||
"express": {
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"@types/lodash": "^4.14.197",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^1.4.7",
|
||||
"@types/node": "^22.8.1",
|
||||
"@types/node": "^22.8.5",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/picomatch": "^3.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
|
||||
@@ -6,10 +6,12 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { OpenTelemetryModule } from 'nestjs-otel';
|
||||
import { commands } from 'src/commands';
|
||||
import { IWorker } from 'src/constants';
|
||||
import { controllers } from 'src/controllers';
|
||||
import { entities } from 'src/entities';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
|
||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
@@ -56,23 +58,25 @@ const imports = [
|
||||
TypeOrmModule.forFeature(entities),
|
||||
];
|
||||
|
||||
abstract class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
private get worker() {
|
||||
return this.getWorker();
|
||||
}
|
||||
|
||||
class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
constructor(
|
||||
@Inject(IWorker) private worker: ImmichWorker,
|
||||
@Inject(ILoggerRepository) logger: ILoggerRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ITelemetryRepository) private telemetryRepository: ITelemetryRepository,
|
||||
) {
|
||||
logger.setAppName(this.worker);
|
||||
}
|
||||
|
||||
abstract getWorker(): ImmichWorker;
|
||||
|
||||
async onModuleInit() {
|
||||
this.telemetryRepository.setup({ repositories: repositories.map(({ useClass }) => useClass) });
|
||||
|
||||
this.jobRepository.setup({ services });
|
||||
if (this.worker === ImmichWorker.MICROSERVICES) {
|
||||
this.jobRepository.startWorkers();
|
||||
}
|
||||
|
||||
this.eventRepository.setup({ services });
|
||||
await this.eventRepository.emit('app.bootstrap', this.worker);
|
||||
}
|
||||
@@ -86,23 +90,15 @@ abstract class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
@Module({
|
||||
imports: [...imports, ScheduleModule.forRoot()],
|
||||
controllers: [...controllers],
|
||||
providers: [...common, ...middleware],
|
||||
providers: [...common, ...middleware, { provide: IWorker, useValue: ImmichWorker.API }],
|
||||
})
|
||||
export class ApiModule extends BaseModule {
|
||||
getWorker() {
|
||||
return ImmichWorker.API;
|
||||
}
|
||||
}
|
||||
export class ApiModule extends BaseModule {}
|
||||
|
||||
@Module({
|
||||
imports: [...imports],
|
||||
providers: [...common, SchedulerRegistry],
|
||||
providers: [...common, { provide: IWorker, useValue: ImmichWorker.MICROSERVICES }, SchedulerRegistry],
|
||||
})
|
||||
export class MicroservicesModule extends BaseModule {
|
||||
getWorker() {
|
||||
return ImmichWorker.MICROSERVICES;
|
||||
}
|
||||
}
|
||||
export class MicroservicesModule extends BaseModule {}
|
||||
|
||||
@Module({
|
||||
imports: [...imports],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ImmichWorker } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
|
||||
const main = async () => {
|
||||
const { workers, port } = new ConfigRepository().getEnv();
|
||||
const { host, workers, port } = new ConfigRepository().getEnv();
|
||||
if (!workers.includes(ImmichWorker.API)) {
|
||||
process.exit();
|
||||
}
|
||||
@@ -11,7 +11,7 @@ const main = async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
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,
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export interface SystemConfig {
|
||||
bframes: number;
|
||||
refs: number;
|
||||
gopSize: number;
|
||||
npl: number;
|
||||
temporalAQ: boolean;
|
||||
cqMode: CQMode;
|
||||
twoPass: boolean;
|
||||
@@ -178,7 +177,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
bframes: -1,
|
||||
refs: 0,
|
||||
gopSize: 0,
|
||||
npl: 0,
|
||||
temporalAQ: false,
|
||||
cqMode: CQMode.AUTO,
|
||||
twoPass: false,
|
||||
|
||||
@@ -13,6 +13,8 @@ export const ADDED_IN_PREFIX = 'This property was added in ';
|
||||
|
||||
export const SALT_ROUNDS = 10;
|
||||
|
||||
export const IWorker = 'IWorker';
|
||||
|
||||
const { version } = JSON.parse(readFileSync('./package.json', 'utf8'));
|
||||
export const serverVersion = new SemVer(version);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import _ from 'lodash';
|
||||
import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants';
|
||||
import { MetadataKey } from 'src/enum';
|
||||
import { EmitEvent } from 'src/interfaces/event.interface';
|
||||
import { JobName, QueueName } from 'src/interfaces/job.interface';
|
||||
import { setUnion } from 'src/utils/set';
|
||||
|
||||
// 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 type JobConfig = {
|
||||
name: JobName;
|
||||
queue: QueueName;
|
||||
};
|
||||
export const OnJob = (config: JobConfig) => SetMetadata(MetadataKey.JOB_CONFIG, config);
|
||||
|
||||
type LifecycleRelease = 'NEXT_RELEASE' | string;
|
||||
type LifecycleMetadata = {
|
||||
addedAt?: LifecycleRelease;
|
||||
|
||||
@@ -134,12 +134,6 @@ export class SystemConfigFFmpegDto {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
gopSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
npl!: number;
|
||||
|
||||
@ValidateBoolean()
|
||||
temporalAQ!: boolean;
|
||||
|
||||
|
||||
@@ -335,6 +335,7 @@ export enum MetadataKey {
|
||||
SHARED_ROUTE = 'shared_route',
|
||||
API_KEY_SECURITY = 'api_key',
|
||||
EVENT_CONFIG = 'event_config',
|
||||
JOB_CONFIG = 'job_config',
|
||||
TELEMETRY_ENABLED = 'telemetry_enabled',
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SystemConfig } from 'src/config';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { JobItem, QueueName } from 'src/interfaces/job.interface';
|
||||
|
||||
export const IEventRepository = 'IEventRepository';
|
||||
|
||||
@@ -38,6 +39,8 @@ type EventMap = {
|
||||
'assets.delete': [{ assetIds: string[]; userId: string }];
|
||||
'assets.restore': [{ assetIds: string[]; userId: string }];
|
||||
|
||||
'job.start': [QueueName, JobItem];
|
||||
|
||||
// session events
|
||||
'session.delete': [{ sessionId: string }];
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { EmailImageAttachment } from 'src/interfaces/notification.interface';
|
||||
|
||||
export enum QueueName {
|
||||
@@ -238,8 +239,8 @@ export type JobItem =
|
||||
|
||||
// Migration
|
||||
| { name: JobName.QUEUE_MIGRATION; data?: IBaseJob }
|
||||
| { name: JobName.MIGRATE_ASSET; data?: IEntityJob }
|
||||
| { name: JobName.MIGRATE_PERSON; data?: IEntityJob }
|
||||
| { name: JobName.MIGRATE_ASSET; data: IEntityJob }
|
||||
| { name: JobName.MIGRATE_PERSON; data: IEntityJob }
|
||||
|
||||
// Metadata Extraction
|
||||
| { name: JobName.QUEUE_METADATA_EXTRACTION; data: IBaseJob }
|
||||
@@ -286,7 +287,7 @@ export type JobItem =
|
||||
| { name: JobName.LIBRARY_SYNC_FILE; data: ILibraryFileJob }
|
||||
| { name: JobName.LIBRARY_QUEUE_SYNC_FILES; 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_QUEUE_SYNC_ALL; data?: IBaseJob }
|
||||
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
|
||||
@@ -305,14 +306,15 @@ export enum JobStatus {
|
||||
FAILED = 'failed',
|
||||
SKIPPED = 'skipped',
|
||||
}
|
||||
|
||||
export type JobHandler<T = any> = (data: T) => Promise<JobStatus>;
|
||||
export type JobItemHandler = (item: JobItem) => Promise<void>;
|
||||
export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] };
|
||||
export type JobOf<T extends JobName> = Jobs[T];
|
||||
|
||||
export const IJobRepository = '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;
|
||||
updateCronJob(name: string, expression?: string, start?: boolean): void;
|
||||
setConcurrency(queueName: QueueName, concurrency: number): void;
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface VideoStreamInfo {
|
||||
frameCount: number;
|
||||
isHDR: boolean;
|
||||
bitrate: number;
|
||||
pixelFormat: string;
|
||||
}
|
||||
|
||||
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> {}
|
||||
}
|
||||
@@ -1,124 +1,122 @@
|
||||
import { getQueueToken } from '@nestjs/bullmq';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { ModuleRef, Reflector } from '@nestjs/core';
|
||||
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 { setTimeout } from 'node:timers/promises';
|
||||
import { JobConfig } from 'src/decorators';
|
||||
import { MetadataKey } from 'src/enum';
|
||||
import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IEntityJob,
|
||||
IJobRepository,
|
||||
JobCounts,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobOf,
|
||||
JobStatus,
|
||||
QueueCleanType,
|
||||
QueueName,
|
||||
QueueStatus,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
|
||||
|
||||
export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
// misc
|
||||
[JobName.ASSET_DELETION]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.ASSET_DELETION_CHECK]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.USER_DELETE_CHECK]: QueueName.BACKGROUND_TASK,
|
||||
[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,
|
||||
type JobMapItem = {
|
||||
jobName: JobName;
|
||||
queueName: QueueName;
|
||||
handler: (job: JobOf<any>) => Promise<JobStatus>;
|
||||
label: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class JobRepository implements IJobRepository {
|
||||
private workers: Partial<Record<QueueName, Worker>> = {};
|
||||
private handlers: Partial<Record<JobName, JobMapItem>> = {};
|
||||
|
||||
constructor(
|
||||
private moduleReference: ModuleRef,
|
||||
private schedulerReqistry: SchedulerRegistry,
|
||||
private moduleRef: ModuleRef,
|
||||
private schedulerRegistry: SchedulerRegistry,
|
||||
@Inject(IConfigRepository) private configRepository: IConfigRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
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 workerHandler: Processor = async (job: Job) => handler(job as JobItem);
|
||||
const workerOptions: WorkerOptions = { ...bull.config, concurrency };
|
||||
this.workers[queueName] = new Worker(queueName, workerHandler, workerOptions);
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
this.logger.debug(`Starting worker for queue: ${queueName}`);
|
||||
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 {
|
||||
@@ -141,11 +139,11 @@ export class JobRepository implements IJobRepository {
|
||||
true,
|
||||
);
|
||||
|
||||
this.schedulerReqistry.addCronJob(name, job);
|
||||
this.schedulerRegistry.addCronJob(name, job);
|
||||
}
|
||||
|
||||
updateCronJob(name: string, expression?: string, start?: boolean): void {
|
||||
const job = this.schedulerReqistry.getCronJob(name);
|
||||
const job = this.schedulerRegistry.getCronJob(name);
|
||||
if (expression) {
|
||||
job.setTime(new CronTime(expression));
|
||||
}
|
||||
@@ -204,6 +202,10 @@ export class JobRepository implements IJobRepository {
|
||||
) as unknown as Promise<JobCounts>;
|
||||
}
|
||||
|
||||
private getQueueName(name: JobName) {
|
||||
return (this.handlers[name] as JobMapItem).queueName;
|
||||
}
|
||||
|
||||
async queueAll(items: JobItem[]): Promise<void> {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
@@ -212,7 +214,7 @@ export class JobRepository implements IJobRepository {
|
||||
const promises = [];
|
||||
const itemsByQueue = {} as Record<string, (JobItem & { data: any; options: JobsOptions | undefined })[]>;
|
||||
for (const item of items) {
|
||||
const queueName = JOBS_TO_QUEUE[item.name];
|
||||
const queueName = this.getQueueName(item.name);
|
||||
const job = {
|
||||
name: item.name,
|
||||
data: item.data || {},
|
||||
@@ -273,11 +275,11 @@ export class JobRepository implements IJobRepository {
|
||||
}
|
||||
|
||||
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> {
|
||||
const existingJob = await this.getQueue(JOBS_TO_QUEUE[name]).getJob(jobId);
|
||||
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId);
|
||||
if (!existingJob) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ export class MediaRepository implements IMediaRepository {
|
||||
rotation: this.parseInt(stream.rotation),
|
||||
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
|
||||
bitrate: this.parseInt(stream.bit_rate),
|
||||
pixelFormat: stream.pix_fmt || 'yuv420p',
|
||||
})),
|
||||
audioStreams: results.streams
|
||||
.filter((stream) => stream.codec_type === 'audio')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import {
|
||||
AssetResponseDto,
|
||||
MemoryLaneResponseDto,
|
||||
@@ -21,12 +22,13 @@ import { MemoryLaneDto } from 'src/dtos/search.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetStatus, Permission } from 'src/enum';
|
||||
import {
|
||||
IAssetDeleteJob,
|
||||
ISidecarWriteJob,
|
||||
JOBS_ASSET_PAGINATION_SIZE,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobOf,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
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);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.ASSET_DELETION_CHECK, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleAssetDeletionCheck(): Promise<JobStatus> {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
const trashedDays = config.trash.enabled ? config.trash.days : 0;
|
||||
@@ -211,7 +214,8 @@ export class AssetService extends BaseService {
|
||||
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 asset = await this.assetRepository.getById(id, {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DateTime } from 'luxon';
|
||||
import { resolve } from 'node:path';
|
||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import {
|
||||
AuditDeletesDto,
|
||||
AuditDeletesResponseDto,
|
||||
@@ -21,13 +22,14 @@ import {
|
||||
StorageFolder,
|
||||
UserPathType,
|
||||
} 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 { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService extends BaseService {
|
||||
@OnJob({ name: JobName.CLEAN_OLD_AUDIT_LOGS, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleCleanup(): Promise<JobStatus> {
|
||||
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
|
||||
return JobStatus.SUCCESS;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { default as path } from 'node:path';
|
||||
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 { DatabaseLock } from 'src/interfaces/database.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 { handlePromiseError } from 'src/utils/misc';
|
||||
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`);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.BACKUP_DATABASE, queue: QueueName.BACKUP_DATABASE })
|
||||
async handleBackupDatabase(): Promise<JobStatus> {
|
||||
this.logger.debug(`Database Backup Started`);
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BadRequestException, Inject } from '@nestjs/common';
|
||||
import { BadRequestException, Inject, Optional } from '@nestjs/common';
|
||||
import sanitize from 'sanitize-filename';
|
||||
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 { UserEntity } from 'src/entities/user.entity';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IActivityRepository } from 'src/interfaces/activity.interface';
|
||||
import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
|
||||
@@ -49,6 +50,7 @@ export class BaseService {
|
||||
protected storageCore: StorageCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IWorker) @Optional() protected worker: ImmichWorker | undefined,
|
||||
@Inject(ILoggerRepository) protected logger: ILoggerRepository,
|
||||
@Inject(IAccessRepository) protected accessRepository: IAccessRepository,
|
||||
@Inject(IActivityRepository) protected activityRepository: IActivityRepository,
|
||||
|
||||
@@ -31,11 +31,23 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('getDuplicates', () => {
|
||||
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([
|
||||
{ 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', () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
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 { BaseService } from 'src/services/base.service';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
@@ -15,11 +16,28 @@ import { usePagination } from 'src/utils/pagination';
|
||||
export class DuplicateService extends BaseService {
|
||||
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
|
||||
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
|
||||
|
||||
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true })));
|
||||
const uniqueAssetIds: string[] = [];
|
||||
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 });
|
||||
if (!isDuplicateDetectionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -40,7 +58,8 @@ export class DuplicateService extends BaseService {
|
||||
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 });
|
||||
if (!isDuplicateDetectionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
|
||||
@@ -17,7 +17,6 @@ import { MapService } from 'src/services/map.service';
|
||||
import { MediaService } from 'src/services/media.service';
|
||||
import { MemoryService } from 'src/services/memory.service';
|
||||
import { MetadataService } from 'src/services/metadata.service';
|
||||
import { MicroservicesService } from 'src/services/microservices.service';
|
||||
import { NotificationService } from 'src/services/notification.service';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { PersonService } from 'src/services/person.service';
|
||||
@@ -60,7 +59,6 @@ export const services = [
|
||||
MediaService,
|
||||
MemoryService,
|
||||
MetadataService,
|
||||
MicroservicesService,
|
||||
NotificationService,
|
||||
PartnerService,
|
||||
PersonService,
|
||||
|
||||
@@ -2,37 +2,25 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { defaults } from 'src/config';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import {
|
||||
IJobRepository,
|
||||
JobCommand,
|
||||
JobHandler,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IJobRepository, JobCommand, JobItem, JobName, JobStatus, QueueName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { ITelemetryRepository } from 'src/interfaces/telemetry.interface';
|
||||
import { JobService } from 'src/services/job.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { newTestService } from 'test/utils';
|
||||
import { Mocked, vitest } 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
|
||||
>;
|
||||
};
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
describe(JobService.name, () => {
|
||||
let sut: JobService;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let telemetryMock: Mocked<ITelemetryRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
({ sut, assetMock, jobMock, systemMock } = newTestService(JobService));
|
||||
({ sut, assetMock, jobMock, loggerMock, telemetryMock } = newTestService(JobService, {
|
||||
worker: ImmichWorker.MICROSERVICES,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -41,7 +29,6 @@ describe(JobService.name, () => {
|
||||
|
||||
describe('onConfigUpdate', () => {
|
||||
it('should update concurrency', () => {
|
||||
sut.onBootstrap(ImmichWorker.MICROSERVICES);
|
||||
sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults });
|
||||
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15);
|
||||
@@ -225,11 +212,19 @@ describe(JobService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should register a handler for each queue', async () => {
|
||||
await sut.init(makeMockHandlers(JobStatus.SUCCESS));
|
||||
expect(systemMock.get).toHaveBeenCalled();
|
||||
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
|
||||
describe('onJobStart', () => {
|
||||
it('should process a successful job', async () => {
|
||||
jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
|
||||
|
||||
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[] }> = [
|
||||
@@ -297,8 +292,9 @@ describe(JobService.name, () => {
|
||||
}
|
||||
}
|
||||
|
||||
await sut.init(makeMockHandlers(JobStatus.SUCCESS));
|
||||
await jobMock.addHandler.mock.calls[0][2](item);
|
||||
jobMock.run.mockResolvedValue(JobStatus.SUCCESS);
|
||||
|
||||
await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
|
||||
|
||||
if (jobs.length > 1) {
|
||||
expect(jobMock.queueAll).toHaveBeenCalledWith(
|
||||
@@ -313,8 +309,9 @@ describe(JobService.name, () => {
|
||||
});
|
||||
|
||||
it(`should not queue any jobs when ${item.name} fails`, async () => {
|
||||
await sut.init(makeMockHandlers(JobStatus.FAILED));
|
||||
await jobMock.addHandler.mock.calls[0][2](item);
|
||||
jobMock.run.mockResolvedValue(JobStatus.FAILED);
|
||||
|
||||
await sut.onJobStart(QueueName.BACKGROUND_TASK, item);
|
||||
|
||||
expect(jobMock.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -4,11 +4,10 @@ import { OnEvent } from 'src/decorators';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||
import { AssetType, ImmichWorker, ManualJobName } from 'src/enum';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import { ArgOf, ArgsOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
ConcurrentQueueName,
|
||||
JobCommand,
|
||||
JobHandler,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobStatus,
|
||||
@@ -39,16 +38,9 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
|
||||
|
||||
@Injectable()
|
||||
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 })
|
||||
onConfigUpdate({ newConfig: config, oldConfig }: ArgOf<'config.update'>) {
|
||||
if (!oldConfig || !this.isMicroservices) {
|
||||
onConfigUpdate({ newConfig: config }: ArgOf<'config.update'>) {
|
||||
if (this.worker !== ImmichWorker.MICROSERVICES) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,41 +169,21 @@ export class JobService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async init(jobHandlers: Record<JobName, JobHandler>) {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
for (const queueName of Object.values(QueueName)) {
|
||||
let concurrency = 1;
|
||||
|
||||
if (this.isConcurrentQueue(queueName)) {
|
||||
concurrency = config.job[queueName].concurrency;
|
||||
@OnEvent({ name: 'job.start' })
|
||||
async onJobStart(...[queueName, job]: ArgsOf<'job.start'>) {
|
||||
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
|
||||
this.telemetryRepository.jobs.addToGauge(queueMetric, 1);
|
||||
try {
|
||||
const status = await this.jobRepository.run(job);
|
||||
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);
|
||||
}
|
||||
|
||||
this.logger.debug(`Registering ${queueName} with a concurrency of ${concurrency}`);
|
||||
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
|
||||
const { name, data } = item;
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to run job handler (${queueName}/${job.name}): ${error}`, error?.stack, job.data);
|
||||
} finally {
|
||||
this.telemetryRepository.jobs.addToGauge(queueMetric, -1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { R_OK } from 'node:constants';
|
||||
import path, { basename, isAbsolute, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import {
|
||||
CreateLibraryDto,
|
||||
LibraryResponseDto,
|
||||
@@ -19,14 +19,7 @@ import { LibraryEntity } from 'src/entities/library.entity';
|
||||
import { AssetType, ImmichWorker } from 'src/enum';
|
||||
import { DatabaseLock } from 'src/interfaces/database.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IEntityJob,
|
||||
ILibraryAssetJob,
|
||||
ILibraryFileJob,
|
||||
JobName,
|
||||
JOBS_LIBRARY_PAGINATION_SIZE,
|
||||
JobStatus,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { JobName, JobOf, JOBS_LIBRARY_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { handlePromiseError } from 'src/utils/misc';
|
||||
@@ -223,6 +216,7 @@ export class LibraryService extends BaseService {
|
||||
return libraries.map((library) => mapLibrary(library));
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.LIBRARY_QUEUE_CLEANUP, queue: QueueName.LIBRARY })
|
||||
async handleQueueCleanup(): Promise<JobStatus> {
|
||||
this.logger.debug('Cleaning up any pending library deletions');
|
||||
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 } });
|
||||
}
|
||||
|
||||
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 assetPagination = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
|
||||
@@ -374,7 +369,8 @@ export class LibraryService extends BaseService {
|
||||
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
|
||||
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 } });
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.LIBRARY_QUEUE_SYNC_ALL, queue: QueueName.LIBRARY })
|
||||
async handleQueueSyncAll(): Promise<JobStatus> {
|
||||
this.logger.debug(`Refreshing all external libraries`);
|
||||
|
||||
@@ -483,7 +480,8 @@ export class LibraryService extends BaseService {
|
||||
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);
|
||||
if (!asset) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -538,7 +536,8 @@ export class LibraryService extends BaseService {
|
||||
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);
|
||||
if (!library) {
|
||||
this.logger.debug(`Library ${job.id} not found, skipping refresh`);
|
||||
@@ -589,7 +588,8 @@ export class LibraryService extends BaseService {
|
||||
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);
|
||||
if (!library) {
|
||||
return JobStatus.SKIPPED;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
ImageFormat,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
@@ -410,7 +409,7 @@ describe(MediaService.name, () => {
|
||||
'-frames:v 1',
|
||||
'-update 1',
|
||||
'-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,
|
||||
}),
|
||||
@@ -445,7 +444,7 @@ describe(MediaService.name, () => {
|
||||
'-frames:v 1',
|
||||
'-update 1',
|
||||
'-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,
|
||||
}),
|
||||
@@ -482,7 +481,7 @@ describe(MediaService.name, () => {
|
||||
'-frames:v 1',
|
||||
'-update 1',
|
||||
'-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,
|
||||
}),
|
||||
@@ -1328,7 +1327,7 @@ describe(MediaService.name, () => {
|
||||
'-map 0:0',
|
||||
'-map 0:1',
|
||||
'-v verbose',
|
||||
'-vf scale=-2:720,format=yuv420p',
|
||||
'-vf scale=-2:720',
|
||||
'-preset 12',
|
||||
'-crf 23',
|
||||
]),
|
||||
@@ -1454,7 +1453,7 @@ describe(MediaService.name, () => {
|
||||
'-map 0:1',
|
||||
'-g 256',
|
||||
'-v verbose',
|
||||
'-vf format=nv12,hwupload_cuda,scale_cuda=-2:720',
|
||||
'-vf hwupload_cuda,scale_cuda=-2:720:format=nv12',
|
||||
'-preset p1',
|
||||
'-cq:v 23',
|
||||
]),
|
||||
@@ -1586,7 +1585,7 @@ describe(MediaService.name, () => {
|
||||
inputOptions: expect.arrayContaining(['-hwaccel cuda', '-hwaccel_output_format cuda']),
|
||||
outputOptions: expect.arrayContaining([
|
||||
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,
|
||||
@@ -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 () => {
|
||||
storageMock.readdir.mockResolvedValue(['renderD128']);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
@@ -1616,7 +1633,7 @@ describe(MediaService.name, () => {
|
||||
'-refs 5',
|
||||
'-g 256',
|
||||
'-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',
|
||||
'-global_quality:v 23',
|
||||
'-maxrate 10000k',
|
||||
@@ -1748,7 +1765,7 @@ describe(MediaService.name, () => {
|
||||
]),
|
||||
outputOptions: expect.arrayContaining([
|
||||
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,
|
||||
@@ -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 () => {
|
||||
storageMock.readdir.mockResolvedValue(['renderD128']);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
@@ -1799,7 +1842,7 @@ describe(MediaService.name, () => {
|
||||
'-map 0:1',
|
||||
'-g 256',
|
||||
'-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',
|
||||
'-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']);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
systemMock.get.mockResolvedValue({
|
||||
@@ -1987,7 +2030,7 @@ describe(MediaService.name, () => {
|
||||
inputOptions: expect.arrayContaining(['-hwaccel vaapi', '-hwaccel_output_format vaapi', '-threads 1']),
|
||||
outputOptions: expect.arrayContaining([
|
||||
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,
|
||||
@@ -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 () => {
|
||||
storageMock.readdir.mockResolvedValue(['renderD128', 'renderD129', 'renderD130']);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
@@ -2069,7 +2133,7 @@ describe(MediaService.name, () => {
|
||||
'-map 0:1',
|
||||
'-g 256',
|
||||
'-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',
|
||||
'-rc_mode CQP',
|
||||
'-qp_init 23',
|
||||
@@ -2140,7 +2204,7 @@ describe(MediaService.name, () => {
|
||||
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
|
||||
outputOptions: expect.arrayContaining([
|
||||
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,
|
||||
@@ -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 () => {
|
||||
storageMock.readdir.mockResolvedValue(['renderD128']);
|
||||
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
|
||||
@@ -2164,7 +2250,7 @@ describe(MediaService.name, () => {
|
||||
inputOptions: [],
|
||||
outputOptions: expect.arrayContaining([
|
||||
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,
|
||||
@@ -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.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
@@ -2185,10 +2271,10 @@ describe(MediaService.name, () => {
|
||||
'/original/path.ext',
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
expect.objectContaining({
|
||||
inputOptions: [],
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.arrayContaining([
|
||||
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,
|
||||
@@ -2209,7 +2295,7 @@ describe(MediaService.name, () => {
|
||||
outputOptions: expect.arrayContaining([
|
||||
'-c:v h264',
|
||||
'-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,
|
||||
}),
|
||||
@@ -2229,16 +2315,16 @@ describe(MediaService.name, () => {
|
||||
outputOptions: expect.arrayContaining([
|
||||
'-c:v h264',
|
||||
'-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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set npl to 250 for reinhard and mobius tone-mapping algorithms', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||
systemMock.get.mockResolvedValue({ ffmpeg: { tonemap: ToneMapping.MOBIUS } });
|
||||
it('should transcode when policy is required and video is not yuv420p', async () => {
|
||||
mediaMock.probe.mockResolvedValue(probeStub.videoStream10Bit);
|
||||
systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } });
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||
await sut.handleVideoConversion({ id: assetStub.video.id });
|
||||
expect(mediaMock.transcode).toHaveBeenCalledWith(
|
||||
@@ -2246,11 +2332,7 @@ describe(MediaService.name, () => {
|
||||
'upload/encoded-video/user-id/as/se/asset-id.mp4',
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.arrayContaining([
|
||||
'-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',
|
||||
]),
|
||||
outputOptions: expect.arrayContaining(['-c:v h264', '-c:a copy', '-vf format=yuv420p']),
|
||||
twoPass: false,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { dirname } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import {
|
||||
@@ -19,11 +20,10 @@ import {
|
||||
} from 'src/enum';
|
||||
import { UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
JOBS_ASSET_PAGINATION_SIZE,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobOf,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
@@ -39,7 +39,8 @@ export class MediaService extends BaseService {
|
||||
private maliOpenCL?: boolean;
|
||||
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) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination, {
|
||||
@@ -90,6 +91,7 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
|
||||
async handleQueueMigration(): Promise<JobStatus> {
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
this.assetRepository.getAll(pagination),
|
||||
@@ -120,7 +122,8 @@ export class MediaService extends BaseService {
|
||||
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 [asset] = await this.assetRepository.getByIds([id], { files: true });
|
||||
if (!asset) {
|
||||
@@ -134,7 +137,8 @@ export class MediaService extends BaseService {
|
||||
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 });
|
||||
if (!asset) {
|
||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found`);
|
||||
@@ -257,7 +261,8 @@ export class MediaService extends BaseService {
|
||||
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 assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
@@ -275,7 +280,8 @@ export class MediaService extends BaseService {
|
||||
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]);
|
||||
if (!asset || asset.type !== AssetType.VIDEO) {
|
||||
return JobStatus.FAILED;
|
||||
@@ -323,9 +329,11 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
@@ -335,10 +343,26 @@ export class MediaService extends BaseService {
|
||||
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
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);
|
||||
|
||||
let partialFallbackSuccess = false;
|
||||
if (ffmpeg.accelDecode) {
|
||||
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}`);
|
||||
@@ -407,7 +431,7 @@ export class MediaService extends BaseService {
|
||||
const isLargerThanTargetBitrate = stream.bitrate > this.parseBitrateToBps(ffmpegConfig.maxBitrate);
|
||||
|
||||
const isTargetVideoCodec = ffmpegConfig.acceptedVideoCodecs.includes(stream.codecName as VideoCodec);
|
||||
const isRequired = !isTargetVideoCodec || stream.isHDR;
|
||||
const isRequired = !isTargetVideoCodec || !stream.pixelFormat.endsWith('420p');
|
||||
|
||||
switch (ffmpegConfig.transcode) {
|
||||
case TranscodePolicy.DISABLED: {
|
||||
@@ -496,7 +520,7 @@ export class MediaService extends BaseService {
|
||||
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
|
||||
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { constants } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { SystemConfig } from 'src/config';
|
||||
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 { AssetEntity } from 'src/entities/asset.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 { DatabaseLock } from 'src/interfaces/database.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
ISidecarWriteJob,
|
||||
JobName,
|
||||
JOBS_ASSET_PAGINATION_SIZE,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { JobName, JobOf, JOBS_ASSET_PAGINATION_SIZE, JobStatus, QueueName } from 'src/interfaces/job.interface';
|
||||
import { ReverseGeocodeResult } from 'src/interfaces/map.interface';
|
||||
import { ImmichTags } from 'src/interfaces/metadata.interface';
|
||||
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 [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
|
||||
if (!asset?.exifInfo) {
|
||||
@@ -159,7 +152,8 @@ export class MetadataService extends BaseService {
|
||||
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 assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
@@ -176,7 +170,8 @@ export class MetadataService extends BaseService {
|
||||
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 [asset] = await this.assetRepository.getByIds([id], { faces: { person: false } });
|
||||
if (!asset) {
|
||||
@@ -192,6 +187,8 @@ export class MetadataService extends BaseService {
|
||||
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
||||
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
||||
|
||||
const { width, height } = this.getImageDimensions(exifTags);
|
||||
|
||||
const exifData: Partial<ExifEntity> = {
|
||||
assetId: asset.id,
|
||||
|
||||
@@ -209,8 +206,8 @@ export class MetadataService extends BaseService {
|
||||
|
||||
// image/file
|
||||
fileSizeInByte: stats.size,
|
||||
exifImageHeight: validate(exifTags.ImageHeight),
|
||||
exifImageWidth: validate(exifTags.ImageWidth),
|
||||
exifImageHeight: validate(height),
|
||||
exifImageWidth: validate(width),
|
||||
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
||||
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
|
||||
bitsPerSample: this.getBitsPerSample(exifTags),
|
||||
@@ -260,7 +257,8 @@ export class MetadataService extends BaseService {
|
||||
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 assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
@@ -280,11 +278,13 @@ export class MetadataService extends BaseService {
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -298,7 +298,8 @@ export class MetadataService extends BaseService {
|
||||
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 [asset] = await this.assetRepository.getByIds([id], { tags: true });
|
||||
if (!asset) {
|
||||
@@ -334,6 +335,19 @@ export class MetadataService extends BaseService {
|
||||
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> {
|
||||
const mediaTags = await this.metadataRepository.readTags(asset.originalPath);
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
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 { AlbumEntity } from 'src/entities/album.entity';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IEmailJob,
|
||||
IEntityJob,
|
||||
INotifyAlbumInviteJob,
|
||||
INotifyAlbumUpdateJob,
|
||||
INotifySignupJob,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobOf,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { EmailImageAttachment, EmailTemplate } from 'src/interfaces/notification.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -176,7 +175,8 @@ export class NotificationService extends BaseService {
|
||||
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 });
|
||||
if (!user) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -207,7 +207,8 @@ export class NotificationService extends BaseService {
|
||||
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 });
|
||||
if (!album) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -254,7 +255,8 @@ export class NotificationService extends BaseService {
|
||||
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 });
|
||||
|
||||
if (!album) {
|
||||
@@ -312,7 +314,8 @@ export class NotificationService extends BaseService {
|
||||
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 });
|
||||
if (!notifications.smtp.enabled) {
|
||||
return JobStatus.SKIPPED;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FACE_THUMBNAIL_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
@@ -33,13 +34,10 @@ import {
|
||||
} from 'src/enum';
|
||||
import { WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IDeferrableJob,
|
||||
IEntityJob,
|
||||
INightlyJob,
|
||||
JOBS_ASSET_PAGINATION_SIZE,
|
||||
JobItem,
|
||||
JobName,
|
||||
JobOf,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
@@ -231,13 +229,15 @@ export class PersonService extends BaseService {
|
||||
this.logger.debug(`Deleted ${people.length} people`);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PERSON_CLEANUP, queue: QueueName.BACKGROUND_TASK })
|
||||
async handlePersonCleanup(): Promise<JobStatus> {
|
||||
const people = await this.personRepository.getAllWithoutFaces();
|
||||
await this.delete(people);
|
||||
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 });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -272,7 +272,8 @@ export class PersonService extends BaseService {
|
||||
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 });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -376,7 +377,8 @@ export class PersonService extends BaseService {
|
||||
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 });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -426,7 +428,8 @@ export class PersonService extends BaseService {
|
||||
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 });
|
||||
if (!isFacialRecognitionEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -509,7 +512,8 @@ export class PersonService extends BaseService {
|
||||
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);
|
||||
if (!person) {
|
||||
return JobStatus.FAILED;
|
||||
@@ -520,7 +524,8 @@ export class PersonService extends BaseService {
|
||||
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 });
|
||||
if (!isFacialRecognitionEnabled(machineLearning) && !isFaceImportEnabled(metadata)) {
|
||||
return JobStatus.SKIPPED;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { SessionResponseDto, mapSession } from 'src/dtos/session.dto';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
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({
|
||||
updatedBefore: DateTime.now().minus({ days: 90 }).toJSDate(),
|
||||
});
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { DatabaseLock } from 'src/interfaces/database.interface';
|
||||
import { ArgOf } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
JOBS_ASSET_PAGINATION_SIZE,
|
||||
JobName,
|
||||
JobStatus,
|
||||
QueueName,
|
||||
} from 'src/interfaces/job.interface';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE, JobName, JobOf, JobStatus, QueueName } from 'src/interfaces/job.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
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 });
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
@@ -111,7 +105,8 @@ export class SmartInfoService extends BaseService {
|
||||
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 });
|
||||
if (!isSmartSearchEnabled(machineLearning)) {
|
||||
return JobStatus.SKIPPED;
|
||||
|
||||
@@ -4,13 +4,13 @@ import { DateTime } from 'luxon';
|
||||
import path from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
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 { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
|
||||
import { DatabaseLock } from 'src/interfaces/database.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 { getLivePhotoMotionFilename } from 'src/utils/file';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
@@ -108,7 +108,8 @@ export class StorageTemplateService extends BaseService {
|
||||
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 storageTemplateEnabled = config.storageTemplate.enabled;
|
||||
if (!storageTemplateEnabled) {
|
||||
@@ -137,6 +138,7 @@ export class StorageTemplateService extends BaseService {
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
|
||||
async handleMigration(): Promise<JobStatus> {
|
||||
this.logger.log('Starting storage template migration');
|
||||
const { storageTemplate } = await this.getConfig({ withCache: true });
|
||||
|
||||
@@ -3,7 +3,8 @@ import { IConfigRepository } from 'src/interfaces/config.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.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 { newTestService } from 'test/utils';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { join } from 'node:path';
|
||||
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 { StorageFolder, SystemMetadataKey } from 'src/enum';
|
||||
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';
|
||||
|
||||
export class ImmichStartupError extends Error {}
|
||||
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
|
||||
import { ImmichStartupError } from 'src/utils/misc';
|
||||
|
||||
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;
|
||||
|
||||
// TODO: one job per file
|
||||
|
||||
@@ -65,7 +65,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
bframes: -1,
|
||||
refs: 0,
|
||||
gopSize: 0,
|
||||
npl: 0,
|
||||
temporalAQ: false,
|
||||
cqMode: CQMode.AUTO,
|
||||
twoPass: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from 'src/dtos/tag.dto';
|
||||
import { TagEntity } from 'src/entities/tag.entity';
|
||||
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 { BaseService } from 'src/services/base.service';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
@@ -131,6 +132,7 @@ export class TagService extends BaseService {
|
||||
return results;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.TAG_CLEANUP, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleTagCleanup() {
|
||||
await this.tagRepository.deleteEmptyTags();
|
||||
return JobStatus.SUCCESS;
|
||||
|
||||
@@ -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 { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { TrashResponseDto } from 'src/dtos/trash.dto';
|
||||
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 { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@@ -44,6 +44,7 @@ export class TrashService extends BaseService {
|
||||
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.QUEUE_TRASH_EMPTY, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleQueueEmptyTrash() {
|
||||
let count = 0;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { DateTime } from 'luxon';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.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 { UserEntity } from 'src/entities/user.entity';
|
||||
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 { BaseService } from 'src/services/base.service';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
@@ -163,11 +164,13 @@ export class UserService extends BaseService {
|
||||
return licenseData;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.USER_SYNC_USAGE, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleUserSyncUsage(): Promise<JobStatus> {
|
||||
await this.userRepository.syncUsage();
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.USER_DELETE_CHECK, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleUserDeleteCheck(): Promise<JobStatus> {
|
||||
const users = await this.userRepository.getDeletedUsers();
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
@@ -181,7 +184,8 @@ export class UserService extends BaseService {
|
||||
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 user = await this.userRepository.get(id, { withDeleted: true });
|
||||
if (!user) {
|
||||
|
||||
@@ -2,13 +2,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import semver, { SemVer } from 'semver';
|
||||
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 { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
|
||||
import { ImmichEnvironment, SystemMetadataKey } from 'src/enum';
|
||||
import { DatabaseLock } from 'src/interfaces/database.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';
|
||||
|
||||
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
|
||||
@@ -48,6 +48,7 @@ export class VersionService extends BaseService {
|
||||
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.VERSION_CHECK, queue: QueueName.BACKGROUND_TASK })
|
||||
async handleVersionCheck(): Promise<JobStatus> {
|
||||
try {
|
||||
this.logger.debug('Running version check');
|
||||
|
||||
@@ -16,7 +16,7 @@ export const logGlobalError = (logger: ILoggerRepository, error: Error) => {
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
logger.error(`Unknown error: ${error}`);
|
||||
logger.error(`Unknown error: ${error}`, error?.stack);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -58,10 +58,9 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
break;
|
||||
}
|
||||
case TranscodeHWAccel.RKMPP: {
|
||||
handler =
|
||||
config.accelDecode && hasMaliOpenCL
|
||||
? new RkmppHwDecodeConfig(config, devices)
|
||||
: new RkmppSwDecodeConfig(config, devices);
|
||||
handler = config.accelDecode
|
||||
? new RkmppHwDecodeConfig(config, devices, hasMaliOpenCL)
|
||||
: new RkmppSwDecodeConfig(config, devices);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
@@ -149,7 +148,11 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -271,33 +274,20 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
|
||||
getColors() {
|
||||
return {
|
||||
primaries: '709',
|
||||
transfer: '709',
|
||||
matrix: '709',
|
||||
primaries: 'bt709',
|
||||
transfer: 'bt709',
|
||||
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) {
|
||||
if (!this.shouldToneMap(videoStream)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const colors = this.getColors();
|
||||
|
||||
return [
|
||||
`zscale=t=linear:npl=${this.getNPL()}`,
|
||||
`tonemap=${this.config.tonemap}:desat=0`,
|
||||
`zscale=p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:range=pc`,
|
||||
];
|
||||
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 [options];
|
||||
}
|
||||
|
||||
getAudioCodec(): string {
|
||||
@@ -336,6 +326,32 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
|
||||
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() {
|
||||
return [VideoCodec.H264, VideoCodec.HEVC];
|
||||
}
|
||||
@@ -379,6 +395,20 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
|
||||
|
||||
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 {
|
||||
@@ -395,19 +425,14 @@ export class ThumbnailConfig extends BaseConfig {
|
||||
}
|
||||
|
||||
getFilterOptions(videoStream: VideoStreamInfo): string[] {
|
||||
const options = [
|
||||
return [
|
||||
'fps=12:eof_action=pass:round=down',
|
||||
'thumbnail=12',
|
||||
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
|
||||
'trim=end_frame=2',
|
||||
'reverse',
|
||||
...super.getFilterOptions(videoStream),
|
||||
];
|
||||
if (this.shouldScale(videoStream)) {
|
||||
options.push(`scale=${this.getScaling(videoStream)}`);
|
||||
}
|
||||
|
||||
options.push(...this.getToneMapping(videoStream), 'format=yuv420p');
|
||||
return options;
|
||||
}
|
||||
|
||||
getPresetOptions() {
|
||||
@@ -423,19 +448,7 @@ export class ThumbnailConfig extends BaseConfig {
|
||||
}
|
||||
|
||||
getScaling(videoStream: VideoStreamInfo) {
|
||||
let options = super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int';
|
||||
if (!this.shouldToneMap(videoStream)) {
|
||||
options += ':out_color_matrix=bt601:out_range=pc';
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
getColors() {
|
||||
return {
|
||||
primaries: '709',
|
||||
transfer: '601',
|
||||
matrix: '470bg',
|
||||
};
|
||||
return super.getScaling(videoStream) + ':flags=lanczos+accurate_rnd+full_chroma_int:out_range=pc';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,15 +543,11 @@ export class AV1Config extends BaseConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export class NvencSwDecodeConfig extends BaseHWConfig {
|
||||
export class NvencConfig extends BaseHWConfig {
|
||||
getSupportedCodecs() {
|
||||
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) {
|
||||
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
|
||||
@@ -557,16 +566,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
|
||||
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() {
|
||||
let presetIndex = this.getPresetIndex();
|
||||
if (presetIndex < 0) {
|
||||
@@ -596,10 +595,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
|
||||
}
|
||||
}
|
||||
|
||||
getThreadOptions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
getRefs() {
|
||||
const bframes = this.getBFrames();
|
||||
if (bframes > 0 && bframes < 3 && this.config.refs < 3) {
|
||||
@@ -607,68 +602,59 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
|
||||
}
|
||||
return this.config.refs;
|
||||
}
|
||||
}
|
||||
|
||||
export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
|
||||
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()];
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!this.shouldToneMap(videoStream)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const colors = this.getColors();
|
||||
const { matrix, primaries, transfer } = this.getColors();
|
||||
const tonemapOptions = [
|
||||
'desat=0',
|
||||
`matrix=${colors.matrix}`,
|
||||
`primaries=${colors.primaries}`,
|
||||
`matrix=${matrix}`,
|
||||
`primaries=${primaries}`,
|
||||
'range=pc',
|
||||
`tonemap=${this.config.tonemap}`,
|
||||
`transfer=${colors.transfer}`,
|
||||
'tonemap_mode=lum',
|
||||
`transfer=${transfer}`,
|
||||
'peak=100',
|
||||
'format=nv12',
|
||||
];
|
||||
|
||||
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() {
|
||||
if (this.devices.length === 0) {
|
||||
throw new Error('No QSV device found');
|
||||
}
|
||||
|
||||
let qsvString = '';
|
||||
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) {
|
||||
qsvString = `,child_device=${hwDevice}`;
|
||||
}
|
||||
@@ -685,15 +671,6 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
|
||||
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() {
|
||||
let presetIndex = this.getPresetIndex();
|
||||
if (presetIndex < 0) {
|
||||
@@ -739,41 +716,9 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
|
||||
getScaling(videoStream: VideoStreamInfo): string {
|
||||
return super.getScaling(videoStream, 1);
|
||||
}
|
||||
}
|
||||
|
||||
export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
|
||||
getBaseInputOptions() {
|
||||
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;
|
||||
getScalingFilter(videoStream: VideoStreamInfo, format: string) {
|
||||
return `scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq:format=${format}`;
|
||||
}
|
||||
|
||||
getToneMapping(videoStream: VideoStreamInfo): string[] {
|
||||
@@ -781,15 +726,17 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
|
||||
return [];
|
||||
}
|
||||
|
||||
const colors = this.getColors();
|
||||
const { matrix, primaries, transfer } = this.getColors();
|
||||
const tonemapOptions = [
|
||||
'desat=0',
|
||||
'format=nv12',
|
||||
`matrix=${colors.matrix}`,
|
||||
`primaries=${colors.primaries}`,
|
||||
`matrix=${matrix}`,
|
||||
`primaries=${primaries}`,
|
||||
`transfer=${transfer}`,
|
||||
'range=pc',
|
||||
`tonemap=${this.config.tonemap}`,
|
||||
`transfer=${colors.transfer}`,
|
||||
'tonemap_mode=lum',
|
||||
'peak=100',
|
||||
];
|
||||
|
||||
return [
|
||||
@@ -798,27 +745,27 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
|
||||
'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() {
|
||||
if (this.devices.length === 0) {
|
||||
throw new Error('No VAAPI device found');
|
||||
}
|
||||
|
||||
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) {
|
||||
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'];
|
||||
}
|
||||
|
||||
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() {
|
||||
let presetIndex = this.getPresetIndex();
|
||||
if (presetIndex < 0) {
|
||||
@@ -877,40 +814,9 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
|
||||
useCQP() {
|
||||
return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9;
|
||||
}
|
||||
}
|
||||
|
||||
export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
|
||||
getBaseInputOptions() {
|
||||
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;
|
||||
getScalingFilter(videoStream: VideoStreamInfo, format: string) {
|
||||
return `scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc:format=${format}`;
|
||||
}
|
||||
|
||||
getToneMapping(videoStream: VideoStreamInfo): string[] {
|
||||
@@ -918,15 +824,17 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
|
||||
return [];
|
||||
}
|
||||
|
||||
const colors = this.getColors();
|
||||
const { matrix, primaries, transfer } = this.getColors();
|
||||
const tonemapOptions = [
|
||||
'desat=0',
|
||||
'format=nv12',
|
||||
`matrix=${colors.matrix}`,
|
||||
`primaries=${colors.primaries}`,
|
||||
`matrix=${matrix}`,
|
||||
`primaries=${primaries}`,
|
||||
`transfer=${transfer}`,
|
||||
'range=pc',
|
||||
`tonemap=${this.config.tonemap}`,
|
||||
`transfer=${colors.transfer}`,
|
||||
'tonemap_mode=lum',
|
||||
'peak=100',
|
||||
];
|
||||
|
||||
return [
|
||||
@@ -935,37 +843,33 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
|
||||
'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(
|
||||
protected config: SystemConfigFFmpegDto,
|
||||
devices: string[] = [],
|
||||
hasMaliOpenCL = false,
|
||||
) {
|
||||
super(config, devices);
|
||||
this.hasMaliOpenCL = hasMaliOpenCL;
|
||||
}
|
||||
|
||||
eligibleForTwoPass(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
getBaseInputOptions(): string[] {
|
||||
getBaseInputOptions() {
|
||||
if (this.devices.length === 0) {
|
||||
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() {
|
||||
@@ -998,41 +902,30 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
|
||||
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) {
|
||||
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 [
|
||||
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`,
|
||||
'hwmap=derive_device=opencl:mode=read',
|
||||
`tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`,
|
||||
'hwmap=derive_device=rkmpp:mode=write:reverse=1',
|
||||
'format=drm_prime',
|
||||
// use RKMPP for scaling, CPU for tone mapping (only works on RK3588, which supports 10-bit output)
|
||||
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,
|
||||
'hwdownload',
|
||||
'format=p010',
|
||||
`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)) {
|
||||
return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`];
|
||||
return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1:async_depth=4`];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
getColors() {
|
||||
return {
|
||||
primaries: 'bt709',
|
||||
transfer: 'bt709',
|
||||
matrix: 'bt709',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,32 @@ import { CLIP_MODEL_INFO, serverVersion } from 'src/constants';
|
||||
import { ImmichCookie, ImmichHeader, MetadataKey } from 'src/enum';
|
||||
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) =>
|
||||
server.externalDomain || `http://localhost:${port}`;
|
||||
|
||||
|
||||
@@ -13,8 +13,7 @@ import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { isStartUpError } from 'src/services/storage.service';
|
||||
import { useSwagger } from 'src/utils/misc';
|
||||
import { isStartUpError, useSwagger } from 'src/utils/misc';
|
||||
|
||||
async function bootstrap() {
|
||||
process.title = 'immich-api';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
import { ConfigRepository } from 'src/repositories/config.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() {
|
||||
const { telemetry } = new ConfigRepository().getEnv();
|
||||
|
||||
25
server/test/fixtures/media.stub.ts
vendored
25
server/test/fixtures/media.stub.ts
vendored
@@ -17,6 +17,7 @@ const probeStubDefaultVideoStream: VideoStreamInfo[] = [
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -43,6 +44,7 @@ export const probeStub = {
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
@@ -53,6 +55,7 @@ export const probeStub = {
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -68,6 +71,7 @@ export const probeStub = {
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -83,6 +87,7 @@ export const probeStub = {
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -102,6 +107,23 @@ export const probeStub = {
|
||||
rotation: 0,
|
||||
isHDR: true,
|
||||
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,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -132,6 +155,7 @@ export const probeStub = {
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -147,6 +171,7 @@ export const probeStub = {
|
||||
rotation: 0,
|
||||
isHDR: false,
|
||||
bitrate: 0,
|
||||
pixelFormat: 'yuv420p',
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Mocked, vitest } from 'vitest';
|
||||
|
||||
export const newJobRepositoryMock = (): Mocked<IJobRepository> => {
|
||||
return {
|
||||
addHandler: vitest.fn(),
|
||||
setup: vitest.fn(),
|
||||
startWorkers: vitest.fn(),
|
||||
run: vitest.fn(),
|
||||
addCronJob: vitest.fn(),
|
||||
updateCronJob: vitest.fn(),
|
||||
setConcurrency: vitest.fn(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { Writable } from 'node:stream';
|
||||
import { PNG } from 'pngjs';
|
||||
import { ImmichWorker } from 'src/enum';
|
||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
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 { Mocked, vitest } from 'vitest';
|
||||
|
||||
type RepositoryOverrides = {
|
||||
metadataRepository: IMetadataRepository;
|
||||
type Overrides = {
|
||||
worker?: ImmichWorker;
|
||||
metadataRepository?: IMetadataRepository;
|
||||
};
|
||||
type BaseServiceArgs = ConstructorParameters<typeof BaseService>;
|
||||
type Constructor<Type, Args extends Array<any>> = {
|
||||
@@ -54,9 +56,11 @@ type Constructor<Type, Args extends Array<any>> = {
|
||||
|
||||
export const newTestService = <T extends BaseService>(
|
||||
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 loggerMock = newLoggerRepositoryMock();
|
||||
@@ -98,6 +102,7 @@ export const newTestService = <T extends BaseService>(
|
||||
const viewMock = newViewRepositoryMock();
|
||||
|
||||
const sut = new Service(
|
||||
worker,
|
||||
loggerMock,
|
||||
accessMock,
|
||||
activityMock,
|
||||
|
||||
@@ -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
|
||||
USER node
|
||||
|
||||
250
web/package-lock.json
generated
250
web/package-lock.json
generated
@@ -23,9 +23,9 @@
|
||||
"justified-layout": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"luxon": "^3.4.4",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"socket.io-client": "~4.7.5",
|
||||
"svelte-gestures": "^5.0.4",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-local-storage-store": "^0.6.4",
|
||||
"svelte-maplibre": "^0.9.13",
|
||||
"thumbhash": "^0.1.1"
|
||||
@@ -35,12 +35,12 @@
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@faker-js/faker": "^9.0.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/kit": "^2.5.18",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@sveltejs/kit": "^2.7.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@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",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/justified-layout": "^4.1.4",
|
||||
@@ -63,7 +63,7 @@
|
||||
"prettier-plugin-sort-json": "^4.0.0",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte": "^5.1.5",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tslib": "^2.6.2",
|
||||
@@ -80,7 +80,7 @@
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.8.0",
|
||||
"@types/node": "^22.8.1",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
@@ -1994,43 +1994,42 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz",
|
||||
"integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.0.tgz",
|
||||
"integrity": "sha512-kpVJwF+gNiMEsoHaw+FJL76IYiwBikkxYU83+BpqQLdVMff19KeRKLd2wisS8niNBMJ2omv5gG+iGDDwd8jzag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^2.1.0",
|
||||
"debug": "^4.3.4",
|
||||
"@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0",
|
||||
"debug": "^4.3.7",
|
||||
"deepmerge": "^4.3.1",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.10",
|
||||
"svelte-hmr": "^0.16.0",
|
||||
"vitefu": "^0.2.5"
|
||||
"magic-string": "^0.30.12",
|
||||
"vitefu": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20"
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"svelte": "^5.0.0-next.96 || ^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/vite-plugin-svelte-inspector": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz",
|
||||
"integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==",
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz",
|
||||
"integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4"
|
||||
"debug": "^4.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20"
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
||||
"svelte": "^4.0.0 || ^5.0.0-next.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0",
|
||||
"svelte": "^5.0.0-next.96 || ^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
},
|
||||
@@ -2230,7 +2229,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.2.4.tgz",
|
||||
"integrity": "sha512-EFdy73+lULQgMJ1WolAymrxWWrPv9DWyDuDFKKlUip2PA/EXuHptzfYOKWljccFWDKhhGOu3dqNmoc2f/h/Ecg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.0.0"
|
||||
},
|
||||
@@ -2797,6 +2795,15 @@
|
||||
"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": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz",
|
||||
@@ -2883,6 +2890,7 @@
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
|
||||
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
}
|
||||
@@ -2960,11 +2968,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
|
||||
"integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
@@ -3271,18 +3280,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": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
@@ -3404,18 +3401,6 @@
|
||||
"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": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||
@@ -3505,11 +3490,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
|
||||
"integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.1.2"
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
@@ -3656,22 +3642,23 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/engine.io-client": {
|
||||
"version": "6.6.1",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.1.tgz",
|
||||
"integrity": "sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==",
|
||||
"version": "6.5.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
|
||||
"integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.17.1",
|
||||
"xmlhttprequest-ssl": "~2.1.1"
|
||||
"xmlhttprequest-ssl": "~2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.1.tgz",
|
||||
"integrity": "sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ==",
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
@@ -4151,8 +4138,7 @@
|
||||
"node_modules/esm-env": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz",
|
||||
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==",
|
||||
"dev": true
|
||||
"integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA=="
|
||||
},
|
||||
"node_modules/esniff": {
|
||||
"version": "2.0.1",
|
||||
@@ -4197,6 +4183,16 @@
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
|
||||
@@ -4222,6 +4218,7 @@
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
@@ -4962,6 +4959,7 @@
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz",
|
||||
"integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "*"
|
||||
}
|
||||
@@ -5329,9 +5327,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.11",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
|
||||
"integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
|
||||
"version": "0.30.12",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
|
||||
"integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0"
|
||||
@@ -5475,11 +5473,6 @@
|
||||
"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": {
|
||||
"version": "0.4.17",
|
||||
"resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz",
|
||||
@@ -5601,9 +5594,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
@@ -5943,16 +5937,6 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
|
||||
@@ -6860,14 +6844,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-client": {
|
||||
"version": "4.8.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
|
||||
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
|
||||
"version": "4.7.5",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.5.tgz",
|
||||
"integrity": "sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.6.1",
|
||||
"engine.io-client": "~6.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -6930,6 +6914,7 @@
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -7176,28 +7161,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "4.2.19",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz",
|
||||
"integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==",
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.1.5.tgz",
|
||||
"integrity": "sha512-AyYondx6wS0g8mmBMfwJVnOYYBswjBv6L4bc99awfbET2KozWvVwxe8NSN7fhx7Pgr7pOfOXIv7K8+Impc0OoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@jridgewell/trace-mapping": "^0.3.18",
|
||||
"@types/estree": "^1.0.1",
|
||||
"acorn": "^8.9.0",
|
||||
"aria-query": "^5.3.0",
|
||||
"axobject-query": "^4.0.0",
|
||||
"code-red": "^1.0.3",
|
||||
"css-tree": "^2.3.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
"is-reference": "^3.0.1",
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
"@types/estree": "^1.0.5",
|
||||
"acorn": "^8.12.1",
|
||||
"acorn-typescript": "^1.4.13",
|
||||
"aria-query": "^5.3.1",
|
||||
"axobject-query": "^4.1.0",
|
||||
"esm-env": "^1.0.0",
|
||||
"esrap": "^1.2.2",
|
||||
"is-reference": "^3.0.2",
|
||||
"locate-character": "^3.0.0",
|
||||
"magic-string": "^0.30.4",
|
||||
"periscopic": "^3.1.0"
|
||||
"magic-string": "^0.30.11",
|
||||
"zimmerframe": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-check": {
|
||||
@@ -7318,18 +7302,6 @@
|
||||
"integrity": "sha512-kElJnoZrQtlkXE0O/RcKioz9NP0Sxx05j31ohyosNkydo6NOEsZB85mhoaCxOQNjxN+QPumYWfmIUsznYFjihA==",
|
||||
"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": {
|
||||
"version": "4.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||
@@ -8420,12 +8401,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz",
|
||||
"integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==",
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.3.tgz",
|
||||
"integrity": "sha512-iKKfOMBHob2WxEJbqbJjHAkmYgvFDPhuqrO82om83S8RLk+17FtyMBfcyeH8GqD0ihShtkMW/zzJgiA51hCNCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"tests/deps/*",
|
||||
"tests/projects/*"
|
||||
],
|
||||
"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": {
|
||||
"vite": {
|
||||
@@ -8757,9 +8743,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/xmlhttprequest-ssl": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.1.tgz",
|
||||
"integrity": "sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
|
||||
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
@@ -8820,6 +8806,12 @@
|
||||
"funding": {
|
||||
"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,7 +8,7 @@
|
||||
"build:stats": "BUILD_STATS=true vite build",
|
||||
"package": "svelte-kit package",
|
||||
"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:watch": "npm run check:svelte -- --watch",
|
||||
"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",
|
||||
"@faker-js/faker": "^9.0.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/kit": "^2.5.18",
|
||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||
"@sveltejs/kit": "^2.7.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@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",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/justified-layout": "^4.1.4",
|
||||
@@ -55,7 +55,7 @@
|
||||
"prettier-plugin-sort-json": "^4.0.0",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte": "^5.1.5",
|
||||
"svelte-check": "^4.0.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tslib": "^2.6.2",
|
||||
@@ -79,9 +79,9 @@
|
||||
"justified-layout": "^4.1.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"luxon": "^3.4.4",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"socket.io-client": "~4.7.5",
|
||||
"svelte-gestures": "^5.0.4",
|
||||
"svelte-i18n": "^4.0.0",
|
||||
"svelte-i18n": "^4.0.1",
|
||||
"svelte-local-storage-store": "^0.6.4",
|
||||
"svelte-maplibre": "^0.9.13",
|
||||
"thumbhash": "^0.1.1"
|
||||
|
||||
17
web/src/lib/__mocks__/animate.mock.ts
Normal file
17
web/src/lib/__mocks__/animate.mock.ts
Normal 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,
|
||||
mdiSelectionSearch,
|
||||
} from '@mdi/js';
|
||||
import { type ComponentType } from 'svelte';
|
||||
import { type Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import JobTileButton from './job-tile-button.svelte';
|
||||
import JobTileStatus from './job-tile-status.svelte';
|
||||
|
||||
export let title: string;
|
||||
export let subtitle: string | undefined;
|
||||
export let description: ComponentType | undefined;
|
||||
export let description: Component | undefined;
|
||||
export let jobCounts: JobCountsDto;
|
||||
export let queueStatus: QueueStatusDto;
|
||||
export let icon: string;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
mdiTagFaces,
|
||||
mdiVideo,
|
||||
} from '@mdi/js';
|
||||
import type { ComponentType } from 'svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import JobTile from './job-tile.svelte';
|
||||
import StorageMigrationDescription from './storage-migration-description.svelte';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
@@ -30,7 +30,7 @@
|
||||
interface JobDetails {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
description?: ComponentType;
|
||||
description?: Component;
|
||||
allText?: string;
|
||||
refreshText?: string;
|
||||
missingText: string;
|
||||
@@ -56,6 +56,7 @@
|
||||
await handleCommand(jobId, dto);
|
||||
};
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: jobDetails = <Partial<Record<JobName, JobDetails>>>{
|
||||
[JobName.ThumbnailGeneration]: {
|
||||
icon: mdiFileJpgBox,
|
||||
|
||||
@@ -343,15 +343,6 @@
|
||||
subtitle={$t('admin.transcoding_advanced_options_description')}
|
||||
>
|
||||
<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
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.transcoding_max_b_frames')}
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: {
|
||||
if (selectedGroupOption.id === AlbumGroupBy.None) {
|
||||
groupIcon = mdiFolderRemoveOutline;
|
||||
@@ -96,8 +97,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: sortIcon = $albumViewSettings.sortOrder === SortOrder.Desc ? mdiArrowDownThin : mdiArrowUpThin;
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: albumFilterNames = ((): Record<AlbumFilter, string> => {
|
||||
return {
|
||||
[AlbumFilter.All]: $t('all'),
|
||||
@@ -106,6 +109,7 @@
|
||||
};
|
||||
})();
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: albumSortByNames = ((): Record<AlbumSortBy, string> => {
|
||||
return {
|
||||
[AlbumSortBy.Title]: $t('sort_title'),
|
||||
@@ -117,6 +121,7 @@
|
||||
};
|
||||
})();
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: albumGroupByNames = ((): Record<AlbumGroupBy, string> => {
|
||||
return {
|
||||
[AlbumGroupBy.None]: $t('group_no'),
|
||||
|
||||
@@ -135,6 +135,7 @@
|
||||
let isOpen = false;
|
||||
|
||||
// Step 1: Filter between Owned and Shared albums, or both.
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: {
|
||||
switch (userSettings.filter) {
|
||||
case AlbumFilter.Owned: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
$albumViewSettings.sortOrder = option.defaultOrder;
|
||||
}
|
||||
};
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: albumSortByNames = ((): Record<AlbumSortBy, string> => {
|
||||
return {
|
||||
[AlbumSortBy.Title]: $t('sort_title'),
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
class="h-[18px] {disabled
|
||||
? 'cursor-not-allowed'
|
||||
: ''} w-full max-h-56 pr-2 items-center overflow-y-auto leading-4 outline-none resize-none bg-gray-200"
|
||||
/>
|
||||
></textarea>
|
||||
</div>
|
||||
{#if isSendingMessage}
|
||||
<div class="flex items-end place-items-center pb-2 ml-0">
|
||||
|
||||
@@ -15,13 +15,31 @@ describe('AssetViewerNavBar component', () => {
|
||||
showShareButton: false,
|
||||
onZoomImage: () => {},
|
||||
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(() => {
|
||||
vi.resetAllMocks();
|
||||
resetSavedUser();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('shows back button', () => {
|
||||
const asset = assetFactory.build({ isTrashed: false });
|
||||
const { getByTitle } = render(AssetViewerNavBar, { asset, ...additionalProps });
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
|
||||
const sharedLink = getSharedLink();
|
||||
$: isOwner = $user && asset.ownerId === $user?.id;
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: showDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
|
||||
// $: showEditorButton =
|
||||
// isOwner &&
|
||||
|
||||
@@ -598,7 +598,7 @@
|
||||
|
||||
{#if stackedAsset.id == asset.id}
|
||||
<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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
<div class="border border-t-0 border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||
<p>
|
||||
{#if $user?.isAdmin}
|
||||
<p>{$t('admin.asset_offline_description')}</p>
|
||||
{$t('admin.asset_offline_description')}
|
||||
{:else}
|
||||
{$t('asset_offline_description')}
|
||||
{/if}
|
||||
@@ -345,43 +345,45 @@
|
||||
</Portal>
|
||||
{/if}
|
||||
|
||||
{#if asset.exifInfo?.fileSizeInByte}
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><Icon path={mdiImageOutline} size="24" /></div>
|
||||
<div class="flex gap-4 py-4">
|
||||
<div><Icon path={mdiImageOutline} size="24" /></div>
|
||||
|
||||
<div>
|
||||
<p class="break-all flex place-items-center gap-2">
|
||||
{asset.originalFileName}
|
||||
{#if isOwner}
|
||||
<CircleIconButton
|
||||
icon={mdiInformationOutline}
|
||||
title={$t('show_file_location')}
|
||||
size="16"
|
||||
padding="2"
|
||||
on:click={toggleAssetPath}
|
||||
/>
|
||||
{/if}
|
||||
<div>
|
||||
<p class="break-all flex place-items-center gap-2">
|
||||
{asset.originalFileName}
|
||||
{#if isOwner}
|
||||
<CircleIconButton
|
||||
icon={mdiInformationOutline}
|
||||
title={$t('show_file_location')}
|
||||
size="16"
|
||||
padding="2"
|
||||
on:click={toggleAssetPath}
|
||||
/>
|
||||
{/if}
|
||||
</p>
|
||||
{#if showAssetPath}
|
||||
<p class="text-xs opacity-50 break-all pb-2" transition:slide={{ duration: 250 }}>
|
||||
{asset.originalPath}
|
||||
</p>
|
||||
{/if}
|
||||
{#if (asset.exifInfo?.exifImageHeight && asset.exifInfo?.exifImageWidth) || asset.exifInfo?.fileSizeInByte}
|
||||
<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)}
|
||||
<p>
|
||||
{getMegapixel(asset.exifInfo.exifImageHeight, asset.exifInfo.exifImageWidth)} MP
|
||||
</p>
|
||||
{@const { width, height } = getDimensions(asset.exifInfo)}
|
||||
<p>{width} x {height}</p>
|
||||
{/if}
|
||||
{@const { width, height } = getDimensions(asset.exifInfo)}
|
||||
<p>{width} x {height}</p>
|
||||
{/if}
|
||||
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
|
||||
{#if asset.exifInfo?.fileSizeInByte}
|
||||
<p>{getByteUnitString(asset.exifInfo.fileSizeInByte, $locale)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{#if showAssetPath}
|
||||
<p class="text-xs opacity-50 break-all" transition:slide={{ duration: 250 }}>
|
||||
{asset.originalPath}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if asset.exifInfo?.make || asset.exifInfo?.model || asset.exifInfo?.fNumber}
|
||||
<div class="flex gap-4 py-4">
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</div>
|
||||
<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] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`} />
|
||||
<div class="h-[7px] rounded-full bg-immich-primary" style={`width: ${download.percentage}%`}></div>
|
||||
</div>
|
||||
<p class="min-w-[4em] whitespace-nowrap text-right">
|
||||
<span class="text-immich-primary">
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
export let onClose: () => void;
|
||||
|
||||
let selectedType: string = editTypes[0].name;
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
$: selectedTypeObj = editTypes.find((t) => t.name === selectedType) || editTypes[0];
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -55,4 +55,4 @@
|
||||
});
|
||||
</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 * as utils from '$lib/utils';
|
||||
import { AssetMediaSize } from '@immich/sdk';
|
||||
@@ -24,6 +25,10 @@ describe('PhotoViewer component', () => {
|
||||
getAssetThumbnailUrlSpy = vi.spyOn(utils, 'getAssetThumbnailUrl');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
Element.prototype.animate = getAnimateMock();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
<div
|
||||
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;"
|
||||
/>
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { render } from '@testing-library/svelte';
|
||||
|
||||
describe('ImageThumbnail component', () => {
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLImageElement.prototype, 'complete', {
|
||||
value: true,
|
||||
});
|
||||
Element.prototype.animate = vi.fn().mockImplementation(() => ({
|
||||
cancel: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
it('shows thumbhash while image is loading', () => {
|
||||
|
||||
@@ -96,5 +96,5 @@
|
||||
class:rounded-full={circle}
|
||||
draggable="false"
|
||||
out:fade={{ duration: THUMBHASH_FADE_DURATION }}
|
||||
/>
|
||||
></canvas>
|
||||
{/if}
|
||||
|
||||
@@ -218,6 +218,7 @@
|
||||
href={currentUrlReplaceAssetId(asset.id)}
|
||||
on:click={(evt) => evt.preventDefault()}
|
||||
tabindex={0}
|
||||
aria-label="Thumbnail URL"
|
||||
>
|
||||
</a>
|
||||
{/if}
|
||||
@@ -255,12 +256,12 @@
|
||||
<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:rounded-xl={selected}
|
||||
/>
|
||||
></div>
|
||||
|
||||
<!-- Outline on focus -->
|
||||
<div
|
||||
class="absolute size-full group-focus-visible:outline outline-4 -outline-offset-4 outline-immich-primary"
|
||||
/>
|
||||
></div>
|
||||
|
||||
<!-- Favorite asset star -->
|
||||
{#if !isSharedLink() && asset.isFavorite}
|
||||
@@ -339,7 +340,7 @@
|
||||
class="absolute top-0 h-full w-full bg-immich-primary opacity-40"
|
||||
in:fade={{ duration: 100 }}
|
||||
out:fade={{ duration: 100 }}
|
||||
/>
|
||||
></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -113,7 +113,10 @@
|
||||
}}
|
||||
on:timeupdate={({ currentTarget }) => {
|
||||
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}
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
{renderedOption.title}
|
||||
</p>
|
||||
{:else}
|
||||
<div />
|
||||
<div></div>
|
||||
<p class="justify-self-start">
|
||||
{renderedOption.title}
|
||||
</p>
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
{#if disabled}
|
||||
<span
|
||||
class="slider slider-disabled cursor-not-allowed border border-transparent before:border before:border-transparent"
|
||||
/>
|
||||
></span>
|
||||
{:else}
|
||||
<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"
|
||||
/>
|
||||
></span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
|
||||
@@ -36,14 +36,14 @@
|
||||
class:hover:opacity-100={selectable}
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
/>
|
||||
></div>
|
||||
|
||||
{#if selected}
|
||||
<div
|
||||
class="absolute left-0 top-0 h-full w-full bg-blue-500/80"
|
||||
class:rounded-full={circle}
|
||||
class:rounded-lg={!circle}
|
||||
/>
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
{#if person.name}
|
||||
|
||||
@@ -44,6 +44,8 @@
|
||||
return personIsHidden;
|
||||
};
|
||||
|
||||
// svelte-ignore reactive_declaration_non_reactive_property
|
||||
// svelte-ignore reactive_declaration_module_script_dependency
|
||||
$: toggleButtonOptions = ((): Record<ToggleVisibility, { icon: string; label: string }> => {
|
||||
return {
|
||||
[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
Reference in New Issue
Block a user