Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d60be3d87 | ||
|
|
765da7b182 | ||
|
|
b037158028 | ||
|
|
a03902f174 | ||
|
|
1d610ad9cb | ||
|
|
dab4870fed |
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.63",
|
||||
"version": "2.2.64",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.63",
|
||||
"version": "2.2.64",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.63",
|
||||
"version": "2.2.64",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
@@ -252,6 +252,13 @@ const milestones: Item[] = [
|
||||
description: 'Browse your photos and videos in their folder structure inside the mobile app',
|
||||
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({
|
||||
icon: mdiTagFaces,
|
||||
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.',
|
||||
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({
|
||||
icon: mdiLinkEdit,
|
||||
iconColor: 'crimson',
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,4 +1,8 @@
|
||||
[
|
||||
{
|
||||
"label": "v1.132.2",
|
||||
"url": "https://v1.132.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.132.1",
|
||||
"url": "https://v1.132.1.archive.immich.app"
|
||||
|
||||
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-e2e",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"../cli": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.63",
|
||||
"version": "2.2.64",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -142,7 +142,7 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if the state mismatches`, async () => {
|
||||
const callbackParams = 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')
|
||||
.send({ ...callbackParams, state });
|
||||
expect(status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
@@ -55,7 +55,6 @@ test.describe('Shared Links', () => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
||||
await page.waitForEvent('download');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,42 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.Result
|
||||
import io.flutter.plugin.common.PluginRegistry
|
||||
import java.security.MessageDigest
|
||||
import java.io.FileInputStream
|
||||
import kotlinx.coroutines.*
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* Android plugin for Dart `BackgroundService`
|
||||
*
|
||||
* Receives messages/method calls from the foreground Dart side to manage
|
||||
* the background service, e.g. start (enqueue), stop (cancel)
|
||||
* Android plugin for Dart `BackgroundService` and file trash operations
|
||||
*/
|
||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
|
||||
|
||||
private var methodChannel: MethodChannel? = null
|
||||
private var fileTrashChannel: MethodChannel? = null
|
||||
private var context: Context? = null
|
||||
private var pendingResult: Result? = null
|
||||
private val permissionRequestCode = 1001
|
||||
private val trashRequestCode = 1002
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
||||
@@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
context = ctx
|
||||
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
|
||||
methodChannel?.setMethodCallHandler(this)
|
||||
|
||||
// Add file trash channel
|
||||
fileTrashChannel = MethodChannel(messenger, "file_trash")
|
||||
fileTrashChannel?.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
private fun onDetachedFromEngine() {
|
||||
methodChannel?.setMethodCallHandler(null)
|
||||
methodChannel = null
|
||||
fileTrashChannel?.setMethodCallHandler(null)
|
||||
fileTrashChannel = null
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
override fun onMethodCall(call: MethodCall, result: Result) {
|
||||
val ctx = context!!
|
||||
when (call.method) {
|
||||
// Existing BackgroundService methods
|
||||
"enable" -> {
|
||||
val args = call.arguments<ArrayList<*>>()!!
|
||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
||||
@@ -114,10 +138,184 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
||||
}
|
||||
}
|
||||
|
||||
// File Trash methods moved from MainActivity
|
||||
"moveToTrash" -> {
|
||||
val mediaUrls = call.argument<List<String>>("mediaUrls")
|
||||
if (mediaUrls != null) {
|
||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||
moveToTrash(mediaUrls, result)
|
||||
} else {
|
||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||
}
|
||||
} else {
|
||||
result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
|
||||
}
|
||||
}
|
||||
|
||||
"restoreFromTrash" -> {
|
||||
val fileName = call.argument<String>("fileName")
|
||||
val type = call.argument<Int>("type")
|
||||
if (fileName != null && type != null) {
|
||||
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||
restoreFromTrash(fileName, type, result)
|
||||
} else {
|
||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||
}
|
||||
} else {
|
||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
||||
}
|
||||
}
|
||||
|
||||
"requestManageMediaPermission" -> {
|
||||
if (!hasManageMediaPermission()) {
|
||||
requestManageMediaPermission(result)
|
||||
} else {
|
||||
Log.e("Manage storage permission", "Permission already granted")
|
||||
result.success(true)
|
||||
}
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(context!!);
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestManageMediaPermission(result: Result) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
pendingResult = result // Store the result callback
|
||||
val activity = activityBinding?.activity ?: return
|
||||
|
||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
|
||||
intent.data = "package:${activity.packageName}".toUri()
|
||||
activity.startActivityForResult(intent, permissionRequestCode)
|
||||
} else {
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun moveToTrash(mediaUrls: List<String>, result: Result) {
|
||||
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())
|
||||
uri.let { toggleTrash(listOf(it), false, result) }
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
|
||||
val activity = activityBinding?.activity
|
||||
val contentResolver = context?.contentResolver
|
||||
if (activity == null || contentResolver == null) {
|
||||
result.error("TrashError", "Activity or ContentResolver not available", null)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
|
||||
pendingResult = result // Store for onActivityResult
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
|
||||
val contentResolver = context?.contentResolver ?: return null
|
||||
val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
|
||||
|
||||
val queryArgs = Bundle().apply {
|
||||
putString(
|
||||
ContentResolver.QUERY_ARG_SQL_SELECTION,
|
||||
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
|
||||
)
|
||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
|
||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||
}
|
||||
|
||||
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
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 null
|
||||
}
|
||||
|
||||
// ActivityAware implementation
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivityForConfigChanges() {
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
|
||||
activityBinding = binding
|
||||
binding.addActivityResultListener(this)
|
||||
}
|
||||
|
||||
override fun onDetachedFromActivity() {
|
||||
activityBinding?.removeActivityResultListener(this)
|
||||
activityBinding = null
|
||||
}
|
||||
|
||||
// ActivityResultListener implementation
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == permissionRequestCode) {
|
||||
val granted = hasManageMediaPermission()
|
||||
pendingResult?.success(granted)
|
||||
pendingResult = null
|
||||
return true
|
||||
}
|
||||
|
||||
if (requestCode == trashRequestCode) {
|
||||
val approved = resultCode == Activity.RESULT_OK
|
||||
pendingResult?.success(approved)
|
||||
pendingResult = null
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private const val TAG = "BackgroundServicePlugin"
|
||||
private const val BUFFER_SIZE = 2 * 1024 * 1024;
|
||||
private const val BUFFER_SIZE = 2 * 1024 * 1024
|
||||
|
||||
@@ -2,14 +2,12 @@ package app.alextran.immich
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import android.os.Bundle
|
||||
import android.content.Intent
|
||||
import androidx.annotation.NonNull
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||
// No need to set up method channel here as it's now handled in the plugin
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 195,
|
||||
"android.injected.version.name" => "1.132.1",
|
||||
"android.injected.version.code" => 196,
|
||||
"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')
|
||||
|
||||
@@ -19,7 +19,7 @@ platform :ios do
|
||||
desc "iOS Release"
|
||||
lane :release do
|
||||
increment_version_number(
|
||||
version_number: "1.132.1"
|
||||
version_number: "1.132.2"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -65,6 +65,7 @@ enum StoreKey<T> {
|
||||
|
||||
// Video settings
|
||||
loadOriginalVideo<bool>._(136),
|
||||
manageLocalMediaAndroid<bool>._(137),
|
||||
|
||||
// Experimental stuff
|
||||
photoManagerCustomFilter<bool>._(1000);
|
||||
|
||||
5
mobile/lib/interfaces/local_files_manager.interface.dart
Normal file
5
mobile/lib/interfaces/local_files_manager.interface.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
abstract interface class ILocalFilesManager {
|
||||
Future<bool> moveToTrash(List<String> mediaUrls);
|
||||
Future<bool> restoreFromTrash(String fileName, int type);
|
||||
Future<bool> requestManageMediaPermission();
|
||||
}
|
||||
@@ -23,6 +23,7 @@ enum PendingAction {
|
||||
assetDelete,
|
||||
assetUploaded,
|
||||
assetHidden,
|
||||
assetTrash,
|
||||
}
|
||||
|
||||
class PendingChange {
|
||||
@@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
socket.on('on_upload_success', _handleOnUploadSuccess);
|
||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||
socket.on('on_asset_delete', _handleOnAssetDelete);
|
||||
socket.on('on_asset_trash', _handleServerUpdates);
|
||||
socket.on('on_asset_trash', _handleOnAssetTrash);
|
||||
socket.on('on_asset_restore', _handleServerUpdates);
|
||||
socket.on('on_asset_update', _handleServerUpdates);
|
||||
socket.on('on_asset_stack_update', _handleServerUpdates);
|
||||
@@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
_debounce.run(handlePendingChanges);
|
||||
}
|
||||
|
||||
Future<void> _handlePendingTrashes() async {
|
||||
final trashChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetTrash)
|
||||
.toList();
|
||||
if (trashChanges.isNotEmpty) {
|
||||
List<String> remoteIds = trashChanges
|
||||
.expand((a) => (a.value as List).map((e) => e.toString()))
|
||||
.toList();
|
||||
|
||||
await _ref.read(syncServiceProvider).handleRemoteAssetRemoval(remoteIds);
|
||||
await _ref.read(assetProvider.notifier).getAllAsset();
|
||||
|
||||
state = state.copyWith(
|
||||
pendingChanges: state.pendingChanges
|
||||
.whereNot((c) => trashChanges.contains(c))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handlePendingDeletes() async {
|
||||
final deleteChanges = state.pendingChanges
|
||||
.where((c) => c.action == PendingAction.assetDelete)
|
||||
@@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
await _handlePendingUploaded();
|
||||
await _handlePendingDeletes();
|
||||
await _handlingPendingHidden();
|
||||
await _handlePendingTrashes();
|
||||
}
|
||||
|
||||
void _handleOnConfigUpdate(dynamic _) {
|
||||
@@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
void _handleOnAssetDelete(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetDelete, data);
|
||||
|
||||
void _handleOnAssetTrash(dynamic data) {
|
||||
addPendingChange(PendingAction.assetTrash, data);
|
||||
}
|
||||
|
||||
void _handleOnAssetHidden(dynamic data) =>
|
||||
addPendingChange(PendingAction.assetHidden, data);
|
||||
|
||||
|
||||
25
mobile/lib/repositories/local_files_manager.repository.dart
Normal file
25
mobile/lib/repositories/local_files_manager.repository.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||
import 'package:immich_mobile/utils/local_files_manager.dart';
|
||||
|
||||
final localFilesManagerRepositoryProvider =
|
||||
Provider((ref) => const LocalFilesManagerRepository());
|
||||
|
||||
class LocalFilesManagerRepository implements ILocalFilesManager {
|
||||
const LocalFilesManagerRepository();
|
||||
|
||||
@override
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
return await LocalFilesManager.moveToTrash(mediaUrls);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
return await LocalFilesManager.restoreFromTrash(fileName, type);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
return await LocalFilesManager.requestManageMediaPermission();
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
|
||||
0,
|
||||
),
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -16,8 +17,10 @@ import 'package:immich_mobile/interfaces/album_api.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/album_media.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/partner.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/exif.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/album.repository.dart';
|
||||
@@ -25,8 +28,10 @@ import 'package:immich_mobile/repositories/album_api.repository.dart';
|
||||
import 'package:immich_mobile/repositories/album_media.repository.dart';
|
||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||
import 'package:immich_mobile/repositories/etag.repository.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner.repository.dart';
|
||||
import 'package:immich_mobile/repositories/partner_api.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/entity.service.dart';
|
||||
import 'package:immich_mobile/services/hash.service.dart';
|
||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||
@@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
|
||||
ref.watch(userRepositoryProvider),
|
||||
ref.watch(userServiceProvider),
|
||||
ref.watch(etagRepositoryProvider),
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(localFilesManagerRepositoryProvider),
|
||||
ref.watch(partnerApiRepositoryProvider),
|
||||
ref.watch(userApiRepositoryProvider),
|
||||
),
|
||||
@@ -69,6 +76,8 @@ class SyncService {
|
||||
final IUserApiRepository _userApiRepository;
|
||||
final AsyncMutex _lock = AsyncMutex();
|
||||
final Logger _log = Logger('SyncService');
|
||||
final AppSettingsService _appSettingsService;
|
||||
final ILocalFilesManager _localFilesManager;
|
||||
|
||||
SyncService(
|
||||
this._hashService,
|
||||
@@ -82,6 +91,8 @@ class SyncService {
|
||||
this._userRepository,
|
||||
this._userService,
|
||||
this._eTagRepository,
|
||||
this._appSettingsService,
|
||||
this._localFilesManager,
|
||||
this._partnerApiRepository,
|
||||
this._userApiRepository,
|
||||
);
|
||||
@@ -238,8 +249,22 @@ class SyncService {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _moveToTrashMatchedAssets(Iterable<String> idsToDelete) async {
|
||||
final List<Asset> localAssets = await _assetRepository.getAllLocal();
|
||||
final List<Asset> matchedAssets = localAssets
|
||||
.where((asset) => idsToDelete.contains(asset.remoteId))
|
||||
.toList();
|
||||
|
||||
final mediaUrls = await Future.wait(
|
||||
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
|
||||
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) {
|
||||
Future<void> handleRemoteAssetRemoval(List<String> idsToDelete) async {
|
||||
return _assetRepository.transaction(() async {
|
||||
await _assetRepository.deleteAllByRemoteId(
|
||||
idsToDelete,
|
||||
@@ -249,6 +274,12 @@ class SyncService {
|
||||
idsToDelete,
|
||||
state: AssetState.merged,
|
||||
);
|
||||
if (Platform.isAndroid &&
|
||||
_appSettingsService.getSetting<bool>(
|
||||
AppSettingsEnum.manageLocalMediaAndroid,
|
||||
)) {
|
||||
await _moveToTrashMatchedAssets(idsToDelete);
|
||||
}
|
||||
if (merged.isEmpty) return;
|
||||
for (final Asset asset in merged) {
|
||||
asset.remoteId = null;
|
||||
@@ -790,10 +821,43 @@ class SyncService {
|
||||
return (existing, toUpsert);
|
||||
}
|
||||
|
||||
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
|
||||
final trashMediaUrls = <String>[];
|
||||
|
||||
for (final asset in assetsList) {
|
||||
if (asset.isTrashed) {
|
||||
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 {
|
||||
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)
|
||||
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
||||
if (assets.isEmpty) return;
|
||||
|
||||
if (Platform.isAndroid &&
|
||||
_appSettingsService.getSetting<bool>(
|
||||
AppSettingsEnum.manageLocalMediaAndroid,
|
||||
)) {
|
||||
_toggleTrashStatusForAssets(assets);
|
||||
}
|
||||
|
||||
try {
|
||||
await _assetRepository.transaction(() async {
|
||||
await _assetRepository.updateAll(assets);
|
||||
|
||||
38
mobile/lib/utils/local_files_manager.dart
Normal file
38
mobile/lib/utils/local_files_manager.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
abstract final class LocalFilesManager {
|
||||
static final Logger _logger = Logger('LocalFilesManager');
|
||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||
|
||||
static Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
try {
|
||||
return await _channel
|
||||
.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error moving file to trash', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
try {
|
||||
return await _channel.invokeMethod(
|
||||
'restoreFromTrash',
|
||||
{'fileName': fileName, 'type': type},
|
||||
);
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error restore file from trash', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> requestManageMediaPermission() async {
|
||||
try {
|
||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import 'dart:async';
|
||||
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/entities/album.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/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 10;
|
||||
|
||||
@@ -69,14 +71,45 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
||||
: (await db.iOSDeviceAssets.where().findAll())
|
||||
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
|
||||
.toList();
|
||||
final localAssets = (await db.assets
|
||||
.where()
|
||||
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
||||
.findAll())
|
||||
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
|
||||
.toList();
|
||||
debugPrint("Device Asset Ids length - ${ids.length}");
|
||||
debugPrint("Local Asset Ids length - ${localAssets.length}");
|
||||
|
||||
final PermissionState ps = await PhotoManager.requestPermissionExtend();
|
||||
if (!ps.hasAccess) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
"[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||
final List<DeviceAssetEntity> toAdd = [];
|
||||
@@ -95,15 +128,27 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (deviceAsset) {
|
||||
debugPrint(
|
||||
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
|
||||
);
|
||||
}
|
||||
},
|
||||
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.deviceAssetEntitys.putAll(toAdd);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||
@@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
|
||||
final advancedTroubleshooting =
|
||||
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||
final manageLocalMediaAndroid =
|
||||
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||
final allowSelfSignedSSLCert =
|
||||
@@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
|
||||
);
|
||||
|
||||
Future<bool> checkAndroidVersion() async {
|
||||
if (Platform.isAndroid) {
|
||||
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
||||
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
||||
int sdkVersion = androidInfo.version.sdkInt;
|
||||
return sdkVersion >= 31;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
final advancedSettings = [
|
||||
SettingsSwitchListTile(
|
||||
enabled: true,
|
||||
@@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
title: "advanced_settings_troubleshooting_title".tr(),
|
||||
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
|
||||
),
|
||||
FutureBuilder<bool>(
|
||||
future: checkAndroidVersion(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData && snapshot.data == true) {
|
||||
return SettingsSwitchListTile(
|
||||
enabled: true,
|
||||
valueNotifier: manageLocalMediaAndroid,
|
||||
title: "advanced_settings_sync_remote_deletions_title".tr(),
|
||||
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
|
||||
onChanged: (value) async {
|
||||
if (value) {
|
||||
final result = await ref
|
||||
.read(localFilesManagerRepositoryProvider)
|
||||
.requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
},
|
||||
),
|
||||
SettingsSliderListTile(
|
||||
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
|
||||
valueNotifier: levelId,
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.132.1
|
||||
- API version: 1.132.2
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 1.132.1+195
|
||||
version: 1.132.2+196
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
@@ -60,6 +60,9 @@ void main() {
|
||||
final MockAlbumMediaRepository albumMediaRepository =
|
||||
MockAlbumMediaRepository();
|
||||
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
|
||||
final MockAppSettingService appSettingService = MockAppSettingService();
|
||||
final MockLocalFilesManagerRepository localFilesManagerRepository =
|
||||
MockLocalFilesManagerRepository();
|
||||
final MockPartnerApiRepository partnerApiRepository =
|
||||
MockPartnerApiRepository();
|
||||
final MockUserApiRepository userApiRepository = MockUserApiRepository();
|
||||
@@ -106,6 +109,8 @@ void main() {
|
||||
userRepository,
|
||||
userService,
|
||||
eTagRepository,
|
||||
appSettingService,
|
||||
localFilesManagerRepository,
|
||||
partnerApiRepository,
|
||||
userApiRepository,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/file_media.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/partner.interface.dart';
|
||||
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
@@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
|
||||
|
||||
class MockAuthRepository extends Mock implements IAuthRepository {}
|
||||
|
||||
class MockPartnerRepository extends Mock implements IPartnerRepository {}
|
||||
|
||||
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
|
||||
|
||||
class MockPartnerRepository extends Mock implements IPartnerRepository {}
|
||||
class MockLocalFilesManagerRepository extends Mock
|
||||
implements ILocalFilesManager {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/services/background.service.dart';
|
||||
import 'package:immich_mobile/services/backup.service.dart';
|
||||
import 'package:immich_mobile/services/entity.service.dart';
|
||||
@@ -25,4 +26,6 @@ class MockNetworkService extends Mock implements NetworkService {}
|
||||
|
||||
class MockSearchApi extends Mock implements SearchApi {}
|
||||
|
||||
class MockAppSettingService extends Mock implements AppSettingsService {}
|
||||
|
||||
class MockBackgroundService extends Mock implements BackgroundService {}
|
||||
|
||||
@@ -7656,7 +7656,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 1.132.1
|
||||
* 1.132.2
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"hasInstallScript": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -44,7 +44,7 @@ const imports = [
|
||||
BullModule.registerQueue(...bull.queues),
|
||||
ClsModule.forRoot(cls.config),
|
||||
OpenTelemetryModule.forRoot(otel),
|
||||
KyselyModule.forRoot(getKyselyConfig(database.config.kysely)),
|
||||
KyselyModule.forRoot(getKyselyConfig(database.config)),
|
||||
];
|
||||
|
||||
class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import 'src/schema';
|
||||
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 command = process.argv[2];
|
||||
@@ -56,7 +56,7 @@ const main = async () => {
|
||||
const getDatabaseClient = () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
return new Kysely<any>(getKyselyConfig(database.config.kysely));
|
||||
return new Kysely<any>(getKyselyConfig(database.config));
|
||||
};
|
||||
|
||||
const runQuery = async (query: string) => {
|
||||
@@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
|
||||
const compare = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
const db = postgres(database.config.kysely);
|
||||
const db = postgres(asPostgresConnectionConfig(database.config));
|
||||
|
||||
const source = schemaFromCode();
|
||||
const target = await schemaFromDatabase(db, {});
|
||||
|
||||
@@ -78,7 +78,7 @@ class SqlGenerator {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [
|
||||
KyselyModule.forRoot({
|
||||
...getKyselyConfig(database.config.kysely),
|
||||
...getKyselyConfig(database.config),
|
||||
log: (event) => {
|
||||
if (event.level === 'query') {
|
||||
this.sqlLogger.logQuery(event.query.sql);
|
||||
|
||||
@@ -80,21 +80,12 @@ describe('getEnv', () => {
|
||||
const { database } = getEnv();
|
||||
expect(database).toEqual({
|
||||
config: {
|
||||
kysely: expect.objectContaining({
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
database: 'immich',
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
}),
|
||||
typeorm: expect.objectContaining({
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
database: 'immich',
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
}),
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
database: 'immich',
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
},
|
||||
skipMigrations: false,
|
||||
vectorExtension: 'vectors',
|
||||
@@ -110,88 +101,9 @@ describe('getEnv', () => {
|
||||
it('should use DB_URL', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
|
||||
const { database } = getEnv();
|
||||
expect(database.config.kysely).toMatchObject({
|
||||
host: 'database1',
|
||||
password: 'postgres2',
|
||||
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',
|
||||
expect(database.config).toMatchObject({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,7 @@ import { Request, Response } from 'express';
|
||||
import { RedisOptions } from 'ioredis';
|
||||
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
|
||||
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import { join } from 'node:path';
|
||||
import { citiesFile, excludePaths, IWorker } from 'src/constants';
|
||||
import { Telemetry } from 'src/decorators';
|
||||
import { EnvDto } from 'src/dtos/env.dto';
|
||||
@@ -22,9 +21,7 @@ import {
|
||||
QueueName,
|
||||
} from 'src/enum';
|
||||
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
|
||||
import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
|
||||
import { setDifference } from 'src/utils/set';
|
||||
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
|
||||
|
||||
export interface EnvData {
|
||||
host?: string;
|
||||
@@ -59,7 +56,7 @@ export interface EnvData {
|
||||
};
|
||||
|
||||
database: {
|
||||
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig };
|
||||
config: DatabaseConnectionParams;
|
||||
skipMigrations: boolean;
|
||||
vectorExtension: VectorExtension;
|
||||
};
|
||||
@@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
|
||||
const isProd = environment === ImmichEnvironment.PRODUCTION;
|
||||
const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
|
||||
const folders = {
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
dist: resolve(`${__dirname}/..`),
|
||||
geodata: join(buildFolder, 'geodata'),
|
||||
web: join(buildFolder, 'www'),
|
||||
};
|
||||
|
||||
const databaseUrl = dto.DB_URL;
|
||||
|
||||
let redisConfig = {
|
||||
host: dto.REDIS_HOSTNAME || 'redis',
|
||||
port: dto.REDIS_PORT || 6379,
|
||||
@@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
|
||||
}
|
||||
}
|
||||
|
||||
const parts = {
|
||||
connectionType: 'parts',
|
||||
host: dto.DB_HOSTNAME || 'database',
|
||||
port: dto.DB_PORT || 5432,
|
||||
username: dto.DB_USERNAME || 'postgres',
|
||||
password: dto.DB_PASSWORD || 'postgres',
|
||||
database: dto.DB_DATABASE_NAME || 'immich',
|
||||
} as const;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
const databaseConnection: DatabaseConnectionParams = dto.DB_URL
|
||||
? { connectionType: 'url', url: dto.DB_URL }
|
||||
: {
|
||||
connectionType: 'parts',
|
||||
host: dto.DB_HOSTNAME || 'database',
|
||||
port: dto.DB_PORT || 5432,
|
||||
username: dto.DB_USERNAME || 'postgres',
|
||||
password: dto.DB_PASSWORD || 'postgres',
|
||||
database: dto.DB_DATABASE_NAME || 'immich',
|
||||
};
|
||||
|
||||
return {
|
||||
host: dto.IMMICH_HOST,
|
||||
@@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
|
||||
},
|
||||
|
||||
database: {
|
||||
config: {
|
||||
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,
|
||||
},
|
||||
|
||||
config: databaseConnection,
|
||||
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
|
||||
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
|
||||
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
|
||||
import { DB } from 'src/db';
|
||||
@@ -205,8 +205,29 @@ export class DatabaseRepository {
|
||||
const { rows } = await tableExists.execute(this.db);
|
||||
const hasTypeOrmMigrations = !!rows[0]?.result;
|
||||
if (hasTypeOrmMigrations) {
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
const dist = resolve(`${__dirname}/..`);
|
||||
|
||||
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.runMigrations(options);
|
||||
await dataSource.destroy();
|
||||
|
||||
@@ -70,7 +70,7 @@ export class BackupService extends BaseService {
|
||||
async handleBackupDatabase(): Promise<JobStatus> {
|
||||
this.logger.debug(`Database Backup Started`);
|
||||
const { database } = this.configRepository.getEnv();
|
||||
const config = database.config.typeorm;
|
||||
const config = database.config;
|
||||
|
||||
const isUrlConnection = config.connectionType === 'url';
|
||||
|
||||
|
||||
@@ -53,22 +53,12 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
kysely: {
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: false,
|
||||
vectorExtension: extension,
|
||||
@@ -292,22 +282,12 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
kysely: {
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: true,
|
||||
vectorExtension: DatabaseExtension.VECTORS,
|
||||
@@ -325,22 +305,12 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
kysely: {
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: true,
|
||||
vectorExtension: DatabaseExtension.VECTOR,
|
||||
|
||||
83
server/src/utils/database.spec.ts
Normal file
83
server/src/utils/database.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,33 +13,57 @@ import {
|
||||
} from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import postgres, { Notice } from 'postgres';
|
||||
import { columns, Exif, Person } from 'src/database';
|
||||
import { DB } from 'src/db';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DatabaseConnectionParams } from 'src/types';
|
||||
|
||||
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
|
||||
|
||||
export type PostgresConnectionConfig = {
|
||||
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 =>
|
||||
const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
|
||||
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 {
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres({
|
||||
@@ -66,6 +90,12 @@ export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig
|
||||
connection: {
|
||||
TimeZone: 'UTC',
|
||||
},
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
ssl: config.ssl,
|
||||
...options,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import { DB } from 'src/db';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
@@ -37,19 +36,10 @@ const globalSetup = async () => {
|
||||
|
||||
const postgresPort = postgresContainer.getMappedPort(5432);
|
||||
const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`;
|
||||
const parsed = parse(postgresUrl);
|
||||
|
||||
process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl;
|
||||
|
||||
const db = new Kysely<DB>(
|
||||
getKyselyConfig({
|
||||
...parsed,
|
||||
ssl: false,
|
||||
host: parsed.host ?? undefined,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
database: parsed.database ?? undefined,
|
||||
}),
|
||||
);
|
||||
const db = new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: postgresUrl }));
|
||||
|
||||
const configRepository = new ConfigRepository();
|
||||
const logger = new LoggingRepository(undefined, configRepository);
|
||||
|
||||
@@ -21,19 +21,12 @@ const envData: EnvData = {
|
||||
|
||||
database: {
|
||||
config: {
|
||||
kysely: { database: 'immich', host: 'database', port: 5432 },
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
database: 'immich',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
name: 'immich',
|
||||
synchronize: false,
|
||||
migrationsRun: true,
|
||||
},
|
||||
connectionType: 'parts',
|
||||
database: 'immich',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
},
|
||||
|
||||
skipMigrations: false,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { Kysely } from 'kysely';
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { Writable } from 'node:stream';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import { PNG } from 'pngjs';
|
||||
import postgres from 'postgres';
|
||||
import { DB } from 'src/db';
|
||||
import { AccessRepository } from 'src/repositories/access.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 { BaseService } from 'src/services/base.service';
|
||||
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 { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
||||
@@ -297,24 +297,20 @@ function* newPngFactory() {
|
||||
|
||||
const pngFactory = newPngFactory();
|
||||
|
||||
const withDatabase = (url: string, name: string) => url.replace('/immich', `/${name}`);
|
||||
|
||||
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 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({ ...parsedOptions, database: dbName }));
|
||||
return new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: withDatabase(testUrl, dbName) }));
|
||||
};
|
||||
|
||||
export const newRandomImage = () => {
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-web",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
@@ -82,7 +82,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.132.1",
|
||||
"version": "1.132.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user