mirror of
https://github.com/immich-app/immich.git
synced 2026-05-20 14:52:34 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2d58c2024 |
@@ -11,7 +11,6 @@ services:
|
|||||||
immich-server:
|
immich-server:
|
||||||
container_name: immich-e2e-server
|
container_name: immich-e2e-server
|
||||||
image: immich-server:latest
|
image: immich-server:latest
|
||||||
shm_size: 128mb
|
|
||||||
build:
|
build:
|
||||||
context: ../
|
context: ../
|
||||||
dockerfile: server/Dockerfile
|
dockerfile: server/Dockerfile
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.13
|
|
||||||
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3
|
|||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-core-2_2.27.10+20617_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.27.10/intel-igc-opencl-2_2.27.10+20617_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/intel-opencl-icd_26.01.36711.4-0_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
|
wget -nv https://github.com/intel/compute-runtime/releases/download/26.01.36711.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||||
dpkg -i *.deb && \
|
dpkg -i *.deb && \
|
||||||
rm *.deb && \
|
rm *.deb && \
|
||||||
apt-get remove wget -yqq && \
|
apt-get remove wget -yqq && \
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
|
|||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
cpu = ["onnxruntime>=1.23.2,<2"]
|
cpu = ["onnxruntime>=1.23.2,<2"]
|
||||||
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
|
cuda = ["onnxruntime-gpu>=1.23.2,<2"]
|
||||||
openvino = ["onnxruntime-openvino>=1.24.1,<2"]
|
openvino = ["onnxruntime-openvino>=1.23.0,<2"]
|
||||||
armnn = ["onnxruntime>=1.23.2,<2"]
|
armnn = ["onnxruntime>=1.23.2,<2"]
|
||||||
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
|
||||||
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
|
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]
|
||||||
|
|||||||
Generated
+42
-8
@@ -262,6 +262,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coloredlogs"
|
||||||
|
version = "15.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "humanfriendly" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorlog"
|
name = "colorlog"
|
||||||
version = "6.9.0"
|
version = "6.9.0"
|
||||||
@@ -874,6 +886,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "humanfriendly"
|
||||||
|
version = "10.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.11"
|
version = "3.11"
|
||||||
@@ -993,7 +1017,7 @@ requires-dist = [
|
|||||||
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime", marker = "extra == 'rknn'", specifier = ">=1.23.2,<2" },
|
||||||
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime-gpu", marker = "extra == 'cuda'", specifier = ">=1.23.2,<2" },
|
||||||
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" },
|
{ name = "onnxruntime-migraphx", marker = "extra == 'rocm'", specifier = ">=1.23.2,<2" },
|
||||||
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.24.1,<2" },
|
{ name = "onnxruntime-openvino", marker = "extra == 'openvino'", specifier = ">=1.23.0,<2" },
|
||||||
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
|
{ name = "opencv-python-headless", specifier = ">=4.7.0.72,<5.0" },
|
||||||
{ name = "orjson", specifier = ">=3.9.5" },
|
{ name = "orjson", specifier = ">=3.9.5" },
|
||||||
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
|
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
|
||||||
@@ -1724,9 +1748,10 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "onnxruntime-openvino"
|
name = "onnxruntime-openvino"
|
||||||
version = "1.24.1"
|
version = "1.23.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "coloredlogs" },
|
||||||
{ name = "flatbuffers" },
|
{ name = "flatbuffers" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
@@ -1734,12 +1759,12 @@ dependencies = [
|
|||||||
{ name = "sympy" },
|
{ name = "sympy" },
|
||||||
]
|
]
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/16/69ca742f0b65c40d4de3ff44bb6abc23c47b23e932bc901116176ae69922/onnxruntime_openvino-1.24.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:3007c803634cc69c6d52af1dea7ce729d9bb62b9a11070fd2f959119199007a8", size = 84430935, upload-time = "2026-02-26T13:44:32.193Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/10/adcd4ac68ffc8dee003553125ef5c091be822e2d7c1077d0bb85690baa9c/onnxruntime_openvino-1.23.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:91938837e6e92e30c63d12fad68a8a4959c40d2eade2bd60f38bdd5b6392f8d3", size = 70481480, upload-time = "2025-10-14T15:19:45.882Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/73/619bb416bbfc40aebdd493fd6800d2637359294fe683d8a6bae3ff8d869a/onnxruntime_openvino-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:8042698232bf67f1f6b219c2b07728d7ae7ddff17d8524588de3675480609aef", size = 13655357, upload-time = "2026-02-26T13:44:35.555Z" },
|
{ url = "https://files.pythonhosted.org/packages/97/95/25f28d6fecf300aa0af393e96af9e00cc676e5dab650ab84f2122610df50/onnxruntime_openvino-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:8f05d2d6a804fb70d3f4329d777ac62439773dcc2df827dd5f42644b10bf1fea", size = 13117353, upload-time = "2025-10-14T15:19:49.014Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/cf/17ba72de2df0fcba349937d2788f154397bbc2d1a2d67772a97e26f6bc5f/onnxruntime_openvino-1.24.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d617fac2f59a6ab5ea59a788c3e1592240a129642519aaeaa774761dfe35150e", size = 84433207, upload-time = "2026-02-26T13:44:41.395Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/0c/8d97419dfeedf419c5fe5293f3dbc59284855a63ad22e71f46c0010c9dc4/onnxruntime_openvino-1.23.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b963ea19bf9856f3d6b2f719d451f2eeae482a8f69c729906465aa4f27f4d39c", size = 70483359, upload-time = "2025-10-14T15:19:52.88Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/37/d301f2c68b19a9485ed5db3047e0fb52478f3e73eb08c7d2a7c61be7cc1c/onnxruntime_openvino-1.24.1-cp312-cp312-win_amd64.whl", hash = "sha256:f186335a9c9b255633275290da7521d3d4d14c7773fee3127bfa040234d3fa5a", size = 13658075, upload-time = "2026-02-26T13:44:44.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/30/ff6111b16ffb4187c462824aa4e95acc20fdd90f856d44a339d56c6dacd6/onnxruntime_openvino-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:937e52657f94c56990a6e5bd4c3705bd6e970834c7c94e23d300dde6848f2889", size = 13117933, upload-time = "2025-10-14T15:19:58.319Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/07/f225999919f56506b603aaa3ff837ad563ab26f86906ed7fa7e5abcd849e/onnxruntime_openvino-1.24.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2c3bb73e68ac27f4891af8a595c1faf574ec68b772e6583c90a0b997a1822782", size = 84433183, upload-time = "2026-02-26T13:44:50.254Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/48/e42f618a8ec5fcf825fed4fdc8125f7105256cc6020b84567ecb88d5e2b7/onnxruntime_openvino-1.23.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:2e93b9a8323e196b7433866054a59260f2206ab6fb0e7223dda91da71f1db8c5", size = 70483088, upload-time = "2025-10-14T15:20:02.425Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/92/46ae2cd565961a89189900f385bb2f13a9fa731ea4674001d23720fbb1e0/onnxruntime_openvino-1.24.1-cp313-cp313-win_amd64.whl", hash = "sha256:434bf49aa71393c577a456c9d76c98e6d6958a833fa0876793e3d5437b5a511a", size = 13658485, upload-time = "2026-02-26T13:44:53.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/4a/f9/a531dc497dc113dc14df9a9de5aacb1676cadebc3ec6cc7cd3ca65cb3db0/onnxruntime_openvino-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:0ebbf70929de4ce269371cb255536bbedef588932d744da0b40e66c38a620f35", size = 13118206, upload-time = "2025-10-14T15:20:05.587Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2179,6 +2204,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/92/8486ede85fcc088f1b3dba4ce92dd29d126fd96b0008ea213167940a2475/pyparsing-3.1.1-py3-none-any.whl", hash = "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", size = 103139, upload-time = "2023-07-30T15:06:59.829Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyreadline3"
|
||||||
|
version = "3.4.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/86/3d61a61f36a0067874a00cb4dceb9028d34b6060e47828f7fc86fb9f7ee9/pyreadline3-3.4.1.tar.gz", hash = "sha256:6f3d1f7b8a31ba32b73917cefc1f28cc660562f39aea8646d30bd6eff21f7bae", size = 86465, upload-time = "2022-01-24T20:05:11.66Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/fc/a3c13ded7b3057680c8ae95a9b6cc83e63657c38e0005c400a5d018a33a7/pyreadline3-3.4.1-py3-none-any.whl", hash = "sha256:b0efb6516fd4fb07b45949053826a62fa4cb353db5be2bbb4a7aa1fdd1e345fb", size = 95203, upload-time = "2022-01-24T20:05:10.442Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytest"
|
name = "pytest"
|
||||||
version = "9.0.2"
|
version = "9.0.2"
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() {
|
|||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface LocalImageApi {
|
interface LocalImageApi {
|
||||||
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
fun requestImage(assetId: String, requestId: Long, width: Long, height: Long, isVideo: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||||
fun cancelRequest(requestId: Long)
|
fun cancelRequest(requestId: Long)
|
||||||
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
|
fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
|
||||||
|
|
||||||
@@ -82,8 +82,7 @@ interface LocalImageApi {
|
|||||||
val widthArg = args[2] as Long
|
val widthArg = args[2] as Long
|
||||||
val heightArg = args[3] as Long
|
val heightArg = args[3] as Long
|
||||||
val isVideoArg = args[4] as Boolean
|
val isVideoArg = args[4] as Boolean
|
||||||
val preferEncodedArg = args[5] as Boolean
|
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
|
||||||
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(LocalImagesPigeonUtils.wrapError(error))
|
reply.reply(LocalImagesPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import android.util.Size
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import app.alextran.immich.NativeBuffer
|
import app.alextran.immich.NativeBuffer
|
||||||
import kotlin.math.*
|
import kotlin.math.*
|
||||||
import java.io.IOException
|
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.Priority
|
import com.bumptech.glide.Priority
|
||||||
@@ -100,17 +99,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
|||||||
width: Long,
|
width: Long,
|
||||||
height: Long,
|
height: Long,
|
||||||
isVideo: Boolean,
|
isVideo: Boolean,
|
||||||
preferEncoded: Boolean,
|
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
) {
|
) {
|
||||||
val signal = CancellationSignal()
|
val signal = CancellationSignal()
|
||||||
val task = threadPool.submit {
|
val task = threadPool.submit {
|
||||||
try {
|
try {
|
||||||
if (preferEncoded) {
|
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
|
||||||
getEncodedImageInternal(assetId, callback, signal)
|
|
||||||
} else {
|
|
||||||
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
when (e) {
|
when (e) {
|
||||||
is OperationCanceledException -> callback(CANCELLED)
|
is OperationCanceledException -> callback(CANCELLED)
|
||||||
@@ -139,35 +133,6 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getEncodedImageInternal(
|
|
||||||
assetId: String,
|
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit,
|
|
||||||
signal: CancellationSignal
|
|
||||||
) {
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
val id = assetId.toLong()
|
|
||||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
|
||||||
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
val bytes = resolver.openInputStream(uri)?.use { it.readBytes() }
|
|
||||||
?: throw IOException("Could not read image data for $assetId")
|
|
||||||
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
val pointer = NativeBuffer.allocate(bytes.size)
|
|
||||||
try {
|
|
||||||
val buffer = NativeBuffer.wrap(pointer, bytes.size)
|
|
||||||
buffer.put(bytes)
|
|
||||||
signal.throwIfCanceled()
|
|
||||||
callback(Result.success(mapOf(
|
|
||||||
"pointer" to pointer,
|
|
||||||
"length" to bytes.size.toLong()
|
|
||||||
)))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
NativeBuffer.free(pointer)
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getThumbnailBufferInternal(
|
private fun getThumbnailBufferInternal(
|
||||||
assetId: String,
|
assetId: String,
|
||||||
width: Long,
|
width: Long,
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
|
|||||||
|
|
||||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||||
interface RemoteImageApi {
|
interface RemoteImageApi {
|
||||||
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, preferEncoded: Boolean, callback: (Result<Map<String, Long>?>) -> Unit)
|
fun requestImage(url: String, headers: Map<String, String>, requestId: Long, callback: (Result<Map<String, Long>?>) -> Unit)
|
||||||
fun cancelRequest(requestId: Long)
|
fun cancelRequest(requestId: Long)
|
||||||
fun clearCache(callback: (Result<Long>) -> Unit)
|
fun clearCache(callback: (Result<Long>) -> Unit)
|
||||||
|
|
||||||
@@ -68,8 +68,7 @@ interface RemoteImageApi {
|
|||||||
val urlArg = args[0] as String
|
val urlArg = args[0] as String
|
||||||
val headersArg = args[1] as Map<String, String>
|
val headersArg = args[1] as Map<String, String>
|
||||||
val requestIdArg = args[2] as Long
|
val requestIdArg = args[2] as Long
|
||||||
val preferEncodedArg = args[3] as Boolean
|
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
|
||||||
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
|
|
||||||
val error = result.exceptionOrNull()
|
val error = result.exceptionOrNull()
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
reply.reply(RemoteImagesPigeonUtils.wrapError(error))
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
|||||||
url: String,
|
url: String,
|
||||||
headers: Map<String, String>,
|
headers: Map<String, String>,
|
||||||
requestId: Long,
|
requestId: Long,
|
||||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
|
||||||
callback: (Result<Map<String, Long>?>) -> Unit
|
callback: (Result<Map<String, Long>?>) -> Unit
|
||||||
) {
|
) {
|
||||||
val signal = CancellationSignal()
|
val signal = CancellationSignal()
|
||||||
|
|||||||
@@ -78,21 +78,6 @@ class FlutterError (
|
|||||||
val details: Any? = null
|
val details: Any? = null
|
||||||
) : Throwable()
|
) : Throwable()
|
||||||
|
|
||||||
enum class PlatformAssetPlaybackStyle(val raw: Int) {
|
|
||||||
UNKNOWN(0),
|
|
||||||
IMAGE(1),
|
|
||||||
VIDEO(2),
|
|
||||||
IMAGE_ANIMATED(3),
|
|
||||||
LIVE_PHOTO(4),
|
|
||||||
VIDEO_LOOPING(5);
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun ofRaw(raw: Int): PlatformAssetPlaybackStyle? {
|
|
||||||
return values().firstOrNull { it.raw == raw }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generated class from Pigeon that represents data sent in messages. */
|
/** Generated class from Pigeon that represents data sent in messages. */
|
||||||
data class PlatformAsset (
|
data class PlatformAsset (
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -107,8 +92,7 @@ data class PlatformAsset (
|
|||||||
val isFavorite: Boolean,
|
val isFavorite: Boolean,
|
||||||
val adjustmentTime: Long? = null,
|
val adjustmentTime: Long? = null,
|
||||||
val latitude: Double? = null,
|
val latitude: Double? = null,
|
||||||
val longitude: Double? = null,
|
val longitude: Double? = null
|
||||||
val playbackStyle: PlatformAssetPlaybackStyle
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
companion object {
|
companion object {
|
||||||
@@ -126,8 +110,7 @@ data class PlatformAsset (
|
|||||||
val adjustmentTime = pigeonVar_list[10] as Long?
|
val adjustmentTime = pigeonVar_list[10] as Long?
|
||||||
val latitude = pigeonVar_list[11] as Double?
|
val latitude = pigeonVar_list[11] as Double?
|
||||||
val longitude = pigeonVar_list[12] as Double?
|
val longitude = pigeonVar_list[12] as Double?
|
||||||
val playbackStyle = pigeonVar_list[13] as PlatformAssetPlaybackStyle
|
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
|
||||||
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude, playbackStyle)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun toList(): List<Any?> {
|
fun toList(): List<Any?> {
|
||||||
@@ -145,7 +128,6 @@ data class PlatformAsset (
|
|||||||
adjustmentTime,
|
adjustmentTime,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
@@ -308,31 +290,26 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||||
return when (type) {
|
return when (type) {
|
||||||
129.toByte() -> {
|
129.toByte() -> {
|
||||||
return (readValue(buffer) as Long?)?.let {
|
|
||||||
PlatformAssetPlaybackStyle.ofRaw(it.toInt())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
130.toByte() -> {
|
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
PlatformAsset.fromList(it)
|
PlatformAsset.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
131.toByte() -> {
|
130.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
PlatformAlbum.fromList(it)
|
PlatformAlbum.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
132.toByte() -> {
|
131.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
SyncDelta.fromList(it)
|
SyncDelta.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
133.toByte() -> {
|
132.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
HashResult.fromList(it)
|
HashResult.fromList(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
134.toByte() -> {
|
133.toByte() -> {
|
||||||
return (readValue(buffer) as? List<Any?>)?.let {
|
return (readValue(buffer) as? List<Any?>)?.let {
|
||||||
CloudIdResult.fromList(it)
|
CloudIdResult.fromList(it)
|
||||||
}
|
}
|
||||||
@@ -342,28 +319,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
|
|||||||
}
|
}
|
||||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||||
when (value) {
|
when (value) {
|
||||||
is PlatformAssetPlaybackStyle -> {
|
|
||||||
stream.write(129)
|
|
||||||
writeValue(stream, value.raw)
|
|
||||||
}
|
|
||||||
is PlatformAsset -> {
|
is PlatformAsset -> {
|
||||||
stream.write(130)
|
stream.write(129)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is PlatformAlbum -> {
|
is PlatformAlbum -> {
|
||||||
stream.write(131)
|
stream.write(130)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is SyncDelta -> {
|
is SyncDelta -> {
|
||||||
stream.write(132)
|
stream.write(131)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is HashResult -> {
|
is HashResult -> {
|
||||||
stream.write(133)
|
stream.write(132)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
is CloudIdResult -> {
|
is CloudIdResult -> {
|
||||||
stream.write(134)
|
stream.write(133)
|
||||||
writeValue(stream, value.toList())
|
writeValue(stream, value.toList())
|
||||||
}
|
}
|
||||||
else -> super.writeValue(stream, value)
|
else -> super.writeValue(stream, value)
|
||||||
|
|||||||
@@ -4,17 +4,11 @@ import android.annotation.SuppressLint
|
|||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import androidx.exifinterface.media.ExifInterface
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import app.alextran.immich.core.ImmichPlugin
|
import app.alextran.immich.core.ImmichPlugin
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.bumptech.glide.load.ImageHeaderParser
|
|
||||||
import com.bumptech.glide.load.ImageHeaderParserUtils
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -34,8 +28,6 @@ sealed class AssetResult {
|
|||||||
data class InvalidAsset(val assetId: String) : AssetResult()
|
data class InvalidAsset(val assetId: String) : AssetResult()
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val TAG = "NativeSyncApiImplBase"
|
|
||||||
|
|
||||||
@SuppressLint("InlinedApi")
|
@SuppressLint("InlinedApi")
|
||||||
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
||||||
private val ctx: Context = context.applicationContext
|
private val ctx: Context = context.applicationContext
|
||||||
@@ -47,13 +39,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
private val hashSemaphore = Semaphore(MAX_CONCURRENT_HASH_OPERATIONS)
|
||||||
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
private const val HASHING_CANCELLED_CODE = "HASH_CANCELLED"
|
||||||
|
|
||||||
// MediaStore.Files.FileColumns.SPECIAL_FORMAT — S Extensions 21+
|
|
||||||
// https://developer.android.com/reference/android/provider/MediaStore.Files.FileColumns#SPECIAL_FORMAT
|
|
||||||
private const val SPECIAL_FORMAT_COLUMN = "_special_format"
|
|
||||||
private const val SPECIAL_FORMAT_GIF = 1
|
|
||||||
private const val SPECIAL_FORMAT_MOTION_PHOTO = 2
|
|
||||||
private const val SPECIAL_FORMAT_ANIMATED_WEBP = 3
|
|
||||||
|
|
||||||
const val MEDIA_SELECTION =
|
const val MEDIA_SELECTION =
|
||||||
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
|
||||||
val MEDIA_SELECTION_ARGS = arrayOf(
|
val MEDIA_SELECTION_ARGS = arrayOf(
|
||||||
@@ -75,15 +60,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
add(MediaStore.MediaColumns.DURATION)
|
add(MediaStore.MediaColumns.DURATION)
|
||||||
add(MediaStore.MediaColumns.ORIENTATION)
|
add(MediaStore.MediaColumns.ORIENTATION)
|
||||||
// IS_FAVORITE is only available on Android 11 and above
|
// IS_FAVORITE is only available on Android 11 and above
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) {
|
||||||
add(MediaStore.MediaColumns.IS_FAVORITE)
|
add(MediaStore.MediaColumns.IS_FAVORITE)
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
||||||
add(SPECIAL_FORMAT_COLUMN)
|
|
||||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
||||||
// Fallback: read XMP from MediaStore to detect Motion Photos
|
|
||||||
add(MediaStore.MediaColumns.XMP)
|
|
||||||
}
|
|
||||||
}.toTypedArray()
|
}.toTypedArray()
|
||||||
|
|
||||||
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
@@ -130,12 +109,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val orientationColumn =
|
val orientationColumn =
|
||||||
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
|
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
|
||||||
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
|
val favoriteColumn = c.getColumnIndex(MediaStore.MediaColumns.IS_FAVORITE)
|
||||||
val specialFormatColumn = c.getColumnIndex(SPECIAL_FORMAT_COLUMN)
|
|
||||||
val xmpColumn = c.getColumnIndex(MediaStore.MediaColumns.XMP)
|
|
||||||
|
|
||||||
while (c.moveToNext()) {
|
while (c.moveToNext()) {
|
||||||
val numericId = c.getLong(idColumn)
|
val id = c.getLong(idColumn).toString()
|
||||||
val id = numericId.toString()
|
|
||||||
val name = c.getStringOrNull(nameColumn)
|
val name = c.getStringOrNull(nameColumn)
|
||||||
val bucketId = c.getStringOrNull(bucketIdColumn)
|
val bucketId = c.getStringOrNull(bucketIdColumn)
|
||||||
val path = c.getStringOrNull(dataColumn)
|
val path = c.getStringOrNull(dataColumn)
|
||||||
@@ -149,11 +125,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
val rawMediaType = c.getInt(mediaTypeColumn)
|
val mediaType = when (c.getInt(mediaTypeColumn)) {
|
||||||
val assetType: Long = when (rawMediaType) {
|
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
|
||||||
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L
|
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
|
||||||
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
|
else -> 0
|
||||||
else -> 0L
|
|
||||||
}
|
}
|
||||||
// Date taken is milliseconds since epoch, Date added is seconds since epoch
|
// Date taken is milliseconds since epoch, Date added is seconds since epoch
|
||||||
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
|
val createdAt = (c.getLong(dateTakenColumn).takeIf { it > 0 }?.div(1000))
|
||||||
@@ -163,19 +138,15 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
val width = c.getInt(widthColumn).toLong()
|
val width = c.getInt(widthColumn).toLong()
|
||||||
val height = c.getInt(heightColumn).toLong()
|
val height = c.getInt(heightColumn).toLong()
|
||||||
// Duration is milliseconds
|
// Duration is milliseconds
|
||||||
val duration = if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0L
|
val duration = if (mediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) 0
|
||||||
else c.getLong(durationColumn) / 1000
|
else c.getLong(durationColumn) / 1000
|
||||||
val orientation = c.getInt(orientationColumn)
|
val orientation = c.getInt(orientationColumn)
|
||||||
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
|
||||||
|
|
||||||
val playbackStyle = detectPlaybackStyle(
|
|
||||||
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
|
|
||||||
)
|
|
||||||
|
|
||||||
val asset = PlatformAsset(
|
val asset = PlatformAsset(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
assetType,
|
mediaType.toLong(),
|
||||||
createdAt,
|
createdAt,
|
||||||
modifiedAt,
|
modifiedAt,
|
||||||
width,
|
width,
|
||||||
@@ -183,7 +154,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
duration,
|
duration,
|
||||||
orientation.toLong(),
|
orientation.toLong(),
|
||||||
isFavorite,
|
isFavorite,
|
||||||
playbackStyle = playbackStyle,
|
|
||||||
)
|
)
|
||||||
yield(AssetResult.ValidAsset(asset, bucketId))
|
yield(AssetResult.ValidAsset(asset, bucketId))
|
||||||
}
|
}
|
||||||
@@ -191,81 +161,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects the playback style for an asset using _special_format (API 33+)
|
|
||||||
* or XMP / MIME / RIFF header fallbacks (pre-33).
|
|
||||||
*/
|
|
||||||
@SuppressLint("NewApi")
|
|
||||||
private fun detectPlaybackStyle(
|
|
||||||
assetId: Long,
|
|
||||||
rawMediaType: Int,
|
|
||||||
specialFormatColumn: Int,
|
|
||||||
xmpColumn: Int,
|
|
||||||
cursor: Cursor
|
|
||||||
): PlatformAssetPlaybackStyle {
|
|
||||||
// video currently has no special formats, so we can short circuit and avoid unnecessary work
|
|
||||||
if (rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO) {
|
|
||||||
return PlatformAssetPlaybackStyle.VIDEO
|
|
||||||
}
|
|
||||||
|
|
||||||
// API 33+: use _special_format from cursor
|
|
||||||
if (specialFormatColumn != -1) {
|
|
||||||
val specialFormat = cursor.getInt(specialFormatColumn)
|
|
||||||
return when {
|
|
||||||
specialFormat == SPECIAL_FORMAT_MOTION_PHOTO -> PlatformAssetPlaybackStyle.LIVE_PHOTO
|
|
||||||
specialFormat == SPECIAL_FORMAT_GIF || specialFormat == SPECIAL_FORMAT_ANIMATED_WEBP -> PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
rawMediaType == MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> PlatformAssetPlaybackStyle.IMAGE
|
|
||||||
else -> PlatformAssetPlaybackStyle.UNKNOWN
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rawMediaType != MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE) {
|
|
||||||
return PlatformAssetPlaybackStyle.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-API 33 fallback
|
|
||||||
val uri = ContentUris.withAppendedId(
|
|
||||||
MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL),
|
|
||||||
assetId
|
|
||||||
)
|
|
||||||
|
|
||||||
// Read XMP from cursor (API 30+) or ExifInterface stream (pre-30)
|
|
||||||
val xmp: String? = if (xmpColumn != -1) {
|
|
||||||
cursor.getBlob(xmpColumn)?.toString(Charsets.UTF_8)
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
|
||||||
ExifInterface(stream).getAttribute(ExifInterface.TAG_XMP)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to read XMP for asset $assetId", e)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (xmp != null && "Camera:MotionPhoto" in xmp) {
|
|
||||||
return PlatformAssetPlaybackStyle.LIVE_PHOTO
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ctx.contentResolver.openInputStream(uri)?.use { stream ->
|
|
||||||
val glide = Glide.get(ctx)
|
|
||||||
val type = ImageHeaderParserUtils.getType(
|
|
||||||
glide.registry.imageHeaderParsers,
|
|
||||||
stream,
|
|
||||||
glide.arrayPool
|
|
||||||
)
|
|
||||||
if (type == ImageHeaderParser.ImageType.GIF || type == ImageHeaderParser.ImageType.ANIMATED_WEBP) {
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE_ANIMATED
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to parse image header for asset $assetId", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
return PlatformAssetPlaybackStyle.IMAGE
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAlbums(): List<PlatformAlbum> {
|
fun getAlbums(): List<PlatformAlbum> {
|
||||||
val albums = mutableListOf<PlatformAlbum>()
|
val albums = mutableListOf<PlatformAlbum>()
|
||||||
val albumsCount = mutableMapOf<String, Int>()
|
val albumsCount = mutableMapOf<String, Int>()
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
|
|||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol LocalImageApi {
|
protocol LocalImageApi {
|
||||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||||
func cancelRequest(requestId: Int64) throws
|
func cancelRequest(requestId: Int64) throws
|
||||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
|
||||||
}
|
}
|
||||||
@@ -90,8 +90,7 @@ class LocalImageApiSetup {
|
|||||||
let widthArg = args[2] as! Int64
|
let widthArg = args[2] as! Int64
|
||||||
let heightArg = args[3] as! Int64
|
let heightArg = args[3] as! Int64
|
||||||
let isVideoArg = args[4] as! Bool
|
let isVideoArg = args[4] as! Bool
|
||||||
let preferEncodedArg = args[5] as! Bool
|
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
|
||||||
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ class LocalImageRequest {
|
|||||||
weak var workItem: DispatchWorkItem?
|
weak var workItem: DispatchWorkItem?
|
||||||
var isCancelled = false
|
var isCancelled = false
|
||||||
let callback: (Result<[String: Int64]?, any Error>) -> Void
|
let callback: (Result<[String: Int64]?, any Error>) -> Void
|
||||||
|
|
||||||
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||||
self.callback = callback
|
self.callback = callback
|
||||||
}
|
}
|
||||||
@@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
requestOptions.version = .current
|
requestOptions.version = .current
|
||||||
return requestOptions
|
return requestOptions
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
|
||||||
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
|
||||||
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
|
||||||
|
|
||||||
private static var rgbaFormat = vImage_CGImageFormat(
|
private static var rgbaFormat = vImage_CGImageFormat(
|
||||||
bitsPerComponent: 8,
|
bitsPerComponent: 8,
|
||||||
bitsPerPixel: 32,
|
bitsPerPixel: 32,
|
||||||
@@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
assetCache.countLimit = 10000
|
assetCache.countLimit = 10000
|
||||||
return assetCache
|
return assetCache
|
||||||
}()
|
}()
|
||||||
|
|
||||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||||
ImageProcessing.queue.async {
|
ImageProcessing.queue.async {
|
||||||
guard let data = Data(base64Encoded: thumbhash)
|
guard let data = Data(base64Encoded: thumbhash)
|
||||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||||
|
|
||||||
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
let (width, height, pointer) = thumbHashToRGBA(hash: data)
|
||||||
completion(.success([
|
completion(.success([
|
||||||
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
|
"pointer": Int64(Int(bitPattern: pointer.baseAddress)),
|
||||||
@@ -63,77 +63,34 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
]))
|
]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||||
let request = LocalImageRequest(callback: completion)
|
let request = LocalImageRequest(callback: completion)
|
||||||
let item = DispatchWorkItem {
|
let item = DispatchWorkItem {
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(ImageProcessing.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageProcessing.semaphore.wait()
|
ImageProcessing.semaphore.wait()
|
||||||
defer {
|
defer {
|
||||||
ImageProcessing.semaphore.signal()
|
ImageProcessing.semaphore.signal()
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(ImageProcessing.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let asset = Self.requestAsset(assetId: assetId)
|
guard let asset = Self.requestAsset(assetId: assetId)
|
||||||
else {
|
else {
|
||||||
Self.remove(requestId: requestId)
|
Self.remove(requestId: requestId)
|
||||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(ImageProcessing.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
if preferEncoded {
|
|
||||||
let dataOptions = PHImageRequestOptions()
|
|
||||||
dataOptions.isNetworkAccessAllowed = true
|
|
||||||
dataOptions.isSynchronous = true
|
|
||||||
dataOptions.version = .current
|
|
||||||
|
|
||||||
var imageData: Data?
|
|
||||||
Self.imageManager.requestImageDataAndOrientation(
|
|
||||||
for: asset,
|
|
||||||
options: dataOptions,
|
|
||||||
resultHandler: { (data, _, _, _) in
|
|
||||||
imageData = data
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if request.isCancelled {
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return completion(ImageProcessing.cancelledResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let data = imageData else {
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
|
|
||||||
}
|
|
||||||
|
|
||||||
let length = data.count
|
|
||||||
let pointer = malloc(length)!
|
|
||||||
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
|
|
||||||
|
|
||||||
if request.isCancelled {
|
|
||||||
free(pointer)
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return completion(ImageProcessing.cancelledResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
request.callback(.success([
|
|
||||||
"pointer": Int64(Int(bitPattern: pointer)),
|
|
||||||
"length": Int64(length),
|
|
||||||
]))
|
|
||||||
Self.remove(requestId: requestId)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var image: UIImage?
|
var image: UIImage?
|
||||||
Self.imageManager.requestImage(
|
Self.imageManager.requestImage(
|
||||||
for: asset,
|
for: asset,
|
||||||
@@ -144,29 +101,29 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
image = _image
|
image = _image
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(ImageProcessing.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let image = image,
|
guard let image = image,
|
||||||
let cgImage = image.cgImage else {
|
let cgImage = image.cgImage else {
|
||||||
Self.remove(requestId: requestId)
|
Self.remove(requestId: requestId)
|
||||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return completion(ImageProcessing.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
|
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
buffer.free()
|
buffer.free()
|
||||||
return completion(ImageProcessing.cancelledResult)
|
return completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.callback(.success([
|
request.callback(.success([
|
||||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
"pointer": Int64(Int(bitPattern: buffer.data)),
|
||||||
"width": Int64(buffer.width),
|
"width": Int64(buffer.width),
|
||||||
@@ -179,24 +136,24 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
|
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
request.workItem = item
|
request.workItem = item
|
||||||
Self.add(requestId: requestId, request: request)
|
Self.add(requestId: requestId, request: request)
|
||||||
ImageProcessing.queue.async(execute: item)
|
ImageProcessing.queue.async(execute: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelRequest(requestId: Int64) {
|
func cancelRequest(requestId: Int64) {
|
||||||
Self.cancel(requestId: requestId)
|
Self.cancel(requestId: requestId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
|
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
|
||||||
requestQueue.sync { requests[requestId] = request }
|
requestQueue.sync { requests[requestId] = request }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func remove(requestId: Int64) -> Void {
|
private static func remove(requestId: Int64) -> Void {
|
||||||
requestQueue.sync { requests[requestId] = nil }
|
requestQueue.sync { requests[requestId] = nil }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func cancel(requestId: Int64) -> Void {
|
private static func cancel(requestId: Int64) -> Void {
|
||||||
requestQueue.async {
|
requestQueue.async {
|
||||||
guard let request = requests.removeValue(forKey: requestId) else { return }
|
guard let request = requests.removeValue(forKey: requestId) else { return }
|
||||||
@@ -207,12 +164,12 @@ class LocalImageApiImpl: LocalImageApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func requestAsset(assetId: String) -> PHAsset? {
|
private static func requestAsset(assetId: String) -> PHAsset? {
|
||||||
var asset: PHAsset?
|
var asset: PHAsset?
|
||||||
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
||||||
if asset != nil { return asset }
|
if asset != nil { return asset }
|
||||||
|
|
||||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
||||||
else { return nil }
|
else { return nil }
|
||||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
|
|||||||
|
|
||||||
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
|
||||||
protocol RemoteImageApi {
|
protocol RemoteImageApi {
|
||||||
func requestImage(url: String, headers: [String: String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
func requestImage(url: String, headers: [String: String], requestId: Int64, completion: @escaping (Result<[String: Int64]?, Error>) -> Void)
|
||||||
func cancelRequest(requestId: Int64) throws
|
func cancelRequest(requestId: Int64) throws
|
||||||
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
func clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
|
||||||
}
|
}
|
||||||
@@ -88,8 +88,7 @@ class RemoteImageApiSetup {
|
|||||||
let urlArg = args[0] as! String
|
let urlArg = args[0] as! String
|
||||||
let headersArg = args[1] as! [String: String]
|
let headersArg = args[1] as! [String: String]
|
||||||
let requestIdArg = args[2] as! Int64
|
let requestIdArg = args[2] as! Int64
|
||||||
let preferEncodedArg = args[3] as! Bool
|
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
|
||||||
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
|
|
||||||
switch result {
|
switch result {
|
||||||
case .success(let res):
|
case .success(let res):
|
||||||
reply(wrapResult(res))
|
reply(wrapResult(res))
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ class RemoteImageRequest {
|
|||||||
let id: Int64
|
let id: Int64
|
||||||
var isCancelled = false
|
var isCancelled = false
|
||||||
let completion: (Result<[String: Int64]?, any Error>) -> Void
|
let completion: (Result<[String: Int64]?, any Error>) -> Void
|
||||||
|
|
||||||
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.task = task
|
self.task = task
|
||||||
@@ -32,93 +32,75 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
kCGImageSourceCreateThumbnailFromImageAlways: true
|
kCGImageSourceCreateThumbnailFromImageAlways: true
|
||||||
] as CFDictionary
|
] as CFDictionary
|
||||||
|
|
||||||
func requestImage(url: String, headers: [String : String], requestId: Int64, preferEncoded: Bool, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
func requestImage(url: String, headers: [String : String], requestId: Int64, completion: @escaping (Result<[String : Int64]?, any Error>) -> Void) {
|
||||||
var urlRequest = URLRequest(url: URL(string: url)!)
|
var urlRequest = URLRequest(url: URL(string: url)!)
|
||||||
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
urlRequest.cachePolicy = .returnCacheDataElseLoad
|
||||||
for (key, value) in headers {
|
for (key, value) in headers {
|
||||||
urlRequest.setValue(value, forHTTPHeaderField: key)
|
urlRequest.setValue(value, forHTTPHeaderField: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
let task = URLSessionManager.shared.session.dataTask(with: urlRequest) { data, response, error in
|
||||||
Self.handleCompletion(requestId: requestId, encoded: preferEncoded, data: data, response: response, error: error)
|
Self.handleCompletion(requestId: requestId, data: data, response: response, error: error)
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
||||||
|
|
||||||
os_unfair_lock_lock(&Self.lock)
|
os_unfair_lock_lock(&Self.lock)
|
||||||
Self.requests[requestId] = request
|
Self.requests[requestId] = request
|
||||||
os_unfair_lock_unlock(&Self.lock)
|
os_unfair_lock_unlock(&Self.lock)
|
||||||
|
|
||||||
task.resume()
|
task.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
|
private static func handleCompletion(requestId: Int64, data: Data?, response: URLResponse?, error: Error?) {
|
||||||
os_unfair_lock_lock(&Self.lock)
|
os_unfair_lock_lock(&Self.lock)
|
||||||
guard let request = requests[requestId] else {
|
guard let request = requests[requestId] else {
|
||||||
return os_unfair_lock_unlock(&Self.lock)
|
return os_unfair_lock_unlock(&Self.lock)
|
||||||
}
|
}
|
||||||
requests[requestId] = nil
|
requests[requestId] = nil
|
||||||
os_unfair_lock_unlock(&Self.lock)
|
os_unfair_lock_unlock(&Self.lock)
|
||||||
|
|
||||||
if let error = error {
|
if let error = error {
|
||||||
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
return request.completion(.failure(error))
|
return request.completion(.failure(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
ImageProcessing.queue.async {
|
ImageProcessing.queue.async {
|
||||||
ImageProcessing.semaphore.wait()
|
ImageProcessing.semaphore.wait()
|
||||||
defer { ImageProcessing.semaphore.signal() }
|
defer { ImageProcessing.semaphore.signal() }
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return raw encoded bytes when requested (for animated images)
|
|
||||||
if encoded {
|
|
||||||
let length = data.count
|
|
||||||
let pointer = malloc(length)!
|
|
||||||
data.copyBytes(to: pointer.assumingMemoryBound(to: UInt8.self), count: length)
|
|
||||||
|
|
||||||
if request.isCancelled {
|
|
||||||
free(pointer)
|
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
|
||||||
}
|
|
||||||
|
|
||||||
return request.completion(
|
|
||||||
.success([
|
|
||||||
"pointer": Int64(Int(bitPattern: pointer)),
|
|
||||||
"length": Int64(length),
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
|
||||||
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
|
||||||
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
|
||||||
}
|
}
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
|
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
|
||||||
|
|
||||||
if request.isCancelled {
|
if request.isCancelled {
|
||||||
buffer.free()
|
buffer.free()
|
||||||
return request.completion(ImageProcessing.cancelledResult)
|
return request.completion(ImageProcessing.cancelledResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
request.completion(
|
request.completion(
|
||||||
.success([
|
.success([
|
||||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
"pointer": Int64(Int(bitPattern: buffer.data)),
|
||||||
@@ -131,17 +113,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cancelRequest(requestId: Int64) {
|
func cancelRequest(requestId: Int64) {
|
||||||
os_unfair_lock_lock(&Self.lock)
|
os_unfair_lock_lock(&Self.lock)
|
||||||
let request = Self.requests[requestId]
|
let request = Self.requests[requestId]
|
||||||
os_unfair_lock_unlock(&Self.lock)
|
os_unfair_lock_unlock(&Self.lock)
|
||||||
|
|
||||||
guard let request = request else { return }
|
guard let request = request else { return }
|
||||||
request.isCancelled = true
|
request.isCancelled = true
|
||||||
request.task?.cancel()
|
request.task?.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
|
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
|
||||||
Task {
|
Task {
|
||||||
let cache = URLSessionManager.shared.session.configuration.urlCache!
|
let cache = URLSessionManager.shared.session.configuration.urlCache!
|
||||||
|
|||||||
@@ -128,15 +128,6 @@ func deepHashMessages(value: Any?, hasher: inout Hasher) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
enum PlatformAssetPlaybackStyle: Int {
|
|
||||||
case unknown = 0
|
|
||||||
case image = 1
|
|
||||||
case video = 2
|
|
||||||
case imageAnimated = 3
|
|
||||||
case livePhoto = 4
|
|
||||||
case videoLooping = 5
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Generated class from Pigeon that represents data sent in messages.
|
/// Generated class from Pigeon that represents data sent in messages.
|
||||||
struct PlatformAsset: Hashable {
|
struct PlatformAsset: Hashable {
|
||||||
var id: String
|
var id: String
|
||||||
@@ -152,7 +143,6 @@ struct PlatformAsset: Hashable {
|
|||||||
var adjustmentTime: Int64? = nil
|
var adjustmentTime: Int64? = nil
|
||||||
var latitude: Double? = nil
|
var latitude: Double? = nil
|
||||||
var longitude: Double? = nil
|
var longitude: Double? = nil
|
||||||
var playbackStyle: PlatformAssetPlaybackStyle
|
|
||||||
|
|
||||||
|
|
||||||
// swift-format-ignore: AlwaysUseLowerCamelCase
|
// swift-format-ignore: AlwaysUseLowerCamelCase
|
||||||
@@ -170,7 +160,6 @@ struct PlatformAsset: Hashable {
|
|||||||
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
|
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
|
||||||
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
let latitude: Double? = nilOrValue(pigeonVar_list[11])
|
||||||
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
let longitude: Double? = nilOrValue(pigeonVar_list[12])
|
||||||
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
|
|
||||||
|
|
||||||
return PlatformAsset(
|
return PlatformAsset(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -185,8 +174,7 @@ struct PlatformAsset: Hashable {
|
|||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
adjustmentTime: adjustmentTime,
|
adjustmentTime: adjustmentTime,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude
|
||||||
playbackStyle: playbackStyle
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
func toList() -> [Any?] {
|
func toList() -> [Any?] {
|
||||||
@@ -204,7 +192,6 @@ struct PlatformAsset: Hashable {
|
|||||||
adjustmentTime,
|
adjustmentTime,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
|
||||||
@@ -362,20 +349,14 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
override func readValue(ofType type: UInt8) -> Any? {
|
override func readValue(ofType type: UInt8) -> Any? {
|
||||||
switch type {
|
switch type {
|
||||||
case 129:
|
case 129:
|
||||||
let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?)
|
|
||||||
if let enumResultAsInt = enumResultAsInt {
|
|
||||||
return PlatformAssetPlaybackStyle(rawValue: enumResultAsInt)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case 130:
|
|
||||||
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
return PlatformAsset.fromList(self.readValue() as! [Any?])
|
||||||
case 131:
|
case 130:
|
||||||
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
return PlatformAlbum.fromList(self.readValue() as! [Any?])
|
||||||
case 132:
|
case 131:
|
||||||
return SyncDelta.fromList(self.readValue() as! [Any?])
|
return SyncDelta.fromList(self.readValue() as! [Any?])
|
||||||
case 133:
|
case 132:
|
||||||
return HashResult.fromList(self.readValue() as! [Any?])
|
return HashResult.fromList(self.readValue() as! [Any?])
|
||||||
case 134:
|
case 133:
|
||||||
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
return CloudIdResult.fromList(self.readValue() as! [Any?])
|
||||||
default:
|
default:
|
||||||
return super.readValue(ofType: type)
|
return super.readValue(ofType: type)
|
||||||
@@ -385,23 +366,20 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
|
|||||||
|
|
||||||
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
|
||||||
override func writeValue(_ value: Any) {
|
override func writeValue(_ value: Any) {
|
||||||
if let value = value as? PlatformAssetPlaybackStyle {
|
if let value = value as? PlatformAsset {
|
||||||
super.writeByte(129)
|
super.writeByte(129)
|
||||||
super.writeValue(value.rawValue)
|
|
||||||
} else if let value = value as? PlatformAsset {
|
|
||||||
super.writeByte(130)
|
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? PlatformAlbum {
|
} else if let value = value as? PlatformAlbum {
|
||||||
super.writeByte(131)
|
super.writeByte(130)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? SyncDelta {
|
} else if let value = value as? SyncDelta {
|
||||||
super.writeByte(132)
|
super.writeByte(131)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? HashResult {
|
} else if let value = value as? HashResult {
|
||||||
super.writeByte(133)
|
super.writeByte(132)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else if let value = value as? CloudIdResult {
|
} else if let value = value as? CloudIdResult {
|
||||||
super.writeByte(134)
|
super.writeByte(133)
|
||||||
super.writeValue(value.toList())
|
super.writeValue(value.toList())
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(value)
|
super.writeValue(value)
|
||||||
|
|||||||
@@ -173,8 +173,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
|
|||||||
type: 0,
|
type: 0,
|
||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
orientation: 0,
|
orientation: 0,
|
||||||
isFavorite: false,
|
isFavorite: false
|
||||||
playbackStyle: .unknown
|
|
||||||
)
|
)
|
||||||
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
import Photos
|
import Photos
|
||||||
|
|
||||||
extension PHAsset {
|
extension PHAsset {
|
||||||
var platformPlaybackStyle: PlatformAssetPlaybackStyle {
|
|
||||||
switch playbackStyle {
|
|
||||||
case .image: return .image
|
|
||||||
case .imageAnimated: return .imageAnimated
|
|
||||||
case .livePhoto: return .livePhoto
|
|
||||||
case .video: return .video
|
|
||||||
case .videoLooping: return .videoLooping
|
|
||||||
@unknown default: return .unknown
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPlatformAsset() -> PlatformAsset {
|
func toPlatformAsset() -> PlatformAsset {
|
||||||
return PlatformAsset(
|
return PlatformAsset(
|
||||||
id: localIdentifier,
|
id: localIdentifier,
|
||||||
@@ -26,8 +15,7 @@ extension PHAsset {
|
|||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
adjustmentTime: adjustmentTimestamp,
|
adjustmentTime: adjustmentTimestamp,
|
||||||
latitude: location?.coordinate.latitude,
|
latitude: location?.coordinate.latitude,
|
||||||
longitude: location?.coordinate.longitude,
|
longitude: location?.coordinate.longitude
|
||||||
playbackStyle: platformPlaybackStyle
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +26,7 @@ extension PHAsset {
|
|||||||
var filename: String? {
|
var filename: String? {
|
||||||
return value(forKey: "filename") as? String
|
return value(forKey: "filename") as? String
|
||||||
}
|
}
|
||||||
|
|
||||||
var adjustmentTimestamp: Int64? {
|
var adjustmentTimestamp: Int64? {
|
||||||
if let date = value(forKey: "adjustmentTimestamp") as? Date {
|
if let date = value(forKey: "adjustmentTimestamp") as? Date {
|
||||||
return Int64(date.timeIntervalSince1970)
|
return Int64(date.timeIntervalSince1970)
|
||||||
|
|||||||
@@ -24,8 +24,6 @@ abstract class ImageRequest {
|
|||||||
|
|
||||||
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
|
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
|
||||||
|
|
||||||
Future<ui.Codec?> loadCodec();
|
|
||||||
|
|
||||||
void cancel() {
|
void cancel() {
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
return;
|
return;
|
||||||
@@ -36,7 +34,7 @@ abstract class ImageRequest {
|
|||||||
|
|
||||||
void _onCancelled();
|
void _onCancelled();
|
||||||
|
|
||||||
Future<(ui.Codec, ui.ImageDescriptor)?> _codecFromEncodedPlatformImage(int address, int length) async {
|
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
||||||
final pointer = Pointer<Uint8>.fromAddress(address);
|
final pointer = Pointer<Uint8>.fromAddress(address);
|
||||||
if (_isCancelled) {
|
if (_isCancelled) {
|
||||||
malloc.free(pointer);
|
malloc.free(pointer);
|
||||||
@@ -69,20 +67,6 @@ abstract class ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (codec, descriptor);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<ui.FrameInfo?> _fromEncodedPlatformImage(int address, int length) async {
|
|
||||||
final result = await _codecFromEncodedPlatformImage(address, length);
|
|
||||||
if (result == null) return null;
|
|
||||||
|
|
||||||
final (codec, descriptor) = result;
|
|
||||||
if (_isCancelled) {
|
|
||||||
descriptor.dispose();
|
|
||||||
codec.dispose();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final frame = await codec.getNextFrame();
|
final frame = await codec.getNextFrame();
|
||||||
descriptor.dispose();
|
descriptor.dispose();
|
||||||
codec.dispose();
|
codec.dispose();
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ class LocalImageRequest extends ImageRequest {
|
|||||||
width: width,
|
width: width,
|
||||||
height: height,
|
height: height,
|
||||||
isVideo: assetType == AssetType.video,
|
isVideo: assetType == AssetType.video,
|
||||||
preferEncoded: false,
|
|
||||||
);
|
);
|
||||||
if (info == null) {
|
if (info == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -32,26 +31,6 @@ class LocalImageRequest extends ImageRequest {
|
|||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ui.Codec?> loadCodec() async {
|
|
||||||
if (_isCancelled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final info = await localImageApi.requestImage(
|
|
||||||
localId,
|
|
||||||
requestId: requestId,
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
isVideo: assetType == AssetType.video,
|
|
||||||
preferEncoded: true,
|
|
||||||
);
|
|
||||||
if (info == null) return null;
|
|
||||||
|
|
||||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
|
||||||
return codec;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> _onCancelled() {
|
Future<void> _onCancelled() {
|
||||||
return localImageApi.cancelRequest(requestId);
|
return localImageApi.cancelRequest(requestId);
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
|
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
|
||||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
|
||||||
final frame = switch (info) {
|
final frame = switch (info) {
|
||||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||||
@@ -23,19 +22,6 @@ class RemoteImageRequest extends ImageRequest {
|
|||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ui.Codec?> loadCodec() async {
|
|
||||||
if (_isCancelled) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: true);
|
|
||||||
if (info == null) return null;
|
|
||||||
|
|
||||||
final (codec, _) = await _codecFromEncodedPlatformImage(info['pointer']!, info['length']!) ?? (null, null);
|
|
||||||
return codec;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> _onCancelled() {
|
Future<void> _onCancelled() {
|
||||||
return remoteImageApi.cancelRequest(requestId);
|
return remoteImageApi.cancelRequest(requestId);
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ class ThumbhashImageRequest extends ImageRequest {
|
|||||||
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ui.Codec?> loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading');
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void _onCancelled() {}
|
void _onCancelled() {}
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -55,7 +55,6 @@ class LocalImageApi {
|
|||||||
required int width,
|
required int width,
|
||||||
required int height,
|
required int height,
|
||||||
required bool isVideo,
|
required bool isVideo,
|
||||||
required bool preferEncoded,
|
|
||||||
}) async {
|
}) async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||||
@@ -70,7 +69,6 @@ class LocalImageApi {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
isVideo,
|
isVideo,
|
||||||
preferEncoded,
|
|
||||||
]);
|
]);
|
||||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
if (pigeonVar_replyList == null) {
|
if (pigeonVar_replyList == null) {
|
||||||
|
|||||||
+9
-22
@@ -29,8 +29,6 @@ bool _deepEquals(Object? a, Object? b) {
|
|||||||
return a == b;
|
return a == b;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
|
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
PlatformAsset({
|
PlatformAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -46,7 +44,6 @@ class PlatformAsset {
|
|||||||
this.adjustmentTime,
|
this.adjustmentTime,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
required this.playbackStyle,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
String id;
|
String id;
|
||||||
@@ -75,8 +72,6 @@ class PlatformAsset {
|
|||||||
|
|
||||||
double? longitude;
|
double? longitude;
|
||||||
|
|
||||||
PlatformAssetPlaybackStyle playbackStyle;
|
|
||||||
|
|
||||||
List<Object?> _toList() {
|
List<Object?> _toList() {
|
||||||
return <Object?>[
|
return <Object?>[
|
||||||
id,
|
id,
|
||||||
@@ -92,7 +87,6 @@ class PlatformAsset {
|
|||||||
adjustmentTime,
|
adjustmentTime,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
playbackStyle,
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +110,6 @@ class PlatformAsset {
|
|||||||
adjustmentTime: result[10] as int?,
|
adjustmentTime: result[10] as int?,
|
||||||
latitude: result[11] as double?,
|
latitude: result[11] as double?,
|
||||||
longitude: result[12] as double?,
|
longitude: result[12] as double?,
|
||||||
playbackStyle: result[13]! as PlatformAssetPlaybackStyle,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,23 +316,20 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
if (value is int) {
|
if (value is int) {
|
||||||
buffer.putUint8(4);
|
buffer.putUint8(4);
|
||||||
buffer.putInt64(value);
|
buffer.putInt64(value);
|
||||||
} else if (value is PlatformAssetPlaybackStyle) {
|
|
||||||
buffer.putUint8(129);
|
|
||||||
writeValue(buffer, value.index);
|
|
||||||
} else if (value is PlatformAsset) {
|
} else if (value is PlatformAsset) {
|
||||||
buffer.putUint8(130);
|
buffer.putUint8(129);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is PlatformAlbum) {
|
} else if (value is PlatformAlbum) {
|
||||||
buffer.putUint8(131);
|
buffer.putUint8(130);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is SyncDelta) {
|
} else if (value is SyncDelta) {
|
||||||
buffer.putUint8(132);
|
buffer.putUint8(131);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is HashResult) {
|
} else if (value is HashResult) {
|
||||||
buffer.putUint8(133);
|
buffer.putUint8(132);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else if (value is CloudIdResult) {
|
} else if (value is CloudIdResult) {
|
||||||
buffer.putUint8(134);
|
buffer.putUint8(133);
|
||||||
writeValue(buffer, value.encode());
|
writeValue(buffer, value.encode());
|
||||||
} else {
|
} else {
|
||||||
super.writeValue(buffer, value);
|
super.writeValue(buffer, value);
|
||||||
@@ -350,17 +340,14 @@ class _PigeonCodec extends StandardMessageCodec {
|
|||||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 129:
|
case 129:
|
||||||
final int? value = readValue(buffer) as int?;
|
|
||||||
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
|
|
||||||
case 130:
|
|
||||||
return PlatformAsset.decode(readValue(buffer)!);
|
return PlatformAsset.decode(readValue(buffer)!);
|
||||||
case 131:
|
case 130:
|
||||||
return PlatformAlbum.decode(readValue(buffer)!);
|
return PlatformAlbum.decode(readValue(buffer)!);
|
||||||
case 132:
|
case 131:
|
||||||
return SyncDelta.decode(readValue(buffer)!);
|
return SyncDelta.decode(readValue(buffer)!);
|
||||||
case 133:
|
case 132:
|
||||||
return HashResult.decode(readValue(buffer)!);
|
return HashResult.decode(readValue(buffer)!);
|
||||||
case 134:
|
case 133:
|
||||||
return CloudIdResult.decode(readValue(buffer)!);
|
return CloudIdResult.decode(readValue(buffer)!);
|
||||||
default:
|
default:
|
||||||
return super.readValueOfType(type, buffer);
|
return super.readValueOfType(type, buffer);
|
||||||
|
|||||||
+1
-7
@@ -53,7 +53,6 @@ class RemoteImageApi {
|
|||||||
String url, {
|
String url, {
|
||||||
required Map<String, String> headers,
|
required Map<String, String> headers,
|
||||||
required int requestId,
|
required int requestId,
|
||||||
required bool preferEncoded,
|
|
||||||
}) async {
|
}) async {
|
||||||
final String pigeonVar_channelName =
|
final String pigeonVar_channelName =
|
||||||
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
|
||||||
@@ -62,12 +61,7 @@ class RemoteImageApi {
|
|||||||
pigeonChannelCodec,
|
pigeonChannelCodec,
|
||||||
binaryMessenger: pigeonVar_binaryMessenger,
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
);
|
);
|
||||||
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
|
||||||
url,
|
|
||||||
headers,
|
|
||||||
requestId,
|
|
||||||
preferEncoded,
|
|
||||||
]);
|
|
||||||
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
if (pigeonVar_replyList == null) {
|
if (pigeonVar_replyList == null) {
|
||||||
throw _createConnectionError(pigeonVar_channelName);
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
|||||||
-10
@@ -8,7 +8,6 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
|
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
/// This delete action has the following behavior:
|
||||||
@@ -26,15 +25,6 @@ class DeletePermanentActionButton extends ConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final count = source == ActionSource.viewer ? 1 : ref.read(multiSelectProvider).selectedAssets.length;
|
|
||||||
final confirm =
|
|
||||||
await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => PermanentDeleteDialog(count: count),
|
|
||||||
) ??
|
|
||||||
false;
|
|
||||||
if (!confirm) return;
|
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
|||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/asset_grid/permanent_delete_dialog.dart';
|
import 'package:immich_mobile/widgets/asset_grid/trash_delete_dialog.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||||
|
|
||||||
/// This delete action has the following behavior:
|
/// This delete action has the following behavior:
|
||||||
@@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget {
|
|||||||
final confirmDelete =
|
final confirmDelete =
|
||||||
await showDialog<bool>(
|
await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => PermanentDeleteDialog(count: selectCount),
|
builder: (context) => TrashDeleteDialog(count: selectCount),
|
||||||
) ??
|
) ??
|
||||||
false;
|
false;
|
||||||
if (!confirmDelete) {
|
if (!confirmDelete) {
|
||||||
|
|||||||
@@ -307,7 +307,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
if (displayAsset.isImage && !isPlayingMotionVideo) {
|
||||||
final size = context.sizeData;
|
final size = context.sizeData;
|
||||||
return PhotoView(
|
return PhotoView(
|
||||||
key: Key(displayAsset.heroTag),
|
key: ValueKey(displayAsset.heroTag),
|
||||||
index: widget.index,
|
index: widget.index,
|
||||||
imageProvider: getFullImageProvider(displayAsset, size: size),
|
imageProvider: getFullImageProvider(displayAsset, size: size),
|
||||||
heroAttributes: heroAttributes,
|
heroAttributes: heroAttributes,
|
||||||
@@ -335,7 +335,7 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PhotoView.customChild(
|
return PhotoView.customChild(
|
||||||
key: Key(displayAsset.heroTag),
|
key: ValueKey(displayAsset),
|
||||||
onDragStart: _onDragStart,
|
onDragStart: _onDragStart,
|
||||||
onDragUpdate: _onDragUpdate,
|
onDragUpdate: _onDragUpdate,
|
||||||
onDragEnd: _onDragEnd,
|
onDragEnd: _onDragEnd,
|
||||||
@@ -351,11 +351,12 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
backgroundDecoration: backgroundDecoration,
|
backgroundDecoration: backgroundDecoration,
|
||||||
child: NativeVideoViewer(
|
child: NativeVideoViewer(
|
||||||
key: _NativeVideoViewerKey(displayAsset.heroTag),
|
key: ValueKey(displayAsset),
|
||||||
asset: displayAsset,
|
asset: displayAsset,
|
||||||
scaleStateNotifier: _videoScaleStateNotifier,
|
scaleStateNotifier: _videoScaleStateNotifier,
|
||||||
disableScaleGestures: showingDetails,
|
disableScaleGestures: showingDetails,
|
||||||
image: Image(
|
image: Image(
|
||||||
|
key: ValueKey(displayAsset.heroTag),
|
||||||
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
image: getFullImageProvider(displayAsset, size: context.sizeData),
|
||||||
height: context.height,
|
height: context.height,
|
||||||
width: context.width,
|
width: context.width,
|
||||||
@@ -459,25 +460,3 @@ class _AssetPageState extends ConsumerState<AssetPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A global key is used for video viewers to prevent them from being
|
|
||||||
// unnecessarily recreated. They're quite expensive, and maintain internal
|
|
||||||
// state. This can cause videos to restart multiple times during normal usage,
|
|
||||||
// like a hero animation.
|
|
||||||
//
|
|
||||||
// A plain ValueKey is insufficient, as it does not allow widgets to reparent. A
|
|
||||||
// GlobalObjectKey is fragile, as it checks if the given objects are identical,
|
|
||||||
// rather than equal. Hero tags are created with string interpolation, which
|
|
||||||
// prevents Dart from interning them. As such, hero tags are not identical, even
|
|
||||||
// if they are equal.
|
|
||||||
class _NativeVideoViewerKey extends GlobalKey {
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
const _NativeVideoViewerKey(this.value) : super.constructor();
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => other is _NativeVideoViewerKey && other.value == value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => value.hashCode;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -420,18 +420,20 @@ class NativeVideoViewer extends HookConsumerWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
// Hide thumbnail once video is visible to avoid it showing in background when zooming out on video.
|
||||||
if (!isVisible.value || controller.value == null) Center(child: image),
|
if (!isVisible.value || controller.value == null) Center(key: ValueKey(asset.heroTag), child: image),
|
||||||
if (aspectRatio.value != null && !isCasting && isCurrent)
|
if (aspectRatio.value != null && !isCasting && isCurrent)
|
||||||
Visibility.maintain(
|
Visibility.maintain(
|
||||||
|
key: ValueKey(asset),
|
||||||
visible: isVisible.value,
|
visible: isVisible.value,
|
||||||
child: PhotoView.customChild(
|
child: PhotoView.customChild(
|
||||||
|
key: ValueKey(asset),
|
||||||
enableRotation: false,
|
enableRotation: false,
|
||||||
disableScaleGestures: disableScaleGestures,
|
disableScaleGestures: disableScaleGestures,
|
||||||
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
|
// Transparent to avoid a black flash when viewer becomes visible but video isn't loaded yet.
|
||||||
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
backgroundDecoration: const BoxDecoration(color: Colors.transparent),
|
||||||
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
|
scaleStateChangedCallback: (state) => scaleStateNotifier?.value = state,
|
||||||
childSize: videoContextSize(aspectRatio.value, context),
|
childSize: videoContextSize(aspectRatio.value, context),
|
||||||
child: NativeVideoPlayerView(onViewReady: initController),
|
child: NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (showControls) const Center(child: VideoViewerControls()),
|
if (showControls) const Center(child: VideoViewerControls()),
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
|
|||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
const UnArchiveActionButton(source: ActionSource.timeline),
|
const UnArchiveActionButton(source: ActionSource.timeline),
|
||||||
const FavoriteActionButton(source: ActionSource.timeline),
|
const FavoriteActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
|
|||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
const UnFavoriteActionButton(source: ActionSource.timeline),
|
const UnFavoriteActionButton(source: ActionSource.timeline),
|
||||||
const ArchiveActionButton(source: ActionSource.timeline),
|
const ArchiveActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
const ShareActionButton(source: ActionSource.timeline),
|
const ShareActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasRemote) ...[
|
if (multiselect.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.timeline),
|
const ShareLinkActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
|
const DownloadActionButton(source: ActionSource.timeline),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.timeline)
|
? const TrashActionButton(source: ActionSource.timeline)
|
||||||
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
: const DeletePermanentActionButton(source: ActionSource.timeline),
|
||||||
@@ -119,11 +119,10 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
|
|||||||
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
const MoveToLockFolderActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
if (multiselect.selectedAssets.length > 1) const StackActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
if (multiselect.hasStacked) const UnStackActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.onlyLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteActionButton(source: ActionSource.timeline),
|
||||||
],
|
],
|
||||||
if (multiselect.onlyLocal || multiselect.hasMerged)
|
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
|
||||||
const DeleteLocalActionButton(source: ActionSource.timeline),
|
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
|
||||||
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
|
|
||||||
],
|
],
|
||||||
slivers: multiselect.hasRemote
|
slivers: multiselect.hasRemote
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
import 'package:async/async.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
@@ -77,29 +75,6 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ui.Codec?> loadCodecRequest(ImageRequest request) async {
|
|
||||||
if (isCancelled) {
|
|
||||||
this.request = null;
|
|
||||||
PaintingBinding.instance.imageCache.evict(this);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
final codec = await request.loadCodec();
|
|
||||||
if (codec == null || isCancelled) {
|
|
||||||
codec?.dispose();
|
|
||||||
PaintingBinding.instance.imageCache.evict(this);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return codec;
|
|
||||||
} catch (e) {
|
|
||||||
PaintingBinding.instance.imageCache.evict(this);
|
|
||||||
rethrow;
|
|
||||||
} finally {
|
|
||||||
this.request = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Stream<ImageInfo> initialImageStream() async* {
|
Stream<ImageInfo> initialImageStream() async* {
|
||||||
final cachedOperation = this.cachedOperation;
|
final cachedOperation = this.cachedOperation;
|
||||||
if (cachedOperation == null) {
|
if (cachedOperation == null) {
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ class MultiSelectState {
|
|||||||
|
|
||||||
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
|
bool get hasStacked => selectedAssets.any((asset) => asset is RemoteAsset && asset.stackId != null);
|
||||||
|
|
||||||
|
bool get hasLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
||||||
|
|
||||||
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
bool get hasMerged => selectedAssets.any((asset) => asset.storage == AssetState.merged);
|
||||||
|
|
||||||
bool get onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
|
|
||||||
|
|
||||||
bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote);
|
|
||||||
|
|
||||||
MultiSelectState copyWith({
|
MultiSelectState copyWith({
|
||||||
Set<BaseAsset>? selectedAssets,
|
Set<BaseAsset>? selectedAssets,
|
||||||
Set<BaseAsset>? lockedSelectionAssets,
|
Set<BaseAsset>? lockedSelectionAssets,
|
||||||
|
|||||||
+2
-2
@@ -3,8 +3,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
|||||||
import 'package:immich_mobile/generated/translations.g.dart';
|
import 'package:immich_mobile/generated/translations.g.dart';
|
||||||
import 'package:immich_ui/immich_ui.dart';
|
import 'package:immich_ui/immich_ui.dart';
|
||||||
|
|
||||||
class PermanentDeleteDialog extends StatelessWidget {
|
class TrashDeleteDialog extends StatelessWidget {
|
||||||
const PermanentDeleteDialog({super.key, required this.count});
|
const TrashDeleteDialog({super.key, required this.count});
|
||||||
|
|
||||||
final int count;
|
final int count;
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
|||||||
pinned: pinned,
|
pinned: pinned,
|
||||||
snap: snap,
|
snap: snap,
|
||||||
expandedHeight: expandedHeight,
|
expandedHeight: expandedHeight,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(bottom: Radius.circular(5))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
centerTitle: false,
|
centerTitle: false,
|
||||||
title: title ?? const _ImmichLogoWithText(),
|
title: title ?? const _ImmichLogoWithText(),
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ abstract class LocalImageApi {
|
|||||||
required int width,
|
required int width,
|
||||||
required int height,
|
required int height,
|
||||||
required bool isVideo,
|
required bool isVideo,
|
||||||
required bool preferEncoded,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void cancelRequest(int requestId);
|
void cancelRequest(int requestId);
|
||||||
|
|||||||
@@ -11,15 +11,6 @@ import 'package:pigeon/pigeon.dart';
|
|||||||
dartPackageName: 'immich_mobile',
|
dartPackageName: 'immich_mobile',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
enum PlatformAssetPlaybackStyle {
|
|
||||||
unknown,
|
|
||||||
image,
|
|
||||||
video,
|
|
||||||
imageAnimated,
|
|
||||||
livePhoto,
|
|
||||||
videoLooping,
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlatformAsset {
|
class PlatformAsset {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
@@ -40,8 +31,6 @@ class PlatformAsset {
|
|||||||
final double? latitude;
|
final double? latitude;
|
||||||
final double? longitude;
|
final double? longitude;
|
||||||
|
|
||||||
final PlatformAssetPlaybackStyle playbackStyle;
|
|
||||||
|
|
||||||
const PlatformAsset({
|
const PlatformAsset({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -56,7 +45,6 @@ class PlatformAsset {
|
|||||||
this.adjustmentTime,
|
this.adjustmentTime,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
this.playbackStyle = PlatformAssetPlaybackStyle.unknown,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ abstract class RemoteImageApi {
|
|||||||
String url, {
|
String url, {
|
||||||
required Map<String, String> headers,
|
required Map<String, String> headers,
|
||||||
required int requestId,
|
required int requestId,
|
||||||
required bool preferEncoded,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
void cancelRequest(int requestId);
|
void cancelRequest(int requestId);
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ void main() {
|
|||||||
durationInSeconds: 0,
|
durationInSeconds: 0,
|
||||||
orientation: 0,
|
orientation: 0,
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final assetsToRestore = [LocalAssetStub.image1];
|
final assetsToRestore = [LocalAssetStub.image1];
|
||||||
@@ -215,7 +214,6 @@ void main() {
|
|||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
createdAt: 1700000000,
|
createdAt: 1700000000,
|
||||||
updatedAt: 1732000000,
|
updatedAt: 1732000000,
|
||||||
playbackStyle: PlatformAssetPlaybackStyle.image
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final localAsset = platformAsset.toLocalAsset();
|
final localAsset = platformAsset.toLocalAsset();
|
||||||
|
|||||||
Generated
+313
-301
File diff suppressed because it is too large
Load Diff
+1
-3
@@ -57,8 +57,7 @@
|
|||||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||||
"@react-email/components": "^0.5.0",
|
"@react-email/components": "^0.5.0",
|
||||||
"@react-email/render": "^1.1.2",
|
"@react-email/render": "^1.1.2",
|
||||||
"@socket.io/postgres-adapter": "^0.5.0",
|
"@socket.io/redis-adapter": "^8.3.0",
|
||||||
"@types/pg": "^8.16.0",
|
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"archiver": "^7.0.0",
|
"archiver": "^7.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
@@ -110,7 +109,6 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"sirv": "^3.0.0",
|
"sirv": "^3.0.0",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"socket.io-adapter": "^2.5.6",
|
|
||||||
"tailwindcss-preset-email": "^1.4.0",
|
"tailwindcss-preset-email": "^1.4.0",
|
||||||
"thumbhash": "^0.1.1",
|
"thumbhash": "^0.1.1",
|
||||||
"transformation-matrix": "^3.1.0",
|
"transformation-matrix": "^3.1.0",
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import cookieParser from 'cookie-parser';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import sirv from 'sirv';
|
import sirv from 'sirv';
|
||||||
import { excludePaths, serverVersion } from 'src/constants';
|
import { excludePaths, serverVersion } from 'src/constants';
|
||||||
import { SocketIoAdapter } from 'src/enum';
|
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { createWebSocketAdapter } from 'src/middleware/websocket.adapter';
|
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
||||||
@@ -26,7 +25,6 @@ export async function configureExpress(
|
|||||||
{
|
{
|
||||||
permitSwaggerWrite = true,
|
permitSwaggerWrite = true,
|
||||||
ssr,
|
ssr,
|
||||||
socketIoAdapter,
|
|
||||||
}: {
|
}: {
|
||||||
/**
|
/**
|
||||||
* Whether to allow swagger module to write to the specs.json
|
* Whether to allow swagger module to write to the specs.json
|
||||||
@@ -38,10 +36,6 @@ export async function configureExpress(
|
|||||||
* Service to use for server-side rendering
|
* Service to use for server-side rendering
|
||||||
*/
|
*/
|
||||||
ssr: typeof ApiService | typeof MaintenanceWorkerService;
|
ssr: typeof ApiService | typeof MaintenanceWorkerService;
|
||||||
/**
|
|
||||||
* Override the Socket.IO adapter. If not specified, uses the adapter from config.
|
|
||||||
*/
|
|
||||||
socketIoAdapter?: SocketIoAdapter;
|
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const configRepository = app.get(ConfigRepository);
|
const configRepository = app.get(ConfigRepository);
|
||||||
@@ -61,7 +55,7 @@ export async function configureExpress(
|
|||||||
}
|
}
|
||||||
|
|
||||||
app.setGlobalPrefix('api', { exclude: excludePaths });
|
app.setGlobalPrefix('api', { exclude: excludePaths });
|
||||||
app.useWebSocketAdapter(await createWebSocketAdapter(app, socketIoAdapter));
|
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||||
|
|
||||||
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
|
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UseInterceptors } from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
|
||||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
import { NextFunction, Response } from 'express';
|
import { NextFunction, Response } from 'express';
|
||||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||||
@@ -10,6 +9,7 @@ import {
|
|||||||
} from 'src/dtos/database-backup.dto';
|
} from 'src/dtos/database-backup.dto';
|
||||||
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
import { ApiTag, ImmichCookie, Permission } from 'src/enum';
|
||||||
import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
import { Authenticated, FileResponse, GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
|
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { LoginDetails } from 'src/services/auth.service';
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
@@ -91,11 +91,6 @@ export class DatabaseBackupController {
|
|||||||
description: 'Uploads .sql/.sql.gz file to restore backup from',
|
description: 'Uploads .sql/.sql.gz file to restore backup from',
|
||||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||||
})
|
})
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileUploadInterceptor)
|
||||||
uploadDatabaseBackup(
|
uploadDatabaseBackup() {}
|
||||||
@UploadedFile()
|
|
||||||
file: Express.Multer.File,
|
|
||||||
): Promise<void> {
|
|
||||||
return this.service.uploadBackup(file);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
|
|||||||
import { DownloadController } from 'src/controllers/download.controller';
|
import { DownloadController } from 'src/controllers/download.controller';
|
||||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||||
import { FaceController } from 'src/controllers/face.controller';
|
import { FaceController } from 'src/controllers/face.controller';
|
||||||
import { InternalController } from 'src/controllers/internal.controller';
|
|
||||||
import { JobController } from 'src/controllers/job.controller';
|
import { JobController } from 'src/controllers/job.controller';
|
||||||
import { LibraryController } from 'src/controllers/library.controller';
|
import { LibraryController } from 'src/controllers/library.controller';
|
||||||
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
import { MaintenanceController } from 'src/controllers/maintenance.controller';
|
||||||
@@ -52,7 +51,6 @@ export const controllers = [
|
|||||||
DownloadController,
|
DownloadController,
|
||||||
DuplicateController,
|
DuplicateController,
|
||||||
FaceController,
|
FaceController,
|
||||||
InternalController,
|
|
||||||
JobController,
|
JobController,
|
||||||
LibraryController,
|
LibraryController,
|
||||||
MaintenanceController,
|
MaintenanceController,
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { Body, Controller, NotFoundException, Post, Req } from '@nestjs/common';
|
|
||||||
import { ApiExcludeController } from '@nestjs/swagger';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { AppRestartEvent, EventRepository } from 'src/repositories/event.repository';
|
|
||||||
|
|
||||||
const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
||||||
|
|
||||||
@ApiExcludeController()
|
|
||||||
@Controller('internal')
|
|
||||||
export class InternalController {
|
|
||||||
constructor(private eventRepository: EventRepository) {}
|
|
||||||
|
|
||||||
@Post('restart')
|
|
||||||
async restart(@Req() req: Request, @Body() dto: AppRestartEvent): Promise<void> {
|
|
||||||
const remoteAddress = req.socket.remoteAddress;
|
|
||||||
if (!remoteAddress || !LOCALHOST_ADDRESSES.has(remoteAddress)) {
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.eventRepository.emit('AppRestart', dto);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,6 +29,7 @@ export enum UploadFieldName {
|
|||||||
ASSET_DATA = 'assetData',
|
ASSET_DATA = 'assetData',
|
||||||
SIDECAR_DATA = 'sidecarData',
|
SIDECAR_DATA = 'sidecarData',
|
||||||
PROFILE_DATA = 'file',
|
PROFILE_DATA = 'file',
|
||||||
|
BACKUP_DATA = 'backup',
|
||||||
}
|
}
|
||||||
|
|
||||||
class AssetMediaBase {
|
class AssetMediaBase {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
import { IsEnum, IsInt, IsString, Matches } from 'class-validator';
|
||||||
import { ImmichEnvironment, LogFormat, LogLevel, SocketIoAdapter } from 'src/enum';
|
import { ImmichEnvironment, LogFormat, LogLevel } from 'src/enum';
|
||||||
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
import { IsIPRange, Optional, ValidateBoolean } from 'src/validation';
|
||||||
|
|
||||||
// TODO import from sql-tools once the swagger plugin supports external enums
|
// TODO import from sql-tools once the swagger plugin supports external enums
|
||||||
@@ -149,11 +149,6 @@ export class EnvDto {
|
|||||||
@Optional()
|
@Optional()
|
||||||
IMMICH_WORKERS_EXCLUDE?: string;
|
IMMICH_WORKERS_EXCLUDE?: string;
|
||||||
|
|
||||||
@IsEnum(SocketIoAdapter)
|
|
||||||
@Optional()
|
|
||||||
@Transform(({ value }) => (value ? String(value).toLowerCase().trim() : value))
|
|
||||||
IMMICH_SOCKETIO_ADAPTER?: SocketIoAdapter;
|
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@Optional()
|
@Optional()
|
||||||
DB_DATABASE_NAME?: string;
|
DB_DATABASE_NAME?: string;
|
||||||
|
|||||||
+1
-5
@@ -490,6 +490,7 @@ export enum MetadataKey {
|
|||||||
export enum RouteKey {
|
export enum RouteKey {
|
||||||
Asset = 'assets',
|
Asset = 'assets',
|
||||||
User = 'users',
|
User = 'users',
|
||||||
|
DatabaseBackup = 'admin/database-backups',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum CacheControl {
|
export enum CacheControl {
|
||||||
@@ -518,11 +519,6 @@ export enum ImmichTelemetry {
|
|||||||
Job = 'job',
|
Job = 'job',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SocketIoAdapter {
|
|
||||||
BroadcastChannel = 'broadcastchannel',
|
|
||||||
Postgres = 'postgres',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum ExifOrientation {
|
export enum ExifOrientation {
|
||||||
Horizontal = 1,
|
Horizontal = 1,
|
||||||
MirrorHorizontal = 2,
|
MirrorHorizontal = 2,
|
||||||
|
|||||||
+20
-17
@@ -1,5 +1,6 @@
|
|||||||
import { Kysely, sql } from 'kysely';
|
import { Kysely, sql } from 'kysely';
|
||||||
import { CommandFactory } from 'nest-commander';
|
import { CommandFactory } from 'nest-commander';
|
||||||
|
import { ChildProcess, fork } from 'node:child_process';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { Worker } from 'node:worker_threads';
|
import { Worker } from 'node:worker_threads';
|
||||||
import { PostgresError } from 'postgres';
|
import { PostgresError } from 'postgres';
|
||||||
@@ -17,7 +18,7 @@ class Workers {
|
|||||||
/**
|
/**
|
||||||
* Currently running workers
|
* Currently running workers
|
||||||
*/
|
*/
|
||||||
workers: Partial<Record<ImmichWorker, { kill: () => Promise<void> | void }>> = {};
|
workers: Partial<Record<ImmichWorker, { kill: (signal: NodeJS.Signals) => Promise<void> | void }>> = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fail-safe in case anything dies during restart
|
* Fail-safe in case anything dies during restart
|
||||||
@@ -100,23 +101,25 @@ class Workers {
|
|||||||
const basePath = dirname(__filename);
|
const basePath = dirname(__filename);
|
||||||
const workerFile = join(basePath, 'workers', `${name}.js`);
|
const workerFile = join(basePath, 'workers', `${name}.js`);
|
||||||
|
|
||||||
const inspectArg = process.execArgv.find((arg) => arg.startsWith('--inspect'));
|
let anyWorker: Worker | ChildProcess;
|
||||||
const workerData: { inspectorPort?: number } = {};
|
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
|
||||||
|
|
||||||
if (inspectArg) {
|
if (name === ImmichWorker.Api) {
|
||||||
const inspectorPorts: Record<ImmichWorker, number> = {
|
const worker = fork(workerFile, [], {
|
||||||
[ImmichWorker.Api]: 9230,
|
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
|
||||||
[ImmichWorker.Microservices]: 9231,
|
});
|
||||||
[ImmichWorker.Maintenance]: 9232,
|
|
||||||
};
|
kill = (signal) => void worker.kill(signal);
|
||||||
workerData.inspectorPort = inspectorPorts[name];
|
anyWorker = worker;
|
||||||
|
} else {
|
||||||
|
const worker = new Worker(workerFile);
|
||||||
|
|
||||||
|
kill = async () => void (await worker.terminate());
|
||||||
|
anyWorker = worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worker = new Worker(workerFile, { workerData });
|
anyWorker.on('error', (error) => this.onError(name, error));
|
||||||
const kill = async () => void (await worker.terminate());
|
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
|
||||||
|
|
||||||
worker.on('error', (error) => this.onError(name, error));
|
|
||||||
worker.on('exit', (exitCode) => this.onExit(name, exitCode));
|
|
||||||
|
|
||||||
this.workers[name] = { kill };
|
this.workers[name] = { kill };
|
||||||
}
|
}
|
||||||
@@ -149,8 +152,8 @@ class Workers {
|
|||||||
console.error(`${name} worker exited with code ${exitCode}`);
|
console.error(`${name} worker exited with code ${exitCode}`);
|
||||||
|
|
||||||
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
|
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
|
||||||
console.error('Terminating api worker');
|
console.error('Killing api process');
|
||||||
void this.workers[ImmichWorker.Api].kill();
|
void this.workers[ImmichWorker.Api].kill('SIGTERM');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,4 @@
|
|||||||
import {
|
import { Body, Controller, Delete, Get, Next, Param, Post, Req, Res, UseInterceptors } from '@nestjs/common';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Delete,
|
|
||||||
Get,
|
|
||||||
Next,
|
|
||||||
NotFoundException,
|
|
||||||
Param,
|
|
||||||
Post,
|
|
||||||
Req,
|
|
||||||
Res,
|
|
||||||
UploadedFile,
|
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
|
||||||
import { NextFunction, Request, Response } from 'express';
|
import { NextFunction, Request, Response } from 'express';
|
||||||
import {
|
import {
|
||||||
MaintenanceAuthDto,
|
MaintenanceAuthDto,
|
||||||
@@ -26,18 +12,16 @@ import { ImmichCookie } from 'src/enum';
|
|||||||
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
import { GetLoginDetails } from 'src/middleware/auth.guard';
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { LoginDetails } from 'src/services/auth.service';
|
import { LoginDetails } from 'src/services/auth.service';
|
||||||
import { sendFile } from 'src/utils/file';
|
import { sendFile } from 'src/utils/file';
|
||||||
import { respondWithCookie } from 'src/utils/response';
|
import { respondWithCookie } from 'src/utils/response';
|
||||||
import { FilenameParamDto } from 'src/validation';
|
import { FilenameParamDto } from 'src/validation';
|
||||||
|
|
||||||
const LOCALHOST_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
||||||
|
|
||||||
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
import type { DatabaseBackupController as _DatabaseBackupController } from 'src/controllers/database-backup.controller';
|
||||||
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
|
import type { ServerController as _ServerController } from 'src/controllers/server.controller';
|
||||||
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
|
||||||
|
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||||
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
import { DatabaseBackupService } from 'src/services/database-backup.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
@@ -97,13 +81,8 @@ export class MaintenanceWorkerController {
|
|||||||
*/
|
*/
|
||||||
@Post('admin/database-backups/upload')
|
@Post('admin/database-backups/upload')
|
||||||
@MaintenanceRoute()
|
@MaintenanceRoute()
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileUploadInterceptor)
|
||||||
uploadDatabaseBackup(
|
uploadDatabaseBackup() {}
|
||||||
@UploadedFile()
|
|
||||||
file: Express.Multer.File,
|
|
||||||
): Promise<void> {
|
|
||||||
return this.databaseBackupService.uploadBackup(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('admin/maintenance/status')
|
@Get('admin/maintenance/status')
|
||||||
maintenanceStatus(@Req() request: Request): Promise<MaintenanceStatusResponseDto> {
|
maintenanceStatus(@Req() request: Request): Promise<MaintenanceStatusResponseDto> {
|
||||||
@@ -135,14 +114,4 @@ export class MaintenanceWorkerController {
|
|||||||
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
|
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
|
||||||
void this.service.setAction(dto);
|
void this.service.setAction(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('internal/restart')
|
|
||||||
internalRestart(@Req() req: Request, @Body() dto: AppRestartEvent): void {
|
|
||||||
const remoteAddress = req.socket.remoteAddress;
|
|
||||||
if (!remoteAddress || !LOCALHOST_ADDRESSES.has(remoteAddress)) {
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.service.handleInternalRestart(dto);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-webs
|
|||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
@@ -291,9 +290,6 @@ export class MaintenanceWorkerService {
|
|||||||
|
|
||||||
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
|
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
|
||||||
if (!lock) {
|
if (!lock) {
|
||||||
// Another maintenance worker has the lock - poll until maintenance mode ends
|
|
||||||
this.logger.log('Another worker has the maintenance lock, polling for maintenance mode changes...');
|
|
||||||
await this.pollForMaintenanceEnd();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,25 +351,4 @@ export class MaintenanceWorkerService {
|
|||||||
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
||||||
this.appRepository.exitApp();
|
this.appRepository.exitApp();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleInternalRestart(state: AppRestartEvent): void {
|
|
||||||
this.maintenanceWebsocketRepository.clientBroadcast('AppRestartV1', state);
|
|
||||||
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
|
|
||||||
this.appRepository.exitApp();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async pollForMaintenanceEnd(): Promise<void> {
|
|
||||||
const pollIntervalMs = 5000;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
||||||
|
|
||||||
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MaintenanceMode);
|
|
||||||
if (!state?.isMaintenanceMode) {
|
|
||||||
this.logger.log('Maintenance mode ended, restarting...');
|
|
||||||
this.appRepository.exitApp();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
import {
|
|
||||||
ClusterAdapterWithHeartbeat,
|
|
||||||
type ClusterAdapterOptions,
|
|
||||||
type ClusterMessage,
|
|
||||||
type ClusterResponse,
|
|
||||||
type ServerId,
|
|
||||||
} from 'socket.io-adapter';
|
|
||||||
|
|
||||||
const BC_CHANNEL_NAME = 'immich:socketio';
|
|
||||||
|
|
||||||
interface BroadcastChannelPayload {
|
|
||||||
type: 'message' | 'response';
|
|
||||||
sourceUid: string;
|
|
||||||
targetUid?: string;
|
|
||||||
data: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Socket.IO adapter using Node.js BroadcastChannel
|
|
||||||
*
|
|
||||||
* Relays messages between worker_threads within a single OS process.
|
|
||||||
* Zero external dependencies. Does NOT work across containers — use
|
|
||||||
* the Postgres adapter for multi-replica deployments.
|
|
||||||
*/
|
|
||||||
class BroadcastChannelAdapter extends ClusterAdapterWithHeartbeat {
|
|
||||||
private readonly channel: BroadcastChannel;
|
|
||||||
|
|
||||||
constructor(nsp: any, opts?: Partial<ClusterAdapterOptions>) {
|
|
||||||
super(nsp, opts ?? {});
|
|
||||||
|
|
||||||
this.channel = new BroadcastChannel(BC_CHANNEL_NAME);
|
|
||||||
this.channel.addEventListener('message', (event: MessageEvent<BroadcastChannelPayload>) => {
|
|
||||||
const msg = event.data;
|
|
||||||
if (msg.sourceUid === this.uid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (msg.type === 'message') {
|
|
||||||
this.onMessage(msg.data as ClusterMessage);
|
|
||||||
} else if (msg.type === 'response' && msg.targetUid === this.uid) {
|
|
||||||
this.onResponse(msg.data as ClusterResponse);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
override doPublish(message: ClusterMessage): Promise<string> {
|
|
||||||
this.channel.postMessage({
|
|
||||||
type: 'message',
|
|
||||||
sourceUid: this.uid,
|
|
||||||
data: message,
|
|
||||||
});
|
|
||||||
return Promise.resolve('');
|
|
||||||
}
|
|
||||||
|
|
||||||
override doPublishResponse(requesterUid: ServerId, response: ClusterResponse): Promise<void> {
|
|
||||||
this.channel.postMessage({
|
|
||||||
type: 'response',
|
|
||||||
sourceUid: this.uid,
|
|
||||||
targetUid: requesterUid,
|
|
||||||
data: response,
|
|
||||||
});
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
override close(): void {
|
|
||||||
super.close();
|
|
||||||
this.channel.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBroadcastChannelAdapter(opts?: Partial<ClusterAdapterOptions>) {
|
|
||||||
const options: Partial<ClusterAdapterOptions> = {
|
|
||||||
...opts,
|
|
||||||
};
|
|
||||||
|
|
||||||
return function (nsp: any) {
|
|
||||||
return new BroadcastChannelAdapter(nsp, options);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -48,6 +48,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
private handlers: {
|
private handlers: {
|
||||||
userProfile: RequestHandler;
|
userProfile: RequestHandler;
|
||||||
assetUpload: RequestHandler;
|
assetUpload: RequestHandler;
|
||||||
|
databaseBackup: RequestHandler;
|
||||||
};
|
};
|
||||||
private defaultStorage: StorageEngine;
|
private defaultStorage: StorageEngine;
|
||||||
|
|
||||||
@@ -77,6 +78,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||||
]),
|
]),
|
||||||
|
databaseBackup: instance.single(UploadFieldName.BACKUP_DATA),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +167,10 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||||||
return this.handlers.userProfile;
|
return this.handlers.userProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case RouteKey.DatabaseBackup: {
|
||||||
|
return this.handlers.databaseBackup;
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,103 +1,21 @@
|
|||||||
import { INestApplication, Logger } from '@nestjs/common';
|
import { INestApplicationContext } from '@nestjs/common';
|
||||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
import { IoAdapter } from '@nestjs/platform-socket.io';
|
||||||
import { Pool, PoolConfig } from 'pg';
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
import type { ServerOptions } from 'socket.io';
|
import { Redis } from 'ioredis';
|
||||||
import { SocketIoAdapter } from 'src/enum';
|
import { ServerOptions } from 'socket.io';
|
||||||
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
|
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { asPostgresConnectionConfig } from 'src/utils/database';
|
|
||||||
|
|
||||||
export type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
|
export class WebSocketAdapter extends IoAdapter {
|
||||||
|
constructor(private app: INestApplicationContext) {
|
||||||
export function asPgPoolSsl(ssl?: Ssl): PoolConfig['ssl'] {
|
|
||||||
if (ssl === undefined || ssl === false || ssl === 'allow') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ssl === true || ssl === 'prefer' || ssl === 'require') {
|
|
||||||
return { rejectUnauthorized: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ssl === 'verify-full') {
|
|
||||||
return { rejectUnauthorized: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return ssl;
|
|
||||||
}
|
|
||||||
|
|
||||||
class BroadcastChannelSocketAdapter extends IoAdapter {
|
|
||||||
private adapterConstructor: ReturnType<typeof createBroadcastChannelAdapter>;
|
|
||||||
|
|
||||||
constructor(app: INestApplication) {
|
|
||||||
super(app);
|
super(app);
|
||||||
this.adapterConstructor = createBroadcastChannelAdapter();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createIOServer(port: number, options?: ServerOptions): any {
|
createIOServer(port: number, options?: ServerOptions): any {
|
||||||
|
const { redis } = this.app.get(ConfigRepository).getEnv();
|
||||||
const server = super.createIOServer(port, options);
|
const server = super.createIOServer(port, options);
|
||||||
server.adapter(this.adapterConstructor);
|
const pubClient = new Redis(redis);
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
server.adapter(createAdapter(pubClient, subClient));
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PostgresSocketAdapter extends IoAdapter {
|
|
||||||
private adapterConstructor: any;
|
|
||||||
|
|
||||||
constructor(app: INestApplication, adapterConstructor: any) {
|
|
||||||
super(app);
|
|
||||||
this.adapterConstructor = adapterConstructor;
|
|
||||||
}
|
|
||||||
|
|
||||||
createIOServer(port: number, options?: ServerOptions): any {
|
|
||||||
const server = super.createIOServer(port, options);
|
|
||||||
server.adapter(this.adapterConstructor);
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createWebSocketAdapter(
|
|
||||||
app: INestApplication,
|
|
||||||
adapterOverride?: SocketIoAdapter,
|
|
||||||
): Promise<IoAdapter> {
|
|
||||||
const logger = new Logger('WebSocketAdapter');
|
|
||||||
const config = new ConfigRepository();
|
|
||||||
const { database, socketIo } = config.getEnv();
|
|
||||||
const adapter = adapterOverride ?? socketIo.adapter;
|
|
||||||
|
|
||||||
switch (adapter) {
|
|
||||||
case SocketIoAdapter.Postgres: {
|
|
||||||
logger.log('Using Postgres Socket.IO adapter');
|
|
||||||
const { createAdapter } = await import('@socket.io/postgres-adapter');
|
|
||||||
const config = asPostgresConnectionConfig(database.config);
|
|
||||||
const pool = new Pool({
|
|
||||||
host: config.host,
|
|
||||||
port: config.port,
|
|
||||||
user: config.username,
|
|
||||||
password: config.password,
|
|
||||||
database: config.database,
|
|
||||||
ssl: asPgPoolSsl(config.ssl),
|
|
||||||
max: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
await pool.query(`
|
|
||||||
CREATE TABLE IF NOT EXISTS socket_io_attachments (
|
|
||||||
id bigserial UNIQUE,
|
|
||||||
created_at timestamptz DEFAULT NOW(),
|
|
||||||
payload bytea
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
pool.on('error', (error) => {
|
|
||||||
logger.error(' Postgres pool error', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
const adapterConstructor = createAdapter(pool);
|
|
||||||
return new PostgresSocketAdapter(app, adapterConstructor);
|
|
||||||
}
|
|
||||||
|
|
||||||
case SocketIoAdapter.BroadcastChannel: {
|
|
||||||
logger.log('Using BroadcastChannel Socket.IO adapter');
|
|
||||||
return new BroadcastChannelSocketAdapter(app);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -562,7 +562,6 @@ select
|
|||||||
"asset"."checksum",
|
"asset"."checksum",
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."isExternal",
|
"asset"."isExternal",
|
||||||
"asset"."visibility",
|
|
||||||
"asset"."originalFileName",
|
"asset"."originalFileName",
|
||||||
"asset"."livePhotoVideoId",
|
"asset"."livePhotoVideoId",
|
||||||
"asset"."fileCreatedAt",
|
"asset"."fileCreatedAt",
|
||||||
@@ -594,7 +593,6 @@ from
|
|||||||
where
|
where
|
||||||
"asset"."deletedAt" is null
|
"asset"."deletedAt" is null
|
||||||
and "asset"."id" = $2
|
and "asset"."id" = $2
|
||||||
and "asset"."visibility" != $3
|
|
||||||
|
|
||||||
-- AssetJobRepository.streamForStorageTemplateJob
|
-- AssetJobRepository.streamForStorageTemplateJob
|
||||||
select
|
select
|
||||||
@@ -604,7 +602,6 @@ select
|
|||||||
"asset"."checksum",
|
"asset"."checksum",
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."isExternal",
|
"asset"."isExternal",
|
||||||
"asset"."visibility",
|
|
||||||
"asset"."originalFileName",
|
"asset"."originalFileName",
|
||||||
"asset"."livePhotoVideoId",
|
"asset"."livePhotoVideoId",
|
||||||
"asset"."fileCreatedAt",
|
"asset"."fileCreatedAt",
|
||||||
@@ -635,7 +632,6 @@ from
|
|||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."deletedAt" is null
|
"asset"."deletedAt" is null
|
||||||
and "asset"."visibility" != $2
|
|
||||||
|
|
||||||
-- AssetJobRepository.streamForDeletedJob
|
-- AssetJobRepository.streamForDeletedJob
|
||||||
select
|
select
|
||||||
|
|||||||
@@ -123,13 +123,13 @@ with
|
|||||||
) as "year"
|
) as "year"
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
"a".*
|
"a".*,
|
||||||
|
to_json("asset_exif") as "exifInfo"
|
||||||
from
|
from
|
||||||
"today"
|
"today"
|
||||||
inner join lateral (
|
inner join lateral (
|
||||||
select
|
select
|
||||||
"asset"."id",
|
"asset".*
|
||||||
"asset"."localDateTime"
|
|
||||||
from
|
from
|
||||||
"asset"
|
"asset"
|
||||||
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
|
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
|
||||||
@@ -151,6 +151,7 @@ with
|
|||||||
limit
|
limit
|
||||||
$7
|
$7
|
||||||
) as "a" on true
|
) as "a" on true
|
||||||
|
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
|
||||||
)
|
)
|
||||||
select
|
select
|
||||||
date_part(
|
date_part(
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { Server as SocketIO } from 'socket.io';
|
||||||
import { ExitCode } from 'src/enum';
|
import { ExitCode } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { AppRestartEvent } from 'src/repositories/event.repository';
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
@@ -21,17 +24,24 @@ export class AppRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
|
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
|
||||||
const { port } = new ConfigRepository().getEnv();
|
const server = new SocketIO();
|
||||||
const url = `http://127.0.0.1:${port}/api/internal/restart`;
|
const { redis } = new ConfigRepository().getEnv();
|
||||||
|
const pubClient = new Redis({ ...redis, lazyConnect: true });
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
|
||||||
const response = await fetch(url, {
|
await Promise.all([pubClient.connect(), subClient.connect()]);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
server.adapter(createAdapter(pubClient, subClient));
|
||||||
body: JSON.stringify(state),
|
|
||||||
|
// => corresponds to notification.service.ts#onAppRestart
|
||||||
|
server.emit('AppRestartV1', state, async () => {
|
||||||
|
const responses = await server.serverSideEmitWithAck('AppRestart', state);
|
||||||
|
if (responses.some((response) => response !== 'ok')) {
|
||||||
|
throw new Error("One or more node(s) returned a non-'ok' response to our restart request!");
|
||||||
|
}
|
||||||
|
|
||||||
|
pubClient.disconnect();
|
||||||
|
subClient.disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to trigger app restart: ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,7 +353,6 @@ export class AssetJobRepository {
|
|||||||
'asset.checksum',
|
'asset.checksum',
|
||||||
'asset.originalPath',
|
'asset.originalPath',
|
||||||
'asset.isExternal',
|
'asset.isExternal',
|
||||||
'asset.visibility',
|
|
||||||
'asset.originalFileName',
|
'asset.originalFileName',
|
||||||
'asset.livePhotoVideoId',
|
'asset.livePhotoVideoId',
|
||||||
'asset.fileCreatedAt',
|
'asset.fileCreatedAt',
|
||||||
@@ -368,16 +367,13 @@ export class AssetJobRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID] })
|
@GenerateSql({ params: [DummyValue.UUID] })
|
||||||
getForStorageTemplateJob(id: string, options?: { includeHidden?: boolean }) {
|
getForStorageTemplateJob(id: string) {
|
||||||
return this.storageTemplateAssetQuery()
|
return this.storageTemplateAssetQuery().where('asset.id', '=', id).executeTakeFirst();
|
||||||
.where('asset.id', '=', id)
|
|
||||||
.$if(!options?.includeHidden, (qb) => qb.where('asset.visibility', '!=', AssetVisibility.Hidden))
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [], stream: true })
|
@GenerateSql({ params: [], stream: true })
|
||||||
streamForStorageTemplateJob() {
|
streamForStorageTemplateJob() {
|
||||||
return this.storageTemplateAssetQuery().where('asset.visibility', '!=', AssetVisibility.Hidden).stream();
|
return this.storageTemplateAssetQuery().stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ export class AssetRepository {
|
|||||||
(qb) =>
|
(qb) =>
|
||||||
qb
|
qb
|
||||||
.selectFrom('asset')
|
.selectFrom('asset')
|
||||||
.select(['asset.id', 'asset.localDateTime'])
|
.selectAll('asset')
|
||||||
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
|
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
|
||||||
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
|
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
|
||||||
.where('asset.ownerId', '=', anyUuid(ownerIds))
|
.where('asset.ownerId', '=', anyUuid(ownerIds))
|
||||||
@@ -423,7 +423,9 @@ export class AssetRepository {
|
|||||||
.as('a'),
|
.as('a'),
|
||||||
(join) => join.onTrue(),
|
(join) => join.onTrue(),
|
||||||
)
|
)
|
||||||
.selectAll('a'),
|
.innerJoin('asset_exif', 'a.id', 'asset_exif.assetId')
|
||||||
|
.selectAll('a')
|
||||||
|
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).as('exifInfo')),
|
||||||
)
|
)
|
||||||
.selectFrom('res')
|
.selectFrom('res')
|
||||||
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
|
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
LogFormat,
|
LogFormat,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
QueueName,
|
QueueName,
|
||||||
SocketIoAdapter,
|
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { VectorExtension } from 'src/types';
|
import { VectorExtension } from 'src/types';
|
||||||
import { setDifference } from 'src/utils/set';
|
import { setDifference } from 'src/utils/set';
|
||||||
@@ -118,10 +117,6 @@ export interface EnvData {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
socketIo: {
|
|
||||||
adapter: SocketIoAdapter;
|
|
||||||
};
|
|
||||||
|
|
||||||
noColor: boolean;
|
noColor: boolean;
|
||||||
nodeVersion?: string;
|
nodeVersion?: string;
|
||||||
}
|
}
|
||||||
@@ -352,10 +347,6 @@ const getEnv = (): EnvData => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
socketIo: {
|
|
||||||
adapter: dto.IMMICH_SOCKETIO_ADAPTER ?? SocketIoAdapter.Postgres,
|
|
||||||
},
|
|
||||||
|
|
||||||
noColor: !!dto.NO_COLOR,
|
noColor: !!dto.NO_COLOR,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ export class AssetMediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case UploadFieldName.BACKUP_DATA: {
|
||||||
|
if (mimeTypes.isBackup(filename)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`Unsupported file type ${filename}`);
|
this.logger.error(`Unsupported file type ${filename}`);
|
||||||
@@ -101,6 +108,7 @@ export class AssetMediaService extends BaseService {
|
|||||||
[UploadFieldName.ASSET_DATA]: extension,
|
[UploadFieldName.ASSET_DATA]: extension,
|
||||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||||
[UploadFieldName.PROFILE_DATA]: extension,
|
[UploadFieldName.PROFILE_DATA]: extension,
|
||||||
|
[UploadFieldName.BACKUP_DATA]: extension === '.gz' ? '.sql.gz' : extension,
|
||||||
};
|
};
|
||||||
|
|
||||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||||
@@ -113,6 +121,9 @@ export class AssetMediaService extends BaseService {
|
|||||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||||
folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id);
|
folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id);
|
||||||
}
|
}
|
||||||
|
if (fieldName === UploadFieldName.BACKUP_DATA) {
|
||||||
|
folder = StorageCore.getFolderLocation(StorageFolder.Backups, `uploaded-${file.originalName}`);
|
||||||
|
}
|
||||||
|
|
||||||
this.storageRepository.mkdirSync(folder);
|
this.storageRepository.mkdirSync(folder);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, Optional } from '@nestjs/common';
|
import { BadRequestException, Injectable, Optional } from '@nestjs/common';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import path, { basename } from 'node:path';
|
import path from 'node:path';
|
||||||
import { PassThrough, Readable, Writable } from 'node:stream';
|
import { PassThrough, Readable, Writable } from 'node:stream';
|
||||||
import { pipeline } from 'node:stream/promises';
|
import { pipeline } from 'node:stream/promises';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
@@ -252,17 +252,6 @@ export class DatabaseBackupService {
|
|||||||
return backupFilePath;
|
return backupFilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadBackup(file: Express.Multer.File): Promise<void> {
|
|
||||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
|
||||||
const fn = basename(file.originalname);
|
|
||||||
if (!isValidDatabaseBackupName(fn)) {
|
|
||||||
throw new BadRequestException('Invalid backup name!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(backupsFolder, `uploaded-${fn}`);
|
|
||||||
await this.storageRepository.createOrOverwriteFile(filePath, file.buffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadBackup(fileName: string): ImmichFileResponse {
|
downloadBackup(fileName: string): ImmichFileResponse {
|
||||||
if (!isValidDatabaseBackupName(fileName)) {
|
if (!isValidDatabaseBackupName(fileName)) {
|
||||||
throw new BadRequestException('Invalid backup name!');
|
throw new BadRequestException('Invalid backup name!');
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ import { userStub } from 'test/fixtures/user.stub';
|
|||||||
import { getForStorageTemplate } from 'test/mappers';
|
import { getForStorageTemplate } from 'test/mappers';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build();
|
|
||||||
const stillAsset = AssetFactory.from({ livePhotoVideoId: motionAsset.id }).exif().build();
|
|
||||||
|
|
||||||
describe(StorageTemplateService.name, () => {
|
describe(StorageTemplateService.name, () => {
|
||||||
let sut: StorageTemplateService;
|
let sut: StorageTemplateService;
|
||||||
let mocks: ServiceMocks;
|
let mocks: ServiceMocks;
|
||||||
@@ -156,58 +153,6 @@ describe(StorageTemplateService.name, () => {
|
|||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should migrate live photo motion video alongside the still image using album in path', async () => {
|
|
||||||
const motionAsset = AssetFactory.from({
|
|
||||||
type: AssetType.Video,
|
|
||||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
|
||||||
})
|
|
||||||
.exif()
|
|
||||||
.build();
|
|
||||||
const stillAsset = AssetFactory.from({
|
|
||||||
livePhotoVideoId: motionAsset.id,
|
|
||||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
|
||||||
})
|
|
||||||
.exif()
|
|
||||||
.build();
|
|
||||||
|
|
||||||
const album = AlbumFactory.from().asset().build();
|
|
||||||
const config = structuredClone(defaults);
|
|
||||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
|
||||||
sut.onConfigInit({ newConfig: config });
|
|
||||||
|
|
||||||
mocks.user.get.mockResolvedValue(userStub.user1);
|
|
||||||
|
|
||||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
|
||||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
|
|
||||||
|
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
|
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
|
||||||
mocks.album.getByAssetId.mockResolvedValue([album]);
|
|
||||||
|
|
||||||
mocks.move.create.mockResolvedValueOnce({
|
|
||||||
id: '123',
|
|
||||||
entityId: stillAsset.id,
|
|
||||||
pathType: AssetPathType.Original,
|
|
||||||
oldPath: stillAsset.originalPath,
|
|
||||||
newPath: newStillPicturePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.move.create.mockResolvedValueOnce({
|
|
||||||
id: '124',
|
|
||||||
entityId: motionAsset.id,
|
|
||||||
pathType: AssetPathType.Original,
|
|
||||||
oldPath: motionAsset.originalPath,
|
|
||||||
newPath: newMotionPicturePath,
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(sut.handleMigrationSingle({ id: stillAsset.id })).resolves.toBe(JobStatus.Success);
|
|
||||||
|
|
||||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use handlebar if condition for album', async () => {
|
it('should use handlebar if condition for album', async () => {
|
||||||
const user = UserFactory.create();
|
const user = UserFactory.create();
|
||||||
const asset = AssetFactory.from().owner(user).exif().build();
|
const asset = AssetFactory.from().owner(user).exif().build();
|
||||||
@@ -764,18 +709,12 @@ describe(StorageTemplateService.name, () => {
|
|||||||
})
|
})
|
||||||
.exif()
|
.exif()
|
||||||
.build();
|
.build();
|
||||||
const album = AlbumFactory.from().asset().build();
|
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
||||||
const config = structuredClone(defaults);
|
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
|
||||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
|
|
||||||
sut.onConfigInit({ newConfig: config });
|
|
||||||
|
|
||||||
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
|
|
||||||
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/${album.albumName}/${stillAsset.originalFileName}`;
|
|
||||||
|
|
||||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
|
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
|
||||||
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
mocks.user.getList.mockResolvedValue([userStub.user1]);
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
||||||
mocks.album.getByAssetId.mockResolvedValue([album]);
|
|
||||||
|
|
||||||
mocks.move.create.mockResolvedValueOnce({
|
mocks.move.create.mockResolvedValueOnce({
|
||||||
id: '123',
|
id: '123',
|
||||||
@@ -796,53 +735,11 @@ describe(StorageTemplateService.name, () => {
|
|||||||
await sut.handleMigration();
|
await sut.handleMigration();
|
||||||
|
|
||||||
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
|
||||||
|
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(motionAsset.id);
|
||||||
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
|
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(2);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, originalPath: newStillPicturePath });
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, originalPath: newMotionPicturePath });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use still photo album info when migrating live photo motion video', async () => {
|
|
||||||
const user = userStub.user1;
|
|
||||||
const album = AlbumFactory.from().asset().build();
|
|
||||||
const config = structuredClone(defaults);
|
|
||||||
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}';
|
|
||||||
|
|
||||||
sut.onConfigInit({ newConfig: config });
|
|
||||||
|
|
||||||
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
|
|
||||||
mocks.user.getList.mockResolvedValue([user]);
|
|
||||||
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
|
|
||||||
mocks.album.getByAssetId.mockResolvedValue([album]);
|
|
||||||
|
|
||||||
mocks.move.create.mockResolvedValueOnce({
|
|
||||||
id: '123',
|
|
||||||
entityId: stillAsset.id,
|
|
||||||
pathType: AssetPathType.Original,
|
|
||||||
oldPath: stillAsset.originalPath,
|
|
||||||
newPath: `/data/library/${user.id}/2022/${album.albumName}/${stillAsset.originalFileName}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.move.create.mockResolvedValueOnce({
|
|
||||||
id: '124',
|
|
||||||
entityId: motionAsset.id,
|
|
||||||
pathType: AssetPathType.Original,
|
|
||||||
oldPath: motionAsset.originalPath,
|
|
||||||
newPath: `/data/library/${user.id}/2022/${album.albumName}/${motionAsset.originalFileName}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.handleMigration();
|
|
||||||
|
|
||||||
expect(mocks.album.getByAssetId).toHaveBeenCalledWith(stillAsset.ownerId, stillAsset.id);
|
|
||||||
expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(2);
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
||||||
id: stillAsset.id,
|
|
||||||
originalPath: expect.stringContaining(`/${album.albumName}/`),
|
|
||||||
});
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({
|
|
||||||
id: motionAsset.id,
|
|
||||||
originalPath: expect.stringContaining(`/${album.albumName}/`),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('file rename correctness', () => {
|
describe('file rename correctness', () => {
|
||||||
|
|||||||
@@ -158,14 +158,12 @@ export class StorageTemplateService extends BaseService {
|
|||||||
|
|
||||||
// move motion part of live photo
|
// move motion part of live photo
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
|
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
|
||||||
includeHidden: true,
|
|
||||||
});
|
|
||||||
if (!livePhotoVideo) {
|
if (!livePhotoVideo) {
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
|
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||||
}
|
}
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
}
|
}
|
||||||
@@ -193,12 +191,10 @@ export class StorageTemplateService extends BaseService {
|
|||||||
|
|
||||||
// move motion part of live photo
|
// move motion part of live photo
|
||||||
if (asset.livePhotoVideoId) {
|
if (asset.livePhotoVideoId) {
|
||||||
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId, {
|
const livePhotoVideo = await this.assetJobRepository.getForStorageTemplateJob(asset.livePhotoVideoId);
|
||||||
includeHidden: true,
|
|
||||||
});
|
|
||||||
if (livePhotoVideo) {
|
if (livePhotoVideo) {
|
||||||
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
|
||||||
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }, asset);
|
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,7 +214,7 @@ export class StorageTemplateService extends BaseService {
|
|||||||
await this.moveRepository.cleanMoveHistorySingle(assetId);
|
await this.moveRepository.cleanMoveHistorySingle(assetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata, stillPhoto?: StorageAsset) {
|
async moveAsset(asset: StorageAsset, metadata: MoveAssetMetadata) {
|
||||||
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
|
if (asset.isExternal || StorageCore.isAndroidMotionPath(asset.originalPath)) {
|
||||||
// External assets are not affected by storage template
|
// External assets are not affected by storage template
|
||||||
// TODO: shouldn't this only apply to external assets?
|
// TODO: shouldn't this only apply to external assets?
|
||||||
@@ -228,7 +224,7 @@ export class StorageTemplateService extends BaseService {
|
|||||||
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
|
return this.databaseRepository.withLock(DatabaseLock.StorageTemplateMigration, async () => {
|
||||||
const { id, originalPath, checksum, fileSizeInByte } = asset;
|
const { id, originalPath, checksum, fileSizeInByte } = asset;
|
||||||
const oldPath = originalPath;
|
const oldPath = originalPath;
|
||||||
const newPath = await this.getTemplatePath(asset, metadata, stillPhoto);
|
const newPath = await this.getTemplatePath(asset, metadata);
|
||||||
|
|
||||||
if (!fileSizeInByte) {
|
if (!fileSizeInByte) {
|
||||||
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
|
this.logger.error(`Asset ${id} missing exif info, skipping storage template migration`);
|
||||||
@@ -259,11 +255,7 @@ export class StorageTemplateService extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getTemplatePath(
|
private async getTemplatePath(asset: StorageAsset, metadata: MoveAssetMetadata): Promise<string> {
|
||||||
asset: StorageAsset,
|
|
||||||
metadata: MoveAssetMetadata,
|
|
||||||
stillPhoto?: StorageAsset,
|
|
||||||
): Promise<string> {
|
|
||||||
const { storageLabel, filename } = metadata;
|
const { storageLabel, filename } = metadata;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -304,12 +296,8 @@ export class StorageTemplateService extends BaseService {
|
|||||||
let albumName = null;
|
let albumName = null;
|
||||||
let albumStartDate = null;
|
let albumStartDate = null;
|
||||||
let albumEndDate = null;
|
let albumEndDate = null;
|
||||||
const assetForMetadata = stillPhoto || asset;
|
|
||||||
|
|
||||||
if (this.template.needsAlbum) {
|
if (this.template.needsAlbum) {
|
||||||
// For motion videos, use the still photo's album information since motion videos
|
const albums = await this.albumRepository.getByAssetId(asset.ownerId, asset.id);
|
||||||
// don't have album metadata attached directly
|
|
||||||
const albums = await this.albumRepository.getByAssetId(assetForMetadata.ownerId, assetForMetadata.id);
|
|
||||||
const album = albums?.[0];
|
const album = albums?.[0];
|
||||||
if (album) {
|
if (album) {
|
||||||
albumName = album.albumName || null;
|
albumName = album.albumName || null;
|
||||||
@@ -322,18 +310,16 @@ export class StorageTemplateService extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For motion videos that are part of live photos, use the still photo's date
|
|
||||||
// to ensure both parts end up in the same folder
|
|
||||||
const storagePath = this.render(this.template.compiled, {
|
const storagePath = this.render(this.template.compiled, {
|
||||||
asset: assetForMetadata,
|
asset,
|
||||||
filename: sanitized,
|
filename: sanitized,
|
||||||
extension,
|
extension,
|
||||||
albumName,
|
albumName,
|
||||||
albumStartDate,
|
albumStartDate,
|
||||||
albumEndDate,
|
albumEndDate,
|
||||||
make: assetForMetadata.make,
|
make: asset.make,
|
||||||
model: assetForMetadata.model,
|
model: asset.model,
|
||||||
lensModel: assetForMetadata.lensModel,
|
lensModel: asset.lensModel,
|
||||||
});
|
});
|
||||||
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
const fullPath = path.normalize(path.join(rootPath, storagePath));
|
||||||
let destination = `${fullPath}.${extension}`;
|
let destination = `${fullPath}.${extension}`;
|
||||||
|
|||||||
@@ -1,11 +1,60 @@
|
|||||||
|
import { createAdapter } from '@socket.io/redis-adapter';
|
||||||
|
import Redis from 'ioredis';
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import { Server as SocketIO } from 'socket.io';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
|
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
|
||||||
import { StorageFolder } from 'src/enum';
|
import { StorageFolder } from 'src/enum';
|
||||||
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
|
import { AppRestartEvent } from 'src/repositories/event.repository';
|
||||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||||
|
|
||||||
|
export function sendOneShotAppRestart(state: AppRestartEvent): void {
|
||||||
|
const server = new SocketIO();
|
||||||
|
const { redis } = new ConfigRepository().getEnv();
|
||||||
|
const pubClient = new Redis(redis);
|
||||||
|
const subClient = pubClient.duplicate();
|
||||||
|
server.adapter(createAdapter(pubClient, subClient));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep trying until we manage to stop Immich
|
||||||
|
*
|
||||||
|
* Sometimes there appear to be communication
|
||||||
|
* issues between to the other servers.
|
||||||
|
*
|
||||||
|
* This issue only occurs with this method.
|
||||||
|
*/
|
||||||
|
async function tryTerminate() {
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const responses = await server.serverSideEmitWithAck('AppRestart', state);
|
||||||
|
if (responses.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
console.error('Encountered an error while telling Immich to stop.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
"\nIt doesn't appear that Immich stopped, trying again in a moment.\nIf Immich is already not running, you can ignore this error.",
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, 1e3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// => corresponds to notification.service.ts#onAppRestart
|
||||||
|
server.emit('AppRestartV1', state, () => {
|
||||||
|
void tryTerminate().finally(() => {
|
||||||
|
pubClient.disconnect();
|
||||||
|
subClient.disconnect();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMaintenanceLoginUrl(
|
export async function createMaintenanceLoginUrl(
|
||||||
baseUrl: string,
|
baseUrl: string,
|
||||||
auth: MaintenanceAuthDto,
|
auth: MaintenanceAuthDto,
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export const mimeTypes = {
|
|||||||
isProfile: (filename: string) => isType(filename, profile),
|
isProfile: (filename: string) => isType(filename, profile),
|
||||||
isSidecar: (filename: string) => isType(filename, sidecar),
|
isSidecar: (filename: string) => isType(filename, sidecar),
|
||||||
isVideo: (filename: string) => isType(filename, video),
|
isVideo: (filename: string) => isType(filename, video),
|
||||||
|
isBackup: (filename: string) => filename.endsWith('.sql') || filename.endsWith('.sql.gz'),
|
||||||
canBeTransparent: (filename: string) => transparentCapableExtensions.has(extname(filename).toLowerCase()),
|
canBeTransparent: (filename: string) => transparentCapableExtensions.has(extname(filename).toLowerCase()),
|
||||||
isRaw: (filename: string) => isType(filename, raw),
|
isRaw: (filename: string) => isType(filename, raw),
|
||||||
lookup,
|
lookup,
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import inspector from 'node:inspector';
|
|
||||||
import { isMainThread, workerData } from 'node:worker_threads';
|
|
||||||
import { configureExpress, configureTelemetry } from 'src/app.common';
|
import { configureExpress, configureTelemetry } from 'src/app.common';
|
||||||
import { ApiModule } from 'src/app.module';
|
import { ApiModule } from 'src/app.module';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ApiService } from 'src/services/api.service';
|
import { ApiService } from 'src/services/api.service';
|
||||||
import { isStartUpError } from 'src/utils/misc';
|
import { isStartUpError } from 'src/utils/misc';
|
||||||
|
|
||||||
export async function bootstrap() {
|
async function bootstrap() {
|
||||||
process.title = 'immich-api';
|
process.title = 'immich-api';
|
||||||
|
|
||||||
const { inspectorPort } = workerData ?? {};
|
|
||||||
if (inspectorPort) {
|
|
||||||
inspector.open(inspectorPort, '0.0.0.0', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
configureTelemetry();
|
configureTelemetry();
|
||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
|
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
|
||||||
@@ -26,12 +19,10 @@ export async function bootstrap() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMainThread || process.send) {
|
bootstrap().catch((error) => {
|
||||||
bootstrap().catch((error) => {
|
if (!isStartUpError(error)) {
|
||||||
if (!isStartUpError(error)) {
|
console.error(error);
|
||||||
console.error(error);
|
}
|
||||||
}
|
// eslint-disable-next-line unicorn/no-process-exit
|
||||||
|
process.exit(1);
|
||||||
process.exit(1);
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import inspector from 'node:inspector';
|
|
||||||
import { isMainThread, workerData } from 'node:worker_threads';
|
|
||||||
import { configureExpress, configureTelemetry } from 'src/app.common';
|
import { configureExpress, configureTelemetry } from 'src/app.common';
|
||||||
import { MaintenanceModule } from 'src/app.module';
|
import { MaintenanceModule } from 'src/app.module';
|
||||||
import { SocketIoAdapter } from 'src/enum';
|
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { isStartUpError } from 'src/utils/misc';
|
import { isStartUpError } from 'src/utils/misc';
|
||||||
|
|
||||||
export async function bootstrap() {
|
async function bootstrap() {
|
||||||
process.title = 'immich-maintenance';
|
process.title = 'immich-maintenance';
|
||||||
|
|
||||||
const { inspectorPort } = workerData ?? {};
|
|
||||||
if (inspectorPort) {
|
|
||||||
inspector.open(inspectorPort, '0.0.0.0', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
configureTelemetry();
|
configureTelemetry();
|
||||||
|
|
||||||
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
||||||
@@ -25,18 +16,13 @@ export async function bootstrap() {
|
|||||||
void configureExpress(app, {
|
void configureExpress(app, {
|
||||||
permitSwaggerWrite: false,
|
permitSwaggerWrite: false,
|
||||||
ssr: MaintenanceWorkerService,
|
ssr: MaintenanceWorkerService,
|
||||||
// Use BroadcastChannel instead of Postgres adapter to avoid crash when
|
|
||||||
// pg_terminate_backend() kills all database connections during restore
|
|
||||||
socketIoAdapter: SocketIoAdapter.BroadcastChannel,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isMainThread) {
|
bootstrap().catch((error) => {
|
||||||
bootstrap().catch((error) => {
|
if (!isStartUpError(error)) {
|
||||||
if (!isStartUpError(error)) {
|
console.error(error);
|
||||||
console.error(error);
|
}
|
||||||
}
|
// eslint-disable-next-line unicorn/no-process-exit
|
||||||
|
process.exit(1);
|
||||||
process.exit(1);
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import inspector from 'node:inspector';
|
import { isMainThread } from 'node:worker_threads';
|
||||||
import { isMainThread, workerData } from 'node:worker_threads';
|
|
||||||
import { MicroservicesModule } from 'src/app.module';
|
import { MicroservicesModule } from 'src/app.module';
|
||||||
import { serverVersion } from 'src/constants';
|
import { serverVersion } from 'src/constants';
|
||||||
import { createWebSocketAdapter } from 'src/middleware/websocket.adapter';
|
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
@@ -11,11 +10,6 @@ import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
|
|||||||
import { isStartUpError } from 'src/utils/misc';
|
import { isStartUpError } from 'src/utils/misc';
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
const { inspectorPort } = workerData ?? {};
|
|
||||||
if (inspectorPort) {
|
|
||||||
inspector.open(inspectorPort, '0.0.0.0', false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { telemetry } = new ConfigRepository().getEnv();
|
const { telemetry } = new ConfigRepository().getEnv();
|
||||||
if (telemetry.metrics.size > 0) {
|
if (telemetry.metrics.size > 0) {
|
||||||
bootstrapTelemetry(telemetry.microservicesPort);
|
bootstrapTelemetry(telemetry.microservicesPort);
|
||||||
@@ -30,7 +24,7 @@ export async function bootstrap() {
|
|||||||
|
|
||||||
logger.setContext('Bootstrap');
|
logger.setContext('Bootstrap');
|
||||||
app.useLogger(logger);
|
app.useLogger(logger);
|
||||||
app.useWebSocketAdapter(await createWebSocketAdapter(app));
|
app.useWebSocketAdapter(new WebSocketAdapter(app));
|
||||||
|
|
||||||
await (host ? app.listen(0, host) : app.listen(0));
|
await (host ? app.listen(0, host) : app.listen(0));
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>)
|
|||||||
isExternal: asset.isExternal,
|
isExternal: asset.isExternal,
|
||||||
checksum: asset.checksum,
|
checksum: asset.checksum,
|
||||||
timeZone: asset.exifInfo.timeZone,
|
timeZone: asset.exifInfo.timeZone,
|
||||||
visibility: asset.visibility,
|
|
||||||
fileCreatedAt: asset.fileCreatedAt,
|
fileCreatedAt: asset.fileCreatedAt,
|
||||||
originalPath: asset.originalPath,
|
originalPath: asset.originalPath,
|
||||||
originalFileName: asset.originalFileName,
|
originalFileName: asset.originalFileName,
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
import { ClusterMessage, ClusterResponse } from 'socket.io-adapter';
|
|
||||||
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
|
|
||||||
const createMockNamespace = () => ({
|
|
||||||
name: '/',
|
|
||||||
sockets: new Map(),
|
|
||||||
adapter: null,
|
|
||||||
server: {
|
|
||||||
encoder: {
|
|
||||||
encode: vi.fn().mockReturnValue([]),
|
|
||||||
},
|
|
||||||
_opts: {},
|
|
||||||
sockets: {
|
|
||||||
sockets: new Map(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BroadcastChannelAdapter', () => {
|
|
||||||
describe('createBroadcastChannelAdapter', () => {
|
|
||||||
it('should return a factory function', () => {
|
|
||||||
const factory = createBroadcastChannelAdapter();
|
|
||||||
expect(typeof factory).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create adapter instance when factory is called', () => {
|
|
||||||
const mockNamespace = createMockNamespace();
|
|
||||||
const factory = createBroadcastChannelAdapter();
|
|
||||||
const adapter = factory(mockNamespace);
|
|
||||||
|
|
||||||
expect(adapter).toBeDefined();
|
|
||||||
expect(adapter.doPublish).toBeDefined();
|
|
||||||
expect(adapter.doPublishResponse).toBeDefined();
|
|
||||||
|
|
||||||
adapter.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BroadcastChannelAdapter message passing', () => {
|
|
||||||
it('should actually send and receive messages between two adapters', async () => {
|
|
||||||
const factory1 = createBroadcastChannelAdapter();
|
|
||||||
const factory2 = createBroadcastChannelAdapter();
|
|
||||||
|
|
||||||
const namespace1 = createMockNamespace();
|
|
||||||
const namespace2 = createMockNamespace();
|
|
||||||
|
|
||||||
const adapter1 = factory1(namespace1);
|
|
||||||
const adapter2 = factory2(namespace2);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const receivedMessages: ClusterMessage[] = [];
|
|
||||||
const messageReceived = new Promise<void>((resolve) => {
|
|
||||||
const originalOnMessage = adapter2.onMessage.bind(adapter2);
|
|
||||||
adapter2.onMessage = (message: ClusterMessage) => {
|
|
||||||
receivedMessages.push(message);
|
|
||||||
resolve();
|
|
||||||
return originalOnMessage(message);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const testMessage = {
|
|
||||||
type: 2,
|
|
||||||
data: {
|
|
||||||
opts: { rooms: new Set(['room1']) },
|
|
||||||
rooms: ['room1'],
|
|
||||||
},
|
|
||||||
nsp: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
void adapter1.doPublish(testMessage as any);
|
|
||||||
|
|
||||||
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
|
|
||||||
|
|
||||||
expect(receivedMessages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
adapter1.close();
|
|
||||||
adapter2.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send ConfigUpdate-style event and receive it on another adapter', async () => {
|
|
||||||
const factory1 = createBroadcastChannelAdapter();
|
|
||||||
const factory2 = createBroadcastChannelAdapter();
|
|
||||||
|
|
||||||
const namespace1 = createMockNamespace();
|
|
||||||
const namespace2 = createMockNamespace();
|
|
||||||
|
|
||||||
const adapter1 = factory1(namespace1);
|
|
||||||
const adapter2 = factory2(namespace2);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const receivedMessages: ClusterMessage[] = [];
|
|
||||||
const messageReceived = new Promise<void>((resolve) => {
|
|
||||||
const originalOnMessage = adapter2.onMessage.bind(adapter2);
|
|
||||||
adapter2.onMessage = (message: ClusterMessage) => {
|
|
||||||
receivedMessages.push(message);
|
|
||||||
if ((message as any)?.data?.event === 'ConfigUpdate') {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
return originalOnMessage(message);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const configUpdateMessage = {
|
|
||||||
type: 2,
|
|
||||||
data: {
|
|
||||||
event: 'ConfigUpdate',
|
|
||||||
payload: { newConfig: { ffmpeg: { crf: 23 } }, oldConfig: { ffmpeg: { crf: 20 } } },
|
|
||||||
opts: { rooms: new Set() },
|
|
||||||
rooms: [],
|
|
||||||
},
|
|
||||||
nsp: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
void adapter1.doPublish(configUpdateMessage as any);
|
|
||||||
|
|
||||||
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
|
|
||||||
|
|
||||||
const configMessages = receivedMessages.filter((m) => (m as any)?.data?.event === 'ConfigUpdate');
|
|
||||||
expect(configMessages.length).toBeGreaterThan(0);
|
|
||||||
expect((configMessages[0] as any).data.payload.newConfig.ffmpeg.crf).toBe(23);
|
|
||||||
|
|
||||||
adapter1.close();
|
|
||||||
adapter2.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send AppRestart-style event and receive it on another adapter', async () => {
|
|
||||||
const factory1 = createBroadcastChannelAdapter();
|
|
||||||
const factory2 = createBroadcastChannelAdapter();
|
|
||||||
|
|
||||||
const namespace1 = createMockNamespace();
|
|
||||||
const namespace2 = createMockNamespace();
|
|
||||||
|
|
||||||
const adapter1 = factory1(namespace1);
|
|
||||||
const adapter2 = factory2(namespace2);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const receivedMessages: ClusterMessage[] = [];
|
|
||||||
const messageReceived = new Promise<void>((resolve) => {
|
|
||||||
const originalOnMessage = adapter2.onMessage.bind(adapter2);
|
|
||||||
adapter2.onMessage = (message: ClusterMessage) => {
|
|
||||||
receivedMessages.push(message);
|
|
||||||
if ((message as any)?.data?.event === 'AppRestart') {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
return originalOnMessage(message);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const appRestartMessage = {
|
|
||||||
type: 2,
|
|
||||||
data: {
|
|
||||||
event: 'AppRestart',
|
|
||||||
payload: { isMaintenanceMode: true },
|
|
||||||
opts: { rooms: new Set() },
|
|
||||||
rooms: [],
|
|
||||||
},
|
|
||||||
nsp: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
void adapter1.doPublish(appRestartMessage as any);
|
|
||||||
|
|
||||||
await Promise.race([messageReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
|
|
||||||
|
|
||||||
const restartMessages = receivedMessages.filter((m) => (m as any)?.data?.event === 'AppRestart');
|
|
||||||
expect(restartMessages.length).toBeGreaterThan(0);
|
|
||||||
expect((restartMessages[0] as any).data.payload.isMaintenanceMode).toBe(true);
|
|
||||||
|
|
||||||
adapter1.close();
|
|
||||||
adapter2.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not receive its own messages (echo prevention)', async () => {
|
|
||||||
const factory = createBroadcastChannelAdapter();
|
|
||||||
const namespace = createMockNamespace();
|
|
||||||
const adapter = factory(namespace);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const receivedOwnMessages: ClusterMessage[] = [];
|
|
||||||
const uniqueMarker = `test-${Date.now()}-${Math.random()}`;
|
|
||||||
|
|
||||||
const originalOnMessage = adapter.onMessage.bind(adapter);
|
|
||||||
adapter.onMessage = (message: ClusterMessage) => {
|
|
||||||
if ((message as any)?.data?.marker === uniqueMarker) {
|
|
||||||
receivedOwnMessages.push(message);
|
|
||||||
}
|
|
||||||
return originalOnMessage(message);
|
|
||||||
};
|
|
||||||
|
|
||||||
const testMessage = {
|
|
||||||
type: 2,
|
|
||||||
data: {
|
|
||||||
marker: uniqueMarker,
|
|
||||||
opts: { rooms: new Set() },
|
|
||||||
rooms: [],
|
|
||||||
},
|
|
||||||
nsp: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
void adapter.doPublish(testMessage as any);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
|
|
||||||
expect(receivedOwnMessages.length).toBe(0);
|
|
||||||
|
|
||||||
adapter.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should send and receive response messages between adapters', async () => {
|
|
||||||
const factory1 = createBroadcastChannelAdapter();
|
|
||||||
const factory2 = createBroadcastChannelAdapter();
|
|
||||||
|
|
||||||
const namespace1 = createMockNamespace();
|
|
||||||
const namespace2 = createMockNamespace();
|
|
||||||
|
|
||||||
const adapter1 = factory1(namespace1);
|
|
||||||
const adapter2 = factory2(namespace2);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const receivedResponses: ClusterResponse[] = [];
|
|
||||||
const responseReceived = new Promise<void>((resolve) => {
|
|
||||||
const originalOnResponse = adapter1.onResponse.bind(adapter1);
|
|
||||||
adapter1.onResponse = (response: ClusterResponse) => {
|
|
||||||
receivedResponses.push(response);
|
|
||||||
resolve();
|
|
||||||
return originalOnResponse(response);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const responseMessage = {
|
|
||||||
type: 3,
|
|
||||||
data: { result: 'success', count: 42 },
|
|
||||||
};
|
|
||||||
|
|
||||||
void adapter2.doPublishResponse((adapter1 as any).uid, responseMessage as any);
|
|
||||||
|
|
||||||
await Promise.race([responseReceived, new Promise((resolve) => setTimeout(resolve, 500))]);
|
|
||||||
|
|
||||||
expect(receivedResponses.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
adapter1.close();
|
|
||||||
adapter2.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BroadcastChannelAdapter lifecycle', () => {
|
|
||||||
it('should close cleanly without errors', () => {
|
|
||||||
const factory = createBroadcastChannelAdapter();
|
|
||||||
const namespace = createMockNamespace();
|
|
||||||
const adapter = factory(namespace);
|
|
||||||
|
|
||||||
expect(() => adapter.close()).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple adapters closing in sequence', () => {
|
|
||||||
const factory1 = createBroadcastChannelAdapter();
|
|
||||||
const factory2 = createBroadcastChannelAdapter();
|
|
||||||
const factory3 = createBroadcastChannelAdapter();
|
|
||||||
|
|
||||||
const adapter1 = factory1(createMockNamespace());
|
|
||||||
const adapter2 = factory2(createMockNamespace());
|
|
||||||
const adapter3 = factory3(createMockNamespace());
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
adapter1.close();
|
|
||||||
adapter2.close();
|
|
||||||
adapter3.close();
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { Server } from 'socket.io';
|
|
||||||
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
|
|
||||||
import { EventRepository } from 'src/repositories/event.repository';
|
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
|
||||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
|
||||||
import { automock } from 'test/utils';
|
|
||||||
import { vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('WebSocket Integration - serverSend with adapters', () => {
|
|
||||||
describe('BroadcastChannel adapter', () => {
|
|
||||||
it('should broadcast ConfigUpdate event through BroadcastChannel adapter', async () => {
|
|
||||||
const createMockNamespace = () => ({
|
|
||||||
name: '/',
|
|
||||||
sockets: new Map(),
|
|
||||||
adapter: null,
|
|
||||||
server: {
|
|
||||||
encoder: { encode: vi.fn().mockReturnValue([]) },
|
|
||||||
_opts: {},
|
|
||||||
sockets: { sockets: new Map() },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const factory1 = createBroadcastChannelAdapter();
|
|
||||||
const factory2 = createBroadcastChannelAdapter();
|
|
||||||
|
|
||||||
const namespace1 = createMockNamespace();
|
|
||||||
const namespace2 = createMockNamespace();
|
|
||||||
|
|
||||||
const adapter1 = factory1(namespace1);
|
|
||||||
const adapter2 = factory2(namespace2);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const receivedMessages: any[] = [];
|
|
||||||
vi.spyOn(adapter2, 'onMessage').mockImplementation((message: any) => {
|
|
||||||
receivedMessages.push(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
const configUpdatePayload = {
|
|
||||||
type: 5,
|
|
||||||
data: {
|
|
||||||
event: 'ConfigUpdate',
|
|
||||||
args: [{ newConfig: { ffmpeg: { crf: 23 } }, oldConfig: { ffmpeg: { crf: 20 } } }],
|
|
||||||
},
|
|
||||||
nsp: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
void adapter1.doPublish(configUpdatePayload as any);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const configMessages = receivedMessages.filter((m) => m?.data?.event === 'ConfigUpdate');
|
|
||||||
expect(configMessages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
adapter1.close();
|
|
||||||
adapter2.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should broadcast AppRestart event through BroadcastChannel adapter', async () => {
|
|
||||||
const createMockNamespace = () => ({
|
|
||||||
name: '/',
|
|
||||||
sockets: new Map(),
|
|
||||||
adapter: null,
|
|
||||||
server: {
|
|
||||||
encoder: { encode: vi.fn().mockReturnValue([]) },
|
|
||||||
_opts: {},
|
|
||||||
sockets: { sockets: new Map() },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const factory1 = createBroadcastChannelAdapter();
|
|
||||||
const factory2 = createBroadcastChannelAdapter();
|
|
||||||
|
|
||||||
const namespace1 = createMockNamespace();
|
|
||||||
const namespace2 = createMockNamespace();
|
|
||||||
|
|
||||||
const adapter1 = factory1(namespace1);
|
|
||||||
const adapter2 = factory2(namespace2);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const receivedMessages: any[] = [];
|
|
||||||
vi.spyOn(adapter2, 'onMessage').mockImplementation((message: any) => {
|
|
||||||
receivedMessages.push(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
const appRestartPayload = {
|
|
||||||
type: 5,
|
|
||||||
data: {
|
|
||||||
event: 'AppRestart',
|
|
||||||
args: [{ isMaintenanceMode: true }],
|
|
||||||
},
|
|
||||||
nsp: '/',
|
|
||||||
};
|
|
||||||
|
|
||||||
void adapter1.doPublish(appRestartPayload as any);
|
|
||||||
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
const restartMessages = receivedMessages.filter((m) => m?.data?.event === 'AppRestart');
|
|
||||||
expect(restartMessages.length).toBeGreaterThan(0);
|
|
||||||
|
|
||||||
adapter1.close();
|
|
||||||
adapter2.close();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('WebsocketRepository with adapter', () => {
|
|
||||||
it('should call serverSideEmit when serverSend is called', () => {
|
|
||||||
const mockServer = {
|
|
||||||
serverSideEmit: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
} as unknown as Server;
|
|
||||||
|
|
||||||
const eventRepository = automock(EventRepository, {
|
|
||||||
args: [undefined, undefined, { setContext: () => {} }],
|
|
||||||
});
|
|
||||||
const loggingRepository = automock(LoggingRepository, {
|
|
||||||
args: [undefined, { getEnv: () => ({ noColor: false }) }],
|
|
||||||
strict: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const websocketRepository = new WebsocketRepository(eventRepository, loggingRepository);
|
|
||||||
(websocketRepository as any).server = mockServer;
|
|
||||||
|
|
||||||
websocketRepository.serverSend('ConfigUpdate', {
|
|
||||||
newConfig: { ffmpeg: { crf: 23 } } as any,
|
|
||||||
oldConfig: { ffmpeg: { crf: 20 } } as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockServer.serverSideEmit).toHaveBeenCalledWith('ConfigUpdate', {
|
|
||||||
newConfig: { ffmpeg: { crf: 23 } },
|
|
||||||
oldConfig: { ffmpeg: { crf: 20 } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call serverSideEmit for AppRestart event', () => {
|
|
||||||
const mockServer = {
|
|
||||||
serverSideEmit: vi.fn(),
|
|
||||||
on: vi.fn(),
|
|
||||||
} as unknown as Server;
|
|
||||||
|
|
||||||
const eventRepository = automock(EventRepository, {
|
|
||||||
args: [undefined, undefined, { setContext: () => {} }],
|
|
||||||
});
|
|
||||||
const loggingRepository = automock(LoggingRepository, {
|
|
||||||
args: [undefined, { getEnv: () => ({ noColor: false }) }],
|
|
||||||
strict: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const websocketRepository = new WebsocketRepository(eventRepository, loggingRepository);
|
|
||||||
(websocketRepository as any).server = mockServer;
|
|
||||||
|
|
||||||
websocketRepository.serverSend('AppRestart', { isMaintenanceMode: true });
|
|
||||||
|
|
||||||
expect(mockServer.serverSideEmit).toHaveBeenCalledWith('AppRestart', { isMaintenanceMode: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
|
||||||
import { IoAdapter } from '@nestjs/platform-socket.io';
|
|
||||||
import { SocketIoAdapter } from 'src/enum';
|
|
||||||
import { asPgPoolSsl, createWebSocketAdapter } from 'src/middleware/websocket.adapter';
|
|
||||||
import { Mocked, vi } from 'vitest';
|
|
||||||
|
|
||||||
describe('asPgPoolSsl', () => {
|
|
||||||
it('should return false for undefined ssl', () => {
|
|
||||||
expect(asPgPoolSsl()).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for ssl = false', () => {
|
|
||||||
expect(asPgPoolSsl(false)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for ssl = "allow"', () => {
|
|
||||||
expect(asPgPoolSsl('allow')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return { rejectUnauthorized: false } for ssl = true', () => {
|
|
||||||
expect(asPgPoolSsl(true)).toEqual({ rejectUnauthorized: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return { rejectUnauthorized: false } for ssl = "prefer"', () => {
|
|
||||||
expect(asPgPoolSsl('prefer')).toEqual({ rejectUnauthorized: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return { rejectUnauthorized: false } for ssl = "require"', () => {
|
|
||||||
expect(asPgPoolSsl('require')).toEqual({ rejectUnauthorized: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return { rejectUnauthorized: true } for ssl = "verify-full"', () => {
|
|
||||||
expect(asPgPoolSsl('verify-full')).toEqual({ rejectUnauthorized: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass through object ssl config unchanged', () => {
|
|
||||||
const sslConfig = { ca: 'certificate', rejectUnauthorized: true };
|
|
||||||
expect(asPgPoolSsl(sslConfig)).toBe(sslConfig);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createWebSocketAdapter', () => {
|
|
||||||
let mockApp: Mocked<INestApplication>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
mockApp = {
|
|
||||||
getHttpServer: vi.fn().mockReturnValue({}),
|
|
||||||
} as unknown as Mocked<INestApplication>;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('BroadcastChannel adapter', () => {
|
|
||||||
it('should create BroadcastChannel adapter when configured', async () => {
|
|
||||||
const adapter = await createWebSocketAdapter(mockApp, SocketIoAdapter.BroadcastChannel);
|
|
||||||
|
|
||||||
expect(adapter).toBeDefined();
|
|
||||||
expect(adapter).toBeInstanceOf(IoAdapter);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Postgres adapter', () => {
|
|
||||||
it('should create Postgres adapter when configured', async () => {
|
|
||||||
const adapter = await createWebSocketAdapter(mockApp, SocketIoAdapter.Postgres);
|
|
||||||
|
|
||||||
expect(adapter).toBeDefined();
|
|
||||||
expect(adapter).toBeInstanceOf(IoAdapter);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat, SocketIoAdapter } from 'src/enum';
|
import { DatabaseExtension, ImmichEnvironment, ImmichWorker, LogFormat } from 'src/enum';
|
||||||
import { ConfigRepository, EnvData } from 'src/repositories/config.repository';
|
import { ConfigRepository, EnvData } from 'src/repositories/config.repository';
|
||||||
import { RepositoryInterface } from 'src/types';
|
import { RepositoryInterface } from 'src/types';
|
||||||
import { Mocked, vitest } from 'vitest';
|
import { Mocked, vitest } from 'vitest';
|
||||||
@@ -99,10 +99,6 @@ const envData: EnvData = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
socketIo: {
|
|
||||||
adapter: SocketIoAdapter.Postgres,
|
|
||||||
},
|
|
||||||
|
|
||||||
noColor: false,
|
noColor: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -98,7 +98,7 @@
|
|||||||
"prettier-plugin-sort-json": "^4.1.1",
|
"prettier-plugin-sort-json": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"rollup-plugin-visualizer": "^6.0.0",
|
"rollup-plugin-visualizer": "^6.0.0",
|
||||||
"svelte": "5.53.5",
|
"svelte": "5.53.0",
|
||||||
"svelte-check": "^4.1.5",
|
"svelte-check": "^4.1.5",
|
||||||
"svelte-eslint-parser": "^1.3.3",
|
"svelte-eslint-parser": "^1.3.3",
|
||||||
"tailwindcss": "^4.1.7",
|
"tailwindcss": "^4.1.7",
|
||||||
|
|||||||
@@ -140,7 +140,7 @@
|
|||||||
</ControlAppBar>
|
</ControlAppBar>
|
||||||
{/if}
|
{/if}
|
||||||
<section class="my-40 mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
|
<section class="my-40 mx-4" bind:clientHeight={viewport.height} bind:clientWidth={viewport.width}>
|
||||||
<GalleryViewer {assets} {assetInteraction} {viewport} allowDeletion={false} />
|
<GalleryViewer {assets} {assetInteraction} {viewport} />
|
||||||
</section>
|
</section>
|
||||||
{:else if assets.length === 1}
|
{:else if assets.length === 1}
|
||||||
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
|
{#await getAssetInfo({ ...authManager.params, id: assets[0].id }) then asset}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@
|
|||||||
pageHeaderOffset?: number;
|
pageHeaderOffset?: number;
|
||||||
slidingWindowOffset?: number;
|
slidingWindowOffset?: number;
|
||||||
arrowNavigation?: boolean;
|
arrowNavigation?: boolean;
|
||||||
allowDeletion?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -61,7 +60,6 @@
|
|||||||
slidingWindowOffset = 0,
|
slidingWindowOffset = 0,
|
||||||
pageHeaderOffset = 0,
|
pageHeaderOffset = 0,
|
||||||
arrowNavigation = true,
|
arrowNavigation = true,
|
||||||
allowDeletion = true,
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
let { isViewing: isViewerOpen, asset: viewingAsset } = assetViewingStore;
|
||||||
@@ -275,15 +273,11 @@
|
|||||||
if (assetInteraction.selectionActive) {
|
if (assetInteraction.selectionActive) {
|
||||||
shortcuts.push(
|
shortcuts.push(
|
||||||
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
|
{ shortcut: { key: 'Escape' }, onShortcut: deselectAllAssets },
|
||||||
{ shortcut: { key: 'D', ctrl: true }, onShortcut: deselectAllAssets },
|
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
||||||
|
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
||||||
|
{ shortcut: { key: 'D', ctrl: true }, onShortcut: () => deselectAllAssets() },
|
||||||
|
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
||||||
);
|
);
|
||||||
if (allowDeletion) {
|
|
||||||
shortcuts.push(
|
|
||||||
{ shortcut: { key: 'Delete' }, onShortcut: onDelete },
|
|
||||||
{ shortcut: { key: 'Delete', shift: true }, onShortcut: () => trashOrDelete(true) },
|
|
||||||
{ shortcut: { key: 'a', shift: true }, onShortcut: toggleArchive },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return shortcuts;
|
return shortcuts;
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
|
||||||
import { getAssetBulkActions } from '$lib/services/asset.service';
|
import { getAssetBulkActions } from '$lib/services/asset.service';
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { mapSettings } from '$lib/stores/preferences.store';
|
import { locale, mapSettings } from '$lib/stores/preferences.store';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
import {
|
import {
|
||||||
updateStackedAssetInTimeline,
|
updateStackedAssetInTimeline,
|
||||||
@@ -90,6 +90,8 @@
|
|||||||
assetFilter: selectedClusterIds,
|
assetFilter: selectedClusterIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const displayedAssetCount = $derived(timelineManager?.assetCount ?? assetCount);
|
||||||
|
|
||||||
$effect.pre(() => {
|
$effect.pre(() => {
|
||||||
void timelineOptions;
|
void timelineOptions;
|
||||||
assetInteraction.clearMultiselect();
|
assetInteraction.clearMultiselect();
|
||||||
@@ -101,7 +103,8 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Icon icon={mdiImageMultiple} size="20" />
|
<Icon icon={mdiImageMultiple} size="20" />
|
||||||
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
|
<p class="text-sm font-medium text-immich-fg dark:text-immich-dark-fg">
|
||||||
{$t('assets_count', { values: { count: assetCount } })}
|
{displayedAssetCount.toLocaleString($locale)}
|
||||||
|
{$t('assets')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CloseButton onclick={onClose} />
|
<CloseButton onclick={onClose} />
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
|
||||||
import { handleRemoveSharedLinkAssets } from '$lib/services/shared-link.service';
|
import { handleRemoveSharedLinkAssets } from '$lib/services/shared-link.service';
|
||||||
import { getAssetControlContext } from '$lib/utils/context';
|
import { getAssetControlContext } from '$lib/utils/context';
|
||||||
import { type SharedLinkResponseDto } from '@immich/sdk';
|
import { type SharedLinkResponseDto } from '@immich/sdk';
|
||||||
@@ -24,8 +23,6 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:document use:shortcut={{ shortcut: { key: 'Delete' }, onShortcut: handleSelect }} />
|
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
shape="round"
|
shape="round"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export const handleUploadDatabaseBackup = async () => {
|
|||||||
try {
|
try {
|
||||||
const [file] = await openFilePicker({ multiple: false });
|
const [file] = await openFilePicker({ multiple: false });
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('backup', file);
|
||||||
|
|
||||||
await uploadRequest<DatabaseBackupUploadDto>({
|
await uploadRequest<DatabaseBackupUploadDto>({
|
||||||
url: getBaseUrl() + '/admin/database-backups/upload',
|
url: getBaseUrl() + '/admin/database-backups/upload',
|
||||||
|
|||||||
Reference in New Issue
Block a user