Compare commits

...

10 Commits

Author SHA1 Message Date
github-actions
0d60be3d87 chore: version v1.132.2 2025-04-25 03:07:06 +00:00
Alex
765da7b182 fix(mobile): mobile migration logic (#17865)
* fix(mobile): mobile migration logic

* add exception

* remove unused comment

* finalize
2025-04-25 00:16:54 +00:00
shenlong
b037158028 fix(mobile): auto trash using MANAGE_MEDIA (#17828)
fix: auto trash using MANAGE_MEDIA

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-24 19:09:50 -05:00
Daimolean
a03902f174 fix(docs): incorrect date sorting (#17858) 2025-04-24 19:40:52 -04:00
Jason Rasmussen
1d610ad9cb refactor: database connection parsing (#17852) 2025-04-24 12:58:29 -04:00
Min Idzelis
dab4870fed fix: flappy e2e test (#17832)
* fix: flappy e2e test

* lint
2025-04-23 23:30:13 -04:00
github-actions
37f5e6e2cb chore: version v1.132.1 2025-04-23 21:43:47 +00:00
Alex
57d622bc43 chore: post release tasks (#17816) 2025-04-23 16:41:08 -05:00
Alex
c167e46ec7 chore: revert #16732 (#17819)
* chore: revert #16732

* lint
2025-04-23 16:40:59 -05:00
Mert
6ce8a1deeb fix(server): bump sharp (#17818)
* bump sharp

* test linking

* link in prod image too

* force global

* keep unnecessary libraries

* override sharp version

* revert dockerfile changes

* add node-gyp and napi

* dev dependency
2025-04-23 17:08:29 -04:00
44 changed files with 1710 additions and 492 deletions

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.62", "version": "2.2.64",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.62", "version": "2.2.64",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.0", "version": "1.132.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.62", "version": "2.2.64",
"description": "Command Line Interface (CLI) for Immich", "description": "Command Line Interface (CLI) for Immich",
"type": "module", "type": "module",
"exports": "./dist/index.js", "exports": "./dist/index.js",

View File

@@ -252,6 +252,13 @@ const milestones: Item[] = [
description: 'Browse your photos and videos in their folder structure inside the mobile app', description: 'Browse your photos and videos in their folder structure inside the mobile app',
release: 'v1.130.0', release: 'v1.130.0',
}), }),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({ withRelease({
icon: mdiTagFaces, icon: mdiTagFaces,
iconColor: 'teal', iconColor: 'teal',
@@ -260,13 +267,6 @@ const milestones: Item[] = [
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.', 'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
release: 'v1.127.0', release: 'v1.127.0',
}), }),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({ withRelease({
icon: mdiLinkEdit, icon: mdiLinkEdit,
iconColor: 'crimson', iconColor: 'crimson',

View File

@@ -1,4 +1,12 @@
[ [
{
"label": "v1.132.2",
"url": "https://v1.132.2.archive.immich.app"
},
{
"label": "v1.132.1",
"url": "https://v1.132.1.archive.immich.app"
},
{ {
"label": "v1.132.0", "label": "v1.132.0",
"url": "https://v1.132.0.archive.immich.app" "url": "https://v1.132.0.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.132.0", "version": "1.132.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.132.0", "version": "1.132.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.1.0", "@eslint/eslintrc": "^3.1.0",
@@ -44,7 +44,7 @@
}, },
"../cli": { "../cli": {
"name": "@immich/cli", "name": "@immich/cli",
"version": "2.2.62", "version": "2.2.64",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
@@ -93,7 +93,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.0", "version": "1.132.2",
"dev": true, "dev": true,
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-e2e", "name": "immich-e2e",
"version": "1.132.0", "version": "1.132.2",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",

View File

@@ -142,7 +142,7 @@ describe(`/oauth`, () => {
it(`should throw an error if the state mismatches`, async () => { it(`should throw an error if the state mismatches`, async () => {
const callbackParams = await loginWithOAuth('oauth-auto-register'); const callbackParams = await loginWithOAuth('oauth-auto-register');
const { state } = await loginWithOAuth('oauth-auto-register'); const { state } = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app) const { status } = await request(app)
.post('/oauth/callback') .post('/oauth/callback')
.send({ ...callbackParams, state }); .send({ ...callbackParams, state });
expect(status).toBeGreaterThanOrEqual(400); expect(status).toBeGreaterThanOrEqual(400);

View File

@@ -55,7 +55,6 @@ test.describe('Shared Links', () => {
await page.goto(`/share/${sharedLink.key}`); await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor(); await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.getByRole('button', { name: 'Download' }).click(); await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
await page.waitForEvent('download'); await page.waitForEvent('download');
}); });

View File

@@ -6,7 +6,6 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" /> <uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
@@ -125,4 +124,4 @@
<data android:scheme="geo" /> <data android:scheme="geo" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -1,17 +1,17 @@
package app.alextran.immich package app.alextran.immich
import android.app.Activity
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -23,6 +23,7 @@ import io.flutter.plugin.common.PluginRegistry
import java.security.MessageDigest import java.security.MessageDigest
import java.io.FileInputStream import java.io.FileInputStream
import kotlinx.coroutines.* import kotlinx.coroutines.*
import androidx.core.net.toUri
/** /**
* Android plugin for Dart `BackgroundService` and file trash operations * Android plugin for Dart `BackgroundService` and file trash operations
@@ -33,7 +34,8 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
private var fileTrashChannel: MethodChannel? = null private var fileTrashChannel: MethodChannel? = null
private var context: Context? = null private var context: Context? = null
private var pendingResult: Result? = null private var pendingResult: Result? = null
private val PERMISSION_REQUEST_CODE = 1001 private val permissionRequestCode = 1001
private val trashRequestCode = 1002
private var activityBinding: ActivityPluginBinding? = null private var activityBinding: ActivityPluginBinding? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
@@ -138,36 +140,35 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
// File Trash methods moved from MainActivity // File Trash methods moved from MainActivity
"moveToTrash" -> { "moveToTrash" -> {
val fileName = call.argument<String>("fileName") val mediaUrls = call.argument<List<String>>("mediaUrls")
if (fileName != null) { if (mediaUrls != null) {
if (hasManageStoragePermission()) { if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
val success = moveToTrash(fileName) moveToTrash(mediaUrls, result)
result.success(success)
} else { } else {
result.error("PERMISSION_DENIED", "Storage permission required", null) result.error("PERMISSION_DENIED", "Media permission required", null)
} }
} else { } else {
result.error("INVALID_NAME", "The file name is not specified.", null) result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
} }
} }
"restoreFromTrash" -> { "restoreFromTrash" -> {
val fileName = call.argument<String>("fileName") val fileName = call.argument<String>("fileName")
if (fileName != null) { val type = call.argument<Int>("type")
if (hasManageStoragePermission()) { if (fileName != null && type != null) {
val success = untrashImage(fileName) if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
result.success(success) restoreFromTrash(fileName, type, result)
} else { } else {
result.error("PERMISSION_DENIED", "Storage permission required", null) result.error("PERMISSION_DENIED", "Media permission required", null)
} }
} else { } else {
result.error("INVALID_NAME", "The file name is not specified.", null) result.error("INVALID_NAME", "The file name is not specified.", null)
} }
} }
"requestManageStoragePermission" -> { "requestManageMediaPermission" -> {
if (!hasManageStoragePermission()) { if (!hasManageMediaPermission()) {
requestManageStoragePermission(result) requestManageMediaPermission(result)
} else { } else {
Log.e("Manage storage permission", "Permission already granted") Log.e("Manage storage permission", "Permission already granted")
result.success(true) result.success(true)
@@ -178,100 +179,98 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
} }
} }
// File Trash methods moved from MainActivity private fun hasManageMediaPermission(): Boolean {
private fun hasManageStoragePermission(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { MediaStore.canManageMedia(context!!);
Environment.isExternalStorageManager() } else {
} else { false
true
} }
} }
private fun requestManageStoragePermission(result: Result) { private fun requestManageMediaPermission(result: Result) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
pendingResult = result // Store the result callback pendingResult = result // Store the result callback
val activity = activityBinding?.activity ?: return val activity = activityBinding?.activity ?: return
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
intent.data = Uri.parse("package:${activity.packageName}") intent.data = "package:${activity.packageName}".toUri()
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE) activity.startActivityForResult(intent, permissionRequestCode)
} else { } else {
result.success(true) result.success(false)
} }
} }
private fun moveToTrash(fileName: String): Boolean { @RequiresApi(Build.VERSION_CODES.R)
val contentResolver = context?.contentResolver ?: return false private fun moveToTrash(mediaUrls: List<String>, result: Result) {
val uri = getFileUri(fileName) val urisToTrash = mediaUrls.map { it.toUri() }
if (urisToTrash.isEmpty()) {
result.error("INVALID_ARGS", "No valid URIs provided", null)
return
}
toggleTrash(urisToTrash, true, result);
}
@RequiresApi(Build.VERSION_CODES.R)
private fun restoreFromTrash(name: String, type: Int, result: Result) {
val uri = getTrashedFileUri(name, type)
if (uri == null) {
Log.e("TrashError", "Asset Uri cannot be found obtained")
result.error("TrashError", "Asset Uri cannot be found obtained", null)
return
}
Log.e("FILE_URI", uri.toString()) Log.e("FILE_URI", uri.toString())
return uri?.let { moveToTrash(it) } ?: false uri.let { toggleTrash(listOf(it), false, result) }
} }
private fun moveToTrash(contentUri: Uri): Boolean { @RequiresApi(Build.VERSION_CODES.R)
val contentResolver = context?.contentResolver ?: return false private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
return try { val activity = activityBinding?.activity
val values = ContentValues().apply { val contentResolver = context?.contentResolver
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash if (activity == null || contentResolver == null) {
result.error("TrashError", "Activity or ContentResolver not available", null)
return
} }
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0 try {
} catch (e: Exception) { val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
Log.e("TrashError", "Error moving to trash", e) pendingResult = result // Store for onActivityResult
false activity.startIntentSenderForResult(
pendingIntent.intentSender,
trashRequestCode,
null, 0, 0, 0
)
} catch (e: Exception) {
Log.e("TrashError", "Error creating or starting trash request", e)
result.error("TrashError", "Error creating or starting trash request", null)
} }
} }
private fun getFileUri(fileName: String): Uri? { @RequiresApi(Build.VERSION_CODES.R)
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
val contentResolver = context?.contentResolver ?: return null val contentResolver = context?.contentResolver ?: return null
val contentUri = MediaStore.Files.getContentUri("external") val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Images.Media._ID)
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
val selectionArgs = arrayOf(fileName)
var fileUri: Uri? = null
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
fileUri = ContentUris.withAppendedId(contentUri, id)
}
}
return fileUri
}
private fun untrashImage(name: String): Boolean {
val contentResolver = context?.contentResolver ?: return false
val uri = getTrashedFileUri(contentResolver, name)
Log.e("FILE_URI", uri.toString())
return uri?.let { untrashImage(it) } ?: false
}
private fun untrashImage(contentUri: Uri): Boolean {
val contentResolver = context?.contentResolver ?: return false
return try {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
}
val updated = contentResolver.update(contentUri, values, null, null)
updated > 0
} catch (e: Exception) {
Log.e("TrashError", "Error restoring file", e)
false
}
}
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
val projection = arrayOf(MediaStore.Files.FileColumns._ID) val projection = arrayOf(MediaStore.Files.FileColumns._ID)
val queryArgs = Bundle().apply { val queryArgs = Bundle().apply {
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?") putString(
ContentResolver.QUERY_ARG_SQL_SELECTION,
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
)
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName)) putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY) putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
} }
contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor -> contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
if (cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID)) val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
// same order as AssetType from dart
val contentUri = when (type) {
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> queryUri
}
return ContentUris.withAppendedId(contentUri, id) return ContentUris.withAppendedId(contentUri, id)
} }
} }
@@ -301,12 +300,19 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
// ActivityResultListener implementation // ActivityResultListener implementation
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) { if (requestCode == permissionRequestCode) {
val granted = hasManageStoragePermission() val granted = hasManageMediaPermission()
pendingResult?.success(granted) pendingResult?.success(granted)
pendingResult = null pendingResult = null
return true return true
} }
if (requestCode == trashRequestCode) {
val approved = resultCode == Activity.RESULT_OK
pendingResult?.success(approved)
pendingResult = null
return true
}
return false return false
} }
} }

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle', task: 'bundle',
build_type: 'Release', build_type: 'Release',
properties: { properties: {
"android.injected.version.code" => 194, "android.injected.version.code" => 196,
"android.injected.version.name" => "1.132.0", "android.injected.version.name" => "1.132.2",
} }
) )
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -541,7 +541,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -685,7 +685,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -715,7 +715,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
@@ -748,7 +748,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -791,7 +791,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -831,7 +831,7 @@
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements; CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 201; CURRENT_PROJECT_VERSION = 202;
CUSTOM_GROUP_ID = group.app.immich.share; CUSTOM_GROUP_ID = group.app.immich.share;
DEVELOPMENT_TEAM = 2F67MQ8R79; DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_USER_SCRIPT_SANDBOXING = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES;

View File

@@ -78,7 +78,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.131.3</string> <string>1.132.0</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -93,7 +93,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>201</string> <string>202</string>
<key>FLTEnableImpeller</key> <key>FLTEnableImpeller</key>
<true/> <true/>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Release" desc "iOS Release"
lane :release do lane :release do
increment_version_number( increment_version_number(
version_number: "1.132.0" version_number: "1.132.2"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View File

@@ -1,5 +1,5 @@
abstract interface class ILocalFilesManager { abstract interface class ILocalFilesManager {
Future<bool> moveToTrash(String fileName); Future<bool> moveToTrash(List<String> mediaUrls);
Future<bool> restoreFromTrash(String fileName); Future<bool> restoreFromTrash(String fileName, int type);
Future<bool> requestManageStoragePermission(); Future<bool> requestManageMediaPermission();
} }

View File

@@ -3,21 +3,23 @@ import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
import 'package:immich_mobile/utils/local_files_manager.dart'; import 'package:immich_mobile/utils/local_files_manager.dart';
final localFilesManagerRepositoryProvider = final localFilesManagerRepositoryProvider =
Provider((ref) => LocalFilesManagerRepository()); Provider((ref) => const LocalFilesManagerRepository());
class LocalFilesManagerRepository implements ILocalFilesManager { class LocalFilesManagerRepository implements ILocalFilesManager {
const LocalFilesManagerRepository();
@override @override
Future<bool> moveToTrash(String fileName) async { Future<bool> moveToTrash(List<String> mediaUrls) async {
return await LocalFilesManager.moveToTrash(fileName); return await LocalFilesManager.moveToTrash(mediaUrls);
} }
@override @override
Future<bool> restoreFromTrash(String fileName) async { Future<bool> restoreFromTrash(String fileName, int type) async {
return await LocalFilesManager.restoreFromTrash(fileName); return await LocalFilesManager.restoreFromTrash(fileName, type);
} }
@override @override
Future<bool> requestManageStoragePermission() async { Future<bool> requestManageMediaPermission() async {
return await LocalFilesManager.requestManageStoragePermission(); return await LocalFilesManager.requestManageMediaPermission();
} }
} }

View File

@@ -255,9 +255,12 @@ class SyncService {
.where((asset) => idsToDelete.contains(asset.remoteId)) .where((asset) => idsToDelete.contains(asset.remoteId))
.toList(); .toList();
for (var asset in matchedAssets) { final mediaUrls = await Future.wait(
_localFilesManager.moveToTrash(asset.fileName); matchedAssets
} .map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)),
);
await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
} }
/// Deletes remote-only assets, updates merged assets to be local-only /// Deletes remote-only assets, updates merged assets to be local-only
@@ -819,13 +822,29 @@ class SyncService {
} }
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async { Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
for (var asset in assetsList) { final trashMediaUrls = <String>[];
for (final asset in assetsList) {
if (asset.isTrashed) { if (asset.isTrashed) {
_localFilesManager.moveToTrash(asset.fileName); final mediaUrl = await asset.local?.getMediaUrl();
if (mediaUrl == null) {
_log.warning(
"Failed to get media URL for asset ${asset.name} while moving to trash",
);
continue;
}
trashMediaUrls.add(mediaUrl);
} else { } else {
_localFilesManager.restoreFromTrash(asset.fileName); await _localFilesManager.restoreFromTrash(
asset.fileName,
asset.type.index,
);
} }
} }
if (trashMediaUrls.isNotEmpty) {
await _localFilesManager.moveToTrash(trashMediaUrls);
}
} }
/// Inserts or updates the assets in the database with their ExifInfo (if any) /// Inserts or updates the assets in the database with their ExifInfo (if any)

View File

@@ -1,38 +1,37 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:logging/logging.dart';
class LocalFilesManager { abstract final class LocalFilesManager {
static final Logger _logger = Logger('LocalFilesManager');
static const MethodChannel _channel = MethodChannel('file_trash'); static const MethodChannel _channel = MethodChannel('file_trash');
static Future<bool> moveToTrash(String fileName) async { static Future<bool> moveToTrash(List<String> mediaUrls) async {
try { try {
final bool success = return await _channel
await _channel.invokeMethod('moveToTrash', {'fileName': fileName}); .invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
return success; } catch (e, s) {
} on PlatformException catch (e) { _logger.warning('Error moving file to trash', e, s);
debugPrint('Error moving to trash: ${e.message}');
return false; return false;
} }
} }
static Future<bool> restoreFromTrash(String fileName) async { static Future<bool> restoreFromTrash(String fileName, int type) async {
try { try {
final bool success = await _channel return await _channel.invokeMethod(
.invokeMethod('restoreFromTrash', {'fileName': fileName}); 'restoreFromTrash',
return success; {'fileName': fileName, 'type': type},
} on PlatformException catch (e) { );
debugPrint('Error restoring file: ${e.message}'); } catch (e, s) {
_logger.warning('Error restore file from trash', e, s);
return false; return false;
} }
} }
static Future<bool> requestManageStoragePermission() async { static Future<bool> requestManageMediaPermission() async {
try { try {
final bool success = return await _channel.invokeMethod('requestManageMediaPermission');
await _channel.invokeMethod('requestManageStoragePermission'); } catch (e, s) {
return success; _logger.warning('Error requesting manage media permission', e, s);
} on PlatformException catch (e) {
debugPrint('Error requesting permission: ${e.message}');
return false; return false;
} }
} }

View File

@@ -3,7 +3,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart'; import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart'; import 'package:immich_mobile/entities/android_device_asset.entity.dart';
@@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart'; import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 10; const int targetVersion = 10;
@@ -69,14 +71,45 @@ Future<void> _migrateDeviceAsset(Isar db) async {
: (await db.iOSDeviceAssets.where().findAll()) : (await db.iOSDeviceAssets.where().findAll())
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash)) .map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
.toList(); .toList();
final localAssets = (await db.assets
.where() final PermissionState ps = await PhotoManager.requestPermissionExtend();
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId)) if (!ps.hasAccess) {
.findAll()) if (kDebugMode) {
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt)) debugPrint(
.toList(); "[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
debugPrint("Device Asset Ids length - ${ids.length}"); );
debugPrint("Local Asset Ids length - ${localAssets.length}"); }
return;
}
List<_DeviceAsset> localAssets = [];
final List<AssetPathEntity> paths =
await PhotoManager.getAssetPathList(onlyAll: true);
if (paths.isEmpty) {
localAssets = (await db.assets
.where()
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
.findAll())
.map(
(a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
)
.toList();
} else {
final AssetPathEntity albumWithAll = paths.first;
final int assetCount = await albumWithAll.assetCountAsync;
final List<AssetEntity> allDeviceAssets =
await albumWithAll.getAssetListRange(start: 0, end: assetCount);
localAssets = allDeviceAssets
.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime))
.toList();
}
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
ids.sort((a, b) => a.assetId.compareTo(b.assetId)); ids.sort((a, b) => a.assetId.compareTo(b.assetId));
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId)); localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
final List<DeviceAssetEntity> toAdd = []; final List<DeviceAssetEntity> toAdd = [];
@@ -95,15 +128,27 @@ Future<void> _migrateDeviceAsset(Isar db) async {
return false; return false;
}, },
onlyFirst: (deviceAsset) { onlyFirst: (deviceAsset) {
debugPrint( if (kDebugMode) {
'DeviceAsset not found in local assets: ${deviceAsset.assetId}', debugPrint(
); '[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
);
}
}, },
onlySecond: (asset) { onlySecond: (asset) {
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}'); if (kDebugMode) {
debugPrint(
'[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}',
);
}
}, },
); );
debugPrint("Total number of device assets migrated - ${toAdd.length}");
if (kDebugMode) {
debugPrint(
"[MIGRATION] Total number of device assets migrated - ${toAdd.length}",
);
}
await db.writeTxn(() async { await db.writeTxn(() async {
await db.deviceAssetEntitys.putAll(toAdd); await db.deviceAssetEntitys.putAll(toAdd);
}); });

View File

@@ -49,7 +49,7 @@ class AdvancedSettings extends HookConsumerWidget {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin(); DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo; AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
int sdkVersion = androidInfo.version.sdkInt; int sdkVersion = androidInfo.version.sdkInt;
return sdkVersion >= 30; return sdkVersion >= 31;
} }
return false; return false;
} }
@@ -74,7 +74,7 @@ class AdvancedSettings extends HookConsumerWidget {
if (value) { if (value) {
final result = await ref final result = await ref
.read(localFilesManagerRepositoryProvider) .read(localFilesManagerRepositoryProvider)
.requestManageStoragePermission(); .requestManageMediaPermission();
manageLocalMediaAndroid.value = result; manageLocalMediaAndroid.value = result;
} }
}, },

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.132.0 - API version: 1.132.2
- Generator version: 7.8.0 - Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen - Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 1.132.0+194 version: 1.132.2+196
environment: environment:
sdk: '>=3.3.0 <4.0.0' sdk: '>=3.3.0 <4.0.0'

View File

@@ -7656,7 +7656,7 @@
"info": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.132.0", "version": "1.132.2",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],

View File

@@ -1,12 +1,12 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.0", "version": "1.132.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.0", "version": "1.132.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.0", "version": "1.132.2",
"description": "Auto-generated TypeScript SDK for the Immich API", "description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module", "type": "module",
"main": "./build/index.js", "main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/** /**
* Immich * Immich
* 1.132.0 * 1.132.2
* DO NOT MODIFY - This file has been generated using oazapfts. * DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts * See https://www.npmjs.com/package/oazapfts
*/ */

View File

@@ -6,14 +6,14 @@ WORKDIR /usr/src/app
COPY server/package.json server/package-lock.json ./ COPY server/package.json server/package-lock.json ./
COPY server/patches ./patches COPY server/patches ./patches
RUN npm ci && \ RUN npm ci && \
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need # exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
# they're marked as optional dependencies, so we need to copy them manually after pruning # they're marked as optional dependencies, so we need to copy them manually after pruning
rm -rf node_modules/@img/sharp-libvips* && \ rm -rf node_modules/@img/sharp-libvips* && \
rm -rf node_modules/@img/sharp-linuxmusl-x64 rm -rf node_modules/@img/sharp-linuxmusl-x64
ENV PATH="${PATH}:/usr/src/app/bin" \ ENV PATH="${PATH}:/usr/src/app/bin" \
IMMICH_ENV=development \ IMMICH_ENV=development \
NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all NVIDIA_VISIBLE_DEVICES=all
ENTRYPOINT ["tini", "--", "/bin/sh"] ENTRYPOINT ["tini", "--", "/bin/sh"]
@@ -47,8 +47,8 @@ FROM ghcr.io/immich-app/base-server-prod:202504081114@sha256:8353bcbdb4e6579300a
WORKDIR /usr/src/app WORKDIR /usr/src/app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
NVIDIA_DRIVER_CAPABILITIES=all \ NVIDIA_DRIVER_CAPABILITIES=all \
NVIDIA_VISIBLE_DEVICES=all NVIDIA_VISIBLE_DEVICES=all
COPY --from=prod /usr/src/app/node_modules ./node_modules COPY --from=prod /usr/src/app/node_modules ./node_modules
COPY --from=prod /usr/src/app/dist ./dist COPY --from=prod /usr/src/app/dist ./dist
COPY --from=prod /usr/src/app/bin ./bin COPY --from=prod /usr/src/app/bin ./bin

1261
server/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich", "name": "immich",
"version": "1.132.0", "version": "1.132.2",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -88,7 +88,7 @@
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0", "sanitize-html": "^2.14.0",
"semver": "^7.6.2", "semver": "^7.6.2",
"sharp": "^0.33.5", "sharp": "^0.34.0",
"sirv": "^3.0.0", "sirv": "^3.0.0",
"tailwindcss-preset-email": "^1.3.2", "tailwindcss-preset-email": "^1.3.2",
"thumbhash": "^0.1.1", "thumbhash": "^0.1.1",
@@ -132,7 +132,8 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"mock-fs": "^5.2.0", "mock-fs": "^5.2.0",
"node-addon-api": "^8.3.0", "node-addon-api": "^8.3.1",
"node-gyp": "^11.2.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"prettier": "^3.0.2", "prettier": "^3.0.2",
@@ -152,5 +153,8 @@
}, },
"volta": { "volta": {
"node": "22.14.0" "node": "22.14.0"
},
"overrides": {
"sharp": "^0.34.0"
} }
} }

View File

@@ -44,7 +44,7 @@ const imports = [
BullModule.registerQueue(...bull.queues), BullModule.registerQueue(...bull.queues),
ClsModule.forRoot(cls.config), ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel), OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config.kysely)), KyselyModule.forRoot(getKyselyConfig(database.config)),
]; ];
class BaseModule implements OnModuleInit, OnModuleDestroy { class BaseModule implements OnModuleInit, OnModuleDestroy {

View File

@@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema'; import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools'; import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { getKyselyConfig } from 'src/utils/database'; import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
const main = async () => { const main = async () => {
const command = process.argv[2]; const command = process.argv[2];
@@ -56,7 +56,7 @@ const main = async () => {
const getDatabaseClient = () => { const getDatabaseClient = () => {
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv(); const { database } = configRepository.getEnv();
return new Kysely<any>(getKyselyConfig(database.config.kysely)); return new Kysely<any>(getKyselyConfig(database.config));
}; };
const runQuery = async (query: string) => { const runQuery = async (query: string) => {
@@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => { const compare = async () => {
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv(); const { database } = configRepository.getEnv();
const db = postgres(database.config.kysely); const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode(); const source = schemaFromCode();
const target = await schemaFromDatabase(db, {}); const target = await schemaFromDatabase(db, {});

View File

@@ -78,7 +78,7 @@ class SqlGenerator {
const moduleFixture = await Test.createTestingModule({ const moduleFixture = await Test.createTestingModule({
imports: [ imports: [
KyselyModule.forRoot({ KyselyModule.forRoot({
...getKyselyConfig(database.config.kysely), ...getKyselyConfig(database.config),
log: (event) => { log: (event) => {
if (event.level === 'query') { if (event.level === 'query') {
this.sqlLogger.logQuery(event.query.sql); this.sqlLogger.logQuery(event.query.sql);

View File

@@ -80,21 +80,12 @@ describe('getEnv', () => {
const { database } = getEnv(); const { database } = getEnv();
expect(database).toEqual({ expect(database).toEqual({
config: { config: {
kysely: expect.objectContaining({ connectionType: 'parts',
host: 'database', host: 'database',
port: 5432, port: 5432,
database: 'immich', database: 'immich',
username: 'postgres', username: 'postgres',
password: 'postgres', password: 'postgres',
}),
typeorm: expect.objectContaining({
type: 'postgres',
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
}),
}, },
skipMigrations: false, skipMigrations: false,
vectorExtension: 'vectors', vectorExtension: 'vectors',
@@ -110,88 +101,9 @@ describe('getEnv', () => {
it('should use DB_URL', () => { it('should use DB_URL', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich'; process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
const { database } = getEnv(); const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ expect(database.config).toMatchObject({
host: 'database1', connectionType: 'url',
password: 'postgres2', url: 'postgres://postgres1:postgres2@database1:54320/immich',
user: 'postgres1',
port: 54_320,
database: 'immich',
});
});
it('should handle sslmode=require', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=prefer', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-ca', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-full', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=no-verify', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } });
});
it('should handle ssl=true', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: true });
});
it('should reject invalid ssl', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid';
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
});
it('should handle socket: URLs', () => {
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/run/postgresql',
database: 'database1',
});
});
it('should handle sockets in postgres: URLs', () => {
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/path/to/socket',
database: 'database2',
}); });
}); });
}); });

View File

@@ -7,8 +7,7 @@ import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis'; import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { join, resolve } from 'node:path'; import { join } from 'node:path';
import { parse } from 'pg-connection-string';
import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators'; import { Telemetry } from 'src/decorators';
import { EnvDto } from 'src/dtos/env.dto'; import { EnvDto } from 'src/dtos/env.dto';
@@ -22,9 +21,7 @@ import {
QueueName, QueueName,
} from 'src/enum'; } from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types'; import { DatabaseConnectionParams, VectorExtension } from 'src/types';
import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
import { setDifference } from 'src/utils/set'; import { setDifference } from 'src/utils/set';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export interface EnvData { export interface EnvData {
host?: string; host?: string;
@@ -59,7 +56,7 @@ export interface EnvData {
}; };
database: { database: {
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig }; config: DatabaseConnectionParams;
skipMigrations: boolean; skipMigrations: boolean;
vectorExtension: VectorExtension; vectorExtension: VectorExtension;
}; };
@@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
const isProd = environment === ImmichEnvironment.PRODUCTION; const isProd = environment === ImmichEnvironment.PRODUCTION;
const buildFolder = dto.IMMICH_BUILD_DATA || '/build'; const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
const folders = { const folders = {
// eslint-disable-next-line unicorn/prefer-module
dist: resolve(`${__dirname}/..`),
geodata: join(buildFolder, 'geodata'), geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'), web: join(buildFolder, 'www'),
}; };
const databaseUrl = dto.DB_URL;
let redisConfig = { let redisConfig = {
host: dto.REDIS_HOSTNAME || 'redis', host: dto.REDIS_HOSTNAME || 'redis',
port: dto.REDIS_PORT || 6379, port: dto.REDIS_PORT || 6379,
@@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
} }
} }
const parts = { const databaseConnection: DatabaseConnectionParams = dto.DB_URL
connectionType: 'parts', ? { connectionType: 'url', url: dto.DB_URL }
host: dto.DB_HOSTNAME || 'database', : {
port: dto.DB_PORT || 5432, connectionType: 'parts',
username: dto.DB_USERNAME || 'postgres', host: dto.DB_HOSTNAME || 'database',
password: dto.DB_PASSWORD || 'postgres', port: dto.DB_PORT || 5432,
database: dto.DB_DATABASE_NAME || 'immich', username: dto.DB_USERNAME || 'postgres',
} as const; password: dto.DB_PASSWORD || 'postgres',
database: dto.DB_DATABASE_NAME || 'immich',
let parsedOptions: PostgresConnectionConfig = parts; };
if (dto.DB_URL) {
const parsed = parse(dto.DB_URL);
if (!isValidSsl(parsed.ssl)) {
throw new Error(`Invalid ssl option: ${parsed.ssl}`);
}
parsedOptions = {
...parsed,
ssl: parsed.ssl,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
};
}
return { return {
host: dto.IMMICH_HOST, host: dto.IMMICH_HOST,
@@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
}, },
database: { database: {
config: { config: databaseConnection,
typeorm: {
type: 'postgres',
entities: [],
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
subscribers: [],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts),
},
kysely: parsedOptions,
},
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false, skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS, vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
}, },

View File

@@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely'; import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises'; import { readdir } from 'node:fs/promises';
import { join } from 'node:path'; import { join, resolve } from 'node:path';
import semver from 'semver'; import semver from 'semver';
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db'; import { DB } from 'src/db';
@@ -205,8 +205,29 @@ export class DatabaseRepository {
const { rows } = await tableExists.execute(this.db); const { rows } = await tableExists.execute(this.db);
const hasTypeOrmMigrations = !!rows[0]?.result; const hasTypeOrmMigrations = !!rows[0]?.result;
if (hasTypeOrmMigrations) { if (hasTypeOrmMigrations) {
// eslint-disable-next-line unicorn/prefer-module
const dist = resolve(`${__dirname}/..`);
this.logger.debug('Running typeorm migrations'); this.logger.debug('Running typeorm migrations');
const dataSource = new DataSource(database.config.typeorm); const dataSource = new DataSource({
type: 'postgres',
entities: [],
subscribers: [],
migrations: [`${dist}/migrations` + '/*.{js,ts}'],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(database.config.connectionType === 'url'
? { url: database.config.url }
: {
host: database.config.host,
port: database.config.port,
username: database.config.username,
password: database.config.password,
database: database.config.database,
}),
});
await dataSource.initialize(); await dataSource.initialize();
await dataSource.runMigrations(options); await dataSource.runMigrations(options);
await dataSource.destroy(); await dataSource.destroy();

View File

@@ -70,7 +70,7 @@ export class BackupService extends BaseService {
async handleBackupDatabase(): Promise<JobStatus> { async handleBackupDatabase(): Promise<JobStatus> {
this.logger.debug(`Database Backup Started`); this.logger.debug(`Database Backup Started`);
const { database } = this.configRepository.getEnv(); const { database } = this.configRepository.getEnv();
const config = database.config.typeorm; const config = database.config;
const isUrlConnection = config.connectionType === 'url'; const isUrlConnection = config.connectionType === 'url';

View File

@@ -53,22 +53,12 @@ describe(DatabaseService.name, () => {
mockEnvData({ mockEnvData({
database: { database: {
config: { config: {
kysely: { connectionType: 'parts',
host: 'database', host: 'database',
port: 5432, port: 5432,
user: 'postgres', username: 'postgres',
password: 'postgres', password: 'postgres',
database: 'immich', database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
}, },
skipMigrations: false, skipMigrations: false,
vectorExtension: extension, vectorExtension: extension,
@@ -292,22 +282,12 @@ describe(DatabaseService.name, () => {
mockEnvData({ mockEnvData({
database: { database: {
config: { config: {
kysely: { connectionType: 'parts',
host: 'database', host: 'database',
port: 5432, port: 5432,
user: 'postgres', username: 'postgres',
password: 'postgres', password: 'postgres',
database: 'immich', database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
}, },
skipMigrations: true, skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS, vectorExtension: DatabaseExtension.VECTORS,
@@ -325,22 +305,12 @@ describe(DatabaseService.name, () => {
mockEnvData({ mockEnvData({
database: { database: {
config: { config: {
kysely: { connectionType: 'parts',
host: 'database', host: 'database',
port: 5432, port: 5432,
user: 'postgres', username: 'postgres',
password: 'postgres', password: 'postgres',
database: 'immich', database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
}, },
skipMigrations: true, skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR, vectorExtension: DatabaseExtension.VECTOR,

View File

@@ -0,0 +1,83 @@
import { asPostgresConnectionConfig } from 'src/utils/database';
describe('database utils', () => {
describe('asPostgresConnectionConfig', () => {
it('should handle sslmode=require', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=prefer', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-ca', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-full', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=no-verify', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify',
}),
).toMatchObject({ ssl: { rejectUnauthorized: false } });
});
it('should handle ssl=true', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true',
}),
).toMatchObject({ ssl: true });
});
it('should reject invalid ssl', () => {
expect(() =>
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid',
}),
).toThrowError('Invalid ssl option');
});
it('should handle socket: URLs', () => {
expect(
asPostgresConnectionConfig({ connectionType: 'url', url: 'socket:/run/postgresql?db=database1' }),
).toMatchObject({ host: '/run/postgresql', database: 'database1' });
});
it('should handle sockets in postgres: URLs', () => {
expect(
asPostgresConnectionConfig({ connectionType: 'url', url: 'postgres:///database2?host=/path/to/socket' }),
).toMatchObject({
host: '/path/to/socket',
database: 'database2',
});
});
});
});

View File

@@ -13,33 +13,57 @@ import {
} from 'kysely'; } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { parse } from 'pg-connection-string';
import postgres, { Notice } from 'postgres'; import postgres, { Notice } from 'postgres';
import { columns, Exif, Person } from 'src/database'; import { columns, Exif, Person } from 'src/database';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { AssetFileType } from 'src/enum'; import { AssetFileType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository'; import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DatabaseConnectionParams } from 'src/types';
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object; type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
export type PostgresConnectionConfig = { const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
host?: string;
password?: string;
user?: string;
port?: number;
database?: string;
max?: number;
client_encoding?: string;
ssl?: Ssl;
application_name?: string;
fallback_application_name?: string;
options?: string;
};
export const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full'; typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full';
export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig => { export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => {
if (params.connectionType === 'parts') {
return {
host: params.host,
port: params.port,
username: params.username,
password: params.password,
database: params.database,
ssl: undefined,
};
}
const { host, port, user, password, database, ...rest } = parse(params.url);
let ssl: Ssl | undefined;
if (rest.ssl) {
if (!isValidSsl(rest.ssl)) {
throw new Error(`Invalid ssl option: ${rest.ssl}`);
}
ssl = rest.ssl;
}
return {
host: host ?? undefined,
port: port ? Number(port) : undefined,
username: user,
password,
database: database ?? undefined,
ssl,
};
};
export const getKyselyConfig = (
params: DatabaseConnectionParams,
options: Partial<postgres.Options<Record<string, postgres.PostgresType>>> = {},
): KyselyConfig => {
const config = asPostgresConnectionConfig(params);
return { return {
dialect: new PostgresJSDialect({ dialect: new PostgresJSDialect({
postgres: postgres({ postgres: postgres({
@@ -66,6 +90,12 @@ export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig
connection: { connection: {
TimeZone: 'UTC', TimeZone: 'UTC',
}, },
host: config.host,
port: config.port,
username: config.username,
password: config.password,
database: config.database,
ssl: config.ssl,
...options, ...options,
}), }),
}), }),

View File

@@ -1,5 +1,4 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { parse } from 'pg-connection-string';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository'; import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -37,19 +36,10 @@ const globalSetup = async () => {
const postgresPort = postgresContainer.getMappedPort(5432); const postgresPort = postgresContainer.getMappedPort(5432);
const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`; const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`;
const parsed = parse(postgresUrl);
process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl; process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl;
const db = new Kysely<DB>( const db = new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: postgresUrl }));
getKyselyConfig({
...parsed,
ssl: false,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
}),
);
const configRepository = new ConfigRepository(); const configRepository = new ConfigRepository();
const logger = new LoggingRepository(undefined, configRepository); const logger = new LoggingRepository(undefined, configRepository);

View File

@@ -21,19 +21,12 @@ const envData: EnvData = {
database: { database: {
config: { config: {
kysely: { database: 'immich', host: 'database', port: 5432 }, connectionType: 'parts',
typeorm: { database: 'immich',
connectionType: 'parts', host: 'database',
database: 'immich', port: 5432,
type: 'postgres', username: 'postgres',
host: 'database', password: 'postgres',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
synchronize: false,
migrationsRun: true,
},
}, },
skipMigrations: false, skipMigrations: false,

View File

@@ -1,9 +1,9 @@
import { ClassConstructor } from 'class-transformer'; import { ClassConstructor } from 'class-transformer';
import { Kysely, sql } from 'kysely'; import { Kysely } from 'kysely';
import { ChildProcessWithoutNullStreams } from 'node:child_process'; import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { parse } from 'pg-connection-string';
import { PNG } from 'pngjs'; import { PNG } from 'pngjs';
import postgres from 'postgres';
import { DB } from 'src/db'; import { DB } from 'src/db';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
@@ -49,7 +49,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
import { ViewRepository } from 'src/repositories/view-repository'; import { ViewRepository } from 'src/repositories/view-repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { RepositoryInterface } from 'src/types'; import { RepositoryInterface } from 'src/types';
import { getKyselyConfig } from 'src/utils/database'; import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock'; import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
@@ -297,24 +297,20 @@ function* newPngFactory() {
const pngFactory = newPngFactory(); const pngFactory = newPngFactory();
const withDatabase = (url: string, name: string) => url.replace('/immich', `/${name}`);
export const getKyselyDB = async (suffix?: string): Promise<Kysely<DB>> => { export const getKyselyDB = async (suffix?: string): Promise<Kysely<DB>> => {
const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!); const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!;
const sql = postgres({
...asPostgresConnectionConfig({ connectionType: 'url', url: withDatabase(testUrl, 'postgres') }),
max: 1,
});
const parsedOptions = {
...parsed,
ssl: false,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
};
const kysely = new Kysely<DB>(getKyselyConfig({ ...parsedOptions, max: 1, database: 'postgres' }));
const randomSuffix = Math.random().toString(36).slice(2, 7); const randomSuffix = Math.random().toString(36).slice(2, 7);
const dbName = `immich_${suffix ?? randomSuffix}`; const dbName = `immich_${suffix ?? randomSuffix}`;
await sql.unsafe(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`);
await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely); return new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: withDatabase(testUrl, dbName) }));
return new Kysely<DB>(getKyselyConfig({ ...parsedOptions, database: dbName }));
}; };
export const newRandomImage = () => { export const newRandomImage = () => {

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.132.0", "version": "1.132.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "immich-web", "name": "immich-web",
"version": "1.132.0", "version": "1.132.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8", "@formatjs/icu-messageformat-parser": "^2.9.8",
@@ -82,7 +82,7 @@
}, },
"../open-api/typescript-sdk": { "../open-api/typescript-sdk": {
"name": "@immich/sdk", "name": "@immich/sdk",
"version": "1.132.0", "version": "1.132.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"dependencies": { "dependencies": {
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"

View File

@@ -1,6 +1,6 @@
{ {
"name": "immich-web", "name": "immich-web",
"version": "1.132.0", "version": "1.132.2",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"scripts": { "scripts": {