Compare commits

..

1 Commits

Author SHA1 Message Date
Jonathan Jogenfors
7eac0847f6 feat: add checksum algorithm field 2026-02-27 15:40:56 +01:00
76 changed files with 691 additions and 1690 deletions

View File

@@ -11,7 +11,6 @@ services:
immich-server:
container_name: immich-e2e-server
image: immich-server:latest
shm_size: 128mb
build:
context: ../
dockerfile: server/Dockerfile

View File

@@ -1 +0,0 @@
3.13

View File

@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:3de9a8d7aedbb7984dc18f2dff178a7850f16c1ae7c3
RUN apt-get update && \
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.28.4/intel-igc-opencl-2_2.28.4+20760_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/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.27.10/intel-igc-opencl-2_2.27.10+20617_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-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 && \
# 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 && \
rm *.deb && \
apt-get remove wget -yqq && \

View File

@@ -49,7 +49,7 @@ dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[project.optional-dependencies]
cpu = ["onnxruntime>=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"]
rknn = ["onnxruntime>=1.23.2,<2", "rknn-toolkit-lite2>=2.3.0,<3"]
rocm = ["onnxruntime-migraphx>=1.23.2,<2"]

View File

@@ -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" },
]
[[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]]
name = "colorlog"
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" },
]
[[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]]
name = "idna"
version = "3.11"
@@ -993,7 +1017,7 @@ requires-dist = [
{ name = "onnxruntime", marker = "extra == 'rknn'", 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-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 = "orjson", specifier = ">=3.9.5" },
{ name = "pillow", specifier = ">=12.1.1,<12.2" },
@@ -1724,9 +1748,10 @@ wheels = [
[[package]]
name = "onnxruntime-openvino"
version = "1.24.1"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coloredlogs" },
{ name = "flatbuffers" },
{ name = "numpy" },
{ name = "packaging" },
@@ -1734,12 +1759,12 @@ dependencies = [
{ name = "sympy" },
]
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/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/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/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/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/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/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/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/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/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/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/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]]
@@ -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" },
]
[[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]]
name = "pytest"
version = "9.0.2"

View File

@@ -59,7 +59,7 @@ private open class LocalImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
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 getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit)
@@ -82,8 +82,7 @@ interface LocalImageApi {
val widthArg = args[2] as Long
val heightArg = args[3] as Long
val isVideoArg = args[4] as Boolean
val preferEncodedArg = args[5] as Boolean
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
api.requestImage(assetIdArg, requestIdArg, widthArg, heightArg, isVideoArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(LocalImagesPigeonUtils.wrapError(error))

View File

@@ -14,7 +14,6 @@ import android.util.Size
import androidx.annotation.RequiresApi
import app.alextran.immich.NativeBuffer
import kotlin.math.*
import java.io.IOException
import java.util.concurrent.Executors
import com.bumptech.glide.Glide
import com.bumptech.glide.Priority
@@ -100,17 +99,12 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
width: Long,
height: Long,
isVideo: Boolean,
preferEncoded: Boolean,
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()
val task = threadPool.submit {
try {
if (preferEncoded) {
getEncodedImageInternal(assetId, callback, signal)
} else {
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
}
getThumbnailBufferInternal(assetId, width, height, isVideo, callback, signal)
} catch (e: Exception) {
when (e) {
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(
assetId: String,
width: Long,

View File

@@ -47,7 +47,7 @@ private open class RemoteImagesPigeonCodec : StandardMessageCodec() {
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
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 clearCache(callback: (Result<Long>) -> Unit)
@@ -68,8 +68,7 @@ interface RemoteImageApi {
val urlArg = args[0] as String
val headersArg = args[1] as Map<String, String>
val requestIdArg = args[2] as Long
val preferEncodedArg = args[3] as Boolean
api.requestImage(urlArg, headersArg, requestIdArg, preferEncodedArg) { result: Result<Map<String, Long>?> ->
api.requestImage(urlArg, headersArg, requestIdArg) { result: Result<Map<String, Long>?> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(RemoteImagesPigeonUtils.wrapError(error))

View File

@@ -51,7 +51,6 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
url: String,
headers: Map<String, String>,
requestId: Long,
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
callback: (Result<Map<String, Long>?>) -> Unit
) {
val signal = CancellationSignal()

View File

@@ -78,21 +78,6 @@ class FlutterError (
val details: Any? = null
) : 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. */
data class PlatformAsset (
val id: String,
@@ -107,8 +92,7 @@ data class PlatformAsset (
val isFavorite: Boolean,
val adjustmentTime: Long? = null,
val latitude: Double? = null,
val longitude: Double? = null,
val playbackStyle: PlatformAssetPlaybackStyle
val longitude: Double? = null
)
{
companion object {
@@ -126,8 +110,7 @@ data class PlatformAsset (
val adjustmentTime = pigeonVar_list[10] as Long?
val latitude = pigeonVar_list[11] 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, playbackStyle)
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTime, latitude, longitude)
}
}
fun toList(): List<Any?> {
@@ -145,7 +128,6 @@ data class PlatformAsset (
adjustmentTime,
latitude,
longitude,
playbackStyle,
)
}
override fun equals(other: Any?): Boolean {
@@ -308,31 +290,26 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
129.toByte() -> {
return (readValue(buffer) as Long?)?.let {
PlatformAssetPlaybackStyle.ofRaw(it.toInt())
}
}
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAsset.fromList(it)
}
}
131.toByte() -> {
130.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
PlatformAlbum.fromList(it)
}
}
132.toByte() -> {
131.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
SyncDelta.fromList(it)
}
}
133.toByte() -> {
132.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
HashResult.fromList(it)
}
}
134.toByte() -> {
133.toByte() -> {
return (readValue(buffer) as? List<Any?>)?.let {
CloudIdResult.fromList(it)
}
@@ -342,28 +319,24 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
when (value) {
is PlatformAssetPlaybackStyle -> {
stream.write(129)
writeValue(stream, value.raw)
}
is PlatformAsset -> {
stream.write(130)
stream.write(129)
writeValue(stream, value.toList())
}
is PlatformAlbum -> {
stream.write(131)
stream.write(130)
writeValue(stream, value.toList())
}
is SyncDelta -> {
stream.write(132)
stream.write(131)
writeValue(stream, value.toList())
}
is HashResult -> {
stream.write(133)
stream.write(132)
writeValue(stream, value.toList())
}
is CloudIdResult -> {
stream.write(134)
stream.write(133)
writeValue(stream, value.toList())
}
else -> super.writeValue(stream, value)

View File

@@ -4,17 +4,11 @@ import android.annotation.SuppressLint
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import androidx.exifinterface.media.ExifInterface
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import androidx.core.database.getStringOrNull
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.Dispatchers
import kotlinx.coroutines.Job
@@ -34,8 +28,6 @@ sealed class AssetResult {
data class InvalidAsset(val assetId: String) : AssetResult()
}
private const val TAG = "NativeSyncApiImplBase"
@SuppressLint("InlinedApi")
open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
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 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 =
"(${MediaStore.Files.FileColumns.MEDIA_TYPE} = ? OR ${MediaStore.Files.FileColumns.MEDIA_TYPE} = ?)"
val MEDIA_SELECTION_ARGS = arrayOf(
@@ -75,15 +60,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
add(MediaStore.MediaColumns.DURATION)
add(MediaStore.MediaColumns.ORIENTATION)
// 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)
}
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()
const val HASH_BUFFER_SIZE = 2 * 1024 * 1024
@@ -130,12 +109,9 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
val orientationColumn =
c.getColumnIndexOrThrow(MediaStore.MediaColumns.ORIENTATION)
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()) {
val numericId = c.getLong(idColumn)
val id = numericId.toString()
val id = c.getLong(idColumn).toString()
val name = c.getStringOrNull(nameColumn)
val bucketId = c.getStringOrNull(bucketIdColumn)
val path = c.getStringOrNull(dataColumn)
@@ -149,11 +125,10 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
continue
}
val rawMediaType = c.getInt(mediaTypeColumn)
val assetType: Long = when (rawMediaType) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1L
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2L
else -> 0L
val mediaType = when (c.getInt(mediaTypeColumn)) {
MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE -> 1
MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO -> 2
else -> 0
}
// Date taken is milliseconds since epoch, Date added is seconds since epoch
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 height = c.getInt(heightColumn).toLong()
// 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
val orientation = c.getInt(orientationColumn)
val isFavorite = if (favoriteColumn == -1) false else c.getInt(favoriteColumn) != 0
val playbackStyle = detectPlaybackStyle(
numericId, rawMediaType, specialFormatColumn, xmpColumn, c
)
val asset = PlatformAsset(
id,
name,
assetType,
mediaType.toLong(),
createdAt,
modifiedAt,
width,
@@ -183,7 +154,6 @@ open class NativeSyncApiImplBase(context: Context) : ImmichPlugin() {
duration,
orientation.toLong(),
isFavorite,
playbackStyle = playbackStyle,
)
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> {
val albums = mutableListOf<PlatformAlbum>()
val albumsCount = mutableMapOf<String, Int>()

View File

@@ -70,7 +70,7 @@ class LocalImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable {
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
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 getThumbhash(thumbhash: String, completion: @escaping (Result<[String: Int64], Error>) -> Void)
}
@@ -90,8 +90,7 @@ class LocalImageApiSetup {
let widthArg = args[2] as! Int64
let heightArg = args[3] as! Int64
let isVideoArg = args[4] as! Bool
let preferEncodedArg = args[5] as! Bool
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg, preferEncoded: preferEncodedArg) { result in
api.requestImage(assetId: assetIdArg, requestId: requestIdArg, width: widthArg, height: heightArg, isVideo: isVideoArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))

View File

@@ -7,7 +7,7 @@ class LocalImageRequest {
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64]?, any Error>) -> Void
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
@@ -30,11 +30,11 @@ class LocalImageApiImpl: LocalImageApi {
requestOptions.version = .current
return requestOptions
}()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
@@ -48,12 +48,12 @@ class LocalImageApiImpl: LocalImageApi {
assetCache.countLimit = 10000
return assetCache
}()
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
ImageProcessing.queue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
completion(.success([
"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 item = DispatchWorkItem {
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
ImageProcessing.semaphore.wait()
defer {
ImageProcessing.semaphore.signal()
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
if request.isCancelled {
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?
Self.imageManager.requestImage(
for: asset,
@@ -144,29 +101,29 @@ class LocalImageApiImpl: LocalImageApi {
image = _image
}
)
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
if request.isCancelled {
return completion(ImageProcessing.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
return completion(ImageProcessing.cancelledResult)
}
request.callback(.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
"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)))
}
}
request.workItem = item
Self.add(requestId: requestId, request: request)
ImageProcessing.queue.async(execute: item)
}
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
@@ -207,12 +164,12 @@ class LocalImageApiImpl: LocalImageApi {
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }

View File

@@ -70,7 +70,7 @@ class RemoteImagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable
/// Generated protocol from Pigeon that represents a handler of messages from Flutter.
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 clearCache(completion: @escaping (Result<Int64, Error>) -> Void)
}
@@ -88,8 +88,7 @@ class RemoteImageApiSetup {
let urlArg = args[0] as! String
let headersArg = args[1] as! [String: String]
let requestIdArg = args[2] as! Int64
let preferEncodedArg = args[3] as! Bool
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg, preferEncoded: preferEncodedArg) { result in
api.requestImage(url: urlArg, headers: headersArg, requestId: requestIdArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))

View File

@@ -8,7 +8,7 @@ class RemoteImageRequest {
let id: Int64
var isCancelled = false
let completion: (Result<[String: Int64]?, any Error>) -> Void
init(id: Int64, task: URLSessionDataTask, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.id = id
self.task = task
@@ -32,93 +32,75 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceCreateThumbnailFromImageAlways: true
] 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)!)
urlRequest.cachePolicy = .returnCacheDataElseLoad
for (key, value) in headers {
urlRequest.setValue(value, forHTTPHeaderField: key)
}
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)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
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)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
}
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
return request.completion(.failure(error))
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
guard let data = data else {
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
}
ImageProcessing.queue.async {
ImageProcessing.semaphore.wait()
defer { ImageProcessing.semaphore.signal() }
if request.isCancelled {
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),
let cgImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, decodeOptions) else {
return request.completion(.failure(PigeonError(code: "", message: "Failed to decode image for request", details: nil)))
}
if request.isCancelled {
return request.completion(ImageProcessing.cancelledResult)
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
if request.isCancelled {
buffer.free()
return request.completion(ImageProcessing.cancelledResult)
}
request.completion(
.success([
"pointer": Int64(Int(bitPattern: buffer.data)),
@@ -131,17 +113,17 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
}
}
func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
Task {
let cache = URLSessionManager.shared.session.configuration.urlCache!

View File

@@ -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.
struct PlatformAsset: Hashable {
var id: String
@@ -152,7 +143,6 @@ struct PlatformAsset: Hashable {
var adjustmentTime: Int64? = nil
var latitude: Double? = nil
var longitude: Double? = nil
var playbackStyle: PlatformAssetPlaybackStyle
// swift-format-ignore: AlwaysUseLowerCamelCase
@@ -170,7 +160,6 @@ struct PlatformAsset: Hashable {
let adjustmentTime: Int64? = nilOrValue(pigeonVar_list[10])
let latitude: Double? = nilOrValue(pigeonVar_list[11])
let longitude: Double? = nilOrValue(pigeonVar_list[12])
let playbackStyle = pigeonVar_list[13] as! PlatformAssetPlaybackStyle
return PlatformAsset(
id: id,
@@ -185,8 +174,7 @@ struct PlatformAsset: Hashable {
isFavorite: isFavorite,
adjustmentTime: adjustmentTime,
latitude: latitude,
longitude: longitude,
playbackStyle: playbackStyle
longitude: longitude
)
}
func toList() -> [Any?] {
@@ -204,7 +192,6 @@ struct PlatformAsset: Hashable {
adjustmentTime,
latitude,
longitude,
playbackStyle,
]
}
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {
@@ -362,20 +349,14 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
override func readValue(ofType type: UInt8) -> Any? {
switch type {
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?])
case 131:
case 130:
return PlatformAlbum.fromList(self.readValue() as! [Any?])
case 132:
case 131:
return SyncDelta.fromList(self.readValue() as! [Any?])
case 133:
case 132:
return HashResult.fromList(self.readValue() as! [Any?])
case 134:
case 133:
return CloudIdResult.fromList(self.readValue() as! [Any?])
default:
return super.readValue(ofType: type)
@@ -385,23 +366,20 @@ private class MessagesPigeonCodecReader: FlutterStandardReader {
private class MessagesPigeonCodecWriter: FlutterStandardWriter {
override func writeValue(_ value: Any) {
if let value = value as? PlatformAssetPlaybackStyle {
if let value = value as? PlatformAsset {
super.writeByte(129)
super.writeValue(value.rawValue)
} else if let value = value as? PlatformAsset {
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? PlatformAlbum {
super.writeByte(131)
super.writeByte(130)
super.writeValue(value.toList())
} else if let value = value as? SyncDelta {
super.writeByte(132)
super.writeByte(131)
super.writeValue(value.toList())
} else if let value = value as? HashResult {
super.writeByte(133)
super.writeByte(132)
super.writeValue(value.toList())
} else if let value = value as? CloudIdResult {
super.writeByte(134)
super.writeByte(133)
super.writeValue(value.toList())
} else {
super.writeValue(value)

View File

@@ -173,8 +173,7 @@ class NativeSyncApiImpl: ImmichPlugin, NativeSyncApi, FlutterPlugin {
type: 0,
durationInSeconds: 0,
orientation: 0,
isFavorite: false,
playbackStyle: .unknown
isFavorite: false
)
if (updatedAssets.contains(AssetWrapper(with: predicate))) {
continue

View File

@@ -1,17 +1,6 @@
import Photos
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 {
return PlatformAsset(
id: localIdentifier,
@@ -26,8 +15,7 @@ extension PHAsset {
isFavorite: isFavorite,
adjustmentTime: adjustmentTimestamp,
latitude: location?.coordinate.latitude,
longitude: location?.coordinate.longitude,
playbackStyle: platformPlaybackStyle
longitude: location?.coordinate.longitude
)
}
@@ -38,7 +26,7 @@ extension PHAsset {
var filename: String? {
return value(forKey: "filename") as? String
}
var adjustmentTimestamp: Int64? {
if let date = value(forKey: "adjustmentTimestamp") as? Date {
return Int64(date.timeIntervalSince1970)

View File

@@ -24,8 +24,6 @@ abstract class ImageRequest {
Future<ImageInfo?> load(ImageDecoderCallback decode, {double scale = 1.0});
Future<ui.Codec?> loadCodec();
void cancel() {
if (_isCancelled) {
return;
@@ -36,7 +34,7 @@ abstract class ImageRequest {
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);
if (_isCancelled) {
malloc.free(pointer);
@@ -69,20 +67,6 @@ abstract class ImageRequest {
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();
descriptor.dispose();
codec.dispose();

View File

@@ -22,7 +22,6 @@ class LocalImageRequest extends ImageRequest {
width: width,
height: height,
isVideo: assetType == AssetType.video,
preferEncoded: false,
);
if (info == null) {
return null;
@@ -32,26 +31,6 @@ class LocalImageRequest extends ImageRequest {
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
Future<void> _onCancelled() {
return localImageApi.cancelRequest(requestId);

View File

@@ -12,8 +12,7 @@ class RemoteImageRequest extends ImageRequest {
return null;
}
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId, preferEncoded: false);
// Android always returns encoded data, so we need to check for both shapes of the response.
final info = await remoteImageApi.requestImage(uri, headers: headers, requestId: requestId);
final frame = switch (info) {
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
{'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);
}
@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
Future<void> _onCancelled() {
return remoteImageApi.cancelRequest(requestId);

View File

@@ -16,9 +16,6 @@ class ThumbhashImageRequest extends ImageRequest {
return frame == null ? null : ImageInfo(image: frame.image, scale: scale);
}
@override
Future<ui.Codec?> loadCodec() => throw UnsupportedError('Thumbhash does not support codec loading');
@override
void _onCancelled() {}
}

View File

@@ -55,7 +55,6 @@ class LocalImageApi {
required int width,
required int height,
required bool isVideo,
required bool preferEncoded,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.LocalImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -70,7 +69,6 @@ class LocalImageApi {
width,
height,
isVideo,
preferEncoded,
]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {

View File

@@ -29,8 +29,6 @@ bool _deepEquals(Object? a, Object? b) {
return a == b;
}
enum PlatformAssetPlaybackStyle { unknown, image, video, imageAnimated, livePhoto, videoLooping }
class PlatformAsset {
PlatformAsset({
required this.id,
@@ -46,7 +44,6 @@ class PlatformAsset {
this.adjustmentTime,
this.latitude,
this.longitude,
required this.playbackStyle,
});
String id;
@@ -75,8 +72,6 @@ class PlatformAsset {
double? longitude;
PlatformAssetPlaybackStyle playbackStyle;
List<Object?> _toList() {
return <Object?>[
id,
@@ -92,7 +87,6 @@ class PlatformAsset {
adjustmentTime,
latitude,
longitude,
playbackStyle,
];
}
@@ -116,7 +110,6 @@ class PlatformAsset {
adjustmentTime: result[10] as int?,
latitude: result[11] as double?,
longitude: result[12] as double?,
playbackStyle: result[13]! as PlatformAssetPlaybackStyle,
);
}
@@ -323,23 +316,20 @@ class _PigeonCodec extends StandardMessageCodec {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is PlatformAssetPlaybackStyle) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else if (value is PlatformAsset) {
buffer.putUint8(130);
buffer.putUint8(129);
writeValue(buffer, value.encode());
} else if (value is PlatformAlbum) {
buffer.putUint8(131);
buffer.putUint8(130);
writeValue(buffer, value.encode());
} else if (value is SyncDelta) {
buffer.putUint8(132);
buffer.putUint8(131);
writeValue(buffer, value.encode());
} else if (value is HashResult) {
buffer.putUint8(133);
buffer.putUint8(132);
writeValue(buffer, value.encode());
} else if (value is CloudIdResult) {
buffer.putUint8(134);
buffer.putUint8(133);
writeValue(buffer, value.encode());
} else {
super.writeValue(buffer, value);
@@ -350,17 +340,14 @@ class _PigeonCodec extends StandardMessageCodec {
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final int? value = readValue(buffer) as int?;
return value == null ? null : PlatformAssetPlaybackStyle.values[value];
case 130:
return PlatformAsset.decode(readValue(buffer)!);
case 131:
case 130:
return PlatformAlbum.decode(readValue(buffer)!);
case 132:
case 131:
return SyncDelta.decode(readValue(buffer)!);
case 133:
case 132:
return HashResult.decode(readValue(buffer)!);
case 134:
case 133:
return CloudIdResult.decode(readValue(buffer)!);
default:
return super.readValueOfType(type, buffer);

View File

@@ -53,7 +53,6 @@ class RemoteImageApi {
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
}) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.RemoteImageApi.requestImage$pigeonVar_messageChannelSuffix';
@@ -62,12 +61,7 @@ class RemoteImageApi {
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[
url,
headers,
requestId,
preferEncoded,
]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[url, headers, requestId]);
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);

View File

@@ -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/providers/infrastructure/action.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';
/// This delete action has the following behavior:
@@ -26,15 +25,6 @@ class DeletePermanentActionButton extends ConsumerWidget {
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);
ref.read(multiSelectProvider.notifier).reset();

View File

@@ -5,7 +5,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.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';
/// This delete action has the following behavior:
@@ -28,7 +28,7 @@ class DeleteTrashActionButton extends ConsumerWidget {
final confirmDelete =
await showDialog<bool>(
context: context,
builder: (context) => PermanentDeleteDialog(count: selectCount),
builder: (context) => TrashDeleteDialog(count: selectCount),
) ??
false;
if (!confirmDelete) {

View File

@@ -36,7 +36,7 @@ class ArchiveBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnArchiveActionButton(source: ActionSource.timeline),
const FavoriteActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),

View File

@@ -75,7 +75,7 @@ class FavoriteBottomSheet extends ConsumerWidget {
const ShareLinkActionButton(source: ActionSource.timeline),
const UnFavoriteActionButton(source: ActionSource.timeline),
const ArchiveActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),

View File

@@ -108,7 +108,7 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const ShareActionButton(source: ActionSource.timeline),
if (multiselect.hasRemote) ...[
const ShareLinkActionButton(source: ActionSource.timeline),
if (multiselect.onlyRemote) const DownloadActionButton(source: ActionSource.timeline),
const DownloadActionButton(source: ActionSource.timeline),
isTrashEnable
? const TrashActionButton(source: ActionSource.timeline)
: const DeletePermanentActionButton(source: ActionSource.timeline),
@@ -119,11 +119,10 @@ class _GeneralBottomSheetState extends ConsumerState<GeneralBottomSheet> {
const MoveToLockFolderActionButton(source: ActionSource.timeline),
if (multiselect.selectedAssets.length > 1) const StackActionButton(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)
const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.onlyLocal) const UploadActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal || multiselect.hasMerged) const DeleteLocalActionButton(source: ActionSource.timeline),
if (multiselect.hasLocal) const UploadActionButton(source: ActionSource.timeline),
],
slivers: multiselect.hasRemote
? [

View File

@@ -1,5 +1,3 @@
import 'dart:ui' as ui;
import 'package:async/async.dart';
import 'package:flutter/widgets.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* {
final cachedOperation = this.cachedOperation;
if (cachedOperation == null) {

View File

@@ -24,12 +24,10 @@ class MultiSelectState {
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 onlyLocal => selectedAssets.any((asset) => asset.storage == AssetState.local);
bool get onlyRemote => selectedAssets.any((asset) => asset.storage == AssetState.remote);
MultiSelectState copyWith({
Set<BaseAsset>? selectedAssets,
Set<BaseAsset>? lockedSelectionAssets,

View File

@@ -3,8 +3,8 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_ui/immich_ui.dart';
class PermanentDeleteDialog extends StatelessWidget {
const PermanentDeleteDialog({super.key, required this.count});
class TrashDeleteDialog extends StatelessWidget {
const TrashDeleteDialog({super.key, required this.count});
final int count;

View File

@@ -21,7 +21,6 @@ abstract class LocalImageApi {
required int width,
required int height,
required bool isVideo,
required bool preferEncoded,
});
void cancelRequest(int requestId);

View File

@@ -11,15 +11,6 @@ import 'package:pigeon/pigeon.dart';
dartPackageName: 'immich_mobile',
),
)
enum PlatformAssetPlaybackStyle {
unknown,
image,
video,
imageAnimated,
livePhoto,
videoLooping,
}
class PlatformAsset {
final String id;
final String name;
@@ -40,8 +31,6 @@ class PlatformAsset {
final double? latitude;
final double? longitude;
final PlatformAssetPlaybackStyle playbackStyle;
const PlatformAsset({
required this.id,
required this.name,
@@ -56,7 +45,6 @@ class PlatformAsset {
this.adjustmentTime,
this.latitude,
this.longitude,
this.playbackStyle = PlatformAssetPlaybackStyle.unknown,
});
}

View File

@@ -19,7 +19,6 @@ abstract class RemoteImageApi {
String url, {
required Map<String, String> headers,
required int requestId,
required bool preferEncoded,
});
void cancelRequest(int requestId);

View File

@@ -131,7 +131,6 @@ void main() {
durationInSeconds: 0,
orientation: 0,
isFavorite: false,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final assetsToRestore = [LocalAssetStub.image1];
@@ -215,7 +214,6 @@ void main() {
isFavorite: false,
createdAt: 1700000000,
updatedAt: 1732000000,
playbackStyle: PlatformAssetPlaybackStyle.image
);
final localAsset = platformAsset.toLocalAsset();

614
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -57,8 +57,7 @@
"@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2",
"@socket.io/postgres-adapter": "^0.5.0",
"@types/pg": "^8.16.0",
"@socket.io/redis-adapter": "^8.3.0",
"ajv": "^8.17.1",
"archiver": "^7.0.0",
"async-lock": "^1.4.0",
@@ -110,7 +109,6 @@
"sharp": "^0.34.5",
"sirv": "^3.0.0",
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.6",
"tailwindcss-preset-email": "^1.4.0",
"thumbhash": "^0.1.1",
"transformation-matrix": "^3.1.0",

View File

@@ -5,9 +5,8 @@ import cookieParser from 'cookie-parser';
import { existsSync } from 'node:fs';
import sirv from 'sirv';
import { excludePaths, serverVersion } from 'src/constants';
import { SocketIoAdapter } from 'src/enum';
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 { LoggingRepository } from 'src/repositories/logging.repository';
import { bootstrapTelemetry } from 'src/repositories/telemetry.repository';
@@ -26,7 +25,6 @@ export async function configureExpress(
{
permitSwaggerWrite = true,
ssr,
socketIoAdapter,
}: {
/**
* 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
*/
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);
@@ -61,7 +55,7 @@ export async function configureExpress(
}
app.setGlobalPrefix('api', { exclude: excludePaths });
app.useWebSocketAdapter(await createWebSocketAdapter(app, socketIoAdapter));
app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, { write: configRepository.isDev() && permitSwaggerWrite });

View File

@@ -10,7 +10,6 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { InternalController } from 'src/controllers/internal.controller';
import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MaintenanceController } from 'src/controllers/maintenance.controller';
@@ -52,7 +51,6 @@ export const controllers = [
DownloadController,
DuplicateController,
FaceController,
InternalController,
JobController,
LibraryController,
MaintenanceController,

View File

@@ -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);
}
}

View File

@@ -5,6 +5,7 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
Permission,
PluginContext,
@@ -111,6 +112,7 @@ export type Memory = {
export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
@@ -329,6 +331,7 @@ export const columns = {
asset: [
'asset.id',
'asset.checksum',
'asset.checksumAlgorithm',
'asset.deviceAssetId',
'asset.deviceId',
'asset.fileCreatedAt',

View File

@@ -13,7 +13,7 @@ import {
} from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { ImageDimensions } from 'src/types';
import { getDimensions } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -147,6 +147,7 @@ export type MapAsset = {
updateId: string;
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;

View File

@@ -1,6 +1,6 @@
import { Transform, Type } from 'class-transformer';
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';
// TODO import from sql-tools once the swagger plugin supports external enums
@@ -149,11 +149,6 @@ export class EnvDto {
@Optional()
IMMICH_WORKERS_EXCLUDE?: string;
@IsEnum(SocketIoAdapter)
@Optional()
@Transform(({ value }) => (value ? String(value).toLowerCase().trim() : value))
IMMICH_SOCKETIO_ADAPTER?: SocketIoAdapter;
@IsString()
@Optional()
DB_DATABASE_NAME?: string;

View File

@@ -37,6 +37,11 @@ export enum AssetType {
Other = 'OTHER',
}
export enum ChecksumAlgorithm {
sha1File = 'sha1-file', // sha1 checksum of the whole file contents
sha1Path = 'sha1-path', // sha1 checksum of "path:" plus the file path, currently used in external libraries, deprecated
}
export enum AssetFileType {
/**
* An full/large-size image extracted/converted from RAW photos
@@ -518,11 +523,6 @@ export enum ImmichTelemetry {
Job = 'job',
}
export enum SocketIoAdapter {
BroadcastChannel = 'broadcastchannel',
Postgres = 'postgres',
}
export enum ExifOrientation {
Horizontal = 1,
MirrorHorizontal = 2,

View File

@@ -1,5 +1,6 @@
import { Kysely, sql } from 'kysely';
import { CommandFactory } from 'nest-commander';
import { ChildProcess, fork } from 'node:child_process';
import { dirname, join } from 'node:path';
import { Worker } from 'node:worker_threads';
import { PostgresError } from 'postgres';
@@ -17,7 +18,7 @@ class 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
@@ -100,23 +101,25 @@ class Workers {
const basePath = dirname(__filename);
const workerFile = join(basePath, 'workers', `${name}.js`);
const inspectArg = process.execArgv.find((arg) => arg.startsWith('--inspect'));
const workerData: { inspectorPort?: number } = {};
let anyWorker: Worker | ChildProcess;
let kill: (signal?: NodeJS.Signals) => Promise<void> | void;
if (inspectArg) {
const inspectorPorts: Record<ImmichWorker, number> = {
[ImmichWorker.Api]: 9230,
[ImmichWorker.Microservices]: 9231,
[ImmichWorker.Maintenance]: 9232,
};
workerData.inspectorPort = inspectorPorts[name];
if (name === ImmichWorker.Api) {
const worker = fork(workerFile, [], {
execArgv: process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg)),
});
kill = (signal) => void worker.kill(signal);
anyWorker = worker;
} else {
const worker = new Worker(workerFile);
kill = async () => void (await worker.terminate());
anyWorker = worker;
}
const worker = new Worker(workerFile, { workerData });
const kill = async () => void (await worker.terminate());
worker.on('error', (error) => this.onError(name, error));
worker.on('exit', (exitCode) => this.onExit(name, exitCode));
anyWorker.on('error', (error) => this.onError(name, error));
anyWorker.on('exit', (exitCode) => this.onExit(name, exitCode));
this.workers[name] = { kill };
}
@@ -149,8 +152,8 @@ class Workers {
console.error(`${name} worker exited with code ${exitCode}`);
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
console.error('Terminating api worker');
void this.workers[ImmichWorker.Api].kill();
console.error('Killing api process');
void this.workers[ImmichWorker.Api].kill('SIGTERM');
}
}

View File

@@ -4,7 +4,6 @@ import {
Delete,
Get,
Next,
NotFoundException,
Param,
Post,
Req,
@@ -26,15 +25,12 @@ import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { GetLoginDetails } from 'src/middleware/auth.guard';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { LoginDetails } from 'src/services/auth.service';
import { sendFile } from 'src/utils/file';
import { respondWithCookie } from 'src/utils/response';
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 { ServerController as _ServerController } from 'src/controllers/server.controller';
import { DatabaseBackupDeleteDto, DatabaseBackupListResponseDto } from 'src/dtos/database-backup.dto';
@@ -135,14 +131,4 @@ export class MaintenanceWorkerController {
setMaintenanceMode(@Body() dto: SetMaintenanceModeDto): void {
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);
}
}

View File

@@ -19,7 +19,6 @@ import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-webs
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { ProcessRepository } from 'src/repositories/process.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@@ -291,9 +290,6 @@ export class MaintenanceWorkerService {
const lock = await this.databaseRepository.tryLock(DatabaseLock.MaintenanceOperation);
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;
}
@@ -355,25 +351,4 @@ export class MaintenanceWorkerService {
this.maintenanceWebsocketRepository.serverSend('AppRestart', state);
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;
}
}
}
}

View File

@@ -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);
};
}

View File

@@ -1,103 +1,21 @@
import { INestApplication, Logger } from '@nestjs/common';
import { INestApplicationContext } from '@nestjs/common';
import { IoAdapter } from '@nestjs/platform-socket.io';
import { Pool, PoolConfig } from 'pg';
import type { ServerOptions } from 'socket.io';
import { SocketIoAdapter } from 'src/enum';
import { createBroadcastChannelAdapter } from 'src/middleware/broadcast-channel.adapter';
import { createAdapter } from '@socket.io/redis-adapter';
import { Redis } from 'ioredis';
import { ServerOptions } from 'socket.io';
import { ConfigRepository } from 'src/repositories/config.repository';
import { asPostgresConnectionConfig } from 'src/utils/database';
export type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
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) {
export class WebSocketAdapter extends IoAdapter {
constructor(private app: INestApplicationContext) {
super(app);
this.adapterConstructor = createBroadcastChannelAdapter();
}
createIOServer(port: number, options?: ServerOptions): any {
const { redis } = this.app.get(ConfigRepository).getEnv();
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;
}
}
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);
}
}
}

View File

@@ -250,6 +250,7 @@ where
select
"asset"."id",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."deviceAssetId",
"asset"."deviceId",
"asset"."fileCreatedAt",

View File

@@ -123,13 +123,13 @@ with
) as "year"
)
select
"a".*
"a".*,
to_json("asset_exif") as "exifInfo"
from
"today"
inner join lateral (
select
"asset"."id",
"asset"."localDateTime"
"asset".*
from
"asset"
inner join "asset_job_status" on "asset"."id" = "asset_job_status"."assetId"
@@ -151,6 +151,7 @@ with
limit
$7
) as "a" on true
inner join "asset_exif" on "a"."id" = "asset_exif"."assetId"
)
select
date_part(

View File

@@ -1,4 +1,7 @@
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 { ConfigRepository } from 'src/repositories/config.repository';
import { AppRestartEvent } from 'src/repositories/event.repository';
@@ -21,17 +24,24 @@ export class AppRepository {
}
async sendOneShotAppRestart(state: AppRestartEvent): Promise<void> {
const { port } = new ConfigRepository().getEnv();
const url = `http://127.0.0.1:${port}/api/internal/restart`;
const server = new SocketIO();
const { redis } = new ConfigRepository().getEnv();
const pubClient = new Redis({ ...redis, lazyConnect: true });
const subClient = pubClient.duplicate();
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state),
await Promise.all([pubClient.connect(), subClient.connect()]);
server.adapter(createAdapter(pubClient, subClient));
// => 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}`);
}
}
}

View File

@@ -404,7 +404,7 @@ export class AssetRepository {
(qb) =>
qb
.selectFrom('asset')
.select(['asset.id', 'asset.localDateTime'])
.selectAll('asset')
.innerJoin('asset_job_status', 'asset.id', 'asset_job_status.assetId')
.where(sql`(asset."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
.where('asset.ownerId', '=', anyUuid(ownerIds))
@@ -423,7 +423,9 @@ export class AssetRepository {
.as('a'),
(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')
.select(sql<number>`date_part('year', ("localDateTime" at time zone 'UTC')::date)::int`.as('year'))

View File

@@ -21,7 +21,6 @@ import {
LogFormat,
LogLevel,
QueueName,
SocketIoAdapter,
} from 'src/enum';
import { VectorExtension } from 'src/types';
import { setDifference } from 'src/utils/set';
@@ -118,10 +117,6 @@ export interface EnvData {
};
};
socketIo: {
adapter: SocketIoAdapter;
};
noColor: boolean;
nodeVersion?: string;
}
@@ -352,10 +347,6 @@ const getEnv = (): EnvData => {
},
},
socketIo: {
adapter: dto.IMMICH_SOCKETIO_ADAPTER ?? SocketIoAdapter.Postgres,
},
noColor: !!dto.NO_COLOR,
};
};

View File

@@ -1,5 +1,5 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, SourceType } from 'src/enum';
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
@@ -15,3 +15,8 @@ export const asset_visibility_enum = registerEnum({
name: 'asset_visibility_enum',
values: Object.values(AssetVisibility),
});
export const asset_checksum_algorithm_enum = registerEnum({
name: 'asset_checksum_algorithm_enum',
values: Object.values(ChecksumAlgorithm),
});

View File

@@ -0,0 +1,35 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "asset_checksum_algorithm_enum" AS ENUM ('sha1-file','sha1-path');`.execute(db);
await sql`ALTER TABLE "asset" ADD "checksumAlgorithm" asset_checksum_algorithm_enum;`.execute(db);
// Update in batches to handle millions of rows efficiently
const batchSize = 10_000;
let updatedRows: number;
do {
const result = await sql`
UPDATE "asset"
SET "checksumAlgorithm" = CASE
WHEN "isExternal" = true THEN 'sha1-path'::asset_checksum_algorithm_enum
ELSE 'sha1-file'::asset_checksum_algorithm_enum
END
WHERE "id" IN (
SELECT "id"
FROM "asset"
WHERE "checksumAlgorithm" IS NULL
LIMIT ${batchSize}
)
`.execute(db);
updatedRows = Number(result.numAffectedRows ?? 0);
} while (updatedRows > 0);
await sql`ALTER TABLE "asset" ALTER COLUMN "checksumAlgorithm" SET NOT NULL;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" DROP COLUMN "checksumAlgorithm";`.execute(db);
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
}

View File

@@ -12,8 +12,8 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { asset_checksum_algorithm_enum, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import { asset_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table';
import { StackTable } from 'src/schema/tables/stack.table';
@@ -98,6 +98,9 @@ export class AssetTable {
@Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum
@Column({ enum: asset_checksum_algorithm_enum })
checksumAlgorithm!: ChecksumAlgorithm;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
livePhotoVideoId!: string | null;

View File

@@ -27,6 +27,7 @@ import {
AssetStatus,
AssetVisibility,
CacheControl,
ChecksumAlgorithm,
JobName,
Permission,
StorageFolder,
@@ -409,6 +410,7 @@ export class AssetMediaService extends BaseService {
deviceId: asset.deviceId,
type: asset.type,
checksum: asset.checksum,
checksumAlgorithm: asset.checksumAlgorithm,
fileCreatedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
@@ -430,6 +432,7 @@ export class AssetMediaService extends BaseService {
libraryId: null,
checksum: file.checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
deviceAssetId: dto.deviceAssetId,

View File

@@ -17,7 +17,17 @@ import {
ValidateLibraryImportPathResponseDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import { AssetStatus, AssetType, CronJob, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import {
AssetStatus,
AssetType,
ChecksumAlgorithm,
CronJob,
DatabaseLock,
ImmichWorker,
JobName,
JobStatus,
QueueName,
} from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AssetSyncResult } from 'src/repositories/library.repository';
import { AssetTable } from 'src/schema/tables/asset.table';
@@ -400,6 +410,7 @@ export class LibraryService extends BaseService {
ownerId,
libraryId,
checksum: this.cryptoRepository.hashSha1(`path:${assetPath}`),
checksumAlgorithm: ChecksumAlgorithm.sha1Path,
originalPath: assetPath,
fileCreatedAt: stat.mtime,

View File

@@ -7,6 +7,7 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
ExifOrientation,
ImmichWorker,
JobName,
@@ -651,6 +652,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -704,6 +706,7 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
@@ -757,6 +760,7 @@ describe(MetadataService.name, () => {
expect(mocks.storage.readFile).toHaveBeenCalledWith(asset.originalPath, expect.any(Object));
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,

View File

@@ -14,6 +14,7 @@ import {
AssetFileType,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
DatabaseLock,
ExifOrientation,
ImmichWorker,
@@ -675,6 +676,7 @@ export class MetadataService extends BaseService {
fileModifiedAt: stats.mtime,
localDateTime: dates.localDateTime,
checksum,
checksumAlgorithm: ChecksumAlgorithm.sha1File,
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,

View File

@@ -1,11 +1,60 @@
import { createAdapter } from '@socket.io/redis-adapter';
import Redis from 'ioredis';
import { SignJWT } from 'jose';
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { Server as SocketIO } from 'socket.io';
import { StorageCore } from 'src/cores/storage.core';
import { MaintenanceAuthDto, MaintenanceDetectInstallResponseDto } from 'src/dtos/maintenance.dto';
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';
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(
baseUrl: string,
auth: MaintenanceAuthDto,

View File

@@ -1,21 +1,14 @@
import { NestFactory } from '@nestjs/core';
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 { ApiModule } from 'src/app.module';
import { AppRepository } from 'src/repositories/app.repository';
import { ApiService } from 'src/services/api.service';
import { isStartUpError } from 'src/utils/misc';
export async function bootstrap() {
async function bootstrap() {
process.title = 'immich-api';
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
@@ -26,12 +19,10 @@ export async function bootstrap() {
});
}
if (!isMainThread || process.send) {
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
process.exit(1);
});
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View File

@@ -1,22 +1,13 @@
import { NestFactory } from '@nestjs/core';
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 { MaintenanceModule } from 'src/app.module';
import { SocketIoAdapter } from 'src/enum';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AppRepository } from 'src/repositories/app.repository';
import { isStartUpError } from 'src/utils/misc';
export async function bootstrap() {
async function bootstrap() {
process.title = 'immich-maintenance';
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
configureTelemetry();
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
@@ -25,18 +16,13 @@ export async function bootstrap() {
void configureExpress(app, {
permitSwaggerWrite: false,
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) => {
if (!isStartUpError(error)) {
console.error(error);
}
process.exit(1);
});
}
bootstrap().catch((error) => {
if (!isStartUpError(error)) {
console.error(error);
}
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});

View File

@@ -1,9 +1,8 @@
import { NestFactory } from '@nestjs/core';
import inspector from 'node:inspector';
import { isMainThread, workerData } from 'node:worker_threads';
import { isMainThread } from 'node:worker_threads';
import { MicroservicesModule } from 'src/app.module';
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 { ConfigRepository } from 'src/repositories/config.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';
export async function bootstrap() {
const { inspectorPort } = workerData ?? {};
if (inspectorPort) {
inspector.open(inspectorPort, '0.0.0.0', false);
}
const { telemetry } = new ConfigRepository().getEnv();
if (telemetry.metrics.size > 0) {
bootstrapTelemetry(telemetry.microservicesPort);
@@ -30,7 +24,7 @@ export async function bootstrap() {
logger.setContext('Bootstrap');
app.useLogger(logger);
app.useWebSocketAdapter(await createWebSocketAdapter(app));
app.useWebSocketAdapter(new WebSocketAdapter(app));
await (host ? app.listen(0, host) : app.listen(0));

View File

@@ -1,5 +1,5 @@
import { Selectable } from 'kysely';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm } from 'src/enum';
import { AssetTable } from 'src/schema/tables/asset.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
@@ -51,6 +51,7 @@ export class AssetFactory {
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: '',
deviceId: '',
duplicateId: null,

View File

@@ -1,7 +1,7 @@
import { UserAdmin } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, ChecksumAlgorithm, SharedLinkType } from 'src/enum';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
@@ -94,6 +94,7 @@ export const sharedLinkStub = {
type: AssetType.Video,
originalPath: 'fake_path/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
fileModifiedAt: today,
fileCreatedAt: today,
localDateTime: today,

View File

@@ -11,6 +11,7 @@ import {
AlbumUserRole,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
SourceType,
SyncEntityType,
@@ -535,6 +536,7 @@ const assetInsert = (asset: Partial<Insertable<AssetTable>> = {}) => {
deviceId: '',
originalFileName: '',
checksum: randomBytes(32),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
type: AssetType.Image,
originalPath: '/path/to/something.jpg',
ownerId: 'not-a-valid-uuid',

View File

@@ -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();
});
});
});

View File

@@ -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 });
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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 { RepositoryInterface } from 'src/types';
import { Mocked, vitest } from 'vitest';
@@ -99,10 +99,6 @@ const envData: EnvData = {
},
},
socketIo: {
adapter: SocketIoAdapter.Postgres,
},
noColor: false,
};

View File

@@ -28,6 +28,7 @@ import {
AssetStatus,
AssetType,
AssetVisibility,
ChecksumAlgorithm,
MemoryType,
Permission,
SourceType,
@@ -249,6 +250,7 @@ const assetFactory = (
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: '',
deviceId: '',
duplicateId: null,

View File

@@ -98,7 +98,7 @@
"prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.53.5",
"svelte": "5.53.0",
"svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.3.3",
"tailwindcss": "^4.1.7",