diff --git a/README.md b/README.md index 44c38e6d14813..5c4b9c39edd1d 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Português Brasileiro Svenska العربية +Tiếng Việt

diff --git a/cli/package-lock.json b/cli/package-lock.json index c66d663576e6c..e508fe843f60d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.22", "license": "GNU Affero General Public License version 3", "dependencies": { "fast-glob": "^3.3.2", @@ -52,7 +52,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/cli/package.json b/cli/package.json index ba2f8468226d4..522a8e593e9e7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.22", "description": "Command Line Interface (CLI) for Immich", "type": "module", "exports": "./dist/index.js", diff --git a/docs/docs/FAQ.mdx b/docs/docs/FAQ.mdx index b1a24e1788a2f..b328d3a047099 100644 --- a/docs/docs/FAQ.mdx +++ b/docs/docs/FAQ.mdx @@ -187,7 +187,7 @@ However, when the trash is emptied, the files will re-appear in the main timelin ### How does smart search work? -Immich uses CLIP models. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). +Immich uses CLIP models. An ML model converts each image to an "embedding", which is essentially a string of numbers that semantically encodes what is in the image. The same is done for the text that you enter when you do a search, and that text embedding is then compared with those of the images to find similar ones. As such, there are no "tags", "labels", or "descriptions" generated that you can look at. For more information about CLIP and its capabilities, read about it [here](https://openai.com/research/clip). ### How does facial recognition work? @@ -333,7 +333,11 @@ You may need to add mount points or docker volumes for the following internal co - `immich-machine-learning:/.cache` - `redis:/data` -The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`. +The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION` and `/cache` for machine-learning. + +:::note Docker Compose Volumes +The Docker Compose top level volume element does not support non-root access, all of the above volumes must be local volume mounts. +::: For a further hardened system, you can add the following block to every container except for `immich_postgres`. diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index 992aaa6d4b5d2..36a8fed81df1e 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,4 +1,12 @@ [ + { + "label": "v1.116.2", + "url": "https://v1.116.2.archive.immich.app" + }, + { + "label": "v1.116.1", + "url": "https://v1.116.1.archive.immich.app" + }, { "label": "v1.116.0", "url": "https://v1.116.0.archive.immich.app" diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 63ad7be469cf7..e7b463b0b2696 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@eslint/eslintrc": "^3.1.0", @@ -45,7 +45,7 @@ }, "../cli": { "name": "@immich/cli", - "version": "2.2.20", + "version": "2.2.22", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { @@ -92,7 +92,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.2", "dev": true, "license": "GNU Affero General Public License version 3", "dependencies": { diff --git a/e2e/package.json b/e2e/package.json index 80bf261a03512..7c0025902dd3a 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.116.0", + "version": "1.116.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile index e394091ae13f8..d982962fbcdc6 100644 --- a/machine-learning/Dockerfile +++ b/machine-learning/Dockerfile @@ -1,6 +1,6 @@ ARG DEVICE=cpu -FROM python:3.11-bookworm@sha256:157a371e60389919fe4a72dff71ce86eaa5234f59114c23b0b346d0d02c74d39 AS builder-cpu +FROM python:3.11-bookworm@sha256:e456ff58048f52f121025159e68bf16248c4122c8b96fadffd89331df50c9994 AS builder-cpu FROM builder-cpu AS builder-openvino @@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv COPY poetry.lock pyproject.toml ./ RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev -FROM python:3.11-slim-bookworm@sha256:669bbd08353610485a94d5d0c976b4b6498c55280fe42c00f7581f85ee9f3121 AS prod-cpu +FROM python:3.11-slim-bookworm@sha256:585cf0799407efc267fe1cce318322ec26e015ac1b3d77f2517d50bc3acfc232 AS prod-cpu FROM prod-cpu AS prod-openvino diff --git a/machine-learning/export/Dockerfile b/machine-learning/export/Dockerfile index 0754f882f3ae0..195e64ab35ad6 100644 --- a/machine-learning/export/Dockerfile +++ b/machine-learning/export/Dockerfile @@ -1,4 +1,4 @@ -FROM mambaorg/micromamba:bookworm-slim@sha256:5f32c5742e2248f2ca07ccae6861371321aba37372bf8e1a80d6f728f1ab4418 AS builder +FROM mambaorg/micromamba:bookworm-slim@sha256:e3797091302382ea841498bc93a7b0a50f7c1448333d5e946d2d1608d0c5f43d AS builder ENV TRANSFORMERS_CACHE=/cache \ PYTHONDONTWRITEBYTECODE=1 \ diff --git a/machine-learning/poetry.lock b/machine-learning/poetry.lock index 84c9ae5d31151..5bb1726378050 100644 --- a/machine-learning/poetry.lock +++ b/machine-learning/poetry.lock @@ -680,13 +680,13 @@ test = ["pytest (>=6)"] [[package]] name = "fastapi-slim" -version = "0.114.2" +version = "0.115.0" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi_slim-0.114.2-py3-none-any.whl", hash = "sha256:52ae76c53a30ad0fa96beb84c1bf4bef9c72e88c2f7c0473e836f01d7ac3ca6b"}, - {file = "fastapi_slim-0.114.2.tar.gz", hash = "sha256:76d0a450826fb0fa740268be55ef04c44807da87a94fbbf5f16338b5a4a2d321"}, + {file = "fastapi_slim-0.115.0-py3-none-any.whl", hash = "sha256:27ab44da95b622e68be7a19f06df1960a320b9d94e689b0adfc055bb26ee9be7"}, + {file = "fastapi_slim-0.115.0.tar.gz", hash = "sha256:b4b962ca2aa0a31010dafdad3d4da99d368a5591223304c6fb385712fad7feb6"}, ] [package.dependencies] @@ -2037,22 +2037,22 @@ reference = "cuda12" [[package]] name = "onnxruntime-openvino" -version = "1.18.0" +version = "1.19.0" description = "ONNX Runtime is a runtime accelerator for Machine Learning models" optional = false python-versions = "*" files = [ - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:565b874d21bcd48126da7d62f57db019f5ec0e1f82ae9b0740afa2ad91f8d331"}, - {file = "onnxruntime_openvino-1.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:7f1931060f710a6c8e32121bb73044c4772ef5925802fc8776d3fe1e87ab3f75"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:eb1723d386f70a8e26398d983ebe35d2c25ba56e9cdb382670ebbf1f5139f8ba"}, - {file = "onnxruntime_openvino-1.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:874a1e263dd86674593e5a879257650b06a8609c4d5768c3d8ed8dc4ae874b9c"}, - {file = "onnxruntime_openvino-1.18.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:597eb18f3de7ead69b08a242d74c4573b28bbfba40ca2a1a40f75bf7a834808e"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8c5658da819b26d9f35f95204e1bdfb74a100a7533e74edab3af6316c1e316e8"}, + {file = "onnxruntime_openvino-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb8de2a60cf78db6e201b0a489479995d166938e9c53b01ff342dc7f5f8251ff"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f3a0b954026286421b3a769c746c403e8f141f3887d1dd601beb7c4dbf77488a"}, + {file = "onnxruntime_openvino-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:12330922ecdb694ea28dbdcf08c172e47a5a84fee603040691341336ee3e42bc"}, + {file = "onnxruntime_openvino-1.19.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:be00502b1a46ba1891cbe49049033745f71c0b99df6d24b979f5b4084b9567d0"}, ] [package.dependencies] coloredlogs = "*" flatbuffers = "*" -numpy = ">=1.26.4" +numpy = ">=1.21.6" packaging = "*" protobuf = "*" sympy = "*" @@ -2576,18 +2576,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.9" +version = "0.0.10" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" files = [ - {file = "python_multipart-0.0.9-py3-none-any.whl", hash = "sha256:97ca7b8ea7b05f977dc3849c3ba99d51689822fab725c3703af7c866a0c2b215"}, - {file = "python_multipart-0.0.9.tar.gz", hash = "sha256:03f54688c663f1b7977105f021043b0793151e4cb1c1a9d4a11fc13d622c4026"}, + {file = "python_multipart-0.0.10-py3-none-any.whl", hash = "sha256:2b06ad9e8d50c7a8db80e3b56dab590137b323410605af2be20d62a5f1ba1dc8"}, + {file = "python_multipart-0.0.10.tar.gz", hash = "sha256:46eb3c6ce6fdda5fb1a03c7e11d490e407c6930a2703fe7aef4da71c374688fa"}, ] -[package.extras] -dev = ["atomicwrites (==1.4.1)", "attrs (==23.2.0)", "coverage (==7.4.1)", "hatch", "invoke (==2.2.0)", "more-itertools (==10.2.0)", "pbr (==6.0.0)", "pluggy (==1.4.0)", "py (==1.11.0)", "pytest (==8.0.0)", "pytest-cov (==4.1.0)", "pytest-timeout (==2.2.0)", "pyyaml (==6.0.1)", "ruff (==0.2.1)"] - [[package]] name = "pywin32" version = "306" @@ -2834,29 +2831,29 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "ruff" -version = "0.6.6" +version = "0.6.8" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.6-py3-none-linux_armv6l.whl", hash = "sha256:f5bc5398457484fc0374425b43b030e4668ed4d2da8ee7fdda0e926c9f11ccfb"}, - {file = "ruff-0.6.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:515a698254c9c47bb84335281a170213b3ee5eb47feebe903e1be10087a167ce"}, - {file = "ruff-0.6.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6bb1b4995775f1837ab70f26698dd73852bbb82e8f70b175d2713c0354fe9182"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c546f412dfae8bb9cc4f27f0e45cdd554e42fecbb34f03312b93368e1cd0a6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59627e97364329e4eae7d86fa7980c10e2b129e2293d25c478ebcb861b3e3fd6"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94c3f78c3d32190aafbb6bc5410c96cfed0a88aadb49c3f852bbc2aa9783a7d8"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:704da526c1e137f38c8a067a4a975fe6834b9f8ba7dbc5fd7503d58148851b8f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efeede5815a24104579a0f6320660536c5ffc1c91ae94f8c65659af915fb9de9"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e368aef0cc02ca3593eae2fb8186b81c9c2b3f39acaaa1108eb6b4d04617e61f"}, - {file = "ruff-0.6.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2653fc3b2a9315bd809725c88dd2446550099728d077a04191febb5ea79a4f79"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bb858cd9ce2d062503337c5b9784d7b583bcf9d1a43c4df6ccb5eab774fbafcb"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:488f8e15c01ea9afb8c0ba35d55bd951f484d0c1b7c5fd746ce3c47ccdedce68"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:aefb0bd15f1cfa4c9c227b6120573bb3d6c4ee3b29fb54a5ad58f03859bc43c6"}, - {file = "ruff-0.6.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a4c0698cc780bcb2c61496cbd56b6a3ac0ad858c966652f7dbf4ceb029252fbe"}, - {file = "ruff-0.6.6-py3-none-win32.whl", hash = "sha256:aadf81ddc8ab5b62da7aae78a91ec933cbae9f8f1663ec0325dae2c364e4ad84"}, - {file = "ruff-0.6.6-py3-none-win_amd64.whl", hash = "sha256:0adb801771bc1f1b8cf4e0a6fdc30776e7c1894810ff3b344e50da82ef50eeb1"}, - {file = "ruff-0.6.6-py3-none-win_arm64.whl", hash = "sha256:4b4d32c137bc781c298964dd4e52f07d6f7d57c03eae97a72d97856844aa510a"}, - {file = "ruff-0.6.6.tar.gz", hash = "sha256:0fc030b6fd14814d69ac0196396f6761921bd20831725c7361e1b8100b818034"}, + {file = "ruff-0.6.8-py3-none-linux_armv6l.whl", hash = "sha256:77944bca110ff0a43b768f05a529fecd0706aac7bcce36d7f1eeb4cbfca5f0f2"}, + {file = "ruff-0.6.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:27b87e1801e786cd6ede4ada3faa5e254ce774de835e6723fd94551464c56b8c"}, + {file = "ruff-0.6.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cd48f945da2a6334f1793d7f701725a76ba93bf3d73c36f6b21fb04d5338dcf5"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:677e03c00f37c66cea033274295a983c7c546edea5043d0c798833adf4cf4c6f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9f1476236b3eacfacfc0f66aa9e6cd39f2a624cb73ea99189556015f27c0bdeb"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f5a2f17c7d32991169195d52a04c95b256378bbf0de8cb98478351eb70d526f"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5fd0d4b7b1457c49e435ee1e437900ced9b35cb8dc5178921dfb7d98d65a08d0"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8034b19b993e9601f2ddf2c517451e17a6ab5cdb1c13fdff50c1442a7171d87"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6cfb227b932ba8ef6e56c9f875d987973cd5e35bc5d05f5abf045af78ad8e098"}, + {file = "ruff-0.6.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef0411eccfc3909269fed47c61ffebdcb84a04504bafa6b6df9b85c27e813b0"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:007dee844738c3d2e6c24ab5bc7d43c99ba3e1943bd2d95d598582e9c1b27750"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ce60058d3cdd8490e5e5471ef086b3f1e90ab872b548814e35930e21d848c9ce"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1085c455d1b3fdb8021ad534379c60353b81ba079712bce7a900e834859182fa"}, + {file = "ruff-0.6.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:70edf6a93b19481affd287d696d9e311388d808671bc209fb8907b46a8c3af44"}, + {file = "ruff-0.6.8-py3-none-win32.whl", hash = "sha256:792213f7be25316f9b46b854df80a77e0da87ec66691e8f012f887b4a671ab5a"}, + {file = "ruff-0.6.8-py3-none-win_amd64.whl", hash = "sha256:ec0517dc0f37cad14a5319ba7bba6e7e339d03fbf967a6d69b0907d61be7a263"}, + {file = "ruff-0.6.8-py3-none-win_arm64.whl", hash = "sha256:8d3bb2e3fbb9875172119021a13eed38849e762499e3cfde9588e4b4d70968dc"}, + {file = "ruff-0.6.8.tar.gz", hash = "sha256:a5bf44b1aa0adaf6d9d20f86162b34f7c593bfedabc51239953e446aefc8ce18"}, ] [[package]] diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 8d1539a79b07d..840aa93c06453 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.116.0" +version = "1.116.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 6a6454bfe986b..d1f09a011f4fe 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 160, - "android.injected.version.name" => "1.116.0", + "android.injected.version.code" => 161, + "android.injected.version.name" => "1.116.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 2d7cdc153cab8..70bddbf10b997 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -401,7 +401,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 175; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 175; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -571,7 +571,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 175; + CURRENT_PROJECT_VERSION = 177; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 1831798a4288e..b684804037010 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -58,11 +58,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.115.0 + 1.116.1 CFBundleSignature ???? CFBundleVersion - 175 + 177 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 1cc5524c40093..8dc3676fb787a 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Release" lane :release do increment_version_number( - version_number: "1.116.0" + version_number: "1.116.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/lib/interfaces/asset_media.interface.dart b/mobile/lib/interfaces/asset_media.interface.dart index f89a238dd47ee..2606d5c23c518 100644 --- a/mobile/lib/interfaces/asset_media.interface.dart +++ b/mobile/lib/interfaces/asset_media.interface.dart @@ -4,4 +4,7 @@ abstract interface class IAssetMediaRepository { Future> deleteAll(List ids); Future get(String id); + + /// Obtaining the correct original filename of the asset + Future getOriginalFilename(String id); } diff --git a/mobile/lib/pages/editing/crop.page.dart b/mobile/lib/pages/editing/crop.page.dart index 729b59ded5911..8bfb8c8bb9bf9 100644 --- a/mobile/lib/pages/editing/crop.page.dart +++ b/mobile/lib/pages/editing/crop.page.dart @@ -51,107 +51,109 @@ class CropImagePage extends HookWidget { ], ), backgroundColor: context.scaffoldBackgroundColor, - body: LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Column( - children: [ - Container( - padding: const EdgeInsets.only(top: 20), - width: constraints.maxWidth * 0.9, - height: constraints.maxHeight * 0.6, - child: CropImage( - controller: cropController, - image: image, - gridColor: Colors.white, + body: SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Column( + children: [ + Container( + padding: const EdgeInsets.only(top: 20), + width: constraints.maxWidth * 0.9, + height: constraints.maxHeight * 0.6, + child: CropImage( + controller: cropController, + image: image, + gridColor: Colors.white, + ), ), - ), - Expanded( - child: Container( - width: double.infinity, - decoration: BoxDecoration( - color: context.scaffoldBackgroundColor, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(20), + Expanded( + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: context.scaffoldBackgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + ), ), - ), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only( - left: 20, - right: 20, - bottom: 10, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: Icon( - Icons.rotate_left, - color: Theme.of(context).iconTheme.color, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only( + left: 20, + right: 20, + bottom: 10, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: Icon( + Icons.rotate_left, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateLeft(); + }, ), - onPressed: () { - cropController.rotateLeft(); - }, - ), - IconButton( - icon: Icon( - Icons.rotate_right, - color: Theme.of(context).iconTheme.color, + IconButton( + icon: Icon( + Icons.rotate_right, + color: Theme.of(context).iconTheme.color, + ), + onPressed: () { + cropController.rotateRight(); + }, ), - onPressed: () { - cropController.rotateRight(); - }, + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: null, + label: 'Free', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 1.0, + label: '1:1', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 16.0 / 9.0, + label: '16:9', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 3.0 / 2.0, + label: '3:2', + ), + _AspectRatioButton( + cropController: cropController, + aspectRatio: aspectRatio, + ratio: 7.0 / 5.0, + label: '7:5', ), ], ), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: null, - label: 'Free', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 1.0, - label: '1:1', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 16.0 / 9.0, - label: '16:9', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 3.0 / 2.0, - label: '3:2', - ), - _AspectRatioButton( - cropController: cropController, - aspectRatio: aspectRatio, - ratio: 7.0 / 5.0, - label: '7:5', - ), - ], - ), - ], + ], + ), ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ); } diff --git a/mobile/lib/providers/app_life_cycle.provider.dart b/mobile/lib/providers/app_life_cycle.provider.dart index 938961efb62ac..5561d3fefd683 100644 --- a/mobile/lib/providers/app_life_cycle.provider.dart +++ b/mobile/lib/providers/app_life_cycle.provider.dart @@ -86,12 +86,16 @@ class AppLifeCycleNotifier extends StateNotifier { void handleAppPause() { state = AppLifeCycleEnum.paused; _wasPaused = true; - // Do not cancel backup if manual upload is in progress - if (_ref.read(backupProvider.notifier).backupProgress != - BackUpProgressEnum.manualInProgress) { - _ref.read(backupProvider.notifier).cancelBackup(); + + if (_ref.read(authenticationProvider).isAuthenticated) { + // Do not cancel backup if manual upload is in progress + if (_ref.read(backupProvider.notifier).backupProgress != + BackUpProgressEnum.manualInProgress) { + _ref.read(backupProvider.notifier).cancelBackup(); + } + _ref.read(websocketProvider.notifier).disconnect(); } - _ref.read(websocketProvider.notifier).disconnect(); + ImmichLogger().flush(); } diff --git a/mobile/lib/repositories/asset_media.repository.dart b/mobile/lib/repositories/asset_media.repository.dart index 20cf680339e53..68fffa08a6fcb 100644 --- a/mobile/lib/repositories/asset_media.repository.dart +++ b/mobile/lib/repositories/asset_media.repository.dart @@ -43,4 +43,17 @@ class AssetMediaRepository implements IAssetMediaRepository { asset.local = local; return asset; } + + @override + Future getOriginalFilename(String id) async { + final entity = await AssetEntity.fromId(id); + + if (entity == null) { + return null; + } + + // titleAsync gets the correct original filename for some assets on iOS + // otherwise using the `entity.title` would return a random GUID + return await entity.titleAsync; + } } diff --git a/mobile/lib/services/background.service.dart b/mobile/lib/services/background.service.dart index d06bc86d4871b..86dfd0c5998c5 100644 --- a/mobile/lib/services/background.service.dart +++ b/mobile/lib/services/background.service.dart @@ -15,6 +15,7 @@ import 'package:immich_mobile/models/backup/success_upload_asset.model.dart'; import 'package:immich_mobile/repositories/album.repository.dart'; import 'package:immich_mobile/repositories/album_api.repository.dart'; import 'package:immich_mobile/repositories/asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/backup.repository.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; @@ -368,6 +369,7 @@ class BackgroundService { BackupRepository backupAlbumRepository = BackupRepository(db); AlbumMediaRepository albumMediaRepository = AlbumMediaRepository(); FileMediaRepository fileMediaRepository = FileMediaRepository(); + AssetMediaRepository assetMediaRepository = AssetMediaRepository(); UserRepository userRepository = UserRepository(db); UserApiRepository userApiRepository = UserApiRepository(apiService.usersApi); @@ -409,6 +411,7 @@ class BackgroundService { albumService, albumMediaRepository, fileMediaRepository, + assetMediaRepository, ); final selectedAlbums = backupService.selectedAlbumsQuery().findAllSync(); diff --git a/mobile/lib/services/backup.service.dart b/mobile/lib/services/backup.service.dart index 19d731d773d75..683339f271ed2 100644 --- a/mobile/lib/services/backup.service.dart +++ b/mobile/lib/services/backup.service.dart @@ -12,6 +12,7 @@ import 'package:immich_mobile/entities/backup_album.entity.dart'; import 'package:immich_mobile/entities/duplicated_asset.entity.dart'; import 'package:immich_mobile/entities/store.entity.dart'; import 'package:immich_mobile/interfaces/album_media.interface.dart'; +import 'package:immich_mobile/interfaces/asset_media.interface.dart'; import 'package:immich_mobile/interfaces/file_media.interface.dart'; import 'package:immich_mobile/models/backup/backup_candidate.model.dart'; import 'package:immich_mobile/models/backup/current_upload_asset.model.dart'; @@ -21,6 +22,7 @@ import 'package:immich_mobile/providers/api.provider.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/providers/db.provider.dart'; import 'package:immich_mobile/repositories/album_media.repository.dart'; +import 'package:immich_mobile/repositories/asset_media.repository.dart'; import 'package:immich_mobile/repositories/file_media.repository.dart'; import 'package:immich_mobile/services/album.service.dart'; import 'package:immich_mobile/services/api.service.dart'; @@ -40,6 +42,7 @@ final backupServiceProvider = Provider( ref.watch(albumServiceProvider), ref.watch(albumMediaRepositoryProvider), ref.watch(fileMediaRepositoryProvider), + ref.watch(assetMediaRepositoryProvider), ), ); @@ -52,6 +55,7 @@ class BackupService { final AlbumService _albumService; final IAlbumMediaRepository _albumMediaRepository; final IFileMediaRepository _fileMediaRepository; + final IAssetMediaRepository _assetMediaRepository; BackupService( this._apiService, @@ -60,6 +64,7 @@ class BackupService { this._albumService, this._albumMediaRepository, this._fileMediaRepository, + this._assetMediaRepository, ); Future?> getDeviceBackupAsset() async { @@ -329,7 +334,9 @@ class BackupService { } if (file != null) { - String originalFileName = asset.fileName; + String? originalFileName = + await _assetMediaRepository.getOriginalFilename(asset.localId!); + originalFileName ??= asset.fileName; if (asset.local!.isLivePhoto) { if (livePhotoFile == null) { diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 9f2261e03d8e6..81827a9079e5a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.116.0 +- API version: 1.116.2 - Generator version: 7.8.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen @@ -416,6 +416,7 @@ Class | Method | HTTP request | Description - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) + - [SystemConfigGeneratedImageDto](doc//SystemConfigGeneratedImageDto.md) - [SystemConfigImageDto](doc//SystemConfigImageDto.md) - [SystemConfigJobDto](doc//SystemConfigJobDto.md) - [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 22b48df2fbcb1..8be44029805d5 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -229,6 +229,7 @@ part 'model/stack_update_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; +part 'model/system_config_generated_image_dto.dart'; part 'model/system_config_image_dto.dart'; part 'model/system_config_job_dto.dart'; part 'model/system_config_library_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 3db3297acb091..9e38eaf30a8a9 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -512,6 +512,8 @@ class ApiClient { return SystemConfigFFmpegDto.fromJson(value); case 'SystemConfigFacesDto': return SystemConfigFacesDto.fromJson(value); + case 'SystemConfigGeneratedImageDto': + return SystemConfigGeneratedImageDto.fromJson(value); case 'SystemConfigImageDto': return SystemConfigImageDto.fromJson(value); case 'SystemConfigJobDto': diff --git a/mobile/openapi/lib/model/system_config_generated_image_dto.dart b/mobile/openapi/lib/model/system_config_generated_image_dto.dart new file mode 100644 index 0000000000000..2192a7cb0cbd5 --- /dev/null +++ b/mobile/openapi/lib/model/system_config_generated_image_dto.dart @@ -0,0 +1,118 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigGeneratedImageDto { + /// Returns a new [SystemConfigGeneratedImageDto] instance. + SystemConfigGeneratedImageDto({ + required this.format, + required this.quality, + required this.size, + }); + + ImageFormat format; + + /// Minimum value: 1 + /// Maximum value: 100 + int quality; + + /// Minimum value: 1 + int size; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigGeneratedImageDto && + other.format == format && + other.quality == quality && + other.size == size; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (format.hashCode) + + (quality.hashCode) + + (size.hashCode); + + @override + String toString() => 'SystemConfigGeneratedImageDto[format=$format, quality=$quality, size=$size]'; + + Map toJson() { + final json = {}; + json[r'format'] = this.format; + json[r'quality'] = this.quality; + json[r'size'] = this.size; + return json; + } + + /// Returns a new [SystemConfigGeneratedImageDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigGeneratedImageDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigGeneratedImageDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigGeneratedImageDto( + format: ImageFormat.fromJson(json[r'format'])!, + quality: mapValueOfType(json, r'quality')!, + size: mapValueOfType(json, r'size')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigGeneratedImageDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigGeneratedImageDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigGeneratedImageDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigGeneratedImageDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'format', + 'quality', + 'size', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_image_dto.dart b/mobile/openapi/lib/model/system_config_image_dto.dart index 681a8c00c3bc0..5309f7745c44d 100644 --- a/mobile/openapi/lib/model/system_config_image_dto.dart +++ b/mobile/openapi/lib/model/system_config_image_dto.dart @@ -15,64 +15,42 @@ class SystemConfigImageDto { SystemConfigImageDto({ required this.colorspace, required this.extractEmbedded, - required this.previewFormat, - required this.previewSize, - required this.quality, - required this.thumbnailFormat, - required this.thumbnailSize, + required this.preview, + required this.thumbnail, }); Colorspace colorspace; bool extractEmbedded; - ImageFormat previewFormat; + SystemConfigGeneratedImageDto preview; - /// Minimum value: 1 - int previewSize; - - /// Minimum value: 1 - /// Maximum value: 100 - int quality; - - ImageFormat thumbnailFormat; - - /// Minimum value: 1 - int thumbnailSize; + SystemConfigGeneratedImageDto thumbnail; @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigImageDto && other.colorspace == colorspace && other.extractEmbedded == extractEmbedded && - other.previewFormat == previewFormat && - other.previewSize == previewSize && - other.quality == quality && - other.thumbnailFormat == thumbnailFormat && - other.thumbnailSize == thumbnailSize; + other.preview == preview && + other.thumbnail == thumbnail; @override int get hashCode => // ignore: unnecessary_parenthesis (colorspace.hashCode) + (extractEmbedded.hashCode) + - (previewFormat.hashCode) + - (previewSize.hashCode) + - (quality.hashCode) + - (thumbnailFormat.hashCode) + - (thumbnailSize.hashCode); + (preview.hashCode) + + (thumbnail.hashCode); @override - String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, previewFormat=$previewFormat, previewSize=$previewSize, quality=$quality, thumbnailFormat=$thumbnailFormat, thumbnailSize=$thumbnailSize]'; + String toString() => 'SystemConfigImageDto[colorspace=$colorspace, extractEmbedded=$extractEmbedded, preview=$preview, thumbnail=$thumbnail]'; Map toJson() { final json = {}; json[r'colorspace'] = this.colorspace; json[r'extractEmbedded'] = this.extractEmbedded; - json[r'previewFormat'] = this.previewFormat; - json[r'previewSize'] = this.previewSize; - json[r'quality'] = this.quality; - json[r'thumbnailFormat'] = this.thumbnailFormat; - json[r'thumbnailSize'] = this.thumbnailSize; + json[r'preview'] = this.preview; + json[r'thumbnail'] = this.thumbnail; return json; } @@ -87,11 +65,8 @@ class SystemConfigImageDto { return SystemConfigImageDto( colorspace: Colorspace.fromJson(json[r'colorspace'])!, extractEmbedded: mapValueOfType(json, r'extractEmbedded')!, - previewFormat: ImageFormat.fromJson(json[r'previewFormat'])!, - previewSize: mapValueOfType(json, r'previewSize')!, - quality: mapValueOfType(json, r'quality')!, - thumbnailFormat: ImageFormat.fromJson(json[r'thumbnailFormat'])!, - thumbnailSize: mapValueOfType(json, r'thumbnailSize')!, + preview: SystemConfigGeneratedImageDto.fromJson(json[r'preview'])!, + thumbnail: SystemConfigGeneratedImageDto.fromJson(json[r'thumbnail'])!, ); } return null; @@ -141,11 +116,8 @@ class SystemConfigImageDto { static const requiredKeys = { 'colorspace', 'extractEmbedded', - 'previewFormat', - 'previewSize', - 'quality', - 'thumbnailFormat', - 'thumbnailSize', + 'preview', + 'thumbnail', }; } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a219b6ddb1575..dc1eb11ca7f24 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 1.116.0+160 +version: 1.116.2+161 environment: sdk: '>=3.3.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index bb0aa83009863..1077762ac3a56 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7409,7 +7409,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.116.0", + "version": "1.116.2", "contact": {} }, "tags": [], @@ -11654,42 +11654,48 @@ ], "type": "object" }, - "SystemConfigImageDto": { + "SystemConfigGeneratedImageDto": { "properties": { - "colorspace": { - "$ref": "#/components/schemas/Colorspace" - }, - "extractEmbedded": { - "type": "boolean" - }, - "previewFormat": { + "format": { "$ref": "#/components/schemas/ImageFormat" }, - "previewSize": { - "minimum": 1, - "type": "integer" - }, "quality": { "maximum": 100, "minimum": 1, "type": "integer" }, - "thumbnailFormat": { - "$ref": "#/components/schemas/ImageFormat" - }, - "thumbnailSize": { + "size": { "minimum": 1, "type": "integer" } }, + "required": [ + "format", + "quality", + "size" + ], + "type": "object" + }, + "SystemConfigImageDto": { + "properties": { + "colorspace": { + "$ref": "#/components/schemas/Colorspace" + }, + "extractEmbedded": { + "type": "boolean" + }, + "preview": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" + }, + "thumbnail": { + "$ref": "#/components/schemas/SystemConfigGeneratedImageDto" + } + }, "required": [ "colorspace", "extractEmbedded", - "previewFormat", - "previewSize", - "quality", - "thumbnailFormat", - "thumbnailSize" + "preview", + "thumbnail" ], "type": "object" }, diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index 3ab9ac0583a35..72d7a3ec546d8 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 45a1fada32eb1..41bc3a3b16017 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.2", "description": "Auto-generated TypeScript SDK for the Immich API", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 63597d49bc6c1..e88f431e8c787 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.116.0 + * 1.116.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ @@ -1100,14 +1100,16 @@ export type SystemConfigFFmpegDto = { transcode: TranscodePolicy; twoPass: boolean; }; +export type SystemConfigGeneratedImageDto = { + format: ImageFormat; + quality: number; + size: number; +}; export type SystemConfigImageDto = { colorspace: Colorspace; extractEmbedded: boolean; - previewFormat: ImageFormat; - previewSize: number; - quality: number; - thumbnailFormat: ImageFormat; - thumbnailSize: number; + preview: SystemConfigGeneratedImageDto; + thumbnail: SystemConfigGeneratedImageDto; }; export type JobSettingsDto = { concurrency: number; diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index d872b8435b058..51ea8238dabb0 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -1,84 +1,90 @@ -

-
+

+
Licença: AGPLv3 Discord -
-
+
+

- +

-

Immich - Solução self-hosted de alta performance para backup de fotos e vídeos

+

Solução self-hosted de alta performance para backup de fotos e vídeos


- +

- English - Català - Español - Français - Italiano - 日本語 - 한국어 - Deutsch - Nederlands - Türkçe - 中文 - Русский - Svenska - العربية + +English +Català +Español +Français +Italiano +日本語 +한국어 +Deutsch +Nederlands +Türkçe +中文 +Русский +Svenska +العربية +Tiếng Việt +

## Avisos - ⚠️ Este projeto está sob **desenvolvimento constante**. -- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a compatibilidade com versões anteriores). -- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e vídeos.** -- ⚠️ Sempre siga o plano [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup para as suas mídias preciosas! +- ⚠️ Podem ocorrer bugs e _breaking changes_ (alterações que quebram a + compatibilidade com versões anteriores). +- ⚠️ **Não use esta solução como a única forma de fazer backup das suas fotos e + vídeos.** +- ⚠️ Sempre siga o plano + [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) de backup + para as suas mídias preciosas! -## Conteúdo +> [!NOTE] +> Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/. -- [Documentação Oficial](https://immich.app/docs) -- [Roadmap](https://github.com/orgs/immich-app/projects/1) -- [Demonstração](#demo) -- [Recursos](#features) -- [Introdução](https://immich.app/docs/overview/introduction) +## Links + +- [Documentação](https://immich.app/docs) +- [Sobre](https://immich.app/docs/overview/introduction) - [Instalação](https://immich.app/docs/install/requirements) +- [Roadmap](https://github.com/orgs/immich-app/projects/1) +- [Demonstração](#demonstração) +- [Funcionalidades](#funcionalidades) +- [Traduções](https://immich.app/docs/developer/translations) - [Diretrizes de Contribuição](https://immich.app/docs/overview/support-the-project) -## Documentação - -Você pode encontrar a documentação principal, incluindo guias de instalação, em https://immich.app/. - ## Demonstração -Você pode acessar a demonstração web em https://demo.immich.app +Acesse a demonstração [aqui](https://demo.immich.app). A demonstração está +hospedada no Nível Gratuito da Oracle VM em Amsterdam com um processador 2.4Ghz +quad-core ARM64 e 24GB de RAM. -No aplicativo para dispositivos móveis, você pode usar `https://demo.immich.app/api` no campo `Server Endpoint URL` +No aplicativo para dispositivos móveis, você pode usar +`https://demo.immich.app/api` no campo `Server Endpoint URL` -```bash title="Credenciais de Demonstração" -Credenciais de Demonstração -email: demo@immich.app -senha: demo -``` +### Credenciais de login -``` -Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM -``` +| Email | Senha | +| --------------- | ----- | +| demo@immich.app | demo | ## Atividades -![Atividades](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de Analytics do Repobeats") -## Recursos +![Atividades](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de Analytics do Repobeats") +## Funcionalidades -| Recursos | Aplicativo Móvel | Web | -|:----------------------------------------------------|------------------|-----| +| Funcionalidades | Aplicativo Móvel | Web | +| :-------------------------------------------------- | ---------------- | --- | | Fazer upload e visualizar fotos e vídeos | Sim | Sim | | Backup automático ao abrir o aplicativo | Sim | N/A | | Prevenir a duplicação de arquivos | Sim | Sim | @@ -88,17 +94,17 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR | Criação de álbuns e álbuns compartilhados | Sim | Sim | | Barra de rolagem arrastável | Sim | Sim | | Suporta formatos RAW | Sim | Sim | -| Visualização de metadados (EXIF, map) | Sim | Sim | -| Pesquisar por metadados, objetos, rostos, and CLIP | Sim | Sim | +| Visualização de metadados (EXIF, mapa) | Sim | Sim | +| Pesquisar por metadados, objetos, rostos, e CLIP | Sim | Sim | | Funções administrativas (gerenciamento de usuários) | Não | Sim | | Backup em segundo plano | Sim | N/A | -| Virtual scroll | Sim | Sim | +| Rolagem virtual | Sim | Sim | | Suporte OAuth | Sim | Sim | | Chaves de API | N/A | Sim | -| Backup e visualização de LivePhoto/MotionPhoto | Sim | Sim | +| Backup e reprodução de LivePhoto/MotionPhoto | Sim | Sim | | Visualização de imagens 360º | Não | Sim | | Estrutura de armazenamento definida pelo usuário | Sim | Sim | -| Compartilhar com o público | Não | Sim | +| Compartilhar com o público | Sim | Sim | | Arquivo e Favoritos | Sim | Sim | | Mapa Global | Sim | Sim | | Compartilhamento com parceiro | Sim | Sim | @@ -108,6 +114,29 @@ Especificações: Nível Gratuito da Oracle VM - Amsterdam - 2.4Ghz quad-core AR | Galeria em modo apenas leitura | Sim | Sim | | Empilhamento de fotos | Sim | Sim | +## Traduções + +Leia mais sobre as traduções +[aqui](https://immich.app/docs/developer/translations). + + +Status da tradução + + +## Atividade do repositório + +![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imagem de análise de atividade Repobeats") + +## Histórico de estrelas + + + + + + Gráfico de histórico de estrelas + + + ## Contribuidores diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md new file mode 100644 index 0000000000000..7ec4b9c948a2a --- /dev/null +++ b/readme_i18n/README_vi_VN.md @@ -0,0 +1,133 @@ +

+
+
Giấy phép: AGPLv3 + + Discord + +
+
+

+ +

+ +

+

Giải pháp quản lý ảnh và video tự lưu trữ hiệu suất cao

+
+ + + +
+

+ +English +Català +Español +Français +Italiano +日本語 +한국어 +Deutsch +Nederlands +Türkçe +中文 +Русский +Português Brasileiro +Svenska +العربية +Tiếng Việt + +

+ +## Tuyên bố miễn trừ trách nhiệm + +- ⚠️ Dự án đang được phát triển **rất tích cực**. +- ⚠️ Dự kiến ​​sẽ có lỗi và thay đổi đột ngột. +- ⚠️ **Không sử dụng ứng dụng như là cách duy nhất để lưu trữ ảnh và video của bạn.** +- ⚠️ Luôn tuân thủ kế hoạch sao lưu [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) cho những bức ảnh và video quý giá của bạn! + +> [!NOTE] +> Bạn có thể tìm thấy tài liệu chính, bao gồm hướng dẫn cài đặt, tại https://immich.app/. + +## Liên kết + +- [Tài liệu](https://immich.app/docs) +- [Giới thiệu](https://immich.app/docs/overview/introduction) +- [Cài đặt](https://immich.app/docs/install/requirements) +- [Lộ trình](https://immich.app/roadmap) +- [Demo](#demo) +- [Tính năng](#Tính-năng) +- [Dịch thuật](https://immich.app/docs/developer/translations) +- [Đóng góp](https://immich.app/docs/overview/support-the-project) + +## Demo + +Truy cập bản demo [tại đây](https://demo.immich.app). Bản demo đang chạy trên máy ảo Oracle Free-tier ở Amsterdam với CPU ARM64 lõi tứ 2,4 GHz và RAM 24 GB. + +Đối với ứng dụng di động, bạn có thể sử dụng `https://demo.immich.app/api` cho `Server Endpoint URL` + +### Thông tin đăng nhập + +| Email | Mật khẩu | +| --------------- | -------- | +| demo@immich.app | demo | + +## Tính năng + +| Tính năng | Mobile | Web | +| :--------------------------------------------------- | ------ | ----- | +| Tải lên và xem video, ảnh | Có | Có | +| Tự động sao lưu khi ứng dụng được mở | Có | N/A | +| Ngăn chặn sự trùng lặp nội dung | Có | Có | +| Album được chọn để sao lưu | Có | N/A | +| Tải ảnh và video xuống thiết bị cục bộ | Có | Có | +| Hỗ trợ nhiều người dùng | Có | Có | +| Album và Album được chia sẻ | Có | Có | +| Thanh cuộn có thể chà / kéo | Có | Có | +| Hỗ trợ định dạng raw | Có | Có | +| Xem metadata (EXIF, bản đồ) | Có | Có | +| Tìm kiếm theo metadata, đối tượng, khuôn mặt và CLIP | Có | Có | +| Chức năng quản trị (quản lý người dùng) | Không | Có | +| Sao lưu trong nền | Có | N/A | +| Cuộn ảo | Có | Có | +| Hỗ trợ OAuth | Có | Có | +| API Keys | N/A | Có | +| Sao lưu và phát lại Live Photo/Motion Photo | Có | Có | +| Hỗ trợ hiển thị hình ảnh 360 độ | Không | Có | +| Cấu trúc lưu trữ do người dùng xác định | Có | Có | +| Chia sẻ công khai | Có | Có | +| Lưu trữ và Yêu thích | Có | Có | +| Bản đồ toàn cầu | Có | Có | +| Chia sẻ đối tác | Có | Có | +| Nhận dạng khuôn mặt và phân cụm | Có | Có | +| Kỷ niệm (x năm trước) | Có | Có | +| Hỗ trợ ngoại tuyến | Có | Không | +| Thư viện chỉ đọc | Có | Có | +| Ảnh xếp chồng | Có | Có | + +## Dịch thuật + +Đọc thêm về dịch thuật [tại đây](https://immich.app/docs/developer/translations). + + +Tình trạng dịch thuật + + +## Hoạt động của repository + +![Hoạt động](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Hình ảnh phân tích Repobeats") + +## Lịch sử Đánh dấu sao + + + + + + Biểu đồ Lịch sử Đánh dấu + + + +## Người đóng góp + + + + diff --git a/server/package-lock.json b/server/package-lock.json index 57c8dd7146732..646a26b1ee9d0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.116.0", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@nestjs/bullmq": "^10.0.1", diff --git a/server/package.json b/server/package.json index 8ba20f6b3bc23..d4816109069e9 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.116.0", + "version": "1.116.2", "description": "", "author": "", "private": true, diff --git a/server/src/config.ts b/server/src/config.ts index 03ea3f111b9ac..3317351f9ff3a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -7,82 +7,20 @@ import { RedisOptions } from 'ioredis'; import Joi, { Root } from 'joi'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { ImmichHeader } from 'src/dtos/auth.dto'; +import { + AudioCodec, + Colorspace, + CQMode, + ImageFormat, + LogLevel, + ToneMapping, + TranscodeHWAccel, + TranscodePolicy, + VideoCodec, + VideoContainer, +} from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; - -export enum TranscodePolicy { - ALL = 'all', - OPTIMAL = 'optimal', - BITRATE = 'bitrate', - REQUIRED = 'required', - DISABLED = 'disabled', -} - -export enum TranscodeTarget { - NONE, - AUDIO, - VIDEO, - ALL, -} - -export enum VideoCodec { - H264 = 'h264', - HEVC = 'hevc', - VP9 = 'vp9', - AV1 = 'av1', -} - -export enum AudioCodec { - MP3 = 'mp3', - AAC = 'aac', - LIBOPUS = 'libopus', -} - -export enum VideoContainer { - MOV = 'mov', - MP4 = 'mp4', - OGG = 'ogg', - WEBM = 'webm', -} - -export enum TranscodeHWAccel { - NVENC = 'nvenc', - QSV = 'qsv', - VAAPI = 'vaapi', - RKMPP = 'rkmpp', - DISABLED = 'disabled', -} - -export enum ToneMapping { - HABLE = 'hable', - MOBIUS = 'mobius', - REINHARD = 'reinhard', - DISABLED = 'disabled', -} - -export enum CQMode { - AUTO = 'auto', - CQP = 'cqp', - ICQ = 'icq', -} - -export enum Colorspace { - SRGB = 'srgb', - P3 = 'p3', -} - -export enum ImageFormat { - JPEG = 'jpeg', - WEBP = 'webp', -} - -export enum LogLevel { - VERBOSE = 'verbose', - DEBUG = 'debug', - LOG = 'log', - WARN = 'warn', - ERROR = 'error', - FATAL = 'fatal', -} +import { ImageOutputConfig } from 'src/interfaces/media.interface'; export interface SystemConfig { ffmpeg: { @@ -172,11 +110,8 @@ export interface SystemConfig { template: string; }; image: { - thumbnailFormat: ImageFormat; - thumbnailSize: number; - previewFormat: ImageFormat; - previewSize: number; - quality: number; + thumbnail: ImageOutputConfig; + preview: ImageOutputConfig; colorspace: Colorspace; extractEmbedded: boolean; }; @@ -322,11 +257,16 @@ export const defaults = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + format: ImageFormat.WEBP, + size: 250, + quality: 80, + }, + preview: { + format: ImageFormat.JPEG, + size: 1440, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/constants.ts b/server/src/constants.ts index 6cfcc41d89ba6..e0a4fe8cef306 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -54,11 +54,6 @@ export const resourcePaths = { export const MOBILE_REDIRECT = 'app.immich:///oauth-callback'; export const LOGIN_URL = '/auth/login?autoLaunch=0'; -export enum AuthType { - PASSWORD = 'password', - OAUTH = 'oauth', -} - export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; export const FACE_THUMBNAIL_SIZE = 250; diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index fb5ec58f2544c..b2d3933be4cbc 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -33,16 +33,17 @@ import { UploadFieldName, } from 'src/dtos/asset-media.dto'; import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor'; import { AssetMediaService } from 'src/services/asset-media.service'; import { sendFile } from 'src/utils/file'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(Route.ASSET) +@Controller(RouteKey.ASSET) export class AssetMediaController { constructor( @Inject(ILoggerRepository) private logger: ILoggerRepository, diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 9d3d23065724c..8a5b5fb0b63a8 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -14,13 +14,13 @@ import { } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MemoryLaneDto } from 'src/dtos/search.dto'; +import { RouteKey } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; -import { Route } from 'src/middleware/file-upload.interceptor'; import { AssetService } from 'src/services/asset.service'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Assets') -@Controller(Route.ASSET) +@Controller(RouteKey.ASSET) export class AssetController { constructor(private service: AssetService) {} diff --git a/server/src/controllers/auth.controller.ts b/server/src/controllers/auth.controller.ts index 7dcef9df5f391..04250f530044f 100644 --- a/server/src/controllers/auth.controller.ts +++ b/server/src/controllers/auth.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, HttpCode, HttpStatus, Post, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { AuthType } from 'src/constants'; import { AuthDto, ChangePasswordDto, @@ -13,6 +12,7 @@ import { ValidateAccessTokenResponseDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; +import { AuthType } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie, respondWithoutCookie } from 'src/utils/response'; diff --git a/server/src/controllers/oauth.controller.ts b/server/src/controllers/oauth.controller.ts index b733dc612b227..4e626b10f01b4 100644 --- a/server/src/controllers/oauth.controller.ts +++ b/server/src/controllers/oauth.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Redirect, Req, Res } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Request, Response } from 'express'; -import { AuthType } from 'src/constants'; import { AuthDto, ImmichCookie, @@ -11,6 +10,7 @@ import { OAuthConfigDto, } from 'src/dtos/auth.dto'; import { UserAdminResponseDto } from 'src/dtos/user.dto'; +import { AuthType } from 'src/enum'; import { Auth, Authenticated, GetLoginDetails } from 'src/middleware/auth.guard'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { respondWithCookie } from 'src/utils/response'; diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 01b225839080d..10076098d6516 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -21,15 +21,16 @@ import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; -import { FileUploadInterceptor, Route } from 'src/middleware/file-upload.interceptor'; +import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor'; import { UserService } from 'src/services/user.service'; import { sendFile } from 'src/utils/file'; import { UUIDParamDto } from 'src/validation'; @ApiTags('Users') -@Controller(Route.USER) +@Controller(RouteKey.USER) export class UserController { constructor( private service: UserService, diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index e20a0c658db7f..8ce8f6b67a228 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -1,12 +1,10 @@ import { randomUUID } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; -import { ImageFormat } from 'src/config'; import { APP_MEDIA_LOCATION } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType, PathType, PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetFileType } from 'src/enum'; +import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -16,14 +14,6 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { getAssetFiles } from 'src/utils/asset.util'; -export enum StorageFolder { - ENCODED_VIDEO = 'encoded-video', - LIBRARY = 'library', - UPLOAD = 'upload', - PROFILE = 'profile', - THUMBNAILS = 'thumbs', -} - export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS)); export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO)); diff --git a/server/src/decorators.ts b/server/src/decorators.ts index 2316e114e885e..9b6910391af9b 100644 --- a/server/src/decorators.ts +++ b/server/src/decorators.ts @@ -4,8 +4,8 @@ import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces'; import { ApiExtension, ApiOperation, ApiProperty, ApiTags } from '@nestjs/swagger'; import _ from 'lodash'; import { ADDED_IN_PREFIX, DEPRECATED_IN_PREFIX, LIFECYCLE_EXTENSION } from 'src/constants'; +import { MetadataKey } from 'src/enum'; import { EmitEvent, ServerEvent } from 'src/interfaces/event.interface'; -import { Metadata } from 'src/middleware/auth.guard'; import { setUnion } from 'src/utils/set'; // PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the @@ -141,7 +141,7 @@ export type EmitConfig = { /** lower value has higher priority, defaults to 0 */ priority?: number; }; -export const OnEmit = (config: EmitConfig) => SetMetadata(Metadata.ON_EMIT_CONFIG, config); +export const OnEmit = (config: EmitConfig) => SetMetadata(MetadataKey.ON_EMIT_CONFIG, config); type LifecycleRelease = 'NEXT_RELEASE' | string; type LifecycleMetadata = { diff --git a/server/src/dtos/audit.dto.ts b/server/src/dtos/audit.dto.ts index dcace5a551213..434da46eba976 100644 --- a/server/src/dtos/audit.dto.ts +++ b/server/src/dtos/audit.dto.ts @@ -1,8 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator'; -import { AssetPathType, PathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; -import { EntityType } from 'src/enum'; +import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from 'src/enum'; import { Optional, ValidateDate, ValidateUUID } from 'src/validation'; const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType }); diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 336f50f39bc8c..c12a54cd613e6 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -18,20 +18,20 @@ import { ValidatorConstraint, ValidatorConstraintInterface, } from 'class-validator'; +import { SystemConfig } from 'src/config'; +import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { AudioCodec, CQMode, Colorspace, ImageFormat, LogLevel, - SystemConfig, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, -} from 'src/config'; -import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; +} from 'src/enum'; import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; import { ValidateBoolean, validateCronExpression } from 'src/validation'; @@ -473,33 +473,35 @@ export class SystemConfigThemeDto { customCss!: string; } -class SystemConfigImageDto { +class SystemConfigGeneratedImageDto { @IsEnum(ImageFormat) @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - thumbnailFormat!: ImageFormat; + format!: ImageFormat; @IsInt() @Min(1) + @Max(100) @Type(() => Number) @ApiProperty({ type: 'integer' }) - thumbnailSize!: number; - - @IsEnum(ImageFormat) - @ApiProperty({ enumName: 'ImageFormat', enum: ImageFormat }) - previewFormat!: ImageFormat; + quality!: number; @IsInt() @Min(1) @Type(() => Number) @ApiProperty({ type: 'integer' }) - previewSize!: number; + size!: number; +} - @IsInt() - @Min(1) - @Max(100) - @Type(() => Number) - @ApiProperty({ type: 'integer' }) - quality!: number; +class SystemConfigImageDto { + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + thumbnail!: SystemConfigGeneratedImageDto; + + @Type(() => SystemConfigGeneratedImageDto) + @ValidateNested() + @IsObject() + preview!: SystemConfigGeneratedImageDto; @IsEnum(Colorspace) @ApiProperty({ enumName: 'Colorspace', enum: Colorspace }) diff --git a/server/src/entities/move.entity.ts b/server/src/entities/move.entity.ts index f3dad6b280306..5cdef5d22ef76 100644 --- a/server/src/entities/move.entity.ts +++ b/server/src/entities/move.entity.ts @@ -1,3 +1,4 @@ +import { PathType } from 'src/enum'; import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm'; @Entity('move_history') @@ -21,21 +22,3 @@ export class MoveEntity { @Column({ type: 'varchar' }) newPath!: string; } - -export enum AssetPathType { - ORIGINAL = 'original', - PREVIEW = 'preview', - THUMBNAIL = 'thumbnail', - ENCODED_VIDEO = 'encoded_video', - SIDECAR = 'sidecar', -} - -export enum PersonPathType { - FACE = 'face', -} - -export enum UserPathType { - PROFILE = 'profile', -} - -export type PathType = AssetPathType | PersonPathType | UserPathType; diff --git a/server/src/enum.ts b/server/src/enum.ts index 027b3160a7c32..e0c1e27859de9 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -1,3 +1,8 @@ +export enum AuthType { + PASSWORD = 'password', + OAUTH = 'oauth', +} + export enum AssetType { IMAGE = 'IMAGE', VIDEO = 'VIDEO', @@ -148,6 +153,14 @@ export enum SharedLinkType { INDIVIDUAL = 'INDIVIDUAL', } +export enum StorageFolder { + ENCODED_VIDEO = 'encoded-video', + LIBRARY = 'library', + UPLOAD = 'upload', + PROFILE = 'profile', + THUMBNAILS = 'thumbs', +} + export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', FACIAL_RECOGNITION_STATE = 'facial-recognition-state', @@ -198,3 +211,120 @@ export enum ManualJobName { TAG_CLEANUP = 'tag-cleanup', USER_CLEANUP = 'user-cleanup', } + +export enum AssetPathType { + ORIGINAL = 'original', + PREVIEW = 'preview', + THUMBNAIL = 'thumbnail', + ENCODED_VIDEO = 'encoded_video', + SIDECAR = 'sidecar', +} + +export enum PersonPathType { + FACE = 'face', +} + +export enum UserPathType { + PROFILE = 'profile', +} + +export type PathType = AssetPathType | PersonPathType | UserPathType; + +export enum TranscodePolicy { + ALL = 'all', + OPTIMAL = 'optimal', + BITRATE = 'bitrate', + REQUIRED = 'required', + DISABLED = 'disabled', +} + +export enum TranscodeTarget { + NONE, + AUDIO, + VIDEO, + ALL, +} + +export enum VideoCodec { + H264 = 'h264', + HEVC = 'hevc', + VP9 = 'vp9', + AV1 = 'av1', +} + +export enum AudioCodec { + MP3 = 'mp3', + AAC = 'aac', + LIBOPUS = 'libopus', +} + +export enum VideoContainer { + MOV = 'mov', + MP4 = 'mp4', + OGG = 'ogg', + WEBM = 'webm', +} + +export enum TranscodeHWAccel { + NVENC = 'nvenc', + QSV = 'qsv', + VAAPI = 'vaapi', + RKMPP = 'rkmpp', + DISABLED = 'disabled', +} + +export enum ToneMapping { + HABLE = 'hable', + MOBIUS = 'mobius', + REINHARD = 'reinhard', + DISABLED = 'disabled', +} + +export enum CQMode { + AUTO = 'auto', + CQP = 'cqp', + ICQ = 'icq', +} + +export enum Colorspace { + SRGB = 'srgb', + P3 = 'p3', +} + +export enum ImageFormat { + JPEG = 'jpeg', + WEBP = 'webp', +} + +export enum LogLevel { + VERBOSE = 'verbose', + DEBUG = 'debug', + LOG = 'log', + WARN = 'warn', + ERROR = 'error', + FATAL = 'fatal', +} + +export enum MetadataKey { + AUTH_ROUTE = 'auth_route', + ADMIN_ROUTE = 'admin_route', + SHARED_ROUTE = 'shared_route', + API_KEY_SECURITY = 'api_key', + ON_EMIT_CONFIG = 'on_emit_config', +} + +export enum RouteKey { + ASSET = 'assets', + USER = 'users', +} + +export enum CacheControl { + PRIVATE_WITH_CACHE = 'private_with_cache', + PRIVATE_WITHOUT_CACHE = 'private_without_cache', + NONE = 'none', +} + +export enum PaginationMode { + LIMIT_OFFSET = 'limit-offset', + SKIP_TAKE = 'skip-take', +} diff --git a/server/src/interfaces/config.interface.ts b/server/src/interfaces/config.interface.ts new file mode 100644 index 0000000000000..11bccbe348b1a --- /dev/null +++ b/server/src/interfaces/config.interface.ts @@ -0,0 +1,14 @@ +import { VectorExtension } from 'src/interfaces/database.interface'; + +export const IConfigRepository = 'IConfigRepository'; + +export interface EnvData { + database: { + skipMigrations: boolean; + vectorExtension: VectorExtension; + }; +} + +export interface IConfigRepository { + getEnv(): EnvData; +} diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 8b6e2c289bd28..af2726b858aee 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -116,7 +116,7 @@ export enum JobName { } export const JOBS_ASSET_PAGINATION_SIZE = 1000; -export const JOBS_LIBRARY_PAGINATION_SIZE = 100_000; +export const JOBS_LIBRARY_PAGINATION_SIZE = 10_000; export interface IBaseJob { force?: boolean; diff --git a/server/src/interfaces/logger.interface.ts b/server/src/interfaces/logger.interface.ts index f0afdce2a521c..42523afa6b513 100644 --- a/server/src/interfaces/logger.interface.ts +++ b/server/src/interfaces/logger.interface.ts @@ -1,4 +1,4 @@ -import { LogLevel } from 'src/config'; +import { LogLevel } from 'src/enum'; export const ILoggerRepository = 'ILoggerRepository'; @@ -6,6 +6,7 @@ export interface ILoggerRepository { setAppName(name: string): void; setContext(message: string): void; setLogLevel(level: LogLevel): void; + isLevelEnabled(level: LogLevel): boolean; verbose(message: any, ...args: any): void; debug(message: any, ...args: any): void; diff --git a/server/src/interfaces/media.interface.ts b/server/src/interfaces/media.interface.ts index f7389d3d068cd..64ba6236e80f0 100644 --- a/server/src/interfaces/media.interface.ts +++ b/server/src/interfaces/media.interface.ts @@ -1,5 +1,5 @@ import { Writable } from 'node:stream'; -import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/config'; +import { ImageFormat, TranscodeTarget, VideoCodec } from 'src/enum'; export const IMediaRepository = 'IMediaRepository'; @@ -10,11 +10,14 @@ export interface CropOptions { height: number; } -export interface ThumbnailOptions { - size: number; +export interface ImageOutputConfig { format: ImageFormat; - colorspace: string; quality: number; + size: number; +} + +export interface ThumbnailOptions extends ImageOutputConfig { + colorspace: string; crop?: CropOptions; processInvalidImages: boolean; } @@ -62,6 +65,10 @@ export interface TranscodeCommand { inputOptions: string[]; outputOptions: string[]; twoPass: boolean; + progress: { + frameCount: number; + percentInterval: number; + }; } export interface BitrateDistribution { @@ -79,6 +86,10 @@ export interface VideoCodecHWConfig extends VideoCodecSWConfig { getSupportedCodecs(): Array; } +export interface ProbeOptions { + countFrames: boolean; +} + export interface IMediaRepository { // image extract(input: string, output: string): Promise; @@ -87,6 +98,6 @@ export interface IMediaRepository { getImageDimensions(input: string): Promise; // video - probe(input: string): Promise; + probe(input: string, options?: ProbeOptions): Promise; transcode(input: string, output: string | Writable, command: TranscodeCommand): Promise; } diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index 1805969beb046..574420e27a1c8 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -7,7 +7,18 @@ export interface ExifDuration { Scale?: number; } -type TagsWithWrongTypes = 'FocalLength' | 'Duration' | 'Description' | 'ImageDescription' | 'RegionInfo'; +type StringOrNumber = string | number; + +type TagsWithWrongTypes = + | 'FocalLength' + | 'Duration' + | 'Description' + | 'ImageDescription' + | 'RegionInfo' + | 'TagsList' + | 'Keywords' + | 'HierarchicalSubject' + | 'ISO'; export interface ImmichTags extends Omit { ContentIdentifier?: string; MotionPhoto?: number; @@ -20,10 +31,14 @@ export interface ImmichTags extends Omit { EmbeddedVideoType?: string; EmbeddedVideoFile?: BinaryField; MotionPhotoVideo?: BinaryField; + TagsList?: StringOrNumber[]; + HierarchicalSubject?: StringOrNumber[]; + Keywords?: StringOrNumber | StringOrNumber[]; + ISO?: number | number[]; // Type is wrong, can also be number. - Description?: string | number; - ImageDescription?: string | number; + Description?: StringOrNumber; + ImageDescription?: StringOrNumber; // Extended properties for image regions, such as faces RegionInfo?: { diff --git a/server/src/interfaces/move.interface.ts b/server/src/interfaces/move.interface.ts index c9d39e78cf497..0e79cfcadc5a8 100644 --- a/server/src/interfaces/move.interface.ts +++ b/server/src/interfaces/move.interface.ts @@ -1,4 +1,5 @@ -import { MoveEntity, PathType } from 'src/entities/move.entity'; +import { MoveEntity } from 'src/entities/move.entity'; +import { PathType } from 'src/enum'; export const IMoveRepository = 'IMoveRepository'; diff --git a/server/src/main.ts b/server/src/main.ts index e32c3e43ac107..48ce179e88753 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -2,7 +2,7 @@ import { CommandFactory } from 'nest-commander'; import { fork } from 'node:child_process'; import { Worker } from 'node:worker_threads'; import { ImmichAdminModule } from 'src/app.module'; -import { LogLevel } from 'src/config'; +import { LogLevel } from 'src/enum'; import { getWorkers } from 'src/utils/workers'; const immichApp = process.argv[2] || process.env.IMMICH_APP; diff --git a/server/src/middleware/auth.guard.ts b/server/src/middleware/auth.guard.ts index d6138f2d3ae24..7bc4f41b21c8f 100644 --- a/server/src/middleware/auth.guard.ts +++ b/server/src/middleware/auth.guard.ts @@ -11,19 +11,11 @@ import { Reflector } from '@nestjs/core'; import { ApiBearerAuth, ApiCookieAuth, ApiOkResponse, ApiQuery, ApiSecurity } from '@nestjs/swagger'; import { Request } from 'express'; import { AuthDto, ImmichQuery } from 'src/dtos/auth.dto'; -import { Permission } from 'src/enum'; +import { MetadataKey, Permission } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthService, LoginDetails } from 'src/services/auth.service'; import { UAParser } from 'ua-parser-js'; -export enum Metadata { - AUTH_ROUTE = 'auth_route', - ADMIN_ROUTE = 'admin_route', - SHARED_ROUTE = 'shared_route', - API_KEY_SECURITY = 'api_key', - ON_EMIT_CONFIG = 'on_emit_config', -} - type AdminRoute = { admin?: true }; type SharedLinkRoute = { sharedLink?: true }; type AuthenticatedOptions = { permission?: Permission } & (AdminRoute | SharedLinkRoute); @@ -32,8 +24,8 @@ export const Authenticated = (options?: AuthenticatedOptions): MethodDecorator = const decorators: MethodDecorator[] = [ ApiBearerAuth(), ApiCookieAuth(), - ApiSecurity(Metadata.API_KEY_SECURITY), - SetMetadata(Metadata.AUTH_ROUTE, options || {}), + ApiSecurity(MetadataKey.API_KEY_SECURITY), + SetMetadata(MetadataKey.AUTH_ROUTE, options || {}), ]; if ((options as SharedLinkRoute)?.sharedLink) { @@ -85,7 +77,7 @@ export class AuthGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { const targets = [context.getHandler()]; - const options = this.reflector.getAllAndOverride(Metadata.AUTH_ROUTE, targets); + const options = this.reflector.getAllAndOverride(MetadataKey.AUTH_ROUTE, targets); if (!options) { return true; } diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts index 6ec8b401efb7f..075a7f504636a 100644 --- a/server/src/middleware/file-upload.interceptor.ts +++ b/server/src/middleware/file-upload.interceptor.ts @@ -7,6 +7,7 @@ import multer, { StorageEngine, diskStorage } from 'multer'; import { createHash, randomUUID } from 'node:crypto'; import { Observable } from 'rxjs'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; +import { RouteKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AssetMediaService, UploadFile } from 'src/services/asset-media.service'; @@ -28,11 +29,6 @@ export function getFiles(files: UploadFiles) { }; } -export enum Route { - ASSET = 'assets', - USER = 'users', -} - export interface ImmichFile extends Express.Multer.File { /** sha1 hash of file */ uuid: string; @@ -115,7 +111,7 @@ export class FileUploadInterceptor implements NestInterceptor { const context_ = context.switchToHttp(); const route = this.reflect.get(PATH_METADATA, context.getClass()); - const handler: RequestHandler | null = this.getHandler(route as Route); + const handler: RequestHandler | null = this.getHandler(route as RouteKey); if (handler) { await new Promise((resolve, reject) => { const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve()); @@ -176,13 +172,13 @@ export class FileUploadInterceptor implements NestInterceptor { return false; } - private getHandler(route: Route) { + private getHandler(route: RouteKey) { switch (route) { - case Route.ASSET: { + case RouteKey.ASSET: { return this.handlers.assetUpload; } - case Route.USER: { + case RouteKey.USER: { return this.handlers.userProfile; } diff --git a/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts new file mode 100644 index 0000000000000..e02203997f723 --- /dev/null +++ b/server/src/migrations/1727471863507-SeparateQualityForThumbnailAndPreview.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SeparateQualityForThumbnailAndPreview1727471863507 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls( + jsonb_build_object( + 'preview', jsonb_build_object( + 'format', value->'image'->'previewFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'previewSize'), + 'thumbnail', jsonb_build_object( + 'format', value->'image'->'thumbnailFormat', + 'quality', value->'image'->'quality', + 'size', value->'image'->'thumbnailSize'), + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace' + ))) + where key = 'system-config'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + update system_metadata + set value = jsonb_set(value, '{image}', jsonb_strip_nulls(jsonb_build_object( + 'previewFormat', value->'image'->'preview'->'format', + 'previewSize', value->'image'->'preview'->'size', + 'thumbnailFormat', value->'image'->'thumbnail'->'format', + 'thumbnailSize', value->'image'->'thumbnail'->'size', + 'extractEmbedded', value->'extractEmbedded', + 'colorspace', value->'colorspace', + 'quality', value->'image'->'preview'->'quality' + ))) + where key = 'system-config'`); + } +} diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 43e765d00b678..0ec347ed77ab7 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -6,7 +6,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; -import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, PaginationMode } from 'src/enum'; import { AssetBuilderOptions, AssetCreate, @@ -30,7 +30,7 @@ import { import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { Brackets, FindOptionsOrder, diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts new file mode 100644 index 0000000000000..f16fa3bbd4ef9 --- /dev/null +++ b/server/src/repositories/config.repository.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { getVectorExtension } from 'src/database.config'; +import { EnvData, IConfigRepository } from 'src/interfaces/config.interface'; + +@Injectable() +export class ConfigRepository implements IConfigRepository { + getEnv(): EnvData { + return { + database: { + skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true', + vectorExtension: getVectorExtension(), + }, + }; + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 7082fc031faa2..fac250d6670d7 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,6 +5,7 @@ import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; @@ -39,6 +40,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; +import { ConfigRepository } from 'src/repositories/config.repository'; import { CryptoRepository } from 'src/repositories/crypto.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { EventRepository } from 'src/repositories/event.repository'; @@ -74,6 +76,7 @@ export const repositories = [ { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, { provide: IAuditRepository, useClass: AuditRepository }, + { provide: IConfigRepository, useClass: ConfigRepository }, { provide: ICryptoRepository, useClass: CryptoRepository }, { provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IEventRepository, useClass: EventRepository }, diff --git a/server/src/repositories/logger.repository.ts b/server/src/repositories/logger.repository.ts index 1e0c7b74d973e..1d7e734e735e6 100644 --- a/server/src/repositories/logger.repository.ts +++ b/server/src/repositories/logger.repository.ts @@ -1,7 +1,7 @@ import { ConsoleLogger, Injectable, Scope } from '@nestjs/common'; import { isLogLevelEnabled } from '@nestjs/common/services/utils/is-log-level-enabled.util'; import { ClsService } from 'nestjs-cls'; -import { LogLevel } from 'src/config'; +import { LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { LogColor } from 'src/utils/logger'; diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a84ef6f596f4e..d001aa3158b0f 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -1,15 +1,16 @@ import { Inject, Injectable } from '@nestjs/common'; import { exiftool } from 'exiftool-vendored'; import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; +import { Duration } from 'luxon'; import fs from 'node:fs/promises'; import { Writable } from 'node:stream'; -import { promisify } from 'node:util'; import sharp from 'sharp'; -import { Colorspace } from 'src/config'; +import { Colorspace, LogLevel } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMediaRepository, ImageDimensions, + ProbeOptions, ThumbnailOptions, TranscodeCommand, VideoInfo, @@ -17,10 +18,22 @@ import { import { Instrumentation } from 'src/utils/instrumentation'; import { handlePromiseError } from 'src/utils/misc'; -const probe = promisify(ffmpeg.ffprobe); +const probe = (input: string, options: string[]): Promise => + new Promise((resolve, reject) => + ffmpeg.ffprobe(input, options, (error, data) => (error ? reject(error) : resolve(data))), + ); sharp.concurrency(0); sharp.cache({ files: 0 }); +type ProgressEvent = { + frames: number; + currentFps: number; + currentKbps: number; + targetSize: number; + timemark: string; + percent?: number; +}; + @Instrumentation() @Injectable() export class MediaRepository implements IMediaRepository { @@ -65,8 +78,8 @@ export class MediaRepository implements IMediaRepository { .toFile(output); } - async probe(input: string): Promise { - const results = await probe(input); + async probe(input: string, options?: ProbeOptions): Promise { + const results = await probe(input, options?.countFrames ? ['-count_packets'] : []); // gets frame count quickly: https://stackoverflow.com/a/28376817 return { format: { formatName: results.format.format_name, @@ -83,10 +96,10 @@ export class MediaRepository implements IMediaRepository { width: stream.width || 0, codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name, codecType: stream.codec_type, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), - rotation: Number.parseInt(`${stream.rotation ?? 0}`), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), + rotation: this.parseInt(stream.rotation), isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67', - bitrate: Number.parseInt(stream.bit_rate ?? '0'), + bitrate: this.parseInt(stream.bit_rate), })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') @@ -94,7 +107,7 @@ export class MediaRepository implements IMediaRepository { index: stream.index, codecType: stream.codec_type, codecName: stream.codec_name, - frameCount: Number.parseInt(stream.nb_frames ?? '0'), + frameCount: this.parseInt(options?.countFrames ? stream.nb_read_packets : stream.nb_frames), })), }; } @@ -156,10 +169,37 @@ export class MediaRepository implements IMediaRepository { } private configureFfmpegCall(input: string, output: string | Writable, options: TranscodeCommand) { - return ffmpeg(input, { niceness: 10 }) + const ffmpegCall = ffmpeg(input, { niceness: 10 }) .inputOptions(options.inputOptions) .outputOptions(options.outputOptions) .output(output) - .on('error', (error, stdout, stderr) => this.logger.error(stderr || error)); + .on('start', (command: string) => this.logger.debug(command)) + .on('error', (error, _, stderr) => this.logger.error(stderr || error)); + + const { frameCount, percentInterval } = options.progress; + const frameInterval = Math.ceil(frameCount / (100 / percentInterval)); + if (this.logger.isLevelEnabled(LogLevel.DEBUG) && frameCount && frameInterval) { + let lastProgressFrame: number = 0; + ffmpegCall.on('progress', (progress: ProgressEvent) => { + if (progress.frames - lastProgressFrame < frameInterval) { + return; + } + + lastProgressFrame = progress.frames; + const percent = ((progress.frames / frameCount) * 100).toFixed(2); + const ms = Math.floor((frameCount - progress.frames) / progress.currentFps) * 1000; + const duration = ms ? Duration.fromMillis(ms).rescale().toHuman({ unitDisplay: 'narrow' }) : ''; + const outputText = output instanceof Writable ? 'stream' : output.split('/').pop(); + this.logger.debug( + `Transcoding ${percent}% done${duration ? `, estimated ${duration} remaining` : ''} for output ${outputText}`, + ); + }); + } + + return ffmpegCall; + } + + private parseInt(value: string | number | undefined): number { + return Number.parseInt(value as string) || 0; } } diff --git a/server/src/repositories/move.repository.ts b/server/src/repositories/move.repository.ts index a8416ff0ac4c6..45fd4465265d7 100644 --- a/server/src/repositories/move.repository.ts +++ b/server/src/repositories/move.repository.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; -import { MoveEntity, PathType } from 'src/entities/move.entity'; +import { MoveEntity } from 'src/entities/move.entity'; +import { PathType } from 'src/enum'; import { IMoveRepository, MoveCreate } from 'src/interfaces/move.interface'; import { Instrumentation } from 'src/utils/instrumentation'; import { Repository } from 'typeorm'; diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2247195cc3ce7..2607d2a9ec7c4 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -6,7 +6,7 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity } from 'src/entities/asset.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { SourceType } from 'src/enum'; +import { PaginationMode, SourceType } from 'src/enum'; import { AssetFaceId, DeleteAllFacesOptions, @@ -19,7 +19,7 @@ import { UpdateFacesData, } from 'src/interfaces/person.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { DataSource, FindManyOptions, FindOptionsRelations, FindOptionsSelect, In, Repository } from 'typeorm'; @Instrumentation() diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 8115c72cf6ac1..60694b6bfe800 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -8,7 +8,7 @@ import { ExifEntity } from 'src/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; import { SmartInfoEntity } from 'src/entities/smart-info.entity'; import { SmartSearchEntity } from 'src/entities/smart-search.entity'; -import { AssetType } from 'src/enum'; +import { AssetType, PaginationMode } from 'src/enum'; import { DatabaseExtension } from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { @@ -23,7 +23,7 @@ import { } from 'src/interfaces/search.interface'; import { asVector, searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; -import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination'; +import { Paginated, PaginationResult, paginatedBuilder } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; import { Repository, SelectQueryBuilder } from 'typeorm'; diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index d7f0c6da0fa37..c03c974b2c8e2 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -4,7 +4,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto'; import { AssetFileEntity } from 'src/entities/asset-files.entity'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, AssetType } from 'src/enum'; +import { AssetStatus, AssetType, CacheControl } from 'src/enum'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; @@ -12,7 +12,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { AssetMediaService } from 'src/services/asset-media.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { fileStub } from 'test/fixtures/file.stub'; diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index d3dce323f0bb7..e1b30e891f936 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -7,7 +7,7 @@ import { } from '@nestjs/common'; import { extname } from 'node:path'; import sanitize from 'sanitize-filename'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AssetBulkUploadCheckResponseDto, AssetMediaResponseDto, @@ -27,7 +27,7 @@ import { } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity'; -import { AssetStatus, AssetType, Permission } from 'src/enum'; +import { AssetStatus, AssetType, CacheControl, Permission, StorageFolder } from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; @@ -37,7 +37,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { requireAccess, requireUploadAccess } from 'src/utils/access'; import { getAssetFiles, onBeforeLink } from 'src/utils/asset.util'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { fromChecksum } from 'src/utils/request'; import { QueryFailedError } from 'typeorm'; diff --git a/server/src/services/audit.service.ts b/server/src/services/audit.service.ts index 72db2b6eb56ce..ced0f49c63716 100644 --- a/server/src/services/audit.service.ts +++ b/server/src/services/audit.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { DateTime } from 'luxon'; import { resolve } from 'node:path'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { AuditDeletesDto, AuditDeletesResponseDto, @@ -12,8 +12,15 @@ import { PathEntityType, } from 'src/dtos/audit.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity'; -import { AssetFileType, DatabaseAction, Permission } from 'src/enum'; +import { + AssetFileType, + AssetPathType, + DatabaseAction, + Permission, + PersonPathType, + StorageFolder, + UserPathType, +} from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { IAuditRepository } from 'src/interfaces/audit.interface'; diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts index acc2d3459ccd1..d22a3b3634ebc 100644 --- a/server/src/services/auth.service.spec.ts +++ b/server/src/services/auth.service.spec.ts @@ -1,9 +1,9 @@ import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { Issuer, generators } from 'openid-client'; -import { AuthType } from 'src/constants'; import { AuthDto, SignUpDto } from 'src/dtos/auth.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; +import { AuthType } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts index 6eaf755d0eb49..6b1e4c512f816 100644 --- a/server/src/services/auth.service.ts +++ b/server/src/services/auth.service.ts @@ -12,7 +12,7 @@ import { DateTime } from 'luxon'; import { IncomingHttpHeaders } from 'node:http'; import { Issuer, UserinfoResponse, custom, generators } from 'openid-client'; import { SystemConfig } from 'src/config'; -import { AuthType, LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; +import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { UserCore } from 'src/cores/user.core'; import { @@ -31,7 +31,7 @@ import { } from 'src/dtos/auth.dto'; import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto'; import { UserEntity } from 'src/entities/user.entity'; -import { Permission } from 'src/enum'; +import { AuthType, Permission } from 'src/enum'; import { IKeyRepository } from 'src/interfaces/api-key.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEventRepository } from 'src/interfaces/event.interface'; diff --git a/server/src/services/database.service.spec.ts b/server/src/services/database.service.spec.ts index c63428560e03c..fc8130cadc273 100644 --- a/server/src/services/database.service.spec.ts +++ b/server/src/services/database.service.spec.ts @@ -1,12 +1,20 @@ -import { DatabaseExtension, EXTENSION_NAMES, IDatabaseRepository } from 'src/interfaces/database.interface'; +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { + DatabaseExtension, + EXTENSION_NAMES, + IDatabaseRepository, + VectorExtension, +} from 'src/interfaces/database.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DatabaseService } from 'src/services/database.service'; +import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { Mocked } from 'vitest'; describe(DatabaseService.name, () => { let sut: DatabaseService; + let configMock: Mocked; let databaseMock: Mocked; let loggerMock: Mocked; let extensionRange: string; @@ -16,9 +24,11 @@ describe(DatabaseService.name, () => { let versionAboveRange: string; beforeEach(() => { + configMock = newConfigRepositoryMock(); databaseMock = newDatabaseRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new DatabaseService(databaseMock, loggerMock); + + sut = new DatabaseService(configMock, databaseMock, loggerMock); extensionRange = '0.2.x'; databaseMock.getExtensionVersionRange.mockReturnValue(extensionRange); @@ -33,11 +43,6 @@ describe(DatabaseService.name, () => { }); }); - afterEach(() => { - delete process.env.DB_SKIP_MIGRATIONS; - delete process.env.DB_VECTOR_EXTENSION; - }); - it('should work', () => { expect(sut).toBeDefined(); }); @@ -50,12 +55,12 @@ describe(DatabaseService.name, () => { expect(databaseMock.getPostgresVersion).toHaveBeenCalledTimes(1); }); - describe.each([ + describe.each(>[ { extension: DatabaseExtension.VECTOR, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTOR] }, { extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] }, ])('should work with $extensionName', ({ extension, extensionName }) => { beforeEach(() => { - process.env.DB_VECTOR_EXTENSION = extensionName; + configMock.getEnv.mockReturnValue({ database: { skipMigrations: false, vectorExtension: extension } }); }); it(`should start up successfully with ${extension}`, async () => { @@ -236,18 +241,28 @@ describe(DatabaseService.name, () => { expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1); expect(loggerMock.fatal).not.toHaveBeenCalled(); }); + }); - it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { - process.env.DB_SKIP_MIGRATIONS = 'true'; + it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => { + configMock.getEnv.mockReturnValue({ + database: { + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTORS, + }, + }); - await expect(sut.onBootstrap()).resolves.toBeUndefined(); + await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(databaseMock.runMigrations).not.toHaveBeenCalled(); - }); + expect(databaseMock.runMigrations).not.toHaveBeenCalled(); }); it(`should throw error if pgvector extension could not be created`, async () => { - process.env.DB_VECTOR_EXTENSION = 'pgvector'; + configMock.getEnv.mockReturnValue({ + database: { + skipMigrations: true, + vectorExtension: DatabaseExtension.VECTOR, + }, + }); databaseMock.getExtensionVersion.mockResolvedValue({ installedVersion: null, availableVersion: minVersionInRange, diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index a5280ff28be23..ee6176115b311 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { Duration } from 'luxon'; import semver from 'semver'; -import { getVectorExtension } from 'src/database.config'; import { OnEmit } from 'src/decorators'; +import { IConfigRepository } from 'src/interfaces/config.interface'; import { DatabaseExtension, DatabaseLock, @@ -67,6 +67,7 @@ export class DatabaseService { private reconnection?: NodeJS.Timeout; constructor( + @Inject(IConfigRepository) private configRepository: IConfigRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { @@ -85,7 +86,8 @@ export class DatabaseService { } await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => { - const extension = getVectorExtension(); + const envData = this.configRepository.getEnv(); + const extension = envData.database.vectorExtension; const name = EXTENSION_NAMES[extension]; const extensionRange = this.databaseRepository.getExtensionVersionRange(extension); @@ -116,7 +118,8 @@ export class DatabaseService { await this.checkReindexing(); - if (process.env.DB_SKIP_MIGRATIONS !== 'true') { + const { database } = this.configRepository.getEnv(); + if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); } }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index bf493de0f39d1..c0903fa101412 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -1,5 +1,8 @@ import { Stats } from 'node:fs'; +import { ExifEntity } from 'src/entities/exif.entity'; import { + AssetFileType, + AssetType, AudioCodec, Colorspace, ImageFormat, @@ -7,9 +10,7 @@ import { TranscodeHWAccel, TranscodePolicy, VideoCodec, -} from 'src/config'; -import { ExifEntity } from 'src/entities/exif.entity'; -import { AssetFileType, AssetType } from 'src/enum'; +} from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -284,7 +285,7 @@ describe(MediaService.name, () => { }); it.each(Object.values(ImageFormat))('should generate a %s preview for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { previewFormat: format } }); + systemMock.get.mockResolvedValue({ image: { preview: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const previewPath = `upload/thumbs/user-id/as/se/asset-id-preview.${format}`; @@ -306,7 +307,7 @@ describe(MediaService.name, () => { }); it('should delete previous preview if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGeneratePreview({ id: assetStub.image.id }); @@ -348,7 +349,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -358,7 +359,7 @@ describe(MediaService.name, () => { 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=601:out_range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -376,7 +377,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -386,7 +387,7 @@ describe(MediaService.name, () => { 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=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); expect(assetMock.upsertFile).toHaveBeenCalledWith({ assetId: 'asset-id', @@ -406,7 +407,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: ['-skip_frame nointra', '-sws_flags accurate_rnd+full_chroma_int'], outputOptions: [ '-fps_mode vfr', @@ -416,7 +417,7 @@ describe(MediaService.name, () => { 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=bt709:t=601:m=bt470bg:range=pc,format=yuv420p`, ], twoPass: false, - }, + }), ); }); @@ -429,11 +430,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringContaining('scale=-2:1440')]), twoPass: false, - }, + }), ); }); @@ -463,7 +464,7 @@ describe(MediaService.name, () => { it.each(Object.values(ImageFormat))( 'should generate a %s thumbnail for an image when specified', async (format) => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: format } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); const thumbnailPath = `upload/thumbs/user-id/as/se/asset-id-thumbnail.${format}`; @@ -486,7 +487,7 @@ describe(MediaService.name, () => { ); it('should delete previous thumbnail if different path', async () => { - systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } }); + systemMock.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.WEBP } } }); assetMock.getByIds.mockResolvedValue([assetStub.image]); await sut.handleGenerateThumbnail({ id: assetStub.image.id }); @@ -730,21 +731,22 @@ describe(MediaService.name, () => { it('should transcode the longest stream', async () => { assetMock.getByIds.mockResolvedValue([assetStub.video]); + loggerMock.isLevelEnabled.mockReturnValue(false); mediaMock.probe.mockResolvedValue(probeStub.multipleVideoStreams); await sut.handleVideoConversion({ id: assetStub.video.id }); - expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext'); + expect(mediaMock.probe).toHaveBeenCalledWith('/original/path.ext', { countFrames: false }); expect(systemMock.get).toHaveBeenCalled(); expect(storageMock.mkdirSync).toHaveBeenCalled(); expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-map 0:0', '-map 0:1']), twoPass: false, - }, + }), ); }); @@ -770,11 +772,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -785,11 +787,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -800,11 +802,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -815,11 +817,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('scale')]), twoPass: false, - }, + }), ); }); @@ -831,11 +833,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:720/)]), twoPass: false, - }, + }), ); }); @@ -847,11 +849,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=720:-2/)]), twoPass: false, - }, + }), ); }); @@ -863,11 +865,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=-2:354/)]), twoPass: false, - }, + }), ); }); @@ -879,11 +881,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([expect.stringMatching(/scale(_.+)?=354:-2/)]), twoPass: false, - }, + }), ); }); @@ -897,11 +899,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a aac']), twoPass: false, - }, + }), ); }); @@ -919,11 +921,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining(['-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -941,11 +943,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-tag:v hvc1']), twoPass: false, - }, + }), ); }); @@ -957,11 +959,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', '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']), twoPass: false, - }, + }), ); }); @@ -972,11 +974,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v copy', '-c:a copy']), twoPass: false, - }, + }), ); }); @@ -1035,11 +1037,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-maxrate 4500k', '-bufsize 9000k']), twoPass: false, - }, + }), ); }); @@ -1051,11 +1053,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264', '-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1067,11 +1069,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', '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']), twoPass: false, - }, + }), ); }); @@ -1089,11 +1091,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-b:v 3104k', '-minrate 1552k', '-maxrate 4500k']), twoPass: true, - }, + }), ); }); @@ -1111,11 +1113,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-maxrate')]), twoPass: true, - }, + }), ); }); @@ -1127,11 +1129,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-cpu-used 2']), twoPass: false, - }, + }), ); }); @@ -1143,11 +1145,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-cpu-used')]), twoPass: false, - }, + }), ); }); @@ -1159,11 +1161,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 2']), twoPass: false, - }, + }), ); }); @@ -1175,11 +1177,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-threads 1', '-x264-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1191,11 +1193,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1207,11 +1209,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v hevc', '-threads 1', '-x265-params frame-threads=1:pools=none']), twoPass: false, - }, + }), ); }); @@ -1223,11 +1225,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.not.arrayContaining([expect.stringContaining('-threads')]), twoPass: false, - }, + }), ); }); @@ -1239,7 +1241,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining([ '-c:v av1', @@ -1254,7 +1256,7 @@ describe(MediaService.name, () => { '-crf 23', ]), twoPass: false, - }, + }), ); }); @@ -1266,11 +1268,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-preset 4']), twoPass: false, - }, + }), ); }); @@ -1282,11 +1284,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1298,11 +1300,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4']), twoPass: false, - }, + }), ); }); @@ -1314,11 +1316,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-svtav1-params lp=4:mbr=2M']), twoPass: false, - }, + }), ); }); @@ -1360,7 +1362,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([ '-tune hq', @@ -1381,7 +1383,7 @@ describe(MediaService.name, () => { '-cq:v 23', ]), twoPass: false, - }, + }), ); }); @@ -1399,11 +1401,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1415,11 +1417,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.arrayContaining(['-cq:v 23', '-maxrate 10000k', '-bufsize 6897k']), twoPass: false, - }, + }), ); }); @@ -1431,11 +1433,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.stringContaining('-maxrate'), twoPass: false, - }, + }), ); }); @@ -1447,11 +1449,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1463,11 +1465,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-multipass')]), twoPass: false, - }, + }), ); }); @@ -1481,7 +1483,7 @@ describe(MediaService.name, () => { 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', @@ -1490,7 +1492,7 @@ describe(MediaService.name, () => { ]), outputOptions: expect.arrayContaining([expect.stringContaining('scale_cuda=-2:720:format=nv12')]), twoPass: false, - }, + }), ); }); @@ -1504,7 +1506,7 @@ describe(MediaService.name, () => { 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( @@ -1512,7 +1514,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1525,7 +1527,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining([ `-c:v h264_qsv`, @@ -1546,7 +1548,7 @@ describe(MediaService.name, () => { '-bufsize 20000k', ]), twoPass: false, - }, + }), ); }); @@ -1565,14 +1567,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device qsv=hw,child_device=/dev/dri/renderD128', '-filter_hw_device hw', ]), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1585,11 +1587,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.not.arrayContaining([expect.stringContaining('-preset')]), twoPass: false, - }, + }), ); }); @@ -1602,11 +1604,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-init_hw_device qsv=hw', '-filter_hw_device hw']), outputOptions: expect.arrayContaining(['-low_power 1']), twoPass: false, - }, + }), ); }); @@ -1632,7 +1634,7 @@ describe(MediaService.name, () => { 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', @@ -1644,7 +1646,7 @@ describe(MediaService.name, () => { expect.stringContaining('scale_qsv=-1:720:async_depth=4:mode=hq:format=nv12'), ]), twoPass: false, - }, + }), ); }); @@ -1661,7 +1663,7 @@ describe(MediaService.name, () => { 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', @@ -1674,7 +1676,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -1690,11 +1692,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining(['-hwaccel qsv', '-qsv_device /dev/dri/renderD129']), outputOptions: expect.any(Array), twoPass: false, - }, + }), ); }); @@ -1707,7 +1709,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1727,7 +1729,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1740,7 +1742,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1753,7 +1755,7 @@ describe(MediaService.name, () => { '-rc_mode 3', ]), twoPass: false, - }, + }), ); }); @@ -1766,7 +1768,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', @@ -1779,7 +1781,7 @@ describe(MediaService.name, () => { '-rc_mode 1', ]), twoPass: false, - }, + }), ); }); @@ -1792,14 +1794,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.not.arrayContaining([expect.stringContaining('-compression_level')]), twoPass: false, - }, + }), ); }); @@ -1812,14 +1814,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/card1', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1832,14 +1834,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD130', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1854,14 +1856,14 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.arrayContaining([ '-init_hw_device vaapi=accel:/dev/dri/renderD128', '-filter_hw_device accel', ]), outputOptions: expect.arrayContaining([`-c:v h264_vaapi`]), twoPass: false, - }, + }), ); }); @@ -1876,11 +1878,11 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenLastCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: expect.any(Array), outputOptions: expect.arrayContaining(['-c:v h264']), twoPass: false, - }, + }), ); }); @@ -1903,7 +1905,7 @@ describe(MediaService.name, () => { 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', @@ -1926,7 +1928,7 @@ describe(MediaService.name, () => { '-qp_init 23', ]), twoPass: false, - }, + }), ); }); @@ -1947,11 +1949,11 @@ describe(MediaService.name, () => { 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([`-c:v hevc_rkmpp`, '-level 153', '-rc_mode AVBR', '-b:v 10000k']), twoPass: false, - }, + }), ); }); @@ -1967,11 +1969,11 @@ describe(MediaService.name, () => { 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([`-c:v h264_rkmpp`, '-level 51', '-rc_mode CQP', '-qp_init 30']), twoPass: false, - }, + }), ); }); @@ -1987,7 +1989,7 @@ describe(MediaService.name, () => { 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( @@ -1995,7 +1997,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2011,7 +2013,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2019,7 +2021,7 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); @@ -2035,7 +2037,7 @@ describe(MediaService.name, () => { expect(mediaMock.transcode).toHaveBeenCalledWith( '/original/path.ext', 'upload/encoded-video/user-id/as/se/asset-id.mp4', - { + expect.objectContaining({ inputOptions: [], outputOptions: expect.arrayContaining([ expect.stringContaining( @@ -2043,69 +2045,101 @@ describe(MediaService.name, () => { ), ]), twoPass: false, - }, + }), ); }); - }); - it('should tonemap when policy is required and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); - 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', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + it('should tonemap when policy is required and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.REQUIRED } }); + 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.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); - it('should tonemap when policy is optimal and video is hdr', async () => { - mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); - systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); - 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', - { - inputOptions: expect.any(Array), - outputOptions: expect.arrayContaining([ - '-c:v h264', - '-c:a copy', - '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', - ]), - twoPass: false, - }, - ); - }); + it('should tonemap when policy is optimal and video is hdr', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + systemMock.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.OPTIMAL } }); + 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.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=100,tonemap=hable:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,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 } }); - 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', - { - 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=bt709:t=bt709:m=bt709:range=pc,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 } }); + 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.any(Array), + outputOptions: expect.arrayContaining([ + '-c:v h264', + '-c:a copy', + '-vf zscale=t=linear:npl=250,tonemap=mobius:desat=0,zscale=p=bt709:t=bt709:m=bt709:range=pc,format=yuv420p', + ]), + twoPass: false, + }), + ); + }); + + it('should count frames for progress when log level is debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); + loggerMock.isLevelEnabled.mockReturnValue(true); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: true }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + assetStub.video.originalPath, + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: expect.any(Array), + outputOptions: expect.any(Array), + twoPass: false, + progress: { + frameCount: probeStub.videoStream2160p.videoStreams[0].frameCount, + percentInterval: expect.any(Number), + }, + }, + ); + }); + + it('should not count frames for progress when log level is not debug', async () => { + mediaMock.probe.mockResolvedValue(probeStub.videoStream2160p); + loggerMock.isLevelEnabled.mockReturnValue(false); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + + expect(mediaMock.probe).toHaveBeenCalledWith(assetStub.video.originalPath, { countFrames: false }); + }); }); describe('isSRGB', () => { diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index e74335bdc391c..1b69c5acd5504 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,21 +1,23 @@ import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common'; import { dirname } from 'node:path'; +import { GeneratedImageType, StorageCore } from 'src/cores/storage.core'; +import { SystemConfigCore } from 'src/cores/system-config.core'; +import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { AssetEntity } from 'src/entities/asset.entity'; import { + AssetFileType, + AssetPathType, + AssetType, AudioCodec, Colorspace, - ImageFormat, + LogLevel, + StorageFolder, TranscodeHWAccel, TranscodePolicy, TranscodeTarget, VideoCodec, VideoContainer, -} from 'src/config'; -import { GeneratedImageType, StorageCore, StorageFolder } from 'src/cores/storage.core'; -import { SystemConfigCore } from 'src/cores/system-config.core'; -import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; -import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; -import { AssetFileType, AssetType } from 'src/enum'; +} from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { @@ -29,7 +31,13 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { AudioStreamInfo, IMediaRepository, VideoFormat, VideoStreamInfo } from 'src/interfaces/media.interface'; +import { + AudioStreamInfo, + IMediaRepository, + TranscodeCommand, + VideoFormat, + VideoStreamInfo, +} from 'src/interfaces/media.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; @@ -166,18 +174,15 @@ export class MediaService { return JobStatus.FAILED; } - await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.previewFormat); - await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); + await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.SUCCESS; } async handleGeneratePreview({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -186,7 +191,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW, image.previewFormat); + const previewPath = await this.generateThumbnail(asset, AssetPathType.PREVIEW); if (!previewPath) { return JobStatus.SKIPPED; } @@ -204,9 +209,9 @@ export class MediaService { return JobStatus.SUCCESS; } - private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType, format: ImageFormat) { + private async generateThumbnail(asset: AssetEntity, type: GeneratedImageType) { const { image, ffmpeg } = await this.configCore.getConfig({ withCache: true }); - const size = type === AssetPathType.PREVIEW ? image.previewSize : image.thumbnailSize; + const { size, format, quality } = image[type]; const path = StorageCore.getImagePath(asset, type, format); this.storageCore.ensureFolders(path); @@ -217,13 +222,13 @@ export class MediaService { const didExtract = shouldExtract && (await this.mediaRepository.extract(asset.originalPath, extractedPath)); try { - const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.previewSize)); + const useExtracted = didExtract && (await this.shouldUseExtractedImage(extractedPath, image.preview.size)); const colorspace = this.isSRGB(asset) ? Colorspace.SRGB : image.colorspace; const imageOptions = { format, size, colorspace, - quality: image.quality, + quality, processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', }; @@ -265,10 +270,7 @@ export class MediaService { } async handleGenerateThumbnail({ id }: IEntityJob): Promise { - const [{ image }, [asset]] = await Promise.all([ - this.configCore.getConfig({ withCache: true }), - this.assetRepository.getByIds([id], { exifInfo: true, files: true }), - ]); + const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, files: true }); if (!asset) { return JobStatus.FAILED; } @@ -277,7 +279,7 @@ export class MediaService { return JobStatus.SKIPPED; } - const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL, image.thumbnailFormat); + const thumbnailPath = await this.generateThumbnail(asset, AssetPathType.THUMBNAIL); if (!thumbnailPath) { return JobStatus.SKIPPED; } @@ -344,7 +346,9 @@ export class MediaService { const output = StorageCore.getEncodedVideoPath(asset); this.storageCore.ensureFolders(output); - const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input); + const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, { + countFrames: this.logger.isLevelEnabled(LogLevel.DEBUG), // makes frame count more reliable for progress logs + }); const mainVideoStream = this.getMainStream(videoStreams); const mainAudioStream = this.getMainStream(audioStreams); if (!mainVideoStream || !format.formatName) { @@ -363,12 +367,14 @@ export class MediaService { this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } }); await this.assetRepository.update({ id: asset.id, encodedVideoPath: null }); + } else { + this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`); } return JobStatus.SKIPPED; } - let command; + let command: TranscodeCommand; try { const config = BaseConfig.create(ffmpeg, await this.getDevices(), await this.hasMaliOpenCL()); command = config.getCommand(target, mainVideoStream, mainAudioStream); @@ -377,16 +383,20 @@ export class MediaService { return JobStatus.FAILED; } - this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(command)}`); + if (ffmpeg.accel === TranscodeHWAccel.DISABLED) { + this.logger.log(`Encoding video ${asset.id} without hardware acceleration`); + } else { + this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`); + } + try { await this.mediaRepository.transcode(input, output, command); - } catch (error) { - this.logger.error(error); - if (ffmpeg.accel !== TranscodeHWAccel.DISABLED) { - this.logger.error( - `Error occurred during transcoding. Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled.`, - ); + } catch (error: any) { + this.logger.error(`Error occurred during transcoding: ${error.message}`); + 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); @@ -553,7 +563,7 @@ export class MediaService { const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); } catch { - this.logger.debug('OpenCL not available for transcoding, using CPU decoding instead.'); + this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding'); this.maliOpenCL = false; } } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index ad01aa5784afe..c74883c283bb2 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -316,7 +316,7 @@ describe(MetadataService.name, () => { it('should handle lists of numbers', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ ISO: [160] as any }); + metadataMock.readTags.mockResolvedValue({ ISO: [160] }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); @@ -411,7 +411,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list with a number', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] as any[] }); + metadataMock.readTags.mockResolvedValue({ Keywords: ['Parent', 2024] }); tagMock.upsertValue.mockResolvedValue(tagStub.parent); await sut.handleMetadataExtraction({ id: assetStub.image.id }); @@ -467,6 +467,17 @@ describe(MetadataService.name, () => { expect(tagMock.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); }); + it('should extract tags from HierarchicalSubject as a list with a number', async () => { + assetMock.getByIds.mockResolvedValue([assetStub.image]); + metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Parent', 2024] }); + tagMock.upsertValue.mockResolvedValue(tagStub.parent); + + await sut.handleMetadataExtraction({ id: assetStub.image.id }); + + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(tagMock.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + }); + it('should extract ignore / characters in a HierarchicalSubject tag', async () => { assetMock.getByIds.mockResolvedValue([assetStub.image]); metadataMock.readTags.mockResolvedValue({ HierarchicalSubject: ['Mom/Dad'] }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index bf76be07311b2..224ef03b3b019 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -11,6 +11,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } 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'; import { PersonEntity } from 'src/entities/person.entity'; import { AssetType, SourceType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; @@ -236,7 +237,7 @@ export class MetadataService { const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags); const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding); - const exifData = { + const exifData: Partial = { assetId: asset.id, // dates @@ -264,7 +265,7 @@ export class MetadataService { make: exifTags.Make ?? null, model: exifTags.Model ?? null, fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)), - iso: validate(exifTags.ISO), + iso: validate(exifTags.ISO) as number, exposureTime: exifTags.ExposureTime ?? null, lensModel: exifTags.LensModel ?? null, fNumber: validate(exifTags.FNumber), @@ -395,13 +396,13 @@ export class MetadataService { } private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) { - const tags: Array = []; + const tags: string[] = []; if (exifTags.TagsList) { - tags.push(...exifTags.TagsList); + tags.push(...exifTags.TagsList.map(String)); } else if (exifTags.HierarchicalSubject) { tags.push( ...exifTags.HierarchicalSubject.map((tag) => - tag + String(tag) // convert | to / .replaceAll('/', '') .replaceAll('|', '/') @@ -413,10 +414,10 @@ export class MetadataService { if (!Array.isArray(keywords)) { keywords = [keywords]; } - tags.push(...keywords); + tags.push(...keywords.map(String)); } - const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags: tags.map(String) }); + const results = await upsertTags(this.tagRepository, { userId: asset.ownerId, tags }); await this.tagRepository.upsertAssetTags({ assetId: asset.id, tagIds: results.map((tag) => tag.id) }); } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index 2b111706f1ea6..03da110ac6049 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -1,9 +1,8 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { Colorspace } from 'src/config'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; -import { SourceType, SystemMetadataKey } from 'src/enum'; +import { CacheControl, Colorspace, SourceType, SystemMetadataKey } from 'src/enum'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -16,7 +15,7 @@ import { FaceSearchResult, ISearchRepository } from 'src/interfaces/search.inter import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { PersonService } from 'src/services/person.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { faceStub } from 'test/fixtures/face.stub'; diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index dd4a4cecf2b56..651c8eebee54e 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,5 +1,4 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; -import { ImageFormat } from 'src/config'; import { FACE_THUMBNAIL_SIZE } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; @@ -23,9 +22,16 @@ import { } from 'src/dtos/person.dto'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetEntity } from 'src/entities/asset.entity'; -import { PersonPathType } from 'src/entities/move.entity'; import { PersonEntity } from 'src/entities/person.entity'; -import { AssetType, Permission, SourceType, SystemMetadataKey } from 'src/enum'; +import { + AssetType, + CacheControl, + ImageFormat, + Permission, + PersonPathType, + SourceType, + SystemMetadataKey, +} from 'src/enum'; import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; @@ -51,7 +57,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { checkAccess, requireAccess } from 'src/utils/access'; import { getAssetFiles } from 'src/utils/asset.util'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { mimeTypes } from 'src/utils/mime-types'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { usePagination } from 'src/utils/pagination'; @@ -568,7 +574,7 @@ export class PersonService { format: ImageFormat.JPEG, size: FACE_THUMBNAIL_SIZE, colorspace: image.colorspace, - quality: image.quality, + quality: image.thumbnail.quality, crop: this.getCrop({ old: { width: oldWidth, height: oldHeight }, new: { width, height } }, { x1, y1, x2, y2 }), processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true', } as const; diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index 9db90e41b3c58..a192c2f308ba0 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { getBuildMetadata, getServerLicensePublicKey } from 'src/config'; import { serverVersion } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; @@ -15,7 +15,7 @@ import { ServerStorageResponseDto, UsageByUserDto, } from 'src/dtos/server.dto'; -import { SystemMetadataKey } from 'src/enum'; +import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 093cc5b2ff1d6..e8e222c7b2491 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -2,7 +2,7 @@ import { Stats } from 'node:fs'; import { SystemConfig, defaults } from 'src/config'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; +import { AssetPathType } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; diff --git a/server/src/services/storage-template.service.ts b/server/src/services/storage-template.service.ts index 9836ad40ace47..30d0eb575f1de 100644 --- a/server/src/services/storage-template.service.ts +++ b/server/src/services/storage-template.service.ts @@ -13,12 +13,11 @@ import { supportedWeekTokens, supportedYearTokens, } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; -import { AssetPathType } from 'src/entities/move.entity'; -import { AssetType } from 'src/enum'; +import { AssetPathType, AssetType, StorageFolder } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index 1591149dc20d8..6d15f097d3956 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -1,8 +1,8 @@ import { Inject, Injectable } from '@nestjs/common'; import { join } from 'node:path'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { OnEmit } from 'src/decorators'; -import { SystemMetadataKey } from 'src/enum'; +import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 52ad6d276b94e..514d8aa0f8d58 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -1,19 +1,18 @@ import { BadRequestException } from '@nestjs/common'; +import { defaults, SystemConfig } from 'src/config'; import { AudioCodec, - CQMode, Colorspace, + CQMode, ImageFormat, LogLevel, - SystemConfig, + SystemMetadataKey, ToneMapping, TranscodeHWAccel, TranscodePolicy, VideoCodec, VideoContainer, - defaults, -} from 'src/config'; -import { SystemMetadataKey } from 'src/enum'; +} from 'src/enum'; import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { QueueName } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; @@ -136,11 +135,16 @@ const updatedConfig = Object.freeze({ template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', }, image: { - thumbnailFormat: ImageFormat.WEBP, - thumbnailSize: 250, - previewFormat: ImageFormat.JPEG, - previewSize: 1440, - quality: 80, + thumbnail: { + size: 250, + format: ImageFormat.WEBP, + quality: 80, + }, + preview: { + size: 1440, + format: ImageFormat.JPEG, + quality: 80, + }, colorspace: Colorspace.P3, extractEmbedded: false, }, diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index 5ec9ab7a5db05..8a7f9123e00c2 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { instanceToPlain } from 'class-transformer'; import _ from 'lodash'; -import { LogLevel, SystemConfig, defaults } from 'src/config'; +import { SystemConfig, defaults } from 'src/config'; import { supportedDayTokens, supportedHourTokens, @@ -15,6 +15,7 @@ import { import { SystemConfigCore } from 'src/cores/system-config.core'; import { OnEmit, OnServerEvent } from 'src/decorators'; import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto'; +import { LogLevel } from 'src/enum'; import { ArgOf, ClientEvent, IEventRepository, ServerEvent } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index 0ac0ea6dbc7cf..f5b564e86f18b 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,6 +1,6 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; import { UserEntity } from 'src/entities/user.entity'; -import { UserMetadataKey } from 'src/enum'; +import { CacheControl, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IJobRepository, JobName } from 'src/interfaces/job.interface'; @@ -9,7 +9,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; import { UserService } from 'src/services/user.service'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { authStub } from 'test/fixtures/auth.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { userStub } from 'test/fixtures/user.stub'; diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index cf918198ab869..dca893aa826b4 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -2,7 +2,7 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nes import { DateTime } from 'luxon'; import { getClientLicensePublicKey, getServerLicensePublicKey } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; -import { StorageCore, StorageFolder } from 'src/cores/storage.core'; +import { StorageCore } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; @@ -11,7 +11,7 @@ import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; -import { UserMetadataKey } from 'src/enum'; +import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; @@ -19,7 +19,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserFindOptions } from 'src/interfaces/user.interface'; -import { CacheControl, ImmichFileResponse } from 'src/utils/file'; +import { ImmichFileResponse } from 'src/utils/file'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; @Injectable() diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts index 064c9f75071ef..f5b079dea4ef3 100644 --- a/server/src/utils/events.ts +++ b/server/src/utils/events.ts @@ -1,8 +1,8 @@ import { ModuleRef, Reflector } from '@nestjs/core'; import _ from 'lodash'; import { EmitConfig } from 'src/decorators'; +import { MetadataKey } from 'src/enum'; import { EmitEvent, EmitHandler, IEventRepository } from 'src/interfaces/event.interface'; -import { Metadata } from 'src/middleware/auth.guard'; import { services } from 'src/services'; type Item = { @@ -35,7 +35,7 @@ export const setupEventHandlers = (moduleRef: ModuleRef) => { continue; } - const options = reflector.get(Metadata.ON_EMIT_CONFIG, handler); + const options = reflector.get(MetadataKey.ON_EMIT_CONFIG, handler); if (!options) { continue; } diff --git a/server/src/utils/file.ts b/server/src/utils/file.ts index 53a4d571dcfa3..3b26c3e1ba1e8 100644 --- a/server/src/utils/file.ts +++ b/server/src/utils/file.ts @@ -3,6 +3,7 @@ import { NextFunction, Response } from 'express'; import { access, constants } from 'node:fs/promises'; import { basename, extname, isAbsolute } from 'node:path'; import { promisify } from 'node:util'; +import { CacheControl } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ImmichReadStream } from 'src/interfaces/storage.interface'; import { isConnectionAborted } from 'src/utils/misc'; @@ -19,12 +20,6 @@ export function getLivePhotoMotionFilename(stillName: string, motionName: string return getFileNameWithoutExtension(stillName) + extname(motionName); } -export enum CacheControl { - PRIVATE_WITH_CACHE = 'private_with_cache', - PRIVATE_WITHOUT_CACHE = 'private_without_cache', - NONE = 'none', -} - export class ImmichFileResponse { public readonly path!: string; public readonly contentType!: string; diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index 8068f4a5e6587..6f0ab4ef81d90 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,5 +1,5 @@ -import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/config'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; +import { CQMode, ToneMapping, TranscodeHWAccel, TranscodeTarget, VideoCodec } from 'src/enum'; import { AudioStreamInfo, BitrateDistribution, @@ -80,6 +80,7 @@ export class BaseConfig implements VideoCodecSWConfig { inputOptions: this.getBaseInputOptions(videoStream), outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'], twoPass: this.eligibleForTwoPass(), + progress: { frameCount: videoStream.frameCount, percentInterval: 5 }, } as TranscodeCommand; if ([TranscodeTarget.ALL, TranscodeTarget.VIDEO].includes(target)) { const filters = this.getFilterOptions(videoStream); diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 47f3f552c47e7..2cef33d4f57c9 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -13,8 +13,8 @@ import path from 'node:path'; import { SystemConfig } from 'src/config'; import { CLIP_MODEL_INFO, isDev, serverVersion } from 'src/constants'; import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; +import { MetadataKey } from 'src/enum'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { Metadata } from 'src/middleware/auth.guard'; /** * @returns a list of strings representing the keys of the object in dot notation @@ -210,7 +210,7 @@ export const useSwagger = (app: INestApplication, force = false) => { in: 'header', name: ImmichHeader.API_KEY, }, - Metadata.API_KEY_SECURITY, + MetadataKey.API_KEY_SECURITY, ) .addServer('/api') .build(); diff --git a/server/src/utils/pagination.ts b/server/src/utils/pagination.ts index dec1a9de0c313..4009f219c1c96 100644 --- a/server/src/utils/pagination.ts +++ b/server/src/utils/pagination.ts @@ -1,4 +1,5 @@ import _ from 'lodash'; +import { PaginationMode } from 'src/enum'; import { FindManyOptions, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; export interface PaginationOptions { @@ -6,11 +7,6 @@ export interface PaginationOptions { skip?: number; } -export enum PaginationMode { - LIMIT_OFFSET = 'limit-offset', - SKIP_TAKE = 'skip-take', -} - export interface PaginatedBuilderOptions { take: number; skip?: number; diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts new file mode 100644 index 0000000000000..40110186f43f3 --- /dev/null +++ b/server/test/repositories/config.repository.mock.ts @@ -0,0 +1,14 @@ +import { IConfigRepository } from 'src/interfaces/config.interface'; +import { DatabaseExtension } from 'src/interfaces/database.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newConfigRepositoryMock = (): Mocked => { + return { + getEnv: vitest.fn().mockReturnValue({ + database: { + skipMigration: false, + vectorExtension: DatabaseExtension.VECTORS, + }, + }), + }; +}; diff --git a/server/test/repositories/logger.repository.mock.ts b/server/test/repositories/logger.repository.mock.ts index 5f7262c7e5d92..6342e9e73cc85 100644 --- a/server/test/repositories/logger.repository.mock.ts +++ b/server/test/repositories/logger.repository.mock.ts @@ -6,7 +6,7 @@ export const newLoggerRepositoryMock = (): Mocked => { setLogLevel: vitest.fn(), setContext: vitest.fn(), setAppName: vitest.fn(), - + isLevelEnabled: vitest.fn(), verbose: vitest.fn(), debug: vitest.fn(), log: vitest.fn(), diff --git a/web/package-lock.json b/web/package-lock.json index 172c315570d43..a32e96e67f78b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@formatjs/icu-messageformat-parser": "^2.7.8", @@ -74,7 +74,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.116.0", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@oazapfts/runtime": "^1.0.2" diff --git a/web/package.json b/web/package.json index 938b4dc9cf008..20553759fad43 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.116.0", + "version": "1.116.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", diff --git a/web/src/lib/actions/click-outside.ts b/web/src/lib/actions/click-outside.ts index bbcb0c405b718..1a421f1f5625e 100644 --- a/web/src/lib/actions/click-outside.ts +++ b/web/src/lib/actions/click-outside.ts @@ -6,6 +6,12 @@ interface Options { onEscape?: () => void; } +/** + * Calls a function when a click occurs outside of the element, or when the escape key is pressed. + * @param node + * @param options Object containing onOutclick and onEscape functions + * @returns + */ export function clickOutside(node: HTMLElement, options: Options = {}): ActionReturn { const { onOutclick, onEscape } = options; diff --git a/web/src/lib/actions/focus-outside.ts b/web/src/lib/actions/focus-outside.ts index 2266ea8f0ff83..c302e33d4ca2c 100644 --- a/web/src/lib/actions/focus-outside.ts +++ b/web/src/lib/actions/focus-outside.ts @@ -2,6 +2,11 @@ interface Options { onFocusOut?: (event: FocusEvent) => void; } +/** + * Calls a function when focus leaves the element. + * @param node + * @param options Object containing onFocusOut function + */ export function focusOutside(node: HTMLElement, options: Options = {}) { const { onFocusOut } = options; diff --git a/web/src/lib/actions/focus.ts b/web/src/lib/actions/focus.ts index 81185625f7332..3b6049f24732f 100644 --- a/web/src/lib/actions/focus.ts +++ b/web/src/lib/actions/focus.ts @@ -1,3 +1,4 @@ +/** Focus the given element when it is mounted. */ export const initInput = (element: HTMLInputElement) => { element.focus(); }; diff --git a/web/src/lib/actions/intersection-observer.ts b/web/src/lib/actions/intersection-observer.ts index 700ae0c3733b4..edbc07e5c1f09 100644 --- a/web/src/lib/actions/intersection-observer.ts +++ b/web/src/lib/actions/intersection-observer.ts @@ -13,7 +13,9 @@ type OnIntersectCallback = (entryOrElement: IntersectionObserverEntry | HTMLElem type OnSeparateCallback = (element: HTMLElement) => unknown; type IntersectionObserverActionProperties = { key?: string; + /** Function to execute when the element leaves the viewport */ onSeparate?: OnSeparateCallback; + /** Function to execute when the element enters the viewport */ onIntersect?: OnIntersectCallback; root?: Element | Document | null; @@ -112,6 +114,12 @@ function _intersectionObserver( }; } +/** + * Monitors an element's visibility in the viewport and calls functions when it enters or leaves (based on a threshold). + * @param element + * @param properties One or multiple configurations for the IntersectionObserver(s) + * @returns + */ export function intersectionObserver( element: HTMLElement, properties: IntersectionObserverActionProperties | IntersectionObserverActionProperties[], diff --git a/web/src/lib/actions/list-navigation.ts b/web/src/lib/actions/list-navigation.ts index b981f675214fe..8f8ed62ed009e 100644 --- a/web/src/lib/actions/list-navigation.ts +++ b/web/src/lib/actions/list-navigation.ts @@ -1,6 +1,11 @@ import { shortcuts } from '$lib/actions/shortcut'; import type { Action } from 'svelte/action'; +/** + * Enables keyboard navigation (up and down arrows) for a list of elements. + * @param node Element which listens for keyboard events + * @param container Element containing the list of elements + */ export const listNavigation: Action = (node, container: HTMLElement) => { const moveFocus = (direction: 'up' | 'down') => { const children = Array.from(container?.children); diff --git a/web/src/lib/actions/shortcut.ts b/web/src/lib/actions/shortcut.ts index df155ea821ad0..6348257c40496 100644 --- a/web/src/lib/actions/shortcut.ts +++ b/web/src/lib/actions/shortcut.ts @@ -10,11 +10,16 @@ export type Shortcut = { export type ShortcutOptions = { shortcut: Shortcut; + /** If true, the event handler will not execute if the event comes from an input field */ ignoreInputFields?: boolean; onShortcut: (event: KeyboardEvent & { currentTarget: T }) => unknown; preventDefault?: boolean; }; +/** Determines whether an event should be ignored. The event will be ignored if: + * - The element dispatching the event is not the same as the element which the event listener is attached to + * - The element dispatching the event is an input field + */ export const shouldIgnoreEvent = (event: KeyboardEvent | ClipboardEvent): boolean => { if (event.target === event.currentTarget) { return false; @@ -33,6 +38,7 @@ export const matchesShortcut = (event: KeyboardEvent, shortcut: Shortcut) => { ); }; +/** Bind a single keyboard shortcut to node. */ export const shortcut = ( node: T, option: ShortcutOptions, @@ -47,6 +53,7 @@ export const shortcut = ( }; }; +/** Binds multiple keyboard shortcuts to node */ export const shortcuts = ( node: T, options: ShortcutOptions[], diff --git a/web/src/lib/actions/thumbhash.ts b/web/src/lib/actions/thumbhash.ts index ab9d28ffc9b4d..e49f04dbee546 100644 --- a/web/src/lib/actions/thumbhash.ts +++ b/web/src/lib/actions/thumbhash.ts @@ -1,6 +1,11 @@ import { decodeBase64 } from '$lib/utils'; import { thumbHashToRGBA } from 'thumbhash'; +/** + * Renders a thumbnail onto a canvas from a base64 encoded hash. + * @param canvas + * @param param1 object containing the base64 encoded hash (base64Thumbhash: yourString) + */ export function thumbhash(canvas: HTMLCanvasElement, { base64ThumbHash }: { base64ThumbHash: string }) { const ctx = canvas.getContext('2d'); if (ctx) { diff --git a/web/src/lib/components/admin-page/settings/image/image-settings.svelte b/web/src/lib/components/admin-page/settings/image/image-settings.svelte index d6fc814b98e4c..b5e381d5f87a0 100644 --- a/web/src/lib/components/admin-page/settings/image/image-settings.svelte +++ b/web/src/lib/components/admin-page/settings/image/image-settings.svelte @@ -11,6 +11,7 @@ SettingInputFieldType, } from '$lib/components/shared-components/settings/setting-input-field.svelte'; import { t } from 'svelte-i18n'; + import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte'; export let savedConfig: SystemConfigDto; export let defaultConfig: SystemConfigDto; @@ -24,73 +25,96 @@
- + + - + - + + - + + - + + + + void)[] = []; + let selectedEditType: string = ''; + let stack: StackResponseDto | null = null; + let zoomToggle = () => void 0; let copyImage: () => Promise; $: isFullScreen = fullscreenElement !== null; - let stack: StackResponseDto | null = null; - const refreshStack = async () => { if (isSharedLink()) { return; @@ -390,11 +392,9 @@ onAction?.(action); }; - let selectedEditType: string = ''; - - function handleUpdateSelectedEditType(type: string) { + const handleUpdateSelectedEditType = (type: string) => { selectedEditType = type; - } + }; @@ -508,6 +508,7 @@ onNextAsset={() => navigateAsset('next')} on:close={closeViewer} {sharedLink} + haveFadeTransition={$slideshowState === SlideshowState.None || $slideshowTransition} /> {/if} {:else} diff --git a/web/src/lib/components/slideshow-settings.svelte b/web/src/lib/components/slideshow-settings.svelte index e2bf6a4b2c22d..6f0397be98f19 100644 --- a/web/src/lib/components/slideshow-settings.svelte +++ b/web/src/lib/components/slideshow-settings.svelte @@ -18,7 +18,7 @@ import SettingDropdown from './shared-components/settings/setting-dropdown.svelte'; import { t } from 'svelte-i18n'; - const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook } = slideshowStore; + const { slideshowDelay, showProgressBar, slideshowNavigation, slideshowLook, slideshowTransition } = slideshowStore; export let onClose = () => {}; @@ -65,6 +65,7 @@ }} /> + ('slideshow-show-progressbar', true); const slideshowDelay = persisted('slideshow-delay', 5, {}); + const slideshowTransition = persisted('slideshow-transition', true); return { restartProgress: { @@ -67,6 +68,7 @@ function createSlideshowStore() { slideshowState, slideshowDelay, showProgressBar, + slideshowTransition, }; }