Compare commits

..

6 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
47 changed files with 734 additions and 321 deletions

6
cli/package-lock.json generated
View File

@@ -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": {

View File

@@ -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",

View File

@@ -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',

View File

@@ -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
View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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')

View File

@@ -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,

View File

@@ -65,6 +65,7 @@ enum StoreKey<T> {
// Video settings
loadOriginalVideo<bool>._(136),
manageLocalMediaAndroid<bool>._(137),
// Experimental stuff
photoManagerCustomFilter<bool>._(1000);

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

View File

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

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

View File

@@ -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),

View File

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

View 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;
}
}
}

View File

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

View File

@@ -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,

View File

@@ -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

View File

@@ -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'

View File

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

View File

@@ -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 {}

View File

@@ -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 {}

View File

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

View File

@@ -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"

View File

@@ -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",

View File

@@ -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
*/

View File

@@ -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": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.132.1",
"version": "1.132.2",
"description": "",
"author": "",
"private": true,

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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,
},

View File

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

View File

@@ -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';

View File

@@ -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,

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';
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,
}),
}),

View File

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

View File

@@ -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,

View File

@@ -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
View File

@@ -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"

View File

@@ -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": {