mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 10:52:30 -08:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d60be3d87 | ||
|
|
765da7b182 | ||
|
|
b037158028 | ||
|
|
a03902f174 | ||
|
|
1d610ad9cb | ||
|
|
dab4870fed | ||
|
|
37f5e6e2cb | ||
|
|
57d622bc43 | ||
|
|
c167e46ec7 | ||
|
|
6ce8a1deeb |
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.62",
|
"version": "2.2.64",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.62",
|
"version": "2.2.64",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.62",
|
"version": "2.2.64",
|
||||||
"description": "Command Line Interface (CLI) for Immich",
|
"description": "Command Line Interface (CLI) for Immich",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./dist/index.js",
|
"exports": "./dist/index.js",
|
||||||
|
|||||||
@@ -252,6 +252,13 @@ const milestones: Item[] = [
|
|||||||
description: 'Browse your photos and videos in their folder structure inside the mobile app',
|
description: 'Browse your photos and videos in their folder structure inside the mobile app',
|
||||||
release: 'v1.130.0',
|
release: 'v1.130.0',
|
||||||
}),
|
}),
|
||||||
|
{
|
||||||
|
icon: mdiStar,
|
||||||
|
iconColor: 'gold',
|
||||||
|
title: '60,000 Stars',
|
||||||
|
description: 'Reached 60K Stars on GitHub!',
|
||||||
|
getDateLabel: withLanguage(new Date(2025, 2, 4)),
|
||||||
|
},
|
||||||
withRelease({
|
withRelease({
|
||||||
icon: mdiTagFaces,
|
icon: mdiTagFaces,
|
||||||
iconColor: 'teal',
|
iconColor: 'teal',
|
||||||
@@ -260,13 +267,6 @@ const milestones: Item[] = [
|
|||||||
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
|
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
|
||||||
release: 'v1.127.0',
|
release: 'v1.127.0',
|
||||||
}),
|
}),
|
||||||
{
|
|
||||||
icon: mdiStar,
|
|
||||||
iconColor: 'gold',
|
|
||||||
title: '60,000 Stars',
|
|
||||||
description: 'Reached 60K Stars on GitHub!',
|
|
||||||
getDateLabel: withLanguage(new Date(2025, 2, 4)),
|
|
||||||
},
|
|
||||||
withRelease({
|
withRelease({
|
||||||
icon: mdiLinkEdit,
|
icon: mdiLinkEdit,
|
||||||
iconColor: 'crimson',
|
iconColor: 'crimson',
|
||||||
|
|||||||
8
docs/static/archived-versions.json
vendored
8
docs/static/archived-versions.json
vendored
@@ -1,4 +1,12 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"label": "v1.132.2",
|
||||||
|
"url": "https://v1.132.2.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v1.132.1",
|
||||||
|
"url": "https://v1.132.1.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.132.0",
|
"label": "v1.132.0",
|
||||||
"url": "https://v1.132.0.archive.immich.app"
|
"url": "https://v1.132.0.archive.immich.app"
|
||||||
|
|||||||
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.1.0",
|
"@eslint/eslintrc": "^3.1.0",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
},
|
},
|
||||||
"../cli": {
|
"../cli": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.62",
|
"version": "2.2.64",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ describe(`/oauth`, () => {
|
|||||||
it(`should throw an error if the state mismatches`, async () => {
|
it(`should throw an error if the state mismatches`, async () => {
|
||||||
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
||||||
const { state } = await loginWithOAuth('oauth-auto-register');
|
const { state } = await loginWithOAuth('oauth-auto-register');
|
||||||
const { status, body } = await request(app)
|
const { status } = await request(app)
|
||||||
.post('/oauth/callback')
|
.post('/oauth/callback')
|
||||||
.send({ ...callbackParams, state });
|
.send({ ...callbackParams, state });
|
||||||
expect(status).toBeGreaterThanOrEqual(400);
|
expect(status).toBeGreaterThanOrEqual(400);
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ test.describe('Shared Links', () => {
|
|||||||
await page.goto(`/share/${sharedLink.key}`);
|
await page.goto(`/share/${sharedLink.key}`);
|
||||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||||
await page.getByRole('button', { name: 'Download' }).click();
|
await page.getByRole('button', { name: 'Download' }).click();
|
||||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
|
||||||
await page.waitForEvent('download');
|
await page.waitForEvent('download');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="32" />
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
|
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
@@ -125,4 +124,4 @@
|
|||||||
<data android:scheme="geo" />
|
<data android:scheme="geo" />
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ContentResolver
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.provider.Settings
|
import android.provider.Settings
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
@@ -23,6 +23,7 @@ import io.flutter.plugin.common.PluginRegistry
|
|||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.io.FileInputStream
|
import java.io.FileInputStream
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Android plugin for Dart `BackgroundService` and file trash operations
|
* Android plugin for Dart `BackgroundService` and file trash operations
|
||||||
@@ -33,7 +34,8 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
private var fileTrashChannel: MethodChannel? = null
|
private var fileTrashChannel: MethodChannel? = null
|
||||||
private var context: Context? = null
|
private var context: Context? = null
|
||||||
private var pendingResult: Result? = null
|
private var pendingResult: Result? = null
|
||||||
private val PERMISSION_REQUEST_CODE = 1001
|
private val permissionRequestCode = 1001
|
||||||
|
private val trashRequestCode = 1002
|
||||||
private var activityBinding: ActivityPluginBinding? = null
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
@@ -138,36 +140,35 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
|
|
||||||
// File Trash methods moved from MainActivity
|
// File Trash methods moved from MainActivity
|
||||||
"moveToTrash" -> {
|
"moveToTrash" -> {
|
||||||
val fileName = call.argument<String>("fileName")
|
val mediaUrls = call.argument<List<String>>("mediaUrls")
|
||||||
if (fileName != null) {
|
if (mediaUrls != null) {
|
||||||
if (hasManageStoragePermission()) {
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
val success = moveToTrash(fileName)
|
moveToTrash(mediaUrls, result)
|
||||||
result.success(success)
|
|
||||||
} else {
|
} else {
|
||||||
result.error("PERMISSION_DENIED", "Storage permission required", null)
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"restoreFromTrash" -> {
|
"restoreFromTrash" -> {
|
||||||
val fileName = call.argument<String>("fileName")
|
val fileName = call.argument<String>("fileName")
|
||||||
if (fileName != null) {
|
val type = call.argument<Int>("type")
|
||||||
if (hasManageStoragePermission()) {
|
if (fileName != null && type != null) {
|
||||||
val success = untrashImage(fileName)
|
if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) && hasManageMediaPermission()) {
|
||||||
result.success(success)
|
restoreFromTrash(fileName, type, result)
|
||||||
} else {
|
} else {
|
||||||
result.error("PERMISSION_DENIED", "Storage permission required", null)
|
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
result.error("INVALID_NAME", "The file name is not specified.", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
"requestManageStoragePermission" -> {
|
"requestManageMediaPermission" -> {
|
||||||
if (!hasManageStoragePermission()) {
|
if (!hasManageMediaPermission()) {
|
||||||
requestManageStoragePermission(result)
|
requestManageMediaPermission(result)
|
||||||
} else {
|
} else {
|
||||||
Log.e("Manage storage permission", "Permission already granted")
|
Log.e("Manage storage permission", "Permission already granted")
|
||||||
result.success(true)
|
result.success(true)
|
||||||
@@ -178,100 +179,98 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// File Trash methods moved from MainActivity
|
private fun hasManageMediaPermission(): Boolean {
|
||||||
private fun hasManageStoragePermission(): Boolean {
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
MediaStore.canManageMedia(context!!);
|
||||||
Environment.isExternalStorageManager()
|
} else {
|
||||||
} else {
|
false
|
||||||
true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun requestManageStoragePermission(result: Result) {
|
private fun requestManageMediaPermission(result: Result) {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
pendingResult = result // Store the result callback
|
pendingResult = result // Store the result callback
|
||||||
val activity = activityBinding?.activity ?: return
|
val activity = activityBinding?.activity ?: return
|
||||||
|
|
||||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
|
||||||
intent.data = Uri.parse("package:${activity.packageName}")
|
intent.data = "package:${activity.packageName}".toUri()
|
||||||
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
|
activity.startActivityForResult(intent, permissionRequestCode)
|
||||||
} else {
|
} else {
|
||||||
result.success(true)
|
result.success(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveToTrash(fileName: String): Boolean {
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
val contentResolver = context?.contentResolver ?: return false
|
private fun moveToTrash(mediaUrls: List<String>, result: Result) {
|
||||||
val uri = getFileUri(fileName)
|
val urisToTrash = mediaUrls.map { it.toUri() }
|
||||||
|
if (urisToTrash.isEmpty()) {
|
||||||
|
result.error("INVALID_ARGS", "No valid URIs provided", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleTrash(urisToTrash, true, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun restoreFromTrash(name: String, type: Int, result: Result) {
|
||||||
|
val uri = getTrashedFileUri(name, type)
|
||||||
|
if (uri == null) {
|
||||||
|
Log.e("TrashError", "Asset Uri cannot be found obtained")
|
||||||
|
result.error("TrashError", "Asset Uri cannot be found obtained", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
Log.e("FILE_URI", uri.toString())
|
Log.e("FILE_URI", uri.toString())
|
||||||
return uri?.let { moveToTrash(it) } ?: false
|
uri.let { toggleTrash(listOf(it), false, result) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun moveToTrash(contentUri: Uri): Boolean {
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
val contentResolver = context?.contentResolver ?: return false
|
private fun toggleTrash(contentUris: List<Uri>, isTrashed: Boolean, result: Result) {
|
||||||
return try {
|
val activity = activityBinding?.activity
|
||||||
val values = ContentValues().apply {
|
val contentResolver = context?.contentResolver
|
||||||
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
|
if (activity == null || contentResolver == null) {
|
||||||
|
result.error("TrashError", "Activity or ContentResolver not available", null)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
val updated = contentResolver.update(contentUri, values, null, null)
|
|
||||||
updated > 0
|
try {
|
||||||
} catch (e: Exception) {
|
val pendingIntent = MediaStore.createTrashRequest(contentResolver, contentUris, isTrashed)
|
||||||
Log.e("TrashError", "Error moving to trash", e)
|
pendingResult = result // Store for onActivityResult
|
||||||
false
|
activity.startIntentSenderForResult(
|
||||||
|
pendingIntent.intentSender,
|
||||||
|
trashRequestCode,
|
||||||
|
null, 0, 0, 0
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("TrashError", "Error creating or starting trash request", e)
|
||||||
|
result.error("TrashError", "Error creating or starting trash request", null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getFileUri(fileName: String): Uri? {
|
@RequiresApi(Build.VERSION_CODES.R)
|
||||||
|
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
|
||||||
val contentResolver = context?.contentResolver ?: return null
|
val contentResolver = context?.contentResolver ?: return null
|
||||||
val contentUri = MediaStore.Files.getContentUri("external")
|
val queryUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||||
val projection = arrayOf(MediaStore.Images.Media._ID)
|
|
||||||
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
|
|
||||||
val selectionArgs = arrayOf(fileName)
|
|
||||||
var fileUri: Uri? = null
|
|
||||||
|
|
||||||
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
|
|
||||||
if (cursor.moveToFirst()) {
|
|
||||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
|
|
||||||
fileUri = ContentUris.withAppendedId(contentUri, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fileUri
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun untrashImage(name: String): Boolean {
|
|
||||||
val contentResolver = context?.contentResolver ?: return false
|
|
||||||
val uri = getTrashedFileUri(contentResolver, name)
|
|
||||||
Log.e("FILE_URI", uri.toString())
|
|
||||||
return uri?.let { untrashImage(it) } ?: false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun untrashImage(contentUri: Uri): Boolean {
|
|
||||||
val contentResolver = context?.contentResolver ?: return false
|
|
||||||
return try {
|
|
||||||
val values = ContentValues().apply {
|
|
||||||
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
|
|
||||||
}
|
|
||||||
val updated = contentResolver.update(contentUri, values, null, null)
|
|
||||||
updated > 0
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e("TrashError", "Error restoring file", e)
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
|
|
||||||
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
|
||||||
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
|
val projection = arrayOf(MediaStore.Files.FileColumns._ID)
|
||||||
|
|
||||||
val queryArgs = Bundle().apply {
|
val queryArgs = Bundle().apply {
|
||||||
putString(ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?")
|
putString(
|
||||||
|
ContentResolver.QUERY_ARG_SQL_SELECTION,
|
||||||
|
"${MediaStore.Files.FileColumns.DISPLAY_NAME} = ?"
|
||||||
|
)
|
||||||
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
|
putStringArray(ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(fileName))
|
||||||
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
putInt(MediaStore.QUERY_ARG_MATCH_TRASHED, MediaStore.MATCH_ONLY)
|
||||||
}
|
}
|
||||||
|
|
||||||
contentResolver.query(contentUri, projection, queryArgs, null)?.use { cursor ->
|
contentResolver.query(queryUri, projection, queryArgs, null)?.use { cursor ->
|
||||||
if (cursor.moveToFirst()) {
|
if (cursor.moveToFirst()) {
|
||||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Files.FileColumns._ID))
|
||||||
|
// same order as AssetType from dart
|
||||||
|
val contentUri = when (type) {
|
||||||
|
1 -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
|
||||||
|
2 -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
|
||||||
|
3 -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||||
|
else -> queryUri
|
||||||
|
}
|
||||||
return ContentUris.withAppendedId(contentUri, id)
|
return ContentUris.withAppendedId(contentUri, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,12 +300,19 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
|||||||
|
|
||||||
// ActivityResultListener implementation
|
// ActivityResultListener implementation
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||||
if (requestCode == PERMISSION_REQUEST_CODE) {
|
if (requestCode == permissionRequestCode) {
|
||||||
val granted = hasManageStoragePermission()
|
val granted = hasManageMediaPermission()
|
||||||
pendingResult?.success(granted)
|
pendingResult?.success(granted)
|
||||||
pendingResult = null
|
pendingResult = null
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (requestCode == trashRequestCode) {
|
||||||
|
val approved = resultCode == Activity.RESULT_OK
|
||||||
|
pendingResult?.success(approved)
|
||||||
|
pendingResult = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 194,
|
"android.injected.version.code" => 196,
|
||||||
"android.injected.version.name" => "1.132.0",
|
"android.injected.version.name" => "1.132.2",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||||
|
|||||||
@@ -541,7 +541,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 201;
|
CURRENT_PROJECT_VERSION = 202;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -685,7 +685,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 201;
|
CURRENT_PROJECT_VERSION = 202;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -715,7 +715,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 201;
|
CURRENT_PROJECT_VERSION = 202;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
@@ -748,7 +748,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 201;
|
CURRENT_PROJECT_VERSION = 202;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -791,7 +791,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 201;
|
CURRENT_PROJECT_VERSION = 202;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -831,7 +831,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 201;
|
CURRENT_PROJECT_VERSION = 202;
|
||||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.131.3</string>
|
<string>1.132.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>201</string>
|
<string>202</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ platform :ios do
|
|||||||
desc "iOS Release"
|
desc "iOS Release"
|
||||||
lane :release do
|
lane :release do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.132.0"
|
version_number: "1.132.2"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
abstract interface class ILocalFilesManager {
|
abstract interface class ILocalFilesManager {
|
||||||
Future<bool> moveToTrash(String fileName);
|
Future<bool> moveToTrash(List<String> mediaUrls);
|
||||||
Future<bool> restoreFromTrash(String fileName);
|
Future<bool> restoreFromTrash(String fileName, int type);
|
||||||
Future<bool> requestManageStoragePermission();
|
Future<bool> requestManageMediaPermission();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,21 +3,23 @@ import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
|||||||
import 'package:immich_mobile/utils/local_files_manager.dart';
|
import 'package:immich_mobile/utils/local_files_manager.dart';
|
||||||
|
|
||||||
final localFilesManagerRepositoryProvider =
|
final localFilesManagerRepositoryProvider =
|
||||||
Provider((ref) => LocalFilesManagerRepository());
|
Provider((ref) => const LocalFilesManagerRepository());
|
||||||
|
|
||||||
class LocalFilesManagerRepository implements ILocalFilesManager {
|
class LocalFilesManagerRepository implements ILocalFilesManager {
|
||||||
|
const LocalFilesManagerRepository();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> moveToTrash(String fileName) async {
|
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||||
return await LocalFilesManager.moveToTrash(fileName);
|
return await LocalFilesManager.moveToTrash(mediaUrls);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> restoreFromTrash(String fileName) async {
|
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||||
return await LocalFilesManager.restoreFromTrash(fileName);
|
return await LocalFilesManager.restoreFromTrash(fileName, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> requestManageStoragePermission() async {
|
Future<bool> requestManageMediaPermission() async {
|
||||||
return await LocalFilesManager.requestManageStoragePermission();
|
return await LocalFilesManager.requestManageMediaPermission();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,9 +255,12 @@ class SyncService {
|
|||||||
.where((asset) => idsToDelete.contains(asset.remoteId))
|
.where((asset) => idsToDelete.contains(asset.remoteId))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
for (var asset in matchedAssets) {
|
final mediaUrls = await Future.wait(
|
||||||
_localFilesManager.moveToTrash(asset.fileName);
|
matchedAssets
|
||||||
}
|
.map((asset) => asset.local?.getMediaUrl() ?? Future.value(null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes remote-only assets, updates merged assets to be local-only
|
/// Deletes remote-only assets, updates merged assets to be local-only
|
||||||
@@ -819,13 +822,29 @@ class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
|
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
|
||||||
for (var asset in assetsList) {
|
final trashMediaUrls = <String>[];
|
||||||
|
|
||||||
|
for (final asset in assetsList) {
|
||||||
if (asset.isTrashed) {
|
if (asset.isTrashed) {
|
||||||
_localFilesManager.moveToTrash(asset.fileName);
|
final mediaUrl = await asset.local?.getMediaUrl();
|
||||||
|
if (mediaUrl == null) {
|
||||||
|
_log.warning(
|
||||||
|
"Failed to get media URL for asset ${asset.name} while moving to trash",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
trashMediaUrls.add(mediaUrl);
|
||||||
} else {
|
} else {
|
||||||
_localFilesManager.restoreFromTrash(asset.fileName);
|
await _localFilesManager.restoreFromTrash(
|
||||||
|
asset.fileName,
|
||||||
|
asset.type.index,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (trashMediaUrls.isNotEmpty) {
|
||||||
|
await _localFilesManager.moveToTrash(trashMediaUrls);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||||
|
|||||||
@@ -1,38 +1,37 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
class LocalFilesManager {
|
abstract final class LocalFilesManager {
|
||||||
|
static final Logger _logger = Logger('LocalFilesManager');
|
||||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||||
|
|
||||||
static Future<bool> moveToTrash(String fileName) async {
|
static Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||||
try {
|
try {
|
||||||
final bool success =
|
return await _channel
|
||||||
await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
|
.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
||||||
return success;
|
} catch (e, s) {
|
||||||
} on PlatformException catch (e) {
|
_logger.warning('Error moving file to trash', e, s);
|
||||||
debugPrint('Error moving to trash: ${e.message}');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> restoreFromTrash(String fileName) async {
|
static Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||||
try {
|
try {
|
||||||
final bool success = await _channel
|
return await _channel.invokeMethod(
|
||||||
.invokeMethod('restoreFromTrash', {'fileName': fileName});
|
'restoreFromTrash',
|
||||||
return success;
|
{'fileName': fileName, 'type': type},
|
||||||
} on PlatformException catch (e) {
|
);
|
||||||
debugPrint('Error restoring file: ${e.message}');
|
} catch (e, s) {
|
||||||
|
_logger.warning('Error restore file from trash', e, s);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> requestManageStoragePermission() async {
|
static Future<bool> requestManageMediaPermission() async {
|
||||||
try {
|
try {
|
||||||
final bool success =
|
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||||
await _channel.invokeMethod('requestManageStoragePermission');
|
} catch (e, s) {
|
||||||
return success;
|
_logger.warning('Error requesting manage media permission', e, s);
|
||||||
} on PlatformException catch (e) {
|
|
||||||
debugPrint('Error requesting permission: ${e.message}');
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.dart';
|
import 'package:immich_mobile/entities/album.entity.dart';
|
||||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||||
@@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||||
import 'package:immich_mobile/utils/diff.dart';
|
import 'package:immich_mobile/utils/diff.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
// ignore: import_rule_photo_manager
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
const int targetVersion = 10;
|
const int targetVersion = 10;
|
||||||
|
|
||||||
@@ -69,14 +71,45 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
|||||||
: (await db.iOSDeviceAssets.where().findAll())
|
: (await db.iOSDeviceAssets.where().findAll())
|
||||||
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
|
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
|
||||||
.toList();
|
.toList();
|
||||||
final localAssets = (await db.assets
|
|
||||||
.where()
|
final PermissionState ps = await PhotoManager.requestPermissionExtend();
|
||||||
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
if (!ps.hasAccess) {
|
||||||
.findAll())
|
if (kDebugMode) {
|
||||||
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
|
debugPrint(
|
||||||
.toList();
|
"[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
|
||||||
debugPrint("Device Asset Ids length - ${ids.length}");
|
);
|
||||||
debugPrint("Local Asset Ids length - ${localAssets.length}");
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<_DeviceAsset> localAssets = [];
|
||||||
|
final List<AssetPathEntity> paths =
|
||||||
|
await PhotoManager.getAssetPathList(onlyAll: true);
|
||||||
|
|
||||||
|
if (paths.isEmpty) {
|
||||||
|
localAssets = (await db.assets
|
||||||
|
.where()
|
||||||
|
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
||||||
|
.findAll())
|
||||||
|
.map(
|
||||||
|
(a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
} else {
|
||||||
|
final AssetPathEntity albumWithAll = paths.first;
|
||||||
|
final int assetCount = await albumWithAll.assetCountAsync;
|
||||||
|
|
||||||
|
final List<AssetEntity> allDeviceAssets =
|
||||||
|
await albumWithAll.getAssetListRange(start: 0, end: assetCount);
|
||||||
|
|
||||||
|
localAssets = allDeviceAssets
|
||||||
|
.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
|
||||||
|
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
|
||||||
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
|
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||||
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
|
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||||
final List<DeviceAssetEntity> toAdd = [];
|
final List<DeviceAssetEntity> toAdd = [];
|
||||||
@@ -95,15 +128,27 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
onlyFirst: (deviceAsset) {
|
onlyFirst: (deviceAsset) {
|
||||||
debugPrint(
|
if (kDebugMode) {
|
||||||
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
|
debugPrint(
|
||||||
);
|
'[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onlySecond: (asset) {
|
onlySecond: (asset) {
|
||||||
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}');
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
'[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}',
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
debugPrint("Total number of device assets migrated - ${toAdd.length}");
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint(
|
||||||
|
"[MIGRATION] Total number of device assets migrated - ${toAdd.length}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await db.writeTxn(() async {
|
await db.writeTxn(() async {
|
||||||
await db.deviceAssetEntitys.putAll(toAdd);
|
await db.deviceAssetEntitys.putAll(toAdd);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
||||||
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
||||||
int sdkVersion = androidInfo.version.sdkInt;
|
int sdkVersion = androidInfo.version.sdkInt;
|
||||||
return sdkVersion >= 30;
|
return sdkVersion >= 31;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
if (value) {
|
if (value) {
|
||||||
final result = await ref
|
final result = await ref
|
||||||
.read(localFilesManagerRepositoryProvider)
|
.read(localFilesManagerRepositoryProvider)
|
||||||
.requestManageStoragePermission();
|
.requestManageMediaPermission();
|
||||||
manageLocalMediaAndroid.value = result;
|
manageLocalMediaAndroid.value = result;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
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:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.132.0
|
- API version: 1.132.2
|
||||||
- Generator version: 7.8.0
|
- Generator version: 7.8.0
|
||||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
|||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.132.0+194
|
version: 1.132.2+196
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
|||||||
@@ -7656,7 +7656,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"contact": {}
|
"contact": {}
|
||||||
},
|
},
|
||||||
"tags": [],
|
"tags": [],
|
||||||
|
|||||||
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",
|
"name": "@immich/sdk",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./build/index.js",
|
"main": "./build/index.js",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Immich
|
* Immich
|
||||||
* 1.132.0
|
* 1.132.2
|
||||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||||
* See https://www.npmjs.com/package/oazapfts
|
* See https://www.npmjs.com/package/oazapfts
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ WORKDIR /usr/src/app
|
|||||||
COPY server/package.json server/package-lock.json ./
|
COPY server/package.json server/package-lock.json ./
|
||||||
COPY server/patches ./patches
|
COPY server/patches ./patches
|
||||||
RUN npm ci && \
|
RUN npm ci && \
|
||||||
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||||
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
||||||
rm -rf node_modules/@img/sharp-libvips* && \
|
rm -rf node_modules/@img/sharp-libvips* && \
|
||||||
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
||||||
ENV PATH="${PATH}:/usr/src/app/bin" \
|
ENV PATH="${PATH}:/usr/src/app/bin" \
|
||||||
IMMICH_ENV=development \
|
IMMICH_ENV=development \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||||
NVIDIA_VISIBLE_DEVICES=all
|
NVIDIA_VISIBLE_DEVICES=all
|
||||||
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
||||||
|
|
||||||
|
|
||||||
@@ -47,8 +47,8 @@ FROM ghcr.io/immich-app/base-server-prod:202504081114@sha256:8353bcbdb4e6579300a
|
|||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||||
NVIDIA_VISIBLE_DEVICES=all
|
NVIDIA_VISIBLE_DEVICES=all
|
||||||
COPY --from=prod /usr/src/app/node_modules ./node_modules
|
COPY --from=prod /usr/src/app/node_modules ./node_modules
|
||||||
COPY --from=prod /usr/src/app/dist ./dist
|
COPY --from=prod /usr/src/app/dist ./dist
|
||||||
COPY --from=prod /usr/src/app/bin ./bin
|
COPY --from=prod /usr/src/app/bin ./bin
|
||||||
|
|||||||
1261
server/package-lock.json
generated
1261
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"sanitize-filename": "^1.6.3",
|
"sanitize-filename": "^1.6.3",
|
||||||
"sanitize-html": "^2.14.0",
|
"sanitize-html": "^2.14.0",
|
||||||
"semver": "^7.6.2",
|
"semver": "^7.6.2",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.34.0",
|
||||||
"sirv": "^3.0.0",
|
"sirv": "^3.0.0",
|
||||||
"tailwindcss-preset-email": "^1.3.2",
|
"tailwindcss-preset-email": "^1.3.2",
|
||||||
"thumbhash": "^0.1.1",
|
"thumbhash": "^0.1.1",
|
||||||
@@ -132,7 +132,8 @@
|
|||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
"mock-fs": "^5.2.0",
|
"mock-fs": "^5.2.0",
|
||||||
"node-addon-api": "^8.3.0",
|
"node-addon-api": "^8.3.1",
|
||||||
|
"node-gyp": "^11.2.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"pngjs": "^7.0.0",
|
"pngjs": "^7.0.0",
|
||||||
"prettier": "^3.0.2",
|
"prettier": "^3.0.2",
|
||||||
@@ -152,5 +153,8 @@
|
|||||||
},
|
},
|
||||||
"volta": {
|
"volta": {
|
||||||
"node": "22.14.0"
|
"node": "22.14.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"sharp": "^0.34.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const imports = [
|
|||||||
BullModule.registerQueue(...bull.queues),
|
BullModule.registerQueue(...bull.queues),
|
||||||
ClsModule.forRoot(cls.config),
|
ClsModule.forRoot(cls.config),
|
||||||
OpenTelemetryModule.forRoot(otel),
|
OpenTelemetryModule.forRoot(otel),
|
||||||
KyselyModule.forRoot(getKyselyConfig(database.config.kysely)),
|
KyselyModule.forRoot(getKyselyConfig(database.config)),
|
||||||
];
|
];
|
||||||
|
|
||||||
class BaseModule implements OnModuleInit, OnModuleDestroy {
|
class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
|
|||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import 'src/schema';
|
import 'src/schema';
|
||||||
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
|
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
|
||||||
import { getKyselyConfig } from 'src/utils/database';
|
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
|
||||||
|
|
||||||
const main = async () => {
|
const main = async () => {
|
||||||
const command = process.argv[2];
|
const command = process.argv[2];
|
||||||
@@ -56,7 +56,7 @@ const main = async () => {
|
|||||||
const getDatabaseClient = () => {
|
const getDatabaseClient = () => {
|
||||||
const configRepository = new ConfigRepository();
|
const configRepository = new ConfigRepository();
|
||||||
const { database } = configRepository.getEnv();
|
const { database } = configRepository.getEnv();
|
||||||
return new Kysely<any>(getKyselyConfig(database.config.kysely));
|
return new Kysely<any>(getKyselyConfig(database.config));
|
||||||
};
|
};
|
||||||
|
|
||||||
const runQuery = async (query: string) => {
|
const runQuery = async (query: string) => {
|
||||||
@@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
|
|||||||
const compare = async () => {
|
const compare = async () => {
|
||||||
const configRepository = new ConfigRepository();
|
const configRepository = new ConfigRepository();
|
||||||
const { database } = configRepository.getEnv();
|
const { database } = configRepository.getEnv();
|
||||||
const db = postgres(database.config.kysely);
|
const db = postgres(asPostgresConnectionConfig(database.config));
|
||||||
|
|
||||||
const source = schemaFromCode();
|
const source = schemaFromCode();
|
||||||
const target = await schemaFromDatabase(db, {});
|
const target = await schemaFromDatabase(db, {});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class SqlGenerator {
|
|||||||
const moduleFixture = await Test.createTestingModule({
|
const moduleFixture = await Test.createTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
KyselyModule.forRoot({
|
KyselyModule.forRoot({
|
||||||
...getKyselyConfig(database.config.kysely),
|
...getKyselyConfig(database.config),
|
||||||
log: (event) => {
|
log: (event) => {
|
||||||
if (event.level === 'query') {
|
if (event.level === 'query') {
|
||||||
this.sqlLogger.logQuery(event.query.sql);
|
this.sqlLogger.logQuery(event.query.sql);
|
||||||
|
|||||||
@@ -80,21 +80,12 @@ describe('getEnv', () => {
|
|||||||
const { database } = getEnv();
|
const { database } = getEnv();
|
||||||
expect(database).toEqual({
|
expect(database).toEqual({
|
||||||
config: {
|
config: {
|
||||||
kysely: expect.objectContaining({
|
connectionType: 'parts',
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
database: 'immich',
|
database: 'immich',
|
||||||
username: 'postgres',
|
username: 'postgres',
|
||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
}),
|
|
||||||
typeorm: expect.objectContaining({
|
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
database: 'immich',
|
|
||||||
username: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
skipMigrations: false,
|
skipMigrations: false,
|
||||||
vectorExtension: 'vectors',
|
vectorExtension: 'vectors',
|
||||||
@@ -110,88 +101,9 @@ describe('getEnv', () => {
|
|||||||
it('should use DB_URL', () => {
|
it('should use DB_URL', () => {
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
|
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
|
||||||
const { database } = getEnv();
|
const { database } = getEnv();
|
||||||
expect(database.config.kysely).toMatchObject({
|
expect(database.config).toMatchObject({
|
||||||
host: 'database1',
|
connectionType: 'url',
|
||||||
password: 'postgres2',
|
url: 'postgres://postgres1:postgres2@database1:54320/immich',
|
||||||
user: 'postgres1',
|
|
||||||
port: 54_320,
|
|
||||||
database: 'immich',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle sslmode=require', () => {
|
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle sslmode=prefer', () => {
|
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle sslmode=verify-ca', () => {
|
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle sslmode=verify-full', () => {
|
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle sslmode=no-verify', () => {
|
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle ssl=true', () => {
|
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({ ssl: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should reject invalid ssl', () => {
|
|
||||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid';
|
|
||||||
|
|
||||||
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle socket: URLs', () => {
|
|
||||||
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({
|
|
||||||
host: '/run/postgresql',
|
|
||||||
database: 'database1',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle sockets in postgres: URLs', () => {
|
|
||||||
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
|
|
||||||
|
|
||||||
const { database } = getEnv();
|
|
||||||
|
|
||||||
expect(database.config.kysely).toMatchObject({
|
|
||||||
host: '/path/to/socket',
|
|
||||||
database: 'database2',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import { Request, Response } from 'express';
|
|||||||
import { RedisOptions } from 'ioredis';
|
import { RedisOptions } from 'ioredis';
|
||||||
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
|
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
|
||||||
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
||||||
import { join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { parse } from 'pg-connection-string';
|
|
||||||
import { citiesFile, excludePaths, IWorker } from 'src/constants';
|
import { citiesFile, excludePaths, IWorker } from 'src/constants';
|
||||||
import { Telemetry } from 'src/decorators';
|
import { Telemetry } from 'src/decorators';
|
||||||
import { EnvDto } from 'src/dtos/env.dto';
|
import { EnvDto } from 'src/dtos/env.dto';
|
||||||
@@ -22,9 +21,7 @@ import {
|
|||||||
QueueName,
|
QueueName,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
|
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
|
||||||
import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
|
|
||||||
import { setDifference } from 'src/utils/set';
|
import { setDifference } from 'src/utils/set';
|
||||||
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
|
|
||||||
|
|
||||||
export interface EnvData {
|
export interface EnvData {
|
||||||
host?: string;
|
host?: string;
|
||||||
@@ -59,7 +56,7 @@ export interface EnvData {
|
|||||||
};
|
};
|
||||||
|
|
||||||
database: {
|
database: {
|
||||||
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig };
|
config: DatabaseConnectionParams;
|
||||||
skipMigrations: boolean;
|
skipMigrations: boolean;
|
||||||
vectorExtension: VectorExtension;
|
vectorExtension: VectorExtension;
|
||||||
};
|
};
|
||||||
@@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
|
|||||||
const isProd = environment === ImmichEnvironment.PRODUCTION;
|
const isProd = environment === ImmichEnvironment.PRODUCTION;
|
||||||
const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
|
const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
|
||||||
const folders = {
|
const folders = {
|
||||||
// eslint-disable-next-line unicorn/prefer-module
|
|
||||||
dist: resolve(`${__dirname}/..`),
|
|
||||||
geodata: join(buildFolder, 'geodata'),
|
geodata: join(buildFolder, 'geodata'),
|
||||||
web: join(buildFolder, 'www'),
|
web: join(buildFolder, 'www'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const databaseUrl = dto.DB_URL;
|
|
||||||
|
|
||||||
let redisConfig = {
|
let redisConfig = {
|
||||||
host: dto.REDIS_HOSTNAME || 'redis',
|
host: dto.REDIS_HOSTNAME || 'redis',
|
||||||
port: dto.REDIS_PORT || 6379,
|
port: dto.REDIS_PORT || 6379,
|
||||||
@@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const parts = {
|
const databaseConnection: DatabaseConnectionParams = dto.DB_URL
|
||||||
connectionType: 'parts',
|
? { connectionType: 'url', url: dto.DB_URL }
|
||||||
host: dto.DB_HOSTNAME || 'database',
|
: {
|
||||||
port: dto.DB_PORT || 5432,
|
connectionType: 'parts',
|
||||||
username: dto.DB_USERNAME || 'postgres',
|
host: dto.DB_HOSTNAME || 'database',
|
||||||
password: dto.DB_PASSWORD || 'postgres',
|
port: dto.DB_PORT || 5432,
|
||||||
database: dto.DB_DATABASE_NAME || 'immich',
|
username: dto.DB_USERNAME || 'postgres',
|
||||||
} as const;
|
password: dto.DB_PASSWORD || 'postgres',
|
||||||
|
database: dto.DB_DATABASE_NAME || 'immich',
|
||||||
let parsedOptions: PostgresConnectionConfig = parts;
|
};
|
||||||
if (dto.DB_URL) {
|
|
||||||
const parsed = parse(dto.DB_URL);
|
|
||||||
if (!isValidSsl(parsed.ssl)) {
|
|
||||||
throw new Error(`Invalid ssl option: ${parsed.ssl}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedOptions = {
|
|
||||||
...parsed,
|
|
||||||
ssl: parsed.ssl,
|
|
||||||
host: parsed.host ?? undefined,
|
|
||||||
port: parsed.port ? Number(parsed.port) : undefined,
|
|
||||||
database: parsed.database ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host: dto.IMMICH_HOST,
|
host: dto.IMMICH_HOST,
|
||||||
@@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: databaseConnection,
|
||||||
typeorm: {
|
|
||||||
type: 'postgres',
|
|
||||||
entities: [],
|
|
||||||
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
|
|
||||||
subscribers: [],
|
|
||||||
migrationsRun: false,
|
|
||||||
synchronize: false,
|
|
||||||
connectTimeoutMS: 10_000, // 10 seconds
|
|
||||||
parseInt8: true,
|
|
||||||
...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts),
|
|
||||||
},
|
|
||||||
kysely: parsedOptions,
|
|
||||||
},
|
|
||||||
|
|
||||||
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
|
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
|
||||||
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
|
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
|
|||||||
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
|
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { readdir } from 'node:fs/promises';
|
import { readdir } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
|
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
@@ -205,8 +205,29 @@ export class DatabaseRepository {
|
|||||||
const { rows } = await tableExists.execute(this.db);
|
const { rows } = await tableExists.execute(this.db);
|
||||||
const hasTypeOrmMigrations = !!rows[0]?.result;
|
const hasTypeOrmMigrations = !!rows[0]?.result;
|
||||||
if (hasTypeOrmMigrations) {
|
if (hasTypeOrmMigrations) {
|
||||||
|
// eslint-disable-next-line unicorn/prefer-module
|
||||||
|
const dist = resolve(`${__dirname}/..`);
|
||||||
|
|
||||||
this.logger.debug('Running typeorm migrations');
|
this.logger.debug('Running typeorm migrations');
|
||||||
const dataSource = new DataSource(database.config.typeorm);
|
const dataSource = new DataSource({
|
||||||
|
type: 'postgres',
|
||||||
|
entities: [],
|
||||||
|
subscribers: [],
|
||||||
|
migrations: [`${dist}/migrations` + '/*.{js,ts}'],
|
||||||
|
migrationsRun: false,
|
||||||
|
synchronize: false,
|
||||||
|
connectTimeoutMS: 10_000, // 10 seconds
|
||||||
|
parseInt8: true,
|
||||||
|
...(database.config.connectionType === 'url'
|
||||||
|
? { url: database.config.url }
|
||||||
|
: {
|
||||||
|
host: database.config.host,
|
||||||
|
port: database.config.port,
|
||||||
|
username: database.config.username,
|
||||||
|
password: database.config.password,
|
||||||
|
database: database.config.database,
|
||||||
|
}),
|
||||||
|
});
|
||||||
await dataSource.initialize();
|
await dataSource.initialize();
|
||||||
await dataSource.runMigrations(options);
|
await dataSource.runMigrations(options);
|
||||||
await dataSource.destroy();
|
await dataSource.destroy();
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ export class BackupService extends BaseService {
|
|||||||
async handleBackupDatabase(): Promise<JobStatus> {
|
async handleBackupDatabase(): Promise<JobStatus> {
|
||||||
this.logger.debug(`Database Backup Started`);
|
this.logger.debug(`Database Backup Started`);
|
||||||
const { database } = this.configRepository.getEnv();
|
const { database } = this.configRepository.getEnv();
|
||||||
const config = database.config.typeorm;
|
const config = database.config;
|
||||||
|
|
||||||
const isUrlConnection = config.connectionType === 'url';
|
const isUrlConnection = config.connectionType === 'url';
|
||||||
|
|
||||||
|
|||||||
@@ -53,22 +53,12 @@ describe(DatabaseService.name, () => {
|
|||||||
mockEnvData({
|
mockEnvData({
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: {
|
||||||
kysely: {
|
connectionType: 'parts',
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
user: 'postgres',
|
username: 'postgres',
|
||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
database: 'immich',
|
database: 'immich',
|
||||||
},
|
|
||||||
typeorm: {
|
|
||||||
connectionType: 'parts',
|
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
username: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
database: 'immich',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
skipMigrations: false,
|
skipMigrations: false,
|
||||||
vectorExtension: extension,
|
vectorExtension: extension,
|
||||||
@@ -292,22 +282,12 @@ describe(DatabaseService.name, () => {
|
|||||||
mockEnvData({
|
mockEnvData({
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: {
|
||||||
kysely: {
|
connectionType: 'parts',
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
user: 'postgres',
|
username: 'postgres',
|
||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
database: 'immich',
|
database: 'immich',
|
||||||
},
|
|
||||||
typeorm: {
|
|
||||||
connectionType: 'parts',
|
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
username: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
database: 'immich',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
skipMigrations: true,
|
skipMigrations: true,
|
||||||
vectorExtension: DatabaseExtension.VECTORS,
|
vectorExtension: DatabaseExtension.VECTORS,
|
||||||
@@ -325,22 +305,12 @@ describe(DatabaseService.name, () => {
|
|||||||
mockEnvData({
|
mockEnvData({
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: {
|
||||||
kysely: {
|
connectionType: 'parts',
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
user: 'postgres',
|
username: 'postgres',
|
||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
database: 'immich',
|
database: 'immich',
|
||||||
},
|
|
||||||
typeorm: {
|
|
||||||
connectionType: 'parts',
|
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
username: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
database: 'immich',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
skipMigrations: true,
|
skipMigrations: true,
|
||||||
vectorExtension: DatabaseExtension.VECTOR,
|
vectorExtension: DatabaseExtension.VECTOR,
|
||||||
|
|||||||
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';
|
} from 'kysely';
|
||||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||||
|
import { parse } from 'pg-connection-string';
|
||||||
import postgres, { Notice } from 'postgres';
|
import postgres, { Notice } from 'postgres';
|
||||||
import { columns, Exif, Person } from 'src/database';
|
import { columns, Exif, Person } from 'src/database';
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
import { AssetFileType } from 'src/enum';
|
import { AssetFileType } from 'src/enum';
|
||||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||||
|
import { DatabaseConnectionParams } from 'src/types';
|
||||||
|
|
||||||
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
|
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
|
||||||
|
|
||||||
export type PostgresConnectionConfig = {
|
const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
|
||||||
host?: string;
|
|
||||||
password?: string;
|
|
||||||
user?: string;
|
|
||||||
port?: number;
|
|
||||||
database?: string;
|
|
||||||
max?: number;
|
|
||||||
client_encoding?: string;
|
|
||||||
ssl?: Ssl;
|
|
||||||
application_name?: string;
|
|
||||||
fallback_application_name?: string;
|
|
||||||
options?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
|
|
||||||
typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full';
|
typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full';
|
||||||
|
|
||||||
export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig => {
|
export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => {
|
||||||
|
if (params.connectionType === 'parts') {
|
||||||
|
return {
|
||||||
|
host: params.host,
|
||||||
|
port: params.port,
|
||||||
|
username: params.username,
|
||||||
|
password: params.password,
|
||||||
|
database: params.database,
|
||||||
|
ssl: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { host, port, user, password, database, ...rest } = parse(params.url);
|
||||||
|
let ssl: Ssl | undefined;
|
||||||
|
if (rest.ssl) {
|
||||||
|
if (!isValidSsl(rest.ssl)) {
|
||||||
|
throw new Error(`Invalid ssl option: ${rest.ssl}`);
|
||||||
|
}
|
||||||
|
ssl = rest.ssl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: host ?? undefined,
|
||||||
|
port: port ? Number(port) : undefined,
|
||||||
|
username: user,
|
||||||
|
password,
|
||||||
|
database: database ?? undefined,
|
||||||
|
ssl,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getKyselyConfig = (
|
||||||
|
params: DatabaseConnectionParams,
|
||||||
|
options: Partial<postgres.Options<Record<string, postgres.PostgresType>>> = {},
|
||||||
|
): KyselyConfig => {
|
||||||
|
const config = asPostgresConnectionConfig(params);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dialect: new PostgresJSDialect({
|
dialect: new PostgresJSDialect({
|
||||||
postgres: postgres({
|
postgres: postgres({
|
||||||
@@ -66,6 +90,12 @@ export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig
|
|||||||
connection: {
|
connection: {
|
||||||
TimeZone: 'UTC',
|
TimeZone: 'UTC',
|
||||||
},
|
},
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
username: config.username,
|
||||||
|
password: config.password,
|
||||||
|
database: config.database,
|
||||||
|
ssl: config.ssl,
|
||||||
...options,
|
...options,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { parse } from 'pg-connection-string';
|
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||||
@@ -37,19 +36,10 @@ const globalSetup = async () => {
|
|||||||
|
|
||||||
const postgresPort = postgresContainer.getMappedPort(5432);
|
const postgresPort = postgresContainer.getMappedPort(5432);
|
||||||
const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`;
|
const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`;
|
||||||
const parsed = parse(postgresUrl);
|
|
||||||
|
|
||||||
process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl;
|
process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl;
|
||||||
|
|
||||||
const db = new Kysely<DB>(
|
const db = new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: postgresUrl }));
|
||||||
getKyselyConfig({
|
|
||||||
...parsed,
|
|
||||||
ssl: false,
|
|
||||||
host: parsed.host ?? undefined,
|
|
||||||
port: parsed.port ? Number(parsed.port) : undefined,
|
|
||||||
database: parsed.database ?? undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const configRepository = new ConfigRepository();
|
const configRepository = new ConfigRepository();
|
||||||
const logger = new LoggingRepository(undefined, configRepository);
|
const logger = new LoggingRepository(undefined, configRepository);
|
||||||
|
|||||||
@@ -21,19 +21,12 @@ const envData: EnvData = {
|
|||||||
|
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: {
|
||||||
kysely: { database: 'immich', host: 'database', port: 5432 },
|
connectionType: 'parts',
|
||||||
typeorm: {
|
database: 'immich',
|
||||||
connectionType: 'parts',
|
host: 'database',
|
||||||
database: 'immich',
|
port: 5432,
|
||||||
type: 'postgres',
|
username: 'postgres',
|
||||||
host: 'database',
|
password: 'postgres',
|
||||||
port: 5432,
|
|
||||||
username: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
name: 'immich',
|
|
||||||
synchronize: false,
|
|
||||||
migrationsRun: true,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
skipMigrations: false,
|
skipMigrations: false,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ClassConstructor } from 'class-transformer';
|
import { ClassConstructor } from 'class-transformer';
|
||||||
import { Kysely, sql } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import { parse } from 'pg-connection-string';
|
|
||||||
import { PNG } from 'pngjs';
|
import { PNG } from 'pngjs';
|
||||||
|
import postgres from 'postgres';
|
||||||
import { DB } from 'src/db';
|
import { DB } from 'src/db';
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||||
@@ -49,7 +49,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
|
|||||||
import { ViewRepository } from 'src/repositories/view-repository';
|
import { ViewRepository } from 'src/repositories/view-repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { RepositoryInterface } from 'src/types';
|
import { RepositoryInterface } from 'src/types';
|
||||||
import { getKyselyConfig } from 'src/utils/database';
|
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
|
||||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||||
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
||||||
@@ -297,24 +297,20 @@ function* newPngFactory() {
|
|||||||
|
|
||||||
const pngFactory = newPngFactory();
|
const pngFactory = newPngFactory();
|
||||||
|
|
||||||
|
const withDatabase = (url: string, name: string) => url.replace('/immich', `/${name}`);
|
||||||
|
|
||||||
export const getKyselyDB = async (suffix?: string): Promise<Kysely<DB>> => {
|
export const getKyselyDB = async (suffix?: string): Promise<Kysely<DB>> => {
|
||||||
const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!);
|
const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!;
|
||||||
|
const sql = postgres({
|
||||||
|
...asPostgresConnectionConfig({ connectionType: 'url', url: withDatabase(testUrl, 'postgres') }),
|
||||||
|
max: 1,
|
||||||
|
});
|
||||||
|
|
||||||
const parsedOptions = {
|
|
||||||
...parsed,
|
|
||||||
ssl: false,
|
|
||||||
host: parsed.host ?? undefined,
|
|
||||||
port: parsed.port ? Number(parsed.port) : undefined,
|
|
||||||
database: parsed.database ?? undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
const kysely = new Kysely<DB>(getKyselyConfig({ ...parsedOptions, max: 1, database: 'postgres' }));
|
|
||||||
const randomSuffix = Math.random().toString(36).slice(2, 7);
|
const randomSuffix = Math.random().toString(36).slice(2, 7);
|
||||||
const dbName = `immich_${suffix ?? randomSuffix}`;
|
const dbName = `immich_${suffix ?? randomSuffix}`;
|
||||||
|
await sql.unsafe(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`);
|
||||||
|
|
||||||
await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely);
|
return new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: withDatabase(testUrl, dbName) }));
|
||||||
|
|
||||||
return new Kysely<DB>(getKyselyConfig({ ...parsedOptions, database: dbName }));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const newRandomImage = () => {
|
export const newRandomImage = () => {
|
||||||
|
|||||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
},
|
},
|
||||||
"../open-api/typescript-sdk": {
|
"../open-api/typescript-sdk": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oazapfts/runtime": "^1.0.2"
|
"@oazapfts/runtime": "^1.0.2"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.132.0",
|
"version": "1.132.2",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user