mirror of
https://github.com/immich-app/immich.git
synced 2025-12-10 23:01:03 -08:00
Compare commits
20 Commits
v1.132.0
...
refactor/w
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e62c3736 | ||
|
|
205260d31c | ||
|
|
3858973be5 | ||
|
|
02994883fe | ||
|
|
a1f8150c30 | ||
|
|
d85ef19bfc | ||
|
|
d0014bdf94 | ||
|
|
e822e3eca9 | ||
|
|
644defa4a1 | ||
|
|
1fe3c7b9b3 | ||
|
|
0d60be3d87 | ||
|
|
765da7b182 | ||
|
|
b037158028 | ||
|
|
a03902f174 | ||
|
|
1d610ad9cb | ||
|
|
dab4870fed | ||
|
|
37f5e6e2cb | ||
|
|
57d622bc43 | ||
|
|
c167e46ec7 | ||
|
|
6ce8a1deeb |
6
cli/package-lock.json
generated
6
cli/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.62",
|
||||
"version": "2.2.65",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.62",
|
||||
"version": "2.2.65",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.62",
|
||||
"version": "2.2.65",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"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.)
|
||||
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
|
||||
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.
|
||||
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/>
|
||||
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.
|
||||
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.
|
||||
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
|
||||
|
||||
:::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
|
||||
|
||||
@@ -252,6 +252,13 @@ const milestones: Item[] = [
|
||||
description: 'Browse your photos and videos in their folder structure inside the mobile app',
|
||||
release: 'v1.130.0',
|
||||
}),
|
||||
{
|
||||
icon: mdiStar,
|
||||
iconColor: 'gold',
|
||||
title: '60,000 Stars',
|
||||
description: 'Reached 60K Stars on GitHub!',
|
||||
getDateLabel: withLanguage(new Date(2025, 2, 4)),
|
||||
},
|
||||
withRelease({
|
||||
icon: mdiTagFaces,
|
||||
iconColor: 'teal',
|
||||
@@ -260,13 +267,6 @@ const milestones: Item[] = [
|
||||
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
|
||||
release: 'v1.127.0',
|
||||
}),
|
||||
{
|
||||
icon: mdiStar,
|
||||
iconColor: 'gold',
|
||||
title: '60,000 Stars',
|
||||
description: 'Reached 60K Stars on GitHub!',
|
||||
getDateLabel: withLanguage(new Date(2025, 2, 4)),
|
||||
},
|
||||
withRelease({
|
||||
icon: mdiLinkEdit,
|
||||
iconColor: 'crimson',
|
||||
|
||||
12
docs/static/archived-versions.json
vendored
12
docs/static/archived-versions.json
vendored
@@ -1,4 +1,16 @@
|
||||
[
|
||||
{
|
||||
"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",
|
||||
"url": "https://v1.132.1.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v1.132.0",
|
||||
"url": "https://v1.132.0.archive.immich.app"
|
||||
|
||||
8
e2e/package-lock.json
generated
8
e2e/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-e2e",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.1.0",
|
||||
@@ -44,7 +44,7 @@
|
||||
},
|
||||
"../cli": {
|
||||
"name": "@immich/cli",
|
||||
"version": "2.2.62",
|
||||
"version": "2.2.65",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"dev": true,
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -142,7 +142,7 @@ describe(`/oauth`, () => {
|
||||
it(`should throw an error if the state mismatches`, async () => {
|
||||
const callbackParams = await loginWithOAuth('oauth-auto-register');
|
||||
const { state } = await loginWithOAuth('oauth-auto-register');
|
||||
const { status, body } = await request(app)
|
||||
const { status } = await request(app)
|
||||
.post('/oauth/callback')
|
||||
.send({ ...callbackParams, state });
|
||||
expect(status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
@@ -25,7 +25,7 @@ test.describe('Registration', () => {
|
||||
|
||||
// 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('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
@@ -59,7 +59,7 @@ test.describe('Registration', () => {
|
||||
await context.clearCookies();
|
||||
|
||||
// login
|
||||
await page.goto('/auth/login');
|
||||
await page.goto('/auth/login?autoLaunch=0');
|
||||
await page.getByLabel('Email').fill('user@immich.cloud');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
@@ -72,7 +72,7 @@ test.describe('Registration', () => {
|
||||
await page.getByRole('button', { name: 'Change password' }).click();
|
||||
|
||||
// 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('Password').fill('new-password');
|
||||
await page.getByRole('button', { name: 'Login' }).click();
|
||||
|
||||
@@ -55,7 +55,6 @@ test.describe('Shared Links', () => {
|
||||
await page.goto(`/share/${sharedLink.key}`);
|
||||
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Download' }).click();
|
||||
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
|
||||
await page.waitForEvent('download');
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
@@ -125,4 +124,4 @@
|
||||
<data android:scheme="geo" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
</manifest>
|
||||
@@ -1,17 +1,17 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
@@ -23,6 +23,7 @@ import io.flutter.plugin.common.PluginRegistry
|
||||
import java.security.MessageDigest
|
||||
import java.io.FileInputStream
|
||||
import kotlinx.coroutines.*
|
||||
import androidx.core.net.toUri
|
||||
|
||||
/**
|
||||
* Android plugin for Dart `BackgroundService` and file trash operations
|
||||
@@ -33,7 +34,8 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
private var fileTrashChannel: MethodChannel? = null
|
||||
private var context: Context? = null
|
||||
private var pendingResult: Result? = null
|
||||
private val PERMISSION_REQUEST_CODE = 1001
|
||||
private val permissionRequestCode = 1001
|
||||
private val trashRequestCode = 1002
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
@@ -138,36 +140,35 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
|
||||
// File Trash methods moved from MainActivity
|
||||
"moveToTrash" -> {
|
||||
val fileName = call.argument<String>("fileName")
|
||||
if (fileName != null) {
|
||||
if (hasManageStoragePermission()) {
|
||||
val success = moveToTrash(fileName)
|
||||
result.success(success)
|
||||
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", "Storage permission required", null)
|
||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||
}
|
||||
} else {
|
||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
||||
result.error("INVALID_NAME", "The mediaUrls is not specified.", null)
|
||||
}
|
||||
}
|
||||
|
||||
"restoreFromTrash" -> {
|
||||
val fileName = call.argument<String>("fileName")
|
||||
if (fileName != null) {
|
||||
if (hasManageStoragePermission()) {
|
||||
val success = untrashImage(fileName)
|
||||
result.success(success)
|
||||
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", "Storage permission required", null)
|
||||
result.error("PERMISSION_DENIED", "Media permission required", null)
|
||||
}
|
||||
} else {
|
||||
result.error("INVALID_NAME", "The file name is not specified.", null)
|
||||
}
|
||||
}
|
||||
|
||||
"requestManageStoragePermission" -> {
|
||||
if (!hasManageStoragePermission()) {
|
||||
requestManageStoragePermission(result)
|
||||
"requestManageMediaPermission" -> {
|
||||
if (!hasManageMediaPermission()) {
|
||||
requestManageMediaPermission(result)
|
||||
} else {
|
||||
Log.e("Manage storage permission", "Permission already granted")
|
||||
result.success(true)
|
||||
@@ -178,100 +179,98 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// File Trash methods moved from MainActivity
|
||||
private fun hasManageStoragePermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
true
|
||||
private fun hasManageMediaPermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
MediaStore.canManageMedia(context!!);
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestManageStoragePermission(result: Result) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
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_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.parse("package:${activity.packageName}")
|
||||
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
|
||||
val intent = Intent(Settings.ACTION_REQUEST_MANAGE_MEDIA)
|
||||
intent.data = "package:${activity.packageName}".toUri()
|
||||
activity.startActivityForResult(intent, permissionRequestCode)
|
||||
} else {
|
||||
result.success(true)
|
||||
result.success(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun moveToTrash(fileName: String): Boolean {
|
||||
val contentResolver = context?.contentResolver ?: return false
|
||||
val uri = getFileUri(fileName)
|
||||
@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())
|
||||
return uri?.let { moveToTrash(it) } ?: false
|
||||
uri.let { toggleTrash(listOf(it), false, result) }
|
||||
}
|
||||
|
||||
private fun moveToTrash(contentUri: Uri): Boolean {
|
||||
val contentResolver = context?.contentResolver ?: return false
|
||||
return try {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_TRASHED, 1) // Move to trash
|
||||
@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
|
||||
}
|
||||
val updated = contentResolver.update(contentUri, values, null, null)
|
||||
updated > 0
|
||||
} catch (e: Exception) {
|
||||
Log.e("TrashError", "Error moving to trash", e)
|
||||
false
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileUri(fileName: String): Uri? {
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun getTrashedFileUri(fileName: String, type: Int): Uri? {
|
||||
val contentResolver = context?.contentResolver ?: return null
|
||||
val contentUri = MediaStore.Files.getContentUri("external")
|
||||
val projection = arrayOf(MediaStore.Images.Media._ID)
|
||||
val selection = "${MediaStore.Images.Media.DISPLAY_NAME} = ?"
|
||||
val selectionArgs = arrayOf(fileName)
|
||||
var fileUri: Uri? = null
|
||||
|
||||
contentResolver.query(contentUri, projection, selection, selectionArgs, null)?.use { cursor ->
|
||||
if (cursor.moveToFirst()) {
|
||||
val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
|
||||
fileUri = ContentUris.withAppendedId(contentUri, id)
|
||||
}
|
||||
}
|
||||
return fileUri
|
||||
}
|
||||
|
||||
private fun untrashImage(name: String): Boolean {
|
||||
val contentResolver = context?.contentResolver ?: return false
|
||||
val uri = getTrashedFileUri(contentResolver, name)
|
||||
Log.e("FILE_URI", uri.toString())
|
||||
return uri?.let { untrashImage(it) } ?: false
|
||||
}
|
||||
|
||||
private fun untrashImage(contentUri: Uri): Boolean {
|
||||
val contentResolver = context?.contentResolver ?: return false
|
||||
return try {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.MediaColumns.IS_TRASHED, 0) // Restore file
|
||||
}
|
||||
val updated = contentResolver.update(contentUri, values, null, null)
|
||||
updated > 0
|
||||
} catch (e: Exception) {
|
||||
Log.e("TrashError", "Error restoring file", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun getTrashedFileUri(contentResolver: ContentResolver, fileName: String): Uri? {
|
||||
val contentUri = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL)
|
||||
val 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} = ?")
|
||||
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(contentUri, projection, queryArgs, null)?.use { cursor ->
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -301,12 +300,19 @@ class BackgroundServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
|
||||
|
||||
// ActivityResultListener implementation
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
||||
if (requestCode == PERMISSION_REQUEST_CODE) {
|
||||
val granted = hasManageStoragePermission()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 194,
|
||||
"android.injected.version.name" => "1.132.0",
|
||||
"android.injected.version.code" => 197,
|
||||
"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')
|
||||
|
||||
@@ -261,9 +261,11 @@
|
||||
97C146ED1CF9000F007C117D = {
|
||||
CreatedOnToolsVersion = 7.3.1;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
FAC6F88F2D287C890078CB2F = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -541,7 +543,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 201;
|
||||
CURRENT_PROJECT_VERSION = 205;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -685,7 +687,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 201;
|
||||
CURRENT_PROJECT_VERSION = 205;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -715,7 +717,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 201;
|
||||
CURRENT_PROJECT_VERSION = 205;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -748,7 +750,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 201;
|
||||
CURRENT_PROJECT_VERSION = 205;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -769,6 +771,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.vdebug.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -791,7 +794,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 201;
|
||||
CURRENT_PROJECT_VERSION = 205;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -811,6 +814,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -831,7 +835,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 201;
|
||||
CURRENT_PROJECT_VERSION = 205;
|
||||
CUSTOM_GROUP_ID = group.app.immich.share;
|
||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -851,6 +855,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = app.alextran.immich.profile.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.131.3</string>
|
||||
<string>1.132.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
@@ -93,7 +93,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>201</string>
|
||||
<string>205</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
||||
@@ -18,8 +18,11 @@ default_platform(:ios)
|
||||
platform :ios do
|
||||
desc "iOS Release"
|
||||
lane :release do
|
||||
enable_automatic_code_signing(
|
||||
path: "./Runner.xcodeproj",
|
||||
)
|
||||
increment_version_number(
|
||||
version_number: "1.132.0"
|
||||
version_number: "1.132.3"
|
||||
)
|
||||
increment_build_number(
|
||||
build_number: latest_testflight_build_number + 1,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
abstract interface class ILocalFilesManager {
|
||||
Future<bool> moveToTrash(String fileName);
|
||||
Future<bool> restoreFromTrash(String fileName);
|
||||
Future<bool> requestManageStoragePermission();
|
||||
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:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.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/services/api.service.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/common/immich_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_avatar.dart';
|
||||
@@ -357,66 +355,51 @@ class PlacesCollectionCard extends StatelessWidget {
|
||||
final widthFactor = isTablet ? 0.25 : 0.5;
|
||||
final size = context.width * widthFactor - 20.0;
|
||||
|
||||
return FutureBuilder<(Position?, LocationPermission?)>(
|
||||
future: MapUtils.checkPermAndGetLocation(
|
||||
context: context,
|
||||
silent: true,
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushRoute(
|
||||
PlacesCollectionRoute(
|
||||
currentLocation: null,
|
||||
),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
var position = snapshot.data?.$1;
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushRoute(
|
||||
PlacesCollectionRoute(
|
||||
currentLocation: position != null
|
||||
? LatLng(position.latitude, position.longitude)
|
||||
: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: size,
|
||||
width: size,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
color:
|
||||
context.colorScheme.secondaryContainer.withAlpha(100),
|
||||
),
|
||||
child: IgnorePointer(
|
||||
child: MapThumbnail(
|
||||
zoom: 8,
|
||||
centre: const LatLng(
|
||||
21.44950,
|
||||
-157.91959,
|
||||
),
|
||||
showAttribution: false,
|
||||
themeMode: context.isDarkTheme
|
||||
? ThemeMode.dark
|
||||
: ThemeMode.light,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: size,
|
||||
width: size,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(20)),
|
||||
color: context.colorScheme.secondaryContainer
|
||||
.withAlpha(100),
|
||||
),
|
||||
child: IgnorePointer(
|
||||
child: snapshot.connectionState ==
|
||||
ConnectionState.waiting
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MapThumbnail(
|
||||
zoom: 8,
|
||||
centre: LatLng(
|
||||
position?.latitude ?? 21.44950,
|
||||
position?.longitude ?? -157.91959,
|
||||
),
|
||||
showAttribution: false,
|
||||
themeMode: context.isDarkTheme
|
||||
? ThemeMode.dark
|
||||
: ThemeMode.light,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'places'.tr(),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'places'.tr(),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -44,7 +44,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
|
||||
}
|
||||
}),
|
||||
child: const Text(
|
||||
'grant_permission',
|
||||
'continue',
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3,21 +3,23 @@ import 'package:immich_mobile/interfaces/local_files_manager.interface.dart';
|
||||
import 'package:immich_mobile/utils/local_files_manager.dart';
|
||||
|
||||
final localFilesManagerRepositoryProvider =
|
||||
Provider((ref) => LocalFilesManagerRepository());
|
||||
Provider((ref) => const LocalFilesManagerRepository());
|
||||
|
||||
class LocalFilesManagerRepository implements ILocalFilesManager {
|
||||
const LocalFilesManagerRepository();
|
||||
|
||||
@override
|
||||
Future<bool> moveToTrash(String fileName) async {
|
||||
return await LocalFilesManager.moveToTrash(fileName);
|
||||
Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
return await LocalFilesManager.moveToTrash(mediaUrls);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> restoreFromTrash(String fileName) async {
|
||||
return await LocalFilesManager.restoreFromTrash(fileName);
|
||||
Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
return await LocalFilesManager.restoreFromTrash(fileName, type);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> requestManageStoragePermission() async {
|
||||
return await LocalFilesManager.requestManageStoragePermission();
|
||||
Future<bool> requestManageMediaPermission() async {
|
||||
return await LocalFilesManager.requestManageMediaPermission();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,9 +255,12 @@ class SyncService {
|
||||
.where((asset) => idsToDelete.contains(asset.remoteId))
|
||||
.toList();
|
||||
|
||||
for (var asset in matchedAssets) {
|
||||
_localFilesManager.moveToTrash(asset.fileName);
|
||||
}
|
||||
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
|
||||
@@ -819,13 +822,29 @@ class SyncService {
|
||||
}
|
||||
|
||||
Future<void> _toggleTrashStatusForAssets(List<Asset> assetsList) async {
|
||||
for (var asset in assetsList) {
|
||||
final trashMediaUrls = <String>[];
|
||||
|
||||
for (final asset in assetsList) {
|
||||
if (asset.isTrashed) {
|
||||
_localFilesManager.moveToTrash(asset.fileName);
|
||||
final mediaUrl = await asset.local?.getMediaUrl();
|
||||
if (mediaUrl == null) {
|
||||
_log.warning(
|
||||
"Failed to get media URL for asset ${asset.name} while moving to trash",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
trashMediaUrls.add(mediaUrl);
|
||||
} else {
|
||||
_localFilesManager.restoreFromTrash(asset.fileName);
|
||||
await _localFilesManager.restoreFromTrash(
|
||||
asset.fileName,
|
||||
asset.type.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (trashMediaUrls.isNotEmpty) {
|
||||
await _localFilesManager.moveToTrash(trashMediaUrls);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts or updates the assets in the database with their ExifInfo (if any)
|
||||
|
||||
@@ -1,38 +1,37 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class LocalFilesManager {
|
||||
abstract final class LocalFilesManager {
|
||||
static final Logger _logger = Logger('LocalFilesManager');
|
||||
static const MethodChannel _channel = MethodChannel('file_trash');
|
||||
|
||||
static Future<bool> moveToTrash(String fileName) async {
|
||||
static Future<bool> moveToTrash(List<String> mediaUrls) async {
|
||||
try {
|
||||
final bool success =
|
||||
await _channel.invokeMethod('moveToTrash', {'fileName': fileName});
|
||||
return success;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('Error moving to trash: ${e.message}');
|
||||
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) async {
|
||||
static Future<bool> restoreFromTrash(String fileName, int type) async {
|
||||
try {
|
||||
final bool success = await _channel
|
||||
.invokeMethod('restoreFromTrash', {'fileName': fileName});
|
||||
return success;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('Error restoring file: ${e.message}');
|
||||
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> requestManageStoragePermission() async {
|
||||
static Future<bool> requestManageMediaPermission() async {
|
||||
try {
|
||||
final bool success =
|
||||
await _channel.invokeMethod('requestManageStoragePermission');
|
||||
return success;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('Error requesting permission: ${e.message}');
|
||||
return await _channel.invokeMethod('requestManageMediaPermission');
|
||||
} catch (e, s) {
|
||||
_logger.warning('Error requesting manage media permission', e, s);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
|
||||
@@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/utils/diff.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
// ignore: import_rule_photo_manager
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
const int targetVersion = 10;
|
||||
|
||||
@@ -69,14 +71,45 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
||||
: (await db.iOSDeviceAssets.where().findAll())
|
||||
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
|
||||
.toList();
|
||||
final localAssets = (await db.assets
|
||||
.where()
|
||||
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
||||
.findAll())
|
||||
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
|
||||
.toList();
|
||||
debugPrint("Device Asset Ids length - ${ids.length}");
|
||||
debugPrint("Local Asset Ids length - ${localAssets.length}");
|
||||
|
||||
final PermissionState ps = await PhotoManager.requestPermissionExtend();
|
||||
if (!ps.hasAccess) {
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
"[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
List<_DeviceAsset> localAssets = [];
|
||||
final List<AssetPathEntity> paths =
|
||||
await PhotoManager.getAssetPathList(onlyAll: true);
|
||||
|
||||
if (paths.isEmpty) {
|
||||
localAssets = (await db.assets
|
||||
.where()
|
||||
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
|
||||
.findAll())
|
||||
.map(
|
||||
(a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
|
||||
)
|
||||
.toList();
|
||||
} else {
|
||||
final AssetPathEntity albumWithAll = paths.first;
|
||||
final int assetCount = await albumWithAll.assetCountAsync;
|
||||
|
||||
final List<AssetEntity> allDeviceAssets =
|
||||
await albumWithAll.getAssetListRange(start: 0, end: assetCount);
|
||||
|
||||
localAssets = allDeviceAssets
|
||||
.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime))
|
||||
.toList();
|
||||
}
|
||||
|
||||
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
|
||||
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
|
||||
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
|
||||
final List<DeviceAssetEntity> toAdd = [];
|
||||
@@ -95,15 +128,27 @@ Future<void> _migrateDeviceAsset(Isar db) async {
|
||||
return false;
|
||||
},
|
||||
onlyFirst: (deviceAsset) {
|
||||
debugPrint(
|
||||
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
|
||||
);
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
|
||||
);
|
||||
}
|
||||
},
|
||||
onlySecond: (asset) {
|
||||
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}');
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
debugPrint("Total number of device assets migrated - ${toAdd.length}");
|
||||
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
"[MIGRATION] Total number of device assets migrated - ${toAdd.length}",
|
||||
);
|
||||
}
|
||||
|
||||
await db.writeTxn(() async {
|
||||
await db.deviceAssetEntitys.putAll(toAdd);
|
||||
});
|
||||
|
||||
@@ -207,9 +207,27 @@ class LoginForm extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
String generateRandomString(int length) {
|
||||
const chars =
|
||||
'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz1234567890';
|
||||
final random = Random.secure();
|
||||
return base64Url
|
||||
.encode(List<int>.generate(32, (i) => random.nextInt(256)));
|
||||
return String.fromCharCodes(
|
||||
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 {
|
||||
@@ -223,7 +241,8 @@ class LoginForm extends HookConsumerWidget {
|
||||
String? oAuthServerUrl;
|
||||
|
||||
final state = generateRandomString(32);
|
||||
final codeVerifier = generateRandomString(64);
|
||||
|
||||
final codeVerifier = randomCodeVerifier();
|
||||
final codeChallenge = await generatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
try {
|
||||
|
||||
@@ -49,7 +49,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
|
||||
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
|
||||
int sdkVersion = androidInfo.version.sdkInt;
|
||||
return sdkVersion >= 30;
|
||||
return sdkVersion >= 31;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -74,7 +74,7 @@ class AdvancedSettings extends HookConsumerWidget {
|
||||
if (value) {
|
||||
final result = await ref
|
||||
.read(localFilesManagerRepositoryProvider)
|
||||
.requestManageStoragePermission();
|
||||
.requestManageMediaPermission();
|
||||
manageLocalMediaAndroid.value = result;
|
||||
}
|
||||
},
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 1.132.0
|
||||
- API version: 1.132.3
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 1.132.0+194
|
||||
version: 1.132.3+197
|
||||
|
||||
environment:
|
||||
sdk: '>=3.3.0 <4.0.0'
|
||||
|
||||
@@ -7656,7 +7656,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [],
|
||||
|
||||
4
open-api/typescript-sdk/package-lock.json
generated
4
open-api/typescript-sdk/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 1.132.0
|
||||
* 1.132.3
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -6,14 +6,14 @@ WORKDIR /usr/src/app
|
||||
COPY server/package.json server/package-lock.json ./
|
||||
COPY server/patches ./patches
|
||||
RUN npm ci && \
|
||||
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
||||
rm -rf node_modules/@img/sharp-libvips* && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
||||
# exiftool-vendored.pl, sharp-linux-x64 and sharp-linux-arm64 are the only ones we need
|
||||
# they're marked as optional dependencies, so we need to copy them manually after pruning
|
||||
rm -rf node_modules/@img/sharp-libvips* && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
||||
ENV PATH="${PATH}:/usr/src/app/bin" \
|
||||
IMMICH_ENV=development \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
IMMICH_ENV=development \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ FROM ghcr.io/immich-app/base-server-prod:202504081114@sha256:8353bcbdb4e6579300a
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
COPY --from=prod /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=prod /usr/src/app/dist ./dist
|
||||
COPY --from=prod /usr/src/app/bin ./bin
|
||||
|
||||
1261
server/package-lock.json
generated
1261
server/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
@@ -88,7 +88,7 @@
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"sanitize-html": "^2.14.0",
|
||||
"semver": "^7.6.2",
|
||||
"sharp": "^0.33.5",
|
||||
"sharp": "^0.34.0",
|
||||
"sirv": "^3.0.0",
|
||||
"tailwindcss-preset-email": "^1.3.2",
|
||||
"thumbhash": "^0.1.1",
|
||||
@@ -132,7 +132,8 @@
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"mock-fs": "^5.2.0",
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-addon-api": "^8.3.1",
|
||||
"node-gyp": "^11.2.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"pngjs": "^7.0.0",
|
||||
"prettier": "^3.0.2",
|
||||
@@ -152,5 +153,8 @@
|
||||
},
|
||||
"volta": {
|
||||
"node": "22.14.0"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.34.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const imports = [
|
||||
BullModule.registerQueue(...bull.queues),
|
||||
ClsModule.forRoot(cls.config),
|
||||
OpenTelemetryModule.forRoot(otel),
|
||||
KyselyModule.forRoot(getKyselyConfig(database.config.kysely)),
|
||||
KyselyModule.forRoot(getKyselyConfig(database.config)),
|
||||
];
|
||||
|
||||
class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import 'src/schema';
|
||||
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
|
||||
|
||||
const main = async () => {
|
||||
const command = process.argv[2];
|
||||
@@ -56,7 +56,7 @@ const main = async () => {
|
||||
const getDatabaseClient = () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
return new Kysely<any>(getKyselyConfig(database.config.kysely));
|
||||
return new Kysely<any>(getKyselyConfig(database.config));
|
||||
};
|
||||
|
||||
const runQuery = async (query: string) => {
|
||||
@@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
|
||||
const compare = async () => {
|
||||
const configRepository = new ConfigRepository();
|
||||
const { database } = configRepository.getEnv();
|
||||
const db = postgres(database.config.kysely);
|
||||
const db = postgres(asPostgresConnectionConfig(database.config));
|
||||
|
||||
const source = schemaFromCode();
|
||||
const target = await schemaFromDatabase(db, {});
|
||||
|
||||
@@ -78,7 +78,7 @@ class SqlGenerator {
|
||||
const moduleFixture = await Test.createTestingModule({
|
||||
imports: [
|
||||
KyselyModule.forRoot({
|
||||
...getKyselyConfig(database.config.kysely),
|
||||
...getKyselyConfig(database.config),
|
||||
log: (event) => {
|
||||
if (event.level === 'query') {
|
||||
this.sqlLogger.logQuery(event.query.sql);
|
||||
|
||||
@@ -80,21 +80,12 @@ describe('getEnv', () => {
|
||||
const { database } = getEnv();
|
||||
expect(database).toEqual({
|
||||
config: {
|
||||
kysely: expect.objectContaining({
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
database: 'immich',
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
}),
|
||||
typeorm: expect.objectContaining({
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
database: 'immich',
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
}),
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
database: 'immich',
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
},
|
||||
skipMigrations: false,
|
||||
vectorExtension: 'vectors',
|
||||
@@ -110,88 +101,9 @@ describe('getEnv', () => {
|
||||
it('should use DB_URL', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
|
||||
const { database } = getEnv();
|
||||
expect(database.config.kysely).toMatchObject({
|
||||
host: 'database1',
|
||||
password: 'postgres2',
|
||||
user: 'postgres1',
|
||||
port: 54_320,
|
||||
database: 'immich',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle sslmode=require', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=prefer', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=verify-ca', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=verify-full', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=no-verify', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } });
|
||||
});
|
||||
|
||||
it('should handle ssl=true', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({ ssl: true });
|
||||
});
|
||||
|
||||
it('should reject invalid ssl', () => {
|
||||
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid';
|
||||
|
||||
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
|
||||
});
|
||||
|
||||
it('should handle socket: URLs', () => {
|
||||
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({
|
||||
host: '/run/postgresql',
|
||||
database: 'database1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle sockets in postgres: URLs', () => {
|
||||
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
|
||||
|
||||
const { database } = getEnv();
|
||||
|
||||
expect(database.config.kysely).toMatchObject({
|
||||
host: '/path/to/socket',
|
||||
database: 'database2',
|
||||
expect(database.config).toMatchObject({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,8 +7,7 @@ import { Request, Response } from 'express';
|
||||
import { RedisOptions } from 'ioredis';
|
||||
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
|
||||
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import { join } from 'node:path';
|
||||
import { citiesFile, excludePaths, IWorker } from 'src/constants';
|
||||
import { Telemetry } from 'src/decorators';
|
||||
import { EnvDto } from 'src/dtos/env.dto';
|
||||
@@ -22,9 +21,7 @@ import {
|
||||
QueueName,
|
||||
} from 'src/enum';
|
||||
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
|
||||
import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
|
||||
import { setDifference } from 'src/utils/set';
|
||||
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
|
||||
|
||||
export interface EnvData {
|
||||
host?: string;
|
||||
@@ -59,7 +56,7 @@ export interface EnvData {
|
||||
};
|
||||
|
||||
database: {
|
||||
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig };
|
||||
config: DatabaseConnectionParams;
|
||||
skipMigrations: boolean;
|
||||
vectorExtension: VectorExtension;
|
||||
};
|
||||
@@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
|
||||
const isProd = environment === ImmichEnvironment.PRODUCTION;
|
||||
const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
|
||||
const folders = {
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
dist: resolve(`${__dirname}/..`),
|
||||
geodata: join(buildFolder, 'geodata'),
|
||||
web: join(buildFolder, 'www'),
|
||||
};
|
||||
|
||||
const databaseUrl = dto.DB_URL;
|
||||
|
||||
let redisConfig = {
|
||||
host: dto.REDIS_HOSTNAME || 'redis',
|
||||
port: dto.REDIS_PORT || 6379,
|
||||
@@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
|
||||
}
|
||||
}
|
||||
|
||||
const parts = {
|
||||
connectionType: 'parts',
|
||||
host: dto.DB_HOSTNAME || 'database',
|
||||
port: dto.DB_PORT || 5432,
|
||||
username: dto.DB_USERNAME || 'postgres',
|
||||
password: dto.DB_PASSWORD || 'postgres',
|
||||
database: dto.DB_DATABASE_NAME || 'immich',
|
||||
} as const;
|
||||
|
||||
let parsedOptions: PostgresConnectionConfig = parts;
|
||||
if (dto.DB_URL) {
|
||||
const parsed = parse(dto.DB_URL);
|
||||
if (!isValidSsl(parsed.ssl)) {
|
||||
throw new Error(`Invalid ssl option: ${parsed.ssl}`);
|
||||
}
|
||||
|
||||
parsedOptions = {
|
||||
...parsed,
|
||||
ssl: parsed.ssl,
|
||||
host: parsed.host ?? undefined,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
database: parsed.database ?? undefined,
|
||||
};
|
||||
}
|
||||
const databaseConnection: DatabaseConnectionParams = dto.DB_URL
|
||||
? { connectionType: 'url', url: dto.DB_URL }
|
||||
: {
|
||||
connectionType: 'parts',
|
||||
host: dto.DB_HOSTNAME || 'database',
|
||||
port: dto.DB_PORT || 5432,
|
||||
username: dto.DB_USERNAME || 'postgres',
|
||||
password: dto.DB_PASSWORD || 'postgres',
|
||||
database: dto.DB_DATABASE_NAME || 'immich',
|
||||
};
|
||||
|
||||
return {
|
||||
host: dto.IMMICH_HOST,
|
||||
@@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
|
||||
},
|
||||
|
||||
database: {
|
||||
config: {
|
||||
typeorm: {
|
||||
type: 'postgres',
|
||||
entities: [],
|
||||
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
|
||||
subscribers: [],
|
||||
migrationsRun: false,
|
||||
synchronize: false,
|
||||
connectTimeoutMS: 10_000, // 10 seconds
|
||||
parseInt8: true,
|
||||
...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts),
|
||||
},
|
||||
kysely: parsedOptions,
|
||||
},
|
||||
|
||||
config: databaseConnection,
|
||||
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
|
||||
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
|
||||
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import semver from 'semver';
|
||||
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
|
||||
import { DB } from 'src/db';
|
||||
@@ -205,8 +205,29 @@ export class DatabaseRepository {
|
||||
const { rows } = await tableExists.execute(this.db);
|
||||
const hasTypeOrmMigrations = !!rows[0]?.result;
|
||||
if (hasTypeOrmMigrations) {
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
const dist = resolve(`${__dirname}/..`);
|
||||
|
||||
this.logger.debug('Running typeorm migrations');
|
||||
const dataSource = new DataSource(database.config.typeorm);
|
||||
const dataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
entities: [],
|
||||
subscribers: [],
|
||||
migrations: [`${dist}/migrations` + '/*.{js,ts}'],
|
||||
migrationsRun: false,
|
||||
synchronize: false,
|
||||
connectTimeoutMS: 10_000, // 10 seconds
|
||||
parseInt8: true,
|
||||
...(database.config.connectionType === 'url'
|
||||
? { url: database.config.url }
|
||||
: {
|
||||
host: database.config.host,
|
||||
port: database.config.port,
|
||||
username: database.config.username,
|
||||
password: database.config.password,
|
||||
database: database.config.database,
|
||||
}),
|
||||
});
|
||||
await dataSource.initialize();
|
||||
await dataSource.runMigrations(options);
|
||||
await dataSource.destroy();
|
||||
|
||||
@@ -70,7 +70,7 @@ export class BackupService extends BaseService {
|
||||
async handleBackupDatabase(): Promise<JobStatus> {
|
||||
this.logger.debug(`Database Backup Started`);
|
||||
const { database } = this.configRepository.getEnv();
|
||||
const config = database.config.typeorm;
|
||||
const config = database.config;
|
||||
|
||||
const isUrlConnection = config.connectionType === 'url';
|
||||
|
||||
|
||||
@@ -53,22 +53,12 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
kysely: {
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: false,
|
||||
vectorExtension: extension,
|
||||
@@ -292,22 +282,12 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
kysely: {
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: true,
|
||||
vectorExtension: DatabaseExtension.VECTORS,
|
||||
@@ -325,22 +305,12 @@ describe(DatabaseService.name, () => {
|
||||
mockEnvData({
|
||||
database: {
|
||||
config: {
|
||||
kysely: {
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
connectionType: 'parts',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
database: 'immich',
|
||||
},
|
||||
skipMigrations: true,
|
||||
vectorExtension: DatabaseExtension.VECTOR,
|
||||
|
||||
83
server/src/utils/database.spec.ts
Normal file
83
server/src/utils/database.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { asPostgresConnectionConfig } from 'src/utils/database';
|
||||
|
||||
describe('database utils', () => {
|
||||
describe('asPostgresConnectionConfig', () => {
|
||||
it('should handle sslmode=require', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require',
|
||||
}),
|
||||
).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=prefer', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer',
|
||||
}),
|
||||
).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=verify-ca', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca',
|
||||
}),
|
||||
).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=verify-full', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full',
|
||||
}),
|
||||
).toMatchObject({ ssl: {} });
|
||||
});
|
||||
|
||||
it('should handle sslmode=no-verify', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify',
|
||||
}),
|
||||
).toMatchObject({ ssl: { rejectUnauthorized: false } });
|
||||
});
|
||||
|
||||
it('should handle ssl=true', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true',
|
||||
}),
|
||||
).toMatchObject({ ssl: true });
|
||||
});
|
||||
|
||||
it('should reject invalid ssl', () => {
|
||||
expect(() =>
|
||||
asPostgresConnectionConfig({
|
||||
connectionType: 'url',
|
||||
url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid',
|
||||
}),
|
||||
).toThrowError('Invalid ssl option');
|
||||
});
|
||||
|
||||
it('should handle socket: URLs', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({ connectionType: 'url', url: 'socket:/run/postgresql?db=database1' }),
|
||||
).toMatchObject({ host: '/run/postgresql', database: 'database1' });
|
||||
});
|
||||
|
||||
it('should handle sockets in postgres: URLs', () => {
|
||||
expect(
|
||||
asPostgresConnectionConfig({ connectionType: 'url', url: 'postgres:///database2?host=/path/to/socket' }),
|
||||
).toMatchObject({
|
||||
host: '/path/to/socket',
|
||||
database: 'database2',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,33 +13,57 @@ import {
|
||||
} from 'kysely';
|
||||
import { PostgresJSDialect } from 'kysely-postgres-js';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import postgres, { Notice } from 'postgres';
|
||||
import { columns, Exif, Person } from 'src/database';
|
||||
import { DB } from 'src/db';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { TimeBucketSize } from 'src/repositories/asset.repository';
|
||||
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
|
||||
import { DatabaseConnectionParams } from 'src/types';
|
||||
|
||||
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
|
||||
|
||||
export type PostgresConnectionConfig = {
|
||||
host?: string;
|
||||
password?: string;
|
||||
user?: string;
|
||||
port?: number;
|
||||
database?: string;
|
||||
max?: number;
|
||||
client_encoding?: string;
|
||||
ssl?: Ssl;
|
||||
application_name?: string;
|
||||
fallback_application_name?: string;
|
||||
options?: string;
|
||||
};
|
||||
|
||||
export const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
|
||||
const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
|
||||
typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full';
|
||||
|
||||
export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig => {
|
||||
export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => {
|
||||
if (params.connectionType === 'parts') {
|
||||
return {
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
password: params.password,
|
||||
database: params.database,
|
||||
ssl: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const { host, port, user, password, database, ...rest } = parse(params.url);
|
||||
let ssl: Ssl | undefined;
|
||||
if (rest.ssl) {
|
||||
if (!isValidSsl(rest.ssl)) {
|
||||
throw new Error(`Invalid ssl option: ${rest.ssl}`);
|
||||
}
|
||||
ssl = rest.ssl;
|
||||
}
|
||||
|
||||
return {
|
||||
host: host ?? undefined,
|
||||
port: port ? Number(port) : undefined,
|
||||
username: user,
|
||||
password,
|
||||
database: database ?? undefined,
|
||||
ssl,
|
||||
};
|
||||
};
|
||||
|
||||
export const getKyselyConfig = (
|
||||
params: DatabaseConnectionParams,
|
||||
options: Partial<postgres.Options<Record<string, postgres.PostgresType>>> = {},
|
||||
): KyselyConfig => {
|
||||
const config = asPostgresConnectionConfig(params);
|
||||
|
||||
return {
|
||||
dialect: new PostgresJSDialect({
|
||||
postgres: postgres({
|
||||
@@ -66,6 +90,12 @@ export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig
|
||||
connection: {
|
||||
TimeZone: 'UTC',
|
||||
},
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
username: config.username,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
ssl: config.ssl,
|
||||
...options,
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import { DB } from 'src/db';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
@@ -37,19 +36,10 @@ const globalSetup = async () => {
|
||||
|
||||
const postgresPort = postgresContainer.getMappedPort(5432);
|
||||
const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`;
|
||||
const parsed = parse(postgresUrl);
|
||||
|
||||
process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl;
|
||||
|
||||
const db = new Kysely<DB>(
|
||||
getKyselyConfig({
|
||||
...parsed,
|
||||
ssl: false,
|
||||
host: parsed.host ?? undefined,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
database: parsed.database ?? undefined,
|
||||
}),
|
||||
);
|
||||
const db = new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: postgresUrl }));
|
||||
|
||||
const configRepository = new ConfigRepository();
|
||||
const logger = new LoggingRepository(undefined, configRepository);
|
||||
|
||||
@@ -21,19 +21,12 @@ const envData: EnvData = {
|
||||
|
||||
database: {
|
||||
config: {
|
||||
kysely: { database: 'immich', host: 'database', port: 5432 },
|
||||
typeorm: {
|
||||
connectionType: 'parts',
|
||||
database: 'immich',
|
||||
type: 'postgres',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
name: 'immich',
|
||||
synchronize: false,
|
||||
migrationsRun: true,
|
||||
},
|
||||
connectionType: 'parts',
|
||||
database: 'immich',
|
||||
host: 'database',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: 'postgres',
|
||||
},
|
||||
|
||||
skipMigrations: false,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ClassConstructor } from 'class-transformer';
|
||||
import { Kysely, sql } from 'kysely';
|
||||
import { Kysely } from 'kysely';
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { Writable } from 'node:stream';
|
||||
import { parse } from 'pg-connection-string';
|
||||
import { PNG } from 'pngjs';
|
||||
import postgres from 'postgres';
|
||||
import { DB } from 'src/db';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
@@ -49,7 +49,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { RepositoryInterface } from 'src/types';
|
||||
import { getKyselyConfig } from 'src/utils/database';
|
||||
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
|
||||
@@ -297,24 +297,20 @@ function* newPngFactory() {
|
||||
|
||||
const pngFactory = newPngFactory();
|
||||
|
||||
const withDatabase = (url: string, name: string) => url.replace('/immich', `/${name}`);
|
||||
|
||||
export const getKyselyDB = async (suffix?: string): Promise<Kysely<DB>> => {
|
||||
const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!);
|
||||
const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!;
|
||||
const sql = postgres({
|
||||
...asPostgresConnectionConfig({ connectionType: 'url', url: withDatabase(testUrl, 'postgres') }),
|
||||
max: 1,
|
||||
});
|
||||
|
||||
const parsedOptions = {
|
||||
...parsed,
|
||||
ssl: false,
|
||||
host: parsed.host ?? undefined,
|
||||
port: parsed.port ? Number(parsed.port) : undefined,
|
||||
database: parsed.database ?? undefined,
|
||||
};
|
||||
|
||||
const kysely = new Kysely<DB>(getKyselyConfig({ ...parsedOptions, max: 1, database: 'postgres' }));
|
||||
const randomSuffix = Math.random().toString(36).slice(2, 7);
|
||||
const dbName = `immich_${suffix ?? randomSuffix}`;
|
||||
await sql.unsafe(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`);
|
||||
|
||||
await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely);
|
||||
|
||||
return new Kysely<DB>(getKyselyConfig({ ...parsedOptions, database: dbName }));
|
||||
return new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: withDatabase(testUrl, dbName) }));
|
||||
};
|
||||
|
||||
export const newRandomImage = () => {
|
||||
|
||||
6
web/package-lock.json
generated
6
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "immich-web",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||
@@ -82,7 +82,7 @@
|
||||
},
|
||||
"../open-api/typescript-sdk": {
|
||||
"name": "@immich/sdk",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"dependencies": {
|
||||
"@oazapfts/runtime": "^1.0.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "1.132.0",
|
||||
"version": "1.132.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
|
||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
@@ -20,6 +19,7 @@
|
||||
import { t } from 'svelte-i18n';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { dragAndDropManager } from '$lib/managers/drag-and-drop.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
@@ -38,10 +38,10 @@
|
||||
|
||||
const assetInteraction = new AssetInteraction();
|
||||
|
||||
dragAndDropFilesStore.subscribe((value) => {
|
||||
if (value.isDragging && value.files.length > 0) {
|
||||
handlePromiseError(fileUploadHandler(value.files, album.id));
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
$effect(() => {
|
||||
if (dragAndDropManager.isDragging && dragAndDropManager.files.length > 0) {
|
||||
handlePromiseError(fileUploadHandler(dragAndDropManager.files, album.id));
|
||||
dragAndDropManager.reset();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { updateNumberOfComments } from '$lib/stores/activity.store';
|
||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||
@@ -47,6 +46,7 @@
|
||||
import PhotoViewer from './photo-viewer.svelte';
|
||||
import SlideshowBar from './slideshow-bar.svelte';
|
||||
import VideoViewer from './video-wrapper-viewer.svelte';
|
||||
import { activityManager } from '$lib/managers/activity.manager.svelte';
|
||||
|
||||
type HasAsset = boolean;
|
||||
|
||||
@@ -137,12 +137,12 @@
|
||||
|
||||
const handleAddComment = () => {
|
||||
numberOfComments++;
|
||||
updateNumberOfComments(1);
|
||||
activityManager.updateNumberOfComments(1);
|
||||
};
|
||||
|
||||
const handleRemoveComment = () => {
|
||||
numberOfComments--;
|
||||
updateNumberOfComments(-1);
|
||||
activityManager.updateNumberOfComments(-1);
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
import AlbumListItemDetails from './album-list-item-details.svelte';
|
||||
import Portal from '$lib/components/shared-components/portal/portal.svelte';
|
||||
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { faceManager } from '$lib/managers/face.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -207,7 +207,7 @@
|
||||
padding="1"
|
||||
size="20"
|
||||
buttonSize="32"
|
||||
onclick={() => (isFaceEditMode.value = !isFaceEditMode.value)}
|
||||
onclick={() => (faceManager.isEditMode = !faceManager.isEditMode)}
|
||||
/>
|
||||
|
||||
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { type DownloadProgress, downloadManager, downloadStore } from '$lib/stores/download-store.svelte';
|
||||
import { downloadManager, type DownloadProgress } from '$lib/managers/download.manager.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { fly, slide } from 'svelte/transition';
|
||||
import { getByteUnitString } from '../../utils/byte-units';
|
||||
@@ -13,15 +13,15 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if downloadStore.isDownloading}
|
||||
{#if downloadManager.isDownloading}
|
||||
<div
|
||||
transition:fly={{ x: -100, duration: 350 }}
|
||||
class="fixed bottom-10 left-2 z-[10000] max-h-[270px] w-[315px] rounded-2xl border bg-immich-bg p-4 text-sm shadow-sm"
|
||||
>
|
||||
<p class="mb-2 text-xs text-gray-500">{$t('downloading').toUpperCase()}</p>
|
||||
<div class="my-2 mb-2 flex max-h-[200px] flex-col overflow-y-auto text-sm">
|
||||
{#each Object.keys(downloadStore.assets) as downloadKey (downloadKey)}
|
||||
{@const download = downloadStore.assets[downloadKey]}
|
||||
{#each Object.keys(downloadManager.assets) as downloadKey (downloadKey)}
|
||||
{@const download = downloadManager.assets[downloadKey]}
|
||||
<div class="mb-2 flex place-items-center" transition:slide>
|
||||
<div class="w-full pr-10">
|
||||
<div class="flex place-items-center justify-between gap-2 text-xs font-medium">
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
import { notificationController } from '$lib/components/shared-components/notification/notification';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk';
|
||||
import { Button, Input } from '@immich/ui';
|
||||
@@ -10,6 +9,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { faceManager } from '$lib/managers/face.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
@@ -140,7 +140,7 @@
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
isFaceEditMode.value = false;
|
||||
faceManager.isEditMode = false;
|
||||
};
|
||||
|
||||
const getPeople = async () => {
|
||||
@@ -303,7 +303,7 @@
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
isFaceEditMode.value = false;
|
||||
faceManager.isEditMode = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import { photoViewerImgElement } from '$lib/stores/assets-store.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { faceManager } from '$lib/managers/face.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -109,7 +109,7 @@
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value && $photoZoomState.currentZoom > 1) {
|
||||
if (faceManager.isEditMode && $photoZoomState.currentZoom > 1) {
|
||||
zoomToggle();
|
||||
}
|
||||
});
|
||||
@@ -235,7 +235,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
{#if faceManager.isEditMode}
|
||||
<FaceEditor htmlElement={$photoViewerImgElement} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
import type { SwipeCustomEvent } from 'svelte-gestures';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte';
|
||||
import { faceManager } from '$lib/managers/face.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
assetId: string;
|
||||
@@ -94,7 +94,7 @@
|
||||
let containerHeight = $state(0);
|
||||
|
||||
$effect(() => {
|
||||
if (isFaceEditMode.value) {
|
||||
if (faceManager.isEditMode) {
|
||||
videoPlayer?.pause();
|
||||
}
|
||||
});
|
||||
@@ -141,7 +141,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isFaceEditMode.value}
|
||||
{#if faceManager.isEditMode}
|
||||
<FaceEditor htmlElement={videoPlayer} {containerWidth} {containerHeight} {assetId} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import { AppRoute, AssetAction } from '$lib/constants';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { getKey, handlePromiseError } from '$lib/utils';
|
||||
import { downloadArchive } from '$lib/utils/asset-utils';
|
||||
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||
@@ -22,6 +21,7 @@
|
||||
import type { Viewport } from '$lib/stores/assets-store.svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { dragAndDropManager } from '$lib/managers/drag-and-drop.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
sharedLink: SharedLinkResponseDto;
|
||||
@@ -35,10 +35,10 @@
|
||||
|
||||
let assets = $derived(sharedLink.assets);
|
||||
|
||||
dragAndDropFilesStore.subscribe((value) => {
|
||||
if (value.isDragging && value.files.length > 0) {
|
||||
handlePromiseError(handleUploadAssets(value.files));
|
||||
dragAndDropFilesStore.set({ isDragging: false, files: [] });
|
||||
$effect(() => {
|
||||
if (dragAndDropManager.isDragging && dragAndDropManager.files.length > 0) {
|
||||
handlePromiseError(handleUploadAssets(dragAndDropManager.files));
|
||||
dragAndDropManager.reset();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
type Padding,
|
||||
} from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
import { contextMenuManager } from '$lib/managers/context-menu.manager.svelte';
|
||||
import {
|
||||
getContextMenuPositionFromBoundingRect,
|
||||
getContextMenuPositionFromEvent,
|
||||
@@ -97,7 +97,7 @@
|
||||
}
|
||||
focusButton();
|
||||
isOpen = false;
|
||||
$selectedIdStore = undefined;
|
||||
contextMenuManager.selectedId = undefined;
|
||||
};
|
||||
|
||||
const handleOptionClick = () => {
|
||||
@@ -124,7 +124,7 @@
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
$optionClickCallbackStore = handleOptionClick;
|
||||
contextMenuManager.optionClickCallback = handleOptionClick;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -139,8 +139,8 @@
|
||||
isOpen,
|
||||
onEscape,
|
||||
openDropdown,
|
||||
selectedId: $selectedIdStore,
|
||||
selectionChanged: (id) => ($selectedIdStore = id),
|
||||
selectedId: contextMenuManager.selectedId,
|
||||
selectionChanged: (id) => (contextMenuManager.selectedId = id),
|
||||
}}
|
||||
onresize={onResize}
|
||||
{...restProps}
|
||||
@@ -178,7 +178,7 @@
|
||||
<ContextMenu
|
||||
{...contextMenuPosition}
|
||||
{direction}
|
||||
ariaActiveDescendant={$selectedIdStore}
|
||||
ariaActiveDescendant={contextMenuManager.selectedId}
|
||||
ariaLabelledBy={buttonId}
|
||||
bind:menuElement={menuContainer}
|
||||
id={menuId}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script lang="ts">
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
import type { Shortcut } from '$lib/actions/shortcut';
|
||||
import { shortcutLabel as computeShortcutLabel, shortcut as bindShortcut } from '$lib/actions/shortcut';
|
||||
import { contextMenuManager } from '$lib/managers/context-menu.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
@@ -29,10 +29,10 @@
|
||||
|
||||
let id: string = generateId();
|
||||
|
||||
let isActive = $derived($selectedIdStore === id);
|
||||
let isActive = $derived(contextMenuManager.selectedId === id);
|
||||
|
||||
const handleClick = () => {
|
||||
$optionClickCallbackStore?.();
|
||||
contextMenuManager.optionClickCallback?.();
|
||||
onClick();
|
||||
};
|
||||
|
||||
@@ -51,8 +51,8 @@
|
||||
<li
|
||||
{id}
|
||||
onclick={handleClick}
|
||||
onmouseover={() => ($selectedIdStore = id)}
|
||||
onmouseleave={() => ($selectedIdStore = undefined)}
|
||||
onmouseover={() => (contextMenuManager.selectedId = id)}
|
||||
onmouseleave={() => (contextMenuManager.selectedId = undefined)}
|
||||
class="w-full p-4 text-left text-sm font-medium {textColor} focus:outline-none focus:ring-2 focus:ring-inset cursor-pointer border-gray-200 flex gap-2 items-center {isActive
|
||||
? activeColor
|
||||
: 'bg-slate-100'}"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import { generateId } from '$lib/utils/generate-id';
|
||||
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
|
||||
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
|
||||
import { contextMenuManager } from '$lib/managers/context-menu.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -60,7 +60,7 @@
|
||||
if (isOpen && menuContainer) {
|
||||
triggerElement = document.activeElement as HTMLElement;
|
||||
menuContainer.focus();
|
||||
$optionClickCallbackStore = closeContextMenu;
|
||||
contextMenuManager.optionClickCallback = closeContextMenu;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -77,8 +77,8 @@
|
||||
closeDropdown: closeContextMenu,
|
||||
container: menuContainer,
|
||||
isOpen,
|
||||
selectedId: $selectedIdStore,
|
||||
selectionChanged: (id) => ($selectedIdStore = id),
|
||||
selectedId: contextMenuManager.selectedId,
|
||||
selectionChanged: (id) => (contextMenuManager.selectedId = id),
|
||||
}}
|
||||
use:shortcuts={[
|
||||
{
|
||||
@@ -96,7 +96,7 @@
|
||||
{direction}
|
||||
{x}
|
||||
{y}
|
||||
ariaActiveDescendant={$selectedIdStore}
|
||||
ariaActiveDescendant={contextMenuManager.selectedId}
|
||||
ariaLabel={title}
|
||||
bind:menuElement={menuContainer}
|
||||
id={menuId}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { shouldIgnoreEvent } from '$lib/actions/shortcut';
|
||||
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
|
||||
import { dragAndDropManager } from '$lib/managers/drag-and-drop.manager.svelte';
|
||||
import { fileUploadHandler } from '$lib/utils/file-uploader';
|
||||
import { isAlbumsRoute, isSharedLinkRoute } from '$lib/utils/navigation';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -124,7 +124,8 @@
|
||||
|
||||
const filesArray: File[] = Array.from<File>(files);
|
||||
if (isShare) {
|
||||
dragAndDropFilesStore.set({ isDragging: true, files: filesArray });
|
||||
dragAndDropManager.isDragging = true;
|
||||
dragAndDropManager.files = filesArray;
|
||||
} else {
|
||||
await fileUploadHandler(filesArray, albumId);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
import ImmichLogo from '$lib/components/shared-components/immich-logo.svelte';
|
||||
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { sidebarStore } from '$lib/stores/sidebar.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { handleLogout } from '$lib/utils/auth';
|
||||
import { getAboutInfo, logout, type ServerAboutResponseDto } from '@immich/sdk';
|
||||
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
|
||||
import { Button, IconButton } from '@immich/ui';
|
||||
import { mdiHelpCircleOutline, mdiMagnify, mdiMenu, mdiTrayArrowUp } from '@mdi/js';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -23,8 +25,6 @@
|
||||
import ThemeButton from '../theme-button.svelte';
|
||||
import UserAvatar from '../user-avatar.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 {
|
||||
showUploadButton?: boolean;
|
||||
@@ -38,11 +38,6 @@
|
||||
let shouldShowHelpPanel = $state(false);
|
||||
let innerWidth: number = $state(0);
|
||||
|
||||
const onLogout = async () => {
|
||||
const { redirectUri } = await logout();
|
||||
await handleLogout(redirectUri);
|
||||
};
|
||||
|
||||
let info: ServerAboutResponseDto | undefined = $state();
|
||||
|
||||
onMount(async () => {
|
||||
@@ -183,7 +178,7 @@
|
||||
{/if}
|
||||
|
||||
{#if shouldShowAccountInfoPanel}
|
||||
<AccountInfoPanel {onLogout} />
|
||||
<AccountInfoPanel onLogout={() => authManager.logout()} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
17
web/src/lib/managers/activity.manager.svelte.ts
Normal file
17
web/src/lib/managers/activity.manager.svelte.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
class ActivityManager {
|
||||
#numberOfComments = $state<number>(0);
|
||||
|
||||
get numberOfComments() {
|
||||
return this.#numberOfComments;
|
||||
}
|
||||
|
||||
set numberOfComments(number: number) {
|
||||
this.#numberOfComments = number;
|
||||
}
|
||||
|
||||
updateNumberOfComments(addOrRemove: 1 | -1) {
|
||||
this.#numberOfComments += addOrRemove;
|
||||
}
|
||||
}
|
||||
|
||||
export const activityManager = new ActivityManager();
|
||||
33
web/src/lib/managers/auth-manager.svelte.ts
Normal file
33
web/src/lib/managers/auth-manager.svelte.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { eventManager } from '$lib/managers/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();
|
||||
22
web/src/lib/managers/context-menu.manager.svelte.ts
Normal file
22
web/src/lib/managers/context-menu.manager.svelte.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
class ContextMenuManager {
|
||||
#selectedId = $state<string | undefined>(undefined);
|
||||
#optionClickCallback = $state<(() => void) | undefined>(undefined);
|
||||
|
||||
get selectedId() {
|
||||
return this.#selectedId;
|
||||
}
|
||||
|
||||
set selectedId(id: string | undefined) {
|
||||
this.#selectedId = id;
|
||||
}
|
||||
|
||||
get optionClickCallback() {
|
||||
return this.#optionClickCallback;
|
||||
}
|
||||
|
||||
set optionClickCallback(callback: (() => void) | undefined) {
|
||||
this.#optionClickCallback = callback;
|
||||
}
|
||||
}
|
||||
|
||||
export const contextMenuManager = new ContextMenuManager();
|
||||
47
web/src/lib/managers/download.manager.svelte.ts
Normal file
47
web/src/lib/managers/download.manager.svelte.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
export interface DownloadProgress {
|
||||
progress: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
abort: AbortController | null;
|
||||
}
|
||||
|
||||
class DownloadManager {
|
||||
#assets = $state<Record<string, DownloadProgress>>({});
|
||||
#isDownloading = $derived(Object.keys(this.#assets).length > 0);
|
||||
|
||||
#update(key: string, value: Partial<DownloadProgress>) {
|
||||
if (!this.#assets[key]) {
|
||||
this.#assets[key] = { progress: 0, total: 0, percentage: 0, abort: null };
|
||||
}
|
||||
|
||||
const item = this.#assets[key];
|
||||
Object.assign(item, value);
|
||||
item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100);
|
||||
}
|
||||
|
||||
get assets() {
|
||||
return this.#assets;
|
||||
}
|
||||
|
||||
get isDownloading() {
|
||||
return this.#isDownloading;
|
||||
}
|
||||
|
||||
add(key: string, total: number, abort?: AbortController) {
|
||||
this.#update(key, { total, abort });
|
||||
}
|
||||
|
||||
clear(key: string) {
|
||||
delete this.#assets[key];
|
||||
}
|
||||
|
||||
update(key: string, progress: number, total?: number) {
|
||||
const download: Partial<DownloadProgress> = { progress };
|
||||
if (total !== undefined) {
|
||||
download.total = total;
|
||||
}
|
||||
this.#update(key, download);
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadManager = new DownloadManager();
|
||||
27
web/src/lib/managers/drag-and-drop.manager.svelte.ts
Normal file
27
web/src/lib/managers/drag-and-drop.manager.svelte.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
class DragAndDropManager {
|
||||
#isDragging = $state<boolean>(false);
|
||||
#files = $state<File[]>([]);
|
||||
|
||||
get isDragging() {
|
||||
return this.#isDragging;
|
||||
}
|
||||
|
||||
get files() {
|
||||
return this.#files;
|
||||
}
|
||||
|
||||
set isDragging(isDragging: boolean) {
|
||||
this.#isDragging = isDragging;
|
||||
}
|
||||
|
||||
set files(files: File[]) {
|
||||
this.#files = files;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#isDragging = false;
|
||||
this.#files = [];
|
||||
}
|
||||
}
|
||||
|
||||
export const dragAndDropManager = new DragAndDropManager();
|
||||
54
web/src/lib/managers/event.manager.svelte.ts
Normal file
54
web/src/lib/managers/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': [];
|
||||
}>();
|
||||
13
web/src/lib/managers/face.manager.svelte.ts
Normal file
13
web/src/lib/managers/face.manager.svelte.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
class FaceManager {
|
||||
#isEditMode = $state(false);
|
||||
|
||||
get isEditMode() {
|
||||
return this.#isEditMode;
|
||||
}
|
||||
|
||||
set isEditMode(isEditMode: boolean) {
|
||||
this.#isEditMode = isEditMode;
|
||||
}
|
||||
}
|
||||
|
||||
export const faceManager = new FaceManager();
|
||||
@@ -1,11 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const numberOfComments = writable<number>(0);
|
||||
|
||||
export const setNumberOfComments = (number: number) => {
|
||||
numberOfComments.set(number);
|
||||
};
|
||||
|
||||
export const updateNumberOfComments = (addOrRemove: 1 | -1) => {
|
||||
numberOfComments.update((n) => n + addOrRemove);
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const selectedIdStore = writable<string | undefined>(undefined);
|
||||
const optionClickCallbackStore = writable<(() => void) | undefined>(undefined);
|
||||
|
||||
export { optionClickCallbackStore, selectedIdStore };
|
||||
@@ -1,51 +0,0 @@
|
||||
export interface DownloadProgress {
|
||||
progress: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
abort: AbortController | null;
|
||||
}
|
||||
|
||||
class DownloadStore {
|
||||
assets = $state<Record<string, DownloadProgress>>({});
|
||||
|
||||
isDownloading = $derived(Object.keys(this.assets).length > 0);
|
||||
|
||||
#update(key: string, value: Partial<DownloadProgress> | null) {
|
||||
if (value === null) {
|
||||
delete this.assets[key];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.assets[key]) {
|
||||
this.assets[key] = { progress: 0, total: 0, percentage: 0, abort: null };
|
||||
}
|
||||
|
||||
const item = this.assets[key];
|
||||
Object.assign(item, value);
|
||||
item.percentage = Math.min(Math.floor((item.progress / item.total) * 100), 100);
|
||||
}
|
||||
|
||||
add(key: string, total: number, abort?: AbortController) {
|
||||
this.#update(key, { total, abort });
|
||||
}
|
||||
|
||||
clear(key: string) {
|
||||
this.#update(key, null);
|
||||
}
|
||||
|
||||
update(key: string, progress: number, total?: number) {
|
||||
const download: Partial<DownloadProgress> = { progress };
|
||||
if (total !== undefined) {
|
||||
download.total = total;
|
||||
}
|
||||
this.#update(key, download);
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadStore = new DownloadStore();
|
||||
|
||||
export const downloadManager = {
|
||||
add: (key: string, total: number, abort?: AbortController) => downloadStore.add(key, total, abort),
|
||||
clear: (key: string) => downloadStore.clear(key),
|
||||
update: (key: string, progress: number, total?: number) => downloadStore.update(key, progress, total),
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
//store to track the state of the drag and drop and the files
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const dragAndDropFilesStore = writable({
|
||||
isDragging: false as boolean,
|
||||
files: [] as File[],
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export const isFaceEditMode = $state({ value: false });
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eventManager } from '$lib/managers/event.manager.svelte';
|
||||
import {
|
||||
getAssetsByOriginalPath,
|
||||
getUniqueOriginalPaths,
|
||||
@@ -16,6 +17,10 @@ class FoldersStore {
|
||||
uniquePaths = $state<string[]>([]);
|
||||
assets = $state<AssetCache>({});
|
||||
|
||||
constructor() {
|
||||
eventManager.on('auth.logout', () => this.clearCache());
|
||||
}
|
||||
|
||||
async fetchUniquePaths() {
|
||||
if (this.initialized) {
|
||||
return;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eventManager } from '$lib/managers/event.manager.svelte';
|
||||
import { asLocalTimeISO } from '$lib/utils/date-time';
|
||||
import {
|
||||
type AssetResponseDto,
|
||||
@@ -24,6 +25,10 @@ export type MemoryAsset = MemoryIndex & {
|
||||
};
|
||||
|
||||
class MemoryStoreSvelte {
|
||||
constructor() {
|
||||
eventManager.on('auth.logout', () => this.clearCache());
|
||||
}
|
||||
|
||||
memories = $state<MemoryResponseDto[]>([]);
|
||||
private initialized = false;
|
||||
private memoryAssets = $derived.by(() => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { eventManager } from '$lib/managers/event.manager.svelte';
|
||||
|
||||
class SearchStore {
|
||||
savedSearchTerms = $state<string[]>([]);
|
||||
isSearchEnabled = $state(false);
|
||||
|
||||
constructor() {
|
||||
eventManager.on('auth.logout', () => this.clearCache());
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.savedSearchTerms = [];
|
||||
this.isSearchEnabled = false;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eventManager } from '$lib/managers/event.manager.svelte';
|
||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||
import { type UserAdminResponseDto, type UserPreferencesResponseDto } from '@immich/sdk';
|
||||
import { writable } from 'svelte/store';
|
||||
@@ -14,3 +15,5 @@ export const resetSavedUser = () => {
|
||||
preferences.set(undefined as unknown as UserPreferencesResponseDto);
|
||||
purchaseStore.setPurchaseStatus(false);
|
||||
};
|
||||
|
||||
eventManager.on('auth.logout', () => resetSavedUser());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eventManager } from '$lib/managers/event.manager.svelte';
|
||||
import type {
|
||||
AlbumResponseDto,
|
||||
ServerAboutResponseDto,
|
||||
@@ -19,8 +20,10 @@ const defaultUserInteraction: UserInteractions = {
|
||||
serverInfo: undefined,
|
||||
};
|
||||
|
||||
export const resetUserInteraction = () => {
|
||||
export const userInteraction = $state<UserInteractions>(defaultUserInteraction);
|
||||
|
||||
const reset = () => {
|
||||
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 { handleLogout } from '$lib/utils/auth';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { createEventEmitter } from '$lib/utils/eventemitter';
|
||||
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
||||
import { io, type Socket } from 'socket.io-client';
|
||||
@@ -50,7 +49,7 @@ websocket
|
||||
.on('disconnect', () => websocketStore.connected.set(false))
|
||||
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
|
||||
.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));
|
||||
|
||||
export const openWebsocketConnection = () => {
|
||||
|
||||
@@ -3,9 +3,9 @@ import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte';
|
||||
import type { InterpolationValues } from '$lib/components/i18n/format-message';
|
||||
import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { downloadManager } from '$lib/managers/download.manager.svelte';
|
||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetsSnapshot, isSelectingAllAssets, type AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import { downloadManager } from '$lib/stores/download-store.svelte';
|
||||
import { preferences } from '$lib/stores/user.store';
|
||||
import { downloadRequest, getKey, withError } from '$lib/utils';
|
||||
import { createAlbum } from '$lib/utils/album-utils';
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
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 { searchStore } from '$lib/stores/search.svelte';
|
||||
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';
|
||||
import { resetUserInteraction, userInteraction } from '$lib/stores/user.svelte';
|
||||
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
||||
import { userInteraction } from '$lib/stores/user.svelte';
|
||||
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -91,19 +87,3 @@ export const getAccountAge = (): number => {
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { AppRoute, AlbumPageViewMode } from '$lib/constants';
|
||||
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
@@ -87,6 +86,7 @@
|
||||
import { confirmAlbumDelete } from '$lib/utils/album-utils';
|
||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { activityManager } from '$lib/managers/activity.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
@@ -191,7 +191,7 @@
|
||||
const getNumberOfComments = async () => {
|
||||
try {
|
||||
const { comments } = await getActivityStatistics({ albumId: album.id });
|
||||
setNumberOfComments(comments);
|
||||
activityManager.numberOfComments = comments;
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_get_number_of_comments'));
|
||||
}
|
||||
@@ -398,7 +398,7 @@
|
||||
let albumId = $derived(album.id);
|
||||
|
||||
$effect(() => {
|
||||
if (!album.isActivityEnabled && $numberOfComments === 0) {
|
||||
if (!album.isActivityEnabled && activityManager.numberOfComments === 0) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
});
|
||||
@@ -420,7 +420,9 @@
|
||||
let isOwned = $derived($user.id == album.ownerId);
|
||||
|
||||
let showActivityStatus = $derived(
|
||||
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0),
|
||||
album.albumUsers.length > 0 &&
|
||||
!$showAssetViewer &&
|
||||
(album.isActivityEnabled || activityManager.numberOfComments > 0),
|
||||
);
|
||||
let isEditor = $derived(
|
||||
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
|
||||
@@ -712,7 +714,7 @@
|
||||
<ActivityStatus
|
||||
disabled={!album.isActivityEnabled}
|
||||
{isLiked}
|
||||
numberOfComments={$numberOfComments}
|
||||
numberOfComments={activityManager.numberOfComments}
|
||||
onFavorite={handleFavorite}
|
||||
onOpenActivityTab={handleOpenAndCloseActivityTab}
|
||||
/>
|
||||
@@ -735,8 +737,8 @@
|
||||
albumId={album.id}
|
||||
{isLiked}
|
||||
bind:reactions
|
||||
onAddComment={() => updateNumberOfComments(1)}
|
||||
onDeleteComment={() => updateNumberOfComments(-1)}
|
||||
onAddComment={() => activityManager.updateNumberOfComments(1)}
|
||||
onDeleteComment={() => activityManager.updateNumberOfComments(-1)}
|
||||
onDeleteLike={() => (isLiked = null)}
|
||||
onClose={handleOpenAndCloseActivityTab}
|
||||
/>
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
||||
import { AssetAction } from '$lib/constants';
|
||||
import { faceManager } from '$lib/managers/face.manager.svelte';
|
||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
import {
|
||||
updateStackedAssetInTimeline,
|
||||
@@ -76,7 +76,7 @@
|
||||
};
|
||||
|
||||
beforeNavigate(() => {
|
||||
isFaceEditMode.value = false;
|
||||
faceManager.isEditMode = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
NotificationType,
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import { downloadManager } from '$lib/stores/download-store.svelte';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { downloadBlob } from '$lib/utils/asset-utils';
|
||||
@@ -17,6 +16,7 @@
|
||||
import { mdiCheckAll, mdiContentCopy, mdiDownload, mdiRefresh, mdiWrench } from '@mdi/js';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import { downloadManager } from '$lib/managers/download.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
import SettingAccordionState from '$lib/components/shared-components/settings/setting-accordion-state.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
|
||||
import { QueryParameter } from '$lib/constants';
|
||||
import { downloadManager } from '$lib/stores/download-store.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { copyToClipboard } from '$lib/utils';
|
||||
import { downloadBlob } from '$lib/utils/asset-utils';
|
||||
@@ -53,6 +52,7 @@
|
||||
import type { Component } from 'svelte';
|
||||
import type { SettingsComponentProps } from '$lib/components/admin-page/settings/admin-settings';
|
||||
import SearchBar from '$lib/components/elements/search-bar.svelte';
|
||||
import { downloadManager } from '$lib/managers/download.manager.svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData;
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AuthPageLayout from '$lib/components/layouts/AuthPageLayout.svelte';
|
||||
import { AppRoute } from '$lib/constants';
|
||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||
import { logout, updateMyUser } from '@immich/sdk';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { updateMyUser } from '@immich/sdk';
|
||||
import { Alert, Button, Field, HelperText, PasswordInput, Stack, Text } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
@@ -25,9 +24,7 @@
|
||||
}
|
||||
|
||||
await updateMyUser({ userUpdateMeDto: { password } });
|
||||
await goto(AppRoute.AUTH_LOGIN);
|
||||
resetSavedUser();
|
||||
await logout();
|
||||
await authManager.logout();
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user