mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
Merge branch 'improve_focus' into keynav_timeline
This commit is contained in:
commit
65e7e9b9b4
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.63",
|
"version": "2.2.65",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/cli",
|
"name": "@immich/cli",
|
||||||
"version": "2.2.63",
|
"version": "2.2.65",
|
||||||
"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.1",
|
"version": "1.132.3",
|
||||||
"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.63",
|
"version": "2.2.65",
|
||||||
"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",
|
||||||
|
@ -14,14 +14,14 @@ online generators you can use.
|
|||||||
2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.)
|
2. Paste the link to your JSON style in either the **Light Style** or **Dark Style**. (You can add different styles which will help make the map style more appropriate depending on whether you set **Immich** to Light or Dark mode.)
|
||||||
3. Save your selections. Reload the map, and enjoy your custom map style!
|
3. Save your selections. Reload the map, and enjoy your custom map style!
|
||||||
|
|
||||||
## Use Maptiler to build a custom style
|
## Use MapTiler to build a custom style
|
||||||
|
|
||||||
Customizing the map style can be done easily using Maptiler, if you do not want to write an entire JSON document by hand.
|
Customizing the map style can be done easily using MapTiler, if you do not want to write an entire JSON document by hand.
|
||||||
|
|
||||||
1. Create a free account at https://cloud.maptiler.com
|
1. Create a free account at https://cloud.maptiler.com
|
||||||
2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there.
|
2. Once logged in, you can either create a brand new map by clicking on **New Map**, selecting a starter map, and then clicking **Customize**, OR by selecting a **Standard Map** and customizing it from there.
|
||||||
3. The **editor** interface is self-explanatory. You can change colors, remove visible layers, or add optional layers (e.g., administrative, topo, hydro, etc.) in the composer.
|
3. The **editor** interface is self-explanatory. You can change colors, remove visible layers, or add optional layers (e.g., administrative, topo, hydro, etc.) in the composer.
|
||||||
4. Once you have your map composed, click on **Save** at the top right. Give it a unique name to save it to your account.
|
4. Once you have your map composed, click on **Save** at the top right. Give it a unique name to save it to your account.
|
||||||
5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. Maptiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>
|
5. Next, **Publish** your style using the **Publish** button at the top right. This will deploy it to production, which means it is able to be exposed over the Internet. MapTiler will present an interactive side-by-side map with the original and your changes prior to publication.<br/>
|
||||||
6. Maptiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
|
6. MapTiler will warn you that changing the map will change it across all apps using the map. Since no apps are using the map yet, this is okay.
|
||||||
7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to Maptiler.
|
7. Clicking on the name of your new map at the top left will bring you to the item's **details** page. From here, copy the link to the JSON style under **Use vector style**. This link will automatically contain your personal API key to MapTiler.
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
# Database Queries
|
# Database Queries
|
||||||
|
|
||||||
:::danger
|
:::danger
|
||||||
Keep in mind that mucking around in the database might set the moon on fire. Avoid modifying the database directly when possible, and always have current backups.
|
Keep in mind that mucking around in the database might set the Moon on fire. Avoid modifying the database directly when possible, and always have current backups.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
|
@ -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.3",
|
||||||
|
"url": "https://v1.132.3.archive.immich.app"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "v1.132.2",
|
||||||
|
"url": "https://v1.132.2.archive.immich.app"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label": "v1.132.1",
|
"label": "v1.132.1",
|
||||||
"url": "https://v1.132.1.archive.immich.app"
|
"url": "https://v1.132.1.archive.immich.app"
|
||||||
|
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-e2e",
|
"name": "immich-e2e",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"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.63",
|
"version": "2.2.65",
|
||||||
"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.1",
|
"version": "1.132.3",
|
||||||
"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.1",
|
"version": "1.132.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
@ -25,7 +25,7 @@ test.describe('Registration', () => {
|
|||||||
|
|
||||||
// login
|
// login
|
||||||
await expect(page).toHaveTitle(/Login/);
|
await expect(page).toHaveTitle(/Login/);
|
||||||
await page.goto('/auth/login');
|
await page.goto('/auth/login?autoLaunch=0');
|
||||||
await page.getByLabel('Email').fill('admin@immich.app');
|
await page.getByLabel('Email').fill('admin@immich.app');
|
||||||
await page.getByLabel('Password').fill('password');
|
await page.getByLabel('Password').fill('password');
|
||||||
await page.getByRole('button', { name: 'Login' }).click();
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
@ -59,7 +59,7 @@ test.describe('Registration', () => {
|
|||||||
await context.clearCookies();
|
await context.clearCookies();
|
||||||
|
|
||||||
// login
|
// login
|
||||||
await page.goto('/auth/login');
|
await page.goto('/auth/login?autoLaunch=0');
|
||||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||||
await page.getByLabel('Password').fill('password');
|
await page.getByLabel('Password').fill('password');
|
||||||
await page.getByRole('button', { name: 'Login' }).click();
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
@ -72,7 +72,7 @@ test.describe('Registration', () => {
|
|||||||
await page.getByRole('button', { name: 'Change password' }).click();
|
await page.getByRole('button', { name: 'Change password' }).click();
|
||||||
|
|
||||||
// login with new password
|
// login with new password
|
||||||
await expect(page).toHaveURL('/auth/login');
|
await expect(page).toHaveURL('/auth/login?autoLaunch=0');
|
||||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||||
await page.getByLabel('Password').fill('new-password');
|
await page.getByLabel('Password').fill('new-password');
|
||||||
await page.getByRole('button', { name: 'Login' }).click();
|
await page.getByRole('button', { name: 'Login' }).click();
|
||||||
|
@ -1,25 +1,42 @@
|
|||||||
package app.alextran.immich
|
package app.alextran.immich
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
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 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.ActivityPluginBinding
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
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.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`
|
* Android plugin for Dart `BackgroundService` and file trash operations
|
||||||
*
|
|
||||||
* Receives messages/method calls from the foreground Dart side to manage
|
|
||||||
* the background service, e.g. start (enqueue), stop (cancel)
|
|
||||||
*/
|
*/
|
||||||
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware, PluginRegistry.ActivityResultListener {
|
||||||
|
|
||||||
private var methodChannel: MethodChannel? = null
|
private var methodChannel: MethodChannel? = null
|
||||||
|
private var fileTrashChannel: MethodChannel? = null
|
||||||
private var context: Context? = 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) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
onAttachedToEngine(binding.applicationContext, binding.binaryMessenger)
|
||||||
@ -29,6 +46,10 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
context = ctx
|
context = ctx
|
||||||
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
|
methodChannel = MethodChannel(messenger, "immich/foregroundChannel")
|
||||||
methodChannel?.setMethodCallHandler(this)
|
methodChannel?.setMethodCallHandler(this)
|
||||||
|
|
||||||
|
// Add file trash channel
|
||||||
|
fileTrashChannel = MethodChannel(messenger, "file_trash")
|
||||||
|
fileTrashChannel?.setMethodCallHandler(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
@ -38,11 +59,14 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
|
|||||||
private fun onDetachedFromEngine() {
|
private fun onDetachedFromEngine() {
|
||||||
methodChannel?.setMethodCallHandler(null)
|
methodChannel?.setMethodCallHandler(null)
|
||||||
methodChannel = 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!!
|
val ctx = context!!
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
// Existing BackgroundService methods
|
||||||
"enable" -> {
|
"enable" -> {
|
||||||
val args = call.arguments<ArrayList<*>>()!!
|
val args = call.arguments<ArrayList<*>>()!!
|
||||||
ctx.getSharedPreferences(BackupWorker.SHARED_PREF_NAME, Context.MODE_PRIVATE)
|
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()
|
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 TAG = "BackgroundServicePlugin"
|
||||||
private const val BUFFER_SIZE = 2 * 1024 * 1024;
|
private const val BUFFER_SIZE = 2 * 1024 * 1024
|
||||||
|
@ -2,14 +2,12 @@ package app.alextran.immich
|
|||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import android.os.Bundle
|
import androidx.annotation.NonNull
|
||||||
import android.content.Intent
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
class MainActivity : FlutterActivity() {
|
||||||
|
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.plugins.add(BackgroundServicePlugin())
|
flutterEngine.plugins.add(BackgroundServicePlugin())
|
||||||
|
// No need to set up method channel here as it's now handled in the plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -35,8 +35,8 @@ platform :android do
|
|||||||
task: 'bundle',
|
task: 'bundle',
|
||||||
build_type: 'Release',
|
build_type: 'Release',
|
||||||
properties: {
|
properties: {
|
||||||
"android.injected.version.code" => 195,
|
"android.injected.version.code" => 197,
|
||||||
"android.injected.version.name" => "1.132.1",
|
"android.injected.version.name" => "1.132.3",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
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')
|
||||||
|
@ -261,9 +261,11 @@
|
|||||||
97C146ED1CF9000F007C117D = {
|
97C146ED1CF9000F007C117D = {
|
||||||
CreatedOnToolsVersion = 7.3.1;
|
CreatedOnToolsVersion = 7.3.1;
|
||||||
LastSwiftMigration = 1100;
|
LastSwiftMigration = 1100;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
FAC6F88F2D287C890078CB2F = {
|
FAC6F88F2D287C890078CB2F = {
|
||||||
CreatedOnToolsVersion = 16.0;
|
CreatedOnToolsVersion = 16.0;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@ -541,7 +543,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 = 202;
|
CURRENT_PROJECT_VERSION = 205;
|
||||||
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 +687,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 = 202;
|
CURRENT_PROJECT_VERSION = 205;
|
||||||
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 +717,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 = 202;
|
CURRENT_PROJECT_VERSION = 205;
|
||||||
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 +750,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 = 202;
|
CURRENT_PROJECT_VERSION = 205;
|
||||||
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;
|
||||||
@ -769,6 +771,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
@ -791,7 +794,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 = 202;
|
CURRENT_PROJECT_VERSION = 205;
|
||||||
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;
|
||||||
@ -811,6 +814,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@ -831,7 +835,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 = 202;
|
CURRENT_PROJECT_VERSION = 205;
|
||||||
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;
|
||||||
@ -851,6 +855,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
|
@ -78,7 +78,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.132.0</string>
|
<string>1.132.3</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>202</string>
|
<string>205</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
@ -18,8 +18,11 @@ default_platform(:ios)
|
|||||||
platform :ios do
|
platform :ios do
|
||||||
desc "iOS Release"
|
desc "iOS Release"
|
||||||
lane :release do
|
lane :release do
|
||||||
|
enable_automatic_code_signing(
|
||||||
|
path: "./Runner.xcodeproj",
|
||||||
|
)
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.132.1"
|
version_number: "1.132.3"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
@ -65,6 +65,7 @@ enum StoreKey<T> {
|
|||||||
|
|
||||||
// Video settings
|
// Video settings
|
||||||
loadOriginalVideo<bool>._(136),
|
loadOriginalVideo<bool>._(136),
|
||||||
|
manageLocalMediaAndroid<bool>._(137),
|
||||||
|
|
||||||
// Experimental stuff
|
// Experimental stuff
|
||||||
photoManagerCustomFilter<bool>._(1000);
|
photoManagerCustomFilter<bool>._(1000);
|
||||||
|
5
mobile/lib/interfaces/local_files_manager.interface.dart
Normal file
5
mobile/lib/interfaces/local_files_manager.interface.dart
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
abstract interface class ILocalFilesManager {
|
||||||
|
Future<bool> moveToTrash(List<String> mediaUrls);
|
||||||
|
Future<bool> restoreFromTrash(String fileName, int type);
|
||||||
|
Future<bool> requestManageMediaPermission();
|
||||||
|
}
|
@ -1,7 +1,6 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||||
@ -13,7 +12,6 @@ import 'package:immich_mobile/providers/server_info.provider.dart';
|
|||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||||
import 'package:immich_mobile/utils/map_utils.dart';
|
|
||||||
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
|
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
|
||||||
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
|
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
|
||||||
import 'package:immich_mobile/widgets/common/user_avatar.dart';
|
import 'package:immich_mobile/widgets/common/user_avatar.dart';
|
||||||
@ -357,19 +355,10 @@ class PlacesCollectionCard extends StatelessWidget {
|
|||||||
final widthFactor = isTablet ? 0.25 : 0.5;
|
final widthFactor = isTablet ? 0.25 : 0.5;
|
||||||
final size = context.width * widthFactor - 20.0;
|
final size = context.width * widthFactor - 20.0;
|
||||||
|
|
||||||
return FutureBuilder<(Position?, LocationPermission?)>(
|
|
||||||
future: MapUtils.checkPermAndGetLocation(
|
|
||||||
context: context,
|
|
||||||
silent: true,
|
|
||||||
),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
var position = snapshot.data?.$1;
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.pushRoute(
|
onTap: () => context.pushRoute(
|
||||||
PlacesCollectionRoute(
|
PlacesCollectionRoute(
|
||||||
currentLocation: position != null
|
currentLocation: null,
|
||||||
? LatLng(position.latitude, position.longitude)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -380,20 +369,16 @@ class PlacesCollectionCard extends StatelessWidget {
|
|||||||
width: size,
|
width: size,
|
||||||
child: DecoratedBox(
|
child: DecoratedBox(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius:
|
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||||
const BorderRadius.all(Radius.circular(20)),
|
color:
|
||||||
color: context.colorScheme.secondaryContainer
|
context.colorScheme.secondaryContainer.withAlpha(100),
|
||||||
.withAlpha(100),
|
|
||||||
),
|
),
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
child: snapshot.connectionState ==
|
child: MapThumbnail(
|
||||||
ConnectionState.waiting
|
|
||||||
? const Center(child: CircularProgressIndicator())
|
|
||||||
: MapThumbnail(
|
|
||||||
zoom: 8,
|
zoom: 8,
|
||||||
centre: LatLng(
|
centre: const LatLng(
|
||||||
position?.latitude ?? 21.44950,
|
21.44950,
|
||||||
position?.longitude ?? -157.91959,
|
-157.91959,
|
||||||
),
|
),
|
||||||
showAttribution: false,
|
showAttribution: false,
|
||||||
themeMode: context.isDarkTheme
|
themeMode: context.isDarkTheme
|
||||||
@ -418,8 +403,6 @@ class PlacesCollectionCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'grant_permission',
|
'continue',
|
||||||
).tr(),
|
).tr(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -23,6 +23,7 @@ enum PendingAction {
|
|||||||
assetDelete,
|
assetDelete,
|
||||||
assetUploaded,
|
assetUploaded,
|
||||||
assetHidden,
|
assetHidden,
|
||||||
|
assetTrash,
|
||||||
}
|
}
|
||||||
|
|
||||||
class PendingChange {
|
class PendingChange {
|
||||||
@ -160,7 +161,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
socket.on('on_upload_success', _handleOnUploadSuccess);
|
socket.on('on_upload_success', _handleOnUploadSuccess);
|
||||||
socket.on('on_config_update', _handleOnConfigUpdate);
|
socket.on('on_config_update', _handleOnConfigUpdate);
|
||||||
socket.on('on_asset_delete', _handleOnAssetDelete);
|
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_restore', _handleServerUpdates);
|
||||||
socket.on('on_asset_update', _handleServerUpdates);
|
socket.on('on_asset_update', _handleServerUpdates);
|
||||||
socket.on('on_asset_stack_update', _handleServerUpdates);
|
socket.on('on_asset_stack_update', _handleServerUpdates);
|
||||||
@ -207,6 +208,26 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
_debounce.run(handlePendingChanges);
|
_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 {
|
Future<void> _handlePendingDeletes() async {
|
||||||
final deleteChanges = state.pendingChanges
|
final deleteChanges = state.pendingChanges
|
||||||
.where((c) => c.action == PendingAction.assetDelete)
|
.where((c) => c.action == PendingAction.assetDelete)
|
||||||
@ -267,6 +288,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
await _handlePendingUploaded();
|
await _handlePendingUploaded();
|
||||||
await _handlePendingDeletes();
|
await _handlePendingDeletes();
|
||||||
await _handlingPendingHidden();
|
await _handlingPendingHidden();
|
||||||
|
await _handlePendingTrashes();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleOnConfigUpdate(dynamic _) {
|
void _handleOnConfigUpdate(dynamic _) {
|
||||||
@ -285,6 +307,10 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
|||||||
void _handleOnAssetDelete(dynamic data) =>
|
void _handleOnAssetDelete(dynamic data) =>
|
||||||
addPendingChange(PendingAction.assetDelete, data);
|
addPendingChange(PendingAction.assetDelete, data);
|
||||||
|
|
||||||
|
void _handleOnAssetTrash(dynamic data) {
|
||||||
|
addPendingChange(PendingAction.assetTrash, data);
|
||||||
|
}
|
||||||
|
|
||||||
void _handleOnAssetHidden(dynamic data) =>
|
void _handleOnAssetHidden(dynamic data) =>
|
||||||
addPendingChange(PendingAction.assetHidden, data);
|
addPendingChange(PendingAction.assetHidden, data);
|
||||||
|
|
||||||
|
25
mobile/lib/repositories/local_files_manager.repository.dart
Normal file
25
mobile/lib/repositories/local_files_manager.repository.dart
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||||
|
import 'package:immich_mobile/utils/local_files_manager.dart';
|
||||||
|
|
||||||
|
final localFilesManagerRepositoryProvider =
|
||||||
|
Provider((ref) => const LocalFilesManagerRepository());
|
||||||
|
|
||||||
|
class LocalFilesManagerRepository implements ILocalFilesManager {
|
||||||
|
const LocalFilesManagerRepository();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||||
|
return await LocalFilesManager.moveToTrash(mediaUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||||
|
return await LocalFilesManager.restoreFromTrash(fileName, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> requestManageMediaPermission() async {
|
||||||
|
return await LocalFilesManager.requestManageMediaPermission();
|
||||||
|
}
|
||||||
|
}
|
@ -61,6 +61,7 @@ enum AppSettingsEnum<T> {
|
|||||||
0,
|
0,
|
||||||
),
|
),
|
||||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||||
|
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
|
||||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||||
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/album_media.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
import 'package:immich_mobile/interfaces/asset.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/etag.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.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/partner_api.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/exif.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/repositories/album.repository.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/album_media.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/asset.repository.dart';
|
import 'package:immich_mobile/repositories/asset.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/etag.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.repository.dart';
|
||||||
import 'package:immich_mobile/repositories/partner_api.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/entity.service.dart';
|
||||||
import 'package:immich_mobile/services/hash.service.dart';
|
import 'package:immich_mobile/services/hash.service.dart';
|
||||||
import 'package:immich_mobile/utils/async_mutex.dart';
|
import 'package:immich_mobile/utils/async_mutex.dart';
|
||||||
@ -48,6 +53,8 @@ final syncServiceProvider = Provider(
|
|||||||
ref.watch(userRepositoryProvider),
|
ref.watch(userRepositoryProvider),
|
||||||
ref.watch(userServiceProvider),
|
ref.watch(userServiceProvider),
|
||||||
ref.watch(etagRepositoryProvider),
|
ref.watch(etagRepositoryProvider),
|
||||||
|
ref.watch(appSettingsServiceProvider),
|
||||||
|
ref.watch(localFilesManagerRepositoryProvider),
|
||||||
ref.watch(partnerApiRepositoryProvider),
|
ref.watch(partnerApiRepositoryProvider),
|
||||||
ref.watch(userApiRepositoryProvider),
|
ref.watch(userApiRepositoryProvider),
|
||||||
),
|
),
|
||||||
@ -69,6 +76,8 @@ class SyncService {
|
|||||||
final IUserApiRepository _userApiRepository;
|
final IUserApiRepository _userApiRepository;
|
||||||
final AsyncMutex _lock = AsyncMutex();
|
final AsyncMutex _lock = AsyncMutex();
|
||||||
final Logger _log = Logger('SyncService');
|
final Logger _log = Logger('SyncService');
|
||||||
|
final AppSettingsService _appSettingsService;
|
||||||
|
final ILocalFilesManager _localFilesManager;
|
||||||
|
|
||||||
SyncService(
|
SyncService(
|
||||||
this._hashService,
|
this._hashService,
|
||||||
@ -82,6 +91,8 @@ class SyncService {
|
|||||||
this._userRepository,
|
this._userRepository,
|
||||||
this._userService,
|
this._userService,
|
||||||
this._eTagRepository,
|
this._eTagRepository,
|
||||||
|
this._appSettingsService,
|
||||||
|
this._localFilesManager,
|
||||||
this._partnerApiRepository,
|
this._partnerApiRepository,
|
||||||
this._userApiRepository,
|
this._userApiRepository,
|
||||||
);
|
);
|
||||||
@ -238,8 +249,22 @@ class SyncService {
|
|||||||
return null;
|
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
|
/// 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 {
|
return _assetRepository.transaction(() async {
|
||||||
await _assetRepository.deleteAllByRemoteId(
|
await _assetRepository.deleteAllByRemoteId(
|
||||||
idsToDelete,
|
idsToDelete,
|
||||||
@ -249,6 +274,12 @@ class SyncService {
|
|||||||
idsToDelete,
|
idsToDelete,
|
||||||
state: AssetState.merged,
|
state: AssetState.merged,
|
||||||
);
|
);
|
||||||
|
if (Platform.isAndroid &&
|
||||||
|
_appSettingsService.getSetting<bool>(
|
||||||
|
AppSettingsEnum.manageLocalMediaAndroid,
|
||||||
|
)) {
|
||||||
|
await _moveToTrashMatchedAssets(idsToDelete);
|
||||||
|
}
|
||||||
if (merged.isEmpty) return;
|
if (merged.isEmpty) return;
|
||||||
for (final Asset asset in merged) {
|
for (final Asset asset in merged) {
|
||||||
asset.remoteId = null;
|
asset.remoteId = null;
|
||||||
@ -790,10 +821,43 @@ class SyncService {
|
|||||||
return (existing, toUpsert);
|
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)
|
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||||
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
Future<void> upsertAssetsWithExif(List<Asset> assets) async {
|
||||||
if (assets.isEmpty) return;
|
if (assets.isEmpty) return;
|
||||||
|
|
||||||
|
if (Platform.isAndroid &&
|
||||||
|
_appSettingsService.getSetting<bool>(
|
||||||
|
AppSettingsEnum.manageLocalMediaAndroid,
|
||||||
|
)) {
|
||||||
|
_toggleTrashStatusForAssets(assets);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await _assetRepository.transaction(() async {
|
await _assetRepository.transaction(() async {
|
||||||
await _assetRepository.updateAll(assets);
|
await _assetRepository.updateAll(assets);
|
||||||
|
38
mobile/lib/utils/local_files_manager.dart
Normal file
38
mobile/lib/utils/local_files_manager.dart
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
abstract final class LocalFilesManager {
|
||||||
|
static final Logger _logger = Logger('LocalFilesManager');
|
||||||
|
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||||
|
|
||||||
|
static Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||||
|
try {
|
||||||
|
return await _channel
|
||||||
|
.invokeMethod('moveToTrash', {'mediaUrls': mediaUrls});
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.warning('Error moving file to trash', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod(
|
||||||
|
'restoreFromTrash',
|
||||||
|
{'fileName': fileName, 'type': type},
|
||||||
|
);
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.warning('Error restore file from trash', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> requestManageMediaPermission() async {
|
||||||
|
try {
|
||||||
|
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||||
|
} catch (e, s) {
|
||||||
|
_logger.warning('Error requesting manage media permission', e, s);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,7 @@
|
|||||||
import 'dart:async';
|
import 'dart: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
|
|
||||||
|
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()
|
.where()
|
||||||
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
||||||
.findAll())
|
.findAll())
|
||||||
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
|
.map(
|
||||||
|
(a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
debugPrint("Device Asset Ids length - ${ids.length}");
|
} else {
|
||||||
debugPrint("Local Asset Ids length - ${localAssets.length}");
|
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) {
|
||||||
|
if (kDebugMode) {
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
|
'[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);
|
||||||
});
|
});
|
||||||
|
@ -207,9 +207,27 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String generateRandomString(int length) {
|
String generateRandomString(int length) {
|
||||||
|
const chars =
|
||||||
|
'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
|
||||||
final random = Random.secure();
|
final random = Random.secure();
|
||||||
return base64Url
|
return String.fromCharCodes(
|
||||||
.encode(List<int>.generate(32, (i) => random.nextInt(256)));
|
Iterable.generate(
|
||||||
|
length,
|
||||||
|
(_) => chars.codeUnitAt(random.nextInt(chars.length)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> randomBytes(int length) {
|
||||||
|
final random = Random.secure();
|
||||||
|
return List<int>.generate(length, (i) => random.nextInt(256));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per specification, the code verifier must be 43-128 characters long
|
||||||
|
/// and consist of characters [A-Z, a-z, 0-9, "-", ".", "_", "~"]
|
||||||
|
/// https://datatracker.ietf.org/doc/html/rfc7636#section-4.1
|
||||||
|
String randomCodeVerifier() {
|
||||||
|
return base64Url.encode(randomBytes(42));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
|
Future<String> generatePKCECodeChallenge(String codeVerifier) async {
|
||||||
@ -223,7 +241,8 @@ class LoginForm extends HookConsumerWidget {
|
|||||||
String? oAuthServerUrl;
|
String? oAuthServerUrl;
|
||||||
|
|
||||||
final state = generateRandomString(32);
|
final state = generateRandomString(32);
|
||||||
final codeVerifier = generateRandomString(64);
|
|
||||||
|
final codeVerifier = randomCodeVerifier();
|
||||||
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
|
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/services/log.service.dart';
|
import 'package:immich_mobile/domain/services/log.service.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.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/services/app_settings.service.dart';
|
||||||
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
|
||||||
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
|
||||||
@ -25,6 +27,8 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
|
|
||||||
final advancedTroubleshooting =
|
final advancedTroubleshooting =
|
||||||
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
|
||||||
|
final manageLocalMediaAndroid =
|
||||||
|
useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
|
||||||
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
|
||||||
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
|
||||||
final allowSelfSignedSSLCert =
|
final allowSelfSignedSSLCert =
|
||||||
@ -40,6 +44,16 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
LogService.I.setlogLevel(Level.LEVELS[levelId.value].toLogLevel()),
|
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 = [
|
final advancedSettings = [
|
||||||
SettingsSwitchListTile(
|
SettingsSwitchListTile(
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@ -47,6 +61,29 @@ class AdvancedSettings extends HookConsumerWidget {
|
|||||||
title: "advanced_settings_troubleshooting_title".tr(),
|
title: "advanced_settings_troubleshooting_title".tr(),
|
||||||
subtitle: "advanced_settings_troubleshooting_subtitle".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(
|
SettingsSliderListTile(
|
||||||
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
|
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
|
||||||
valueNotifier: levelId,
|
valueNotifier: levelId,
|
||||||
|
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@ -3,7 +3,7 @@ Immich API
|
|||||||
|
|
||||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||||
|
|
||||||
- API version: 1.132.1
|
- API version: 1.132.3
|
||||||
- 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.1+195
|
version: 1.132.3+197
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.3.0 <4.0.0'
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
@ -60,6 +60,9 @@ void main() {
|
|||||||
final MockAlbumMediaRepository albumMediaRepository =
|
final MockAlbumMediaRepository albumMediaRepository =
|
||||||
MockAlbumMediaRepository();
|
MockAlbumMediaRepository();
|
||||||
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
|
final MockAlbumApiRepository albumApiRepository = MockAlbumApiRepository();
|
||||||
|
final MockAppSettingService appSettingService = MockAppSettingService();
|
||||||
|
final MockLocalFilesManagerRepository localFilesManagerRepository =
|
||||||
|
MockLocalFilesManagerRepository();
|
||||||
final MockPartnerApiRepository partnerApiRepository =
|
final MockPartnerApiRepository partnerApiRepository =
|
||||||
MockPartnerApiRepository();
|
MockPartnerApiRepository();
|
||||||
final MockUserApiRepository userApiRepository = MockUserApiRepository();
|
final MockUserApiRepository userApiRepository = MockUserApiRepository();
|
||||||
@ -106,6 +109,8 @@ void main() {
|
|||||||
userRepository,
|
userRepository,
|
||||||
userService,
|
userService,
|
||||||
eTagRepository,
|
eTagRepository,
|
||||||
|
appSettingService,
|
||||||
|
localFilesManagerRepository,
|
||||||
partnerApiRepository,
|
partnerApiRepository,
|
||||||
userApiRepository,
|
userApiRepository,
|
||||||
);
|
);
|
||||||
|
@ -10,6 +10,7 @@ import 'package:immich_mobile/interfaces/auth_api.interface.dart';
|
|||||||
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
|
import 'package:immich_mobile/interfaces/backup_album.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
import 'package:immich_mobile/interfaces/etag.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/file_media.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.interface.dart';
|
||||||
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
import 'package:immich_mobile/interfaces/partner_api.interface.dart';
|
||||||
import 'package:mocktail/mocktail.dart';
|
import 'package:mocktail/mocktail.dart';
|
||||||
@ -41,6 +42,9 @@ class MockAuthApiRepository extends Mock implements IAuthApiRepository {}
|
|||||||
|
|
||||||
class MockAuthRepository extends Mock implements IAuthRepository {}
|
class MockAuthRepository extends Mock implements IAuthRepository {}
|
||||||
|
|
||||||
|
class MockPartnerRepository extends Mock implements IPartnerRepository {}
|
||||||
|
|
||||||
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
|
class MockPartnerApiRepository extends Mock implements IPartnerApiRepository {}
|
||||||
|
|
||||||
class MockPartnerRepository extends Mock implements IPartnerRepository {}
|
class MockLocalFilesManagerRepository extends Mock
|
||||||
|
implements ILocalFilesManager {}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:immich_mobile/services/album.service.dart';
|
import 'package:immich_mobile/services/album.service.dart';
|
||||||
import 'package:immich_mobile/services/api.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/background.service.dart';
|
||||||
import 'package:immich_mobile/services/backup.service.dart';
|
import 'package:immich_mobile/services/backup.service.dart';
|
||||||
import 'package:immich_mobile/services/entity.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 MockSearchApi extends Mock implements SearchApi {}
|
||||||
|
|
||||||
|
class MockAppSettingService extends Mock implements AppSettingsService {}
|
||||||
|
|
||||||
class MockBackgroundService extends Mock implements BackgroundService {}
|
class MockBackgroundService extends Mock implements BackgroundService {}
|
||||||
|
@ -7656,7 +7656,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Immich",
|
"title": "Immich",
|
||||||
"description": "Immich API",
|
"description": "Immich API",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"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.1",
|
"version": "1.132.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@immich/sdk",
|
"name": "@immich/sdk",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"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.1",
|
"version": "1.132.3",
|
||||||
"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.1
|
* 1.132.3
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
|
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich",
|
"name": "immich",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": 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",
|
"name": "immich",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
@ -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: 'url', url: dto.DB_URL }
|
||||||
|
: {
|
||||||
connectionType: 'parts',
|
connectionType: 'parts',
|
||||||
host: dto.DB_HOSTNAME || 'database',
|
host: dto.DB_HOSTNAME || 'database',
|
||||||
port: dto.DB_PORT || 5432,
|
port: dto.DB_PORT || 5432,
|
||||||
username: dto.DB_USERNAME || 'postgres',
|
username: dto.DB_USERNAME || 'postgres',
|
||||||
password: dto.DB_PASSWORD || 'postgres',
|
password: dto.DB_PASSWORD || 'postgres',
|
||||||
database: dto.DB_DATABASE_NAME || 'immich',
|
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,
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
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,23 +53,13 @@ describe(DatabaseService.name, () => {
|
|||||||
mockEnvData({
|
mockEnvData({
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: {
|
||||||
kysely: {
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
user: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
database: 'immich',
|
|
||||||
},
|
|
||||||
typeorm: {
|
|
||||||
connectionType: 'parts',
|
connectionType: 'parts',
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
username: 'postgres',
|
username: 'postgres',
|
||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
database: 'immich',
|
database: 'immich',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
skipMigrations: false,
|
skipMigrations: false,
|
||||||
vectorExtension: extension,
|
vectorExtension: extension,
|
||||||
},
|
},
|
||||||
@ -292,23 +282,13 @@ describe(DatabaseService.name, () => {
|
|||||||
mockEnvData({
|
mockEnvData({
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: {
|
||||||
kysely: {
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
user: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
database: 'immich',
|
|
||||||
},
|
|
||||||
typeorm: {
|
|
||||||
connectionType: 'parts',
|
connectionType: 'parts',
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
username: 'postgres',
|
username: 'postgres',
|
||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
database: 'immich',
|
database: 'immich',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
skipMigrations: true,
|
skipMigrations: true,
|
||||||
vectorExtension: DatabaseExtension.VECTORS,
|
vectorExtension: DatabaseExtension.VECTORS,
|
||||||
},
|
},
|
||||||
@ -325,23 +305,13 @@ describe(DatabaseService.name, () => {
|
|||||||
mockEnvData({
|
mockEnvData({
|
||||||
database: {
|
database: {
|
||||||
config: {
|
config: {
|
||||||
kysely: {
|
|
||||||
host: 'database',
|
|
||||||
port: 5432,
|
|
||||||
user: 'postgres',
|
|
||||||
password: 'postgres',
|
|
||||||
database: 'immich',
|
|
||||||
},
|
|
||||||
typeorm: {
|
|
||||||
connectionType: 'parts',
|
connectionType: 'parts',
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
username: 'postgres',
|
username: 'postgres',
|
||||||
password: 'postgres',
|
password: 'postgres',
|
||||||
database: 'immich',
|
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 },
|
|
||||||
typeorm: {
|
|
||||||
connectionType: 'parts',
|
connectionType: 'parts',
|
||||||
database: 'immich',
|
database: 'immich',
|
||||||
type: 'postgres',
|
|
||||||
host: 'database',
|
host: 'database',
|
||||||
port: 5432,
|
port: 5432,
|
||||||
username: 'postgres',
|
username: 'postgres',
|
||||||
password: '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.1",
|
"version": "1.132.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "immich-web",
|
"name": "immich-web",
|
||||||
"version": "1.132.1",
|
"version": "1.132.3",
|
||||||
"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.1",
|
"version": "1.132.3",
|
||||||
"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.1",
|
"version": "1.132.3",
|
||||||
"license": "GNU Affero General Public License version 3",
|
"license": "GNU Affero General Public License version 3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -10,11 +10,13 @@
|
|||||||
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
||||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { authManager } from '$lib/stores/auth-manager.svelte';
|
||||||
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
|
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||||
import { user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { userInteraction } from '$lib/stores/user.svelte';
|
import { userInteraction } from '$lib/stores/user.svelte';
|
||||||
import { handleLogout } from '$lib/utils/auth';
|
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||||
import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk';
|
|
||||||
import { Button, IconButton } from '@immich/ui';
|
import { Button, IconButton } from '@immich/ui';
|
||||||
import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
|
import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@ -23,8 +25,6 @@
|
|||||||
import ThemeButton from '../theme-button.svelte';
|
import ThemeButton from '../theme-button.svelte';
|
||||||
import UserAvatar from '../user-avatar.svelte';
|
import UserAvatar from '../user-avatar.svelte';
|
||||||
import AccountInfoPanel from './account-info-panel.svelte';
|
import AccountInfoPanel from './account-info-panel.svelte';
|
||||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showUploadButton?: boolean;
|
showUploadButton?: boolean;
|
||||||
@ -38,11 +38,6 @@
|
|||||||
let shouldShowHelpPanel = $state(false);
|
let shouldShowHelpPanel = $state(false);
|
||||||
let innerWidth: number = $state(0);
|
let innerWidth: number = $state(0);
|
||||||
|
|
||||||
const onLogout = async () => {
|
|
||||||
const { redirectUri } = await logout();
|
|
||||||
await handleLogout(redirectUri);
|
|
||||||
};
|
|
||||||
|
|
||||||
let info: ServerAboutResponseDto | undefined = $state();
|
let info: ServerAboutResponseDto | undefined = $state();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@ -183,7 +178,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if shouldShowAccountInfoPanel}
|
{#if shouldShowAccountInfoPanel}
|
||||||
<AccountInfoPanel {onLogout} />
|
<AccountInfoPanel onLogout={() => authManager.logout()} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
33
web/src/lib/stores/auth-manager.svelte.ts
Normal file
33
web/src/lib/stores/auth-manager.svelte.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { eventManager } from '$lib/stores/event-manager.svelte';
|
||||||
|
import { logout } from '@immich/sdk';
|
||||||
|
|
||||||
|
class AuthManager {
|
||||||
|
async logout() {
|
||||||
|
let redirectUri;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await logout();
|
||||||
|
if (response.redirectUri) {
|
||||||
|
redirectUri = response.redirectUri;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Error logging out:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectUri = redirectUri ?? AppRoute.AUTH_LOGIN;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (redirectUri.startsWith('/')) {
|
||||||
|
await goto(redirectUri);
|
||||||
|
} else {
|
||||||
|
globalThis.location.href = redirectUri;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
eventManager.emit('auth.logout');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authManager = new AuthManager();
|
54
web/src/lib/stores/event-manager.svelte.ts
Normal file
54
web/src/lib/stores/event-manager.svelte.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;
|
||||||
|
|
||||||
|
class EventManager<EventMap extends Record<string, unknown[]>> {
|
||||||
|
private listeners: {
|
||||||
|
[K in keyof EventMap]?: {
|
||||||
|
listener: Listener<EventMap, K>;
|
||||||
|
once?: boolean;
|
||||||
|
}[];
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
||||||
|
return this.addListener(key, listener, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
||||||
|
return this.addListener(key, listener, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
off<K extends keyof EventMap>(key: K, listener: Listener<EventMap, K>) {
|
||||||
|
if (this.listeners[key]) {
|
||||||
|
this.listeners[key] = this.listeners[key].filter((item) => item.listener !== listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit<T extends keyof EventMap>(key: T, ...params: EventMap[T]) {
|
||||||
|
if (!this.listeners[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const { listener } of this.listeners[key]) {
|
||||||
|
listener(...params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// remove one time listeners
|
||||||
|
this.listeners[key] = this.listeners[key].filter((item) => !item.once);
|
||||||
|
}
|
||||||
|
|
||||||
|
private addListener<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void, once: boolean) {
|
||||||
|
if (!this.listeners[key]) {
|
||||||
|
this.listeners[key] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.listeners[key].push({ listener, once });
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const eventManager = new EventManager<{
|
||||||
|
'user.login': [];
|
||||||
|
'auth.logout': [];
|
||||||
|
}>();
|
@ -1,3 +1,4 @@
|
|||||||
|
import { eventManager } from '$lib/stores/event-manager.svelte';
|
||||||
import {
|
import {
|
||||||
getAssetsByOriginalPath,
|
getAssetsByOriginalPath,
|
||||||
getUniqueOriginalPaths,
|
getUniqueOriginalPaths,
|
||||||
@ -16,6 +17,10 @@ class FoldersStore {
|
|||||||
uniquePaths = $state<string[]>([]);
|
uniquePaths = $state<string[]>([]);
|
||||||
assets = $state<AssetCache>({});
|
assets = $state<AssetCache>({});
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
eventManager.on('auth.logout', () => this.clearCache());
|
||||||
|
}
|
||||||
|
|
||||||
async fetchUniquePaths() {
|
async fetchUniquePaths() {
|
||||||
if (this.initialized) {
|
if (this.initialized) {
|
||||||
return;
|
return;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { eventManager } from '$lib/stores/event-manager.svelte';
|
||||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||||
import {
|
import {
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
@ -24,6 +25,10 @@ export type MemoryAsset = MemoryIndex & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class MemoryStoreSvelte {
|
class MemoryStoreSvelte {
|
||||||
|
constructor() {
|
||||||
|
eventManager.on('auth.logout', () => this.clearCache());
|
||||||
|
}
|
||||||
|
|
||||||
memories = $state<MemoryResponseDto[]>([]);
|
memories = $state<MemoryResponseDto[]>([]);
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
private memoryAssets = $derived.by(() => {
|
private memoryAssets = $derived.by(() => {
|
||||||
|
@ -1,7 +1,13 @@
|
|||||||
|
import { eventManager } from '$lib/stores/event-manager.svelte';
|
||||||
|
|
||||||
class SearchStore {
|
class SearchStore {
|
||||||
savedSearchTerms = $state<string[]>([]);
|
savedSearchTerms = $state<string[]>([]);
|
||||||
isSearchEnabled = $state(false);
|
isSearchEnabled = $state(false);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
eventManager.on('auth.logout', () => this.clearCache());
|
||||||
|
}
|
||||||
|
|
||||||
clearCache() {
|
clearCache() {
|
||||||
this.savedSearchTerms = [];
|
this.savedSearchTerms = [];
|
||||||
this.isSearchEnabled = false;
|
this.isSearchEnabled = false;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { eventManager } from '$lib/stores/event-manager.svelte';
|
||||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||||
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
|
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
@ -14,3 +15,5 @@ export const resetSavedUser = () => {
|
|||||||
preferences.set(undefined as unknown as UserPreferencesResponseDto);
|
preferences.set(undefined as unknown as UserPreferencesResponseDto);
|
||||||
purchaseStore.setPurchaseStatus(false);
|
purchaseStore.setPurchaseStatus(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
eventManager.on('auth.logout', () => resetSavedUser());
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { eventManager } from '$lib/stores/event-manager.svelte';
|
||||||
import type {
|
import type {
|
||||||
AlbumResponseDto,
|
AlbumResponseDto,
|
||||||
ServerAboutResponseDto,
|
ServerAboutResponseDto,
|
||||||
@ -19,8 +20,10 @@ const defaultUserInteraction: UserInteractions = {
|
|||||||
serverInfo: undefined,
|
serverInfo: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resetUserInteraction = () => {
|
export const userInteraction = $state<UserInteractions>(defaultUserInteraction);
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
Object.assign(userInteraction, defaultUserInteraction);
|
Object.assign(userInteraction, defaultUserInteraction);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userInteraction = $state<UserInteractions>(defaultUserInteraction);
|
eventManager.on('auth.logout', () => reset());
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { AppRoute } from '$lib/constants';
|
import { authManager } from '$lib/stores/auth-manager.svelte';
|
||||||
import { handleLogout } from '$lib/utils/auth';
|
|
||||||
import { createEventEmitter } from '$lib/utils/eventemitter';
|
import { createEventEmitter } from '$lib/utils/eventemitter';
|
||||||
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
@ -50,7 +49,7 @@ websocket
|
|||||||
.on('disconnect', () => websocketStore.connected.set(false))
|
.on('disconnect', () => websocketStore.connected.set(false))
|
||||||
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
|
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
|
||||||
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
|
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
|
||||||
.on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN))
|
.on('on_session_delete', () => authManager.logout())
|
||||||
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
||||||
|
|
||||||
export const openWebsocketConnection = () => {
|
export const openWebsocketConnection = () => {
|
||||||
|
@ -1,11 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { foldersStore } from '$lib/stores/folders.svelte';
|
|
||||||
import { memoryStore } from '$lib/stores/memory.store.svelte';
|
|
||||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
||||||
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';
|
import { userInteraction } from '$lib/stores/user.svelte';
|
||||||
import { resetUserInteraction, userInteraction } from '$lib/stores/user.svelte';
|
|
||||||
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -91,19 +87,3 @@ export const getAccountAge = (): number => {
|
|||||||
|
|
||||||
return Number(accountAge);
|
return Number(accountAge);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const handleLogout = async (redirectUri: string) => {
|
|
||||||
try {
|
|
||||||
if (redirectUri.startsWith('/')) {
|
|
||||||
await goto(redirectUri);
|
|
||||||
} else {
|
|
||||||
globalThis.location.href = redirectUri;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
resetSavedUser();
|
|
||||||
resetUserInteraction();
|
|
||||||
foldersStore.clearCache();
|
|
||||||
memoryStore.clearCache();
|
|
||||||
searchStore.clearCache();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
@ -26,35 +26,14 @@ export const focusNext = (selector: (element: HTMLElement | SVGElement) => boole
|
|||||||
focusElements[0].focus();
|
focusElements[0].focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (forwardDirection) {
|
const totalElements = focusElements.length;
|
||||||
let i = index + 1;
|
let i = index;
|
||||||
while (i !== index) {
|
do {
|
||||||
|
i = (i + (forwardDirection ? 1 : -1) + totalElements) % totalElements;
|
||||||
const next = focusElements[i];
|
const next = focusElements[i];
|
||||||
if (!isTabbable(next) || !selector(next)) {
|
if (isTabbable(next) && selector(next)) {
|
||||||
if (i === focusElements.length - 1) {
|
|
||||||
i = 0;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.focus();
|
next.focus();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} while (i !== index);
|
||||||
let i = index - 1;
|
|
||||||
while (i !== index && i >= 0) {
|
|
||||||
const next = focusElements[i];
|
|
||||||
if (!isTabbable(next) || !selector(next)) {
|
|
||||||
if (i === 0) {
|
|
||||||
i = focusElements.length - 1;
|
|
||||||
} else {
|
|
||||||
i--;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next.focus();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
||||||
import { AppRoute } from '$lib/constants';
|
import { authManager } from '$lib/stores/auth-manager.svelte';
|
||||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { logout, updateMyUser } from '@immich/sdk';
|
import { updateMyUser } from '@immich/sdk';
|
||||||
import { Alert, Button, Field, HelperText, PasswordInput, Stack, Text } from '@immich/ui';
|
import { Alert, Button, Field, HelperText, PasswordInput, Stack, Text } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
@ -25,9 +24,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
await updateMyUser({ userUpdateMeDto: { password } });
|
await updateMyUser({ userUpdateMeDto: { password } });
|
||||||
await goto(AppRoute.AUTH_LOGIN);
|
await authManager.logout();
|
||||||
resetSavedUser();
|
|
||||||
await logout();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user