Compare commits

..

20 Commits

Author SHA1 Message Date
Daniel Dietzler
d1e62c3736 refactor: web stores => managers (1) 2025-04-28 13:20:07 +02:00
Alex
205260d31c chore: post release tasks (#17895) 2025-04-27 23:02:03 -05:00
Alex
3858973be5 chore(mobile): translation (#17920) 2025-04-27 23:00:40 -05:00
github-actions
02994883fe chore: version v1.132.3 2025-04-25 19:44:05 +00:00
Alex
a1f8150c30 fix: Authelia OAuth code verifier value contains invalid characters (#17886)
* fix(mobile): Authelia OAuth code verifier value contains invalid characters

* Refactor

* Refactoring with Jason

* Refactoring with Jason
2025-04-25 19:39:14 +00:00
Yaros
d85ef19bfc fix(mobile): revert get location on app start (#17882) 2025-04-25 10:38:30 -05:00
Jason Rasmussen
d0014bdf94 refactor: event manager (#17862)
* refactor: event manager

* refactor: event manager
2025-04-25 08:36:31 -04:00
Martin Mikita
e822e3eca9 docs: update MapTiler name (#17863) 2025-04-25 08:57:44 +00:00
Alex
644defa4a1 chore: post release tasks (#17867) 2025-04-25 04:14:40 +00:00
Matthew Momjian
1fe3c7b9b3 fix(docs): priorities (Capitalization) (#17866)
priorities
2025-04-25 04:07:42 +00:00
github-actions
0d60be3d87 chore: version v1.132.2 2025-04-25 03:07:06 +00:00
Alex
765da7b182 fix(mobile): mobile migration logic (#17865)
* fix(mobile): mobile migration logic

* add exception

* remove unused comment

* finalize
2025-04-25 00:16:54 +00:00
shenlong
b037158028 fix(mobile): auto trash using MANAGE_MEDIA (#17828)
fix: auto trash using MANAGE_MEDIA

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2025-04-24 19:09:50 -05:00
Daimolean
a03902f174 fix(docs): incorrect date sorting (#17858) 2025-04-24 19:40:52 -04:00
Jason Rasmussen
1d610ad9cb refactor: database connection parsing (#17852) 2025-04-24 12:58:29 -04:00
Min Idzelis
dab4870fed fix: flappy e2e test (#17832)
* fix: flappy e2e test

* lint
2025-04-23 23:30:13 -04:00
github-actions
37f5e6e2cb chore: version v1.132.1 2025-04-23 21:43:47 +00:00
Alex
57d622bc43 chore: post release tasks (#17816) 2025-04-23 16:41:08 -05:00
Alex
c167e46ec7 chore: revert #16732 (#17819)
* chore: revert #16732

* lint
2025-04-23 16:40:59 -05:00
Mert
6ce8a1deeb fix(server): bump sharp (#17818)
* bump sharp

* test linking

* link in prod image too

* force global

* keep unnecessary libraries

* override sharp version

* revert dockerfile changes

* add node-gyp and napi

* dev dependency
2025-04-23 17:08:29 -04:00
88 changed files with 2105 additions and 740 deletions

6
cli/package-lock.json generated
View File

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

View File

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

View File

@@ -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/>![Maptiler Publication Settings](img/immich_map_styles_publish.webp)
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/>![MapTiler Publication Settings](img/immich_map_styles_publish.webp)
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.

View File

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

View File

@@ -252,6 +252,13 @@ const milestones: Item[] = [
description: 'Browse your photos and videos in their folder structure inside the mobile app',
release: 'v1.130.0',
}),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({
icon: mdiTagFaces,
iconColor: 'teal',
@@ -260,13 +267,6 @@ const milestones: Item[] = [
'Manually tag or remove faces in photos and videos, even when automatic detection misses or misidentifies them.',
release: 'v1.127.0',
}),
{
icon: mdiStar,
iconColor: 'gold',
title: '60,000 Stars',
description: 'Reached 60K Stars on GitHub!',
getDateLabel: withLanguage(new Date(2025, 2, 4)),
},
withRelease({
icon: mdiLinkEdit,
iconColor: 'crimson',

View File

@@ -1,4 +1,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
View File

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

View File

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

View File

@@ -142,7 +142,7 @@ describe(`/oauth`, () => {
it(`should throw an error if the state mismatches`, async () => {
const callbackParams = await loginWithOAuth('oauth-auto-register');
const { state } = await loginWithOAuth('oauth-auto-register');
const { status, body } = await request(app)
const { status } = await request(app)
.post('/oauth/callback')
.send({ ...callbackParams, state });
expect(status).toBeGreaterThanOrEqual(400);

View File

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

View File

@@ -55,7 +55,6 @@ test.describe('Shared Links', () => {
await page.goto(`/share/${sharedLink.key}`);
await page.getByRole('heading', { name: 'Test Album' }).waitFor();
await page.getByRole('button', { name: 'Download' }).click();
await page.getByText('DOWNLOADING', { exact: true }).waitFor();
await page.waitForEvent('download');
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ class PermissionOnboardingPage extends HookConsumerWidget {
}
}),
child: const Text(
'grant_permission',
'continue',
).tr(),
),
],

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:flutter/foundation.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/android_device_asset.entity.dart';
@@ -17,6 +17,8 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart';
// ignore: import_rule_photo_manager
import 'package:photo_manager/photo_manager.dart';
const int targetVersion = 10;
@@ -69,14 +71,45 @@ Future<void> _migrateDeviceAsset(Isar db) async {
: (await db.iOSDeviceAssets.where().findAll())
.map((i) => _DeviceAsset(assetId: i.id, hash: i.hash))
.toList();
final localAssets = (await db.assets
.where()
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
.findAll())
.map((a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt))
.toList();
debugPrint("Device Asset Ids length - ${ids.length}");
debugPrint("Local Asset Ids length - ${localAssets.length}");
final PermissionState ps = await PhotoManager.requestPermissionExtend();
if (!ps.hasAccess) {
if (kDebugMode) {
debugPrint(
"[MIGRATION] Photo library permission not granted. Skipping device asset migration.",
);
}
return;
}
List<_DeviceAsset> localAssets = [];
final List<AssetPathEntity> paths =
await PhotoManager.getAssetPathList(onlyAll: true);
if (paths.isEmpty) {
localAssets = (await db.assets
.where()
.anyOf(ids, (query, id) => query.localIdEqualTo(id.assetId))
.findAll())
.map(
(a) => _DeviceAsset(assetId: a.localId!, dateTime: a.fileModifiedAt),
)
.toList();
} else {
final AssetPathEntity albumWithAll = paths.first;
final int assetCount = await albumWithAll.assetCountAsync;
final List<AssetEntity> allDeviceAssets =
await albumWithAll.getAssetListRange(start: 0, end: assetCount);
localAssets = allDeviceAssets
.map((a) => _DeviceAsset(assetId: a.id, dateTime: a.modifiedDateTime))
.toList();
}
debugPrint("[MIGRATION] Device Asset Ids length - ${ids.length}");
debugPrint("[MIGRATION] Local Asset Ids length - ${localAssets.length}");
ids.sort((a, b) => a.assetId.compareTo(b.assetId));
localAssets.sort((a, b) => a.assetId.compareTo(b.assetId));
final List<DeviceAssetEntity> toAdd = [];
@@ -95,15 +128,27 @@ Future<void> _migrateDeviceAsset(Isar db) async {
return false;
},
onlyFirst: (deviceAsset) {
debugPrint(
'DeviceAsset not found in local assets: ${deviceAsset.assetId}',
);
if (kDebugMode) {
debugPrint(
'[MIGRATION] Local asset not found in DeviceAsset: ${deviceAsset.assetId}',
);
}
},
onlySecond: (asset) {
debugPrint('Local asset not found in DeviceAsset: ${asset.assetId}');
if (kDebugMode) {
debugPrint(
'[MIGRATION] Local asset not found in DeviceAsset: ${asset.assetId}',
);
}
},
);
debugPrint("Total number of device assets migrated - ${toAdd.length}");
if (kDebugMode) {
debugPrint(
"[MIGRATION] Total number of device assets migrated - ${toAdd.length}",
);
}
await db.writeTxn(() async {
await db.deviceAssetEntitys.putAll(toAdd);
});

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.132.0
- API version: 1.132.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.132.0+194
version: 1.132.3+197
environment:
sdk: '>=3.3.0 <4.0.0'

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -44,7 +44,7 @@ const imports = [
BullModule.registerQueue(...bull.queues),
ClsModule.forRoot(cls.config),
OpenTelemetryModule.forRoot(otel),
KyselyModule.forRoot(getKyselyConfig(database.config.kysely)),
KyselyModule.forRoot(getKyselyConfig(database.config)),
];
class BaseModule implements OnModuleInit, OnModuleDestroy {

View File

@@ -10,7 +10,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import 'src/schema';
import { schemaDiff, schemaFromCode, schemaFromDatabase } from 'src/sql-tools';
import { getKyselyConfig } from 'src/utils/database';
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
const main = async () => {
const command = process.argv[2];
@@ -56,7 +56,7 @@ const main = async () => {
const getDatabaseClient = () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
return new Kysely<any>(getKyselyConfig(database.config.kysely));
return new Kysely<any>(getKyselyConfig(database.config));
};
const runQuery = async (query: string) => {
@@ -105,7 +105,7 @@ const create = (path: string, up: string[], down: string[]) => {
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const db = postgres(database.config.kysely);
const db = postgres(asPostgresConnectionConfig(database.config));
const source = schemaFromCode();
const target = await schemaFromDatabase(db, {});

View File

@@ -78,7 +78,7 @@ class SqlGenerator {
const moduleFixture = await Test.createTestingModule({
imports: [
KyselyModule.forRoot({
...getKyselyConfig(database.config.kysely),
...getKyselyConfig(database.config),
log: (event) => {
if (event.level === 'query') {
this.sqlLogger.logQuery(event.query.sql);

View File

@@ -80,21 +80,12 @@ describe('getEnv', () => {
const { database } = getEnv();
expect(database).toEqual({
config: {
kysely: expect.objectContaining({
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
}),
typeorm: expect.objectContaining({
type: 'postgres',
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
}),
connectionType: 'parts',
host: 'database',
port: 5432,
database: 'immich',
username: 'postgres',
password: 'postgres',
},
skipMigrations: false,
vectorExtension: 'vectors',
@@ -110,88 +101,9 @@ describe('getEnv', () => {
it('should use DB_URL', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: 'database1',
password: 'postgres2',
user: 'postgres1',
port: 54_320,
database: 'immich',
});
});
it('should handle sslmode=require', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=prefer', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-ca', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-full', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: {} });
});
it('should handle sslmode=no-verify', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: { rejectUnauthorized: false } });
});
it('should handle ssl=true', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({ ssl: true });
});
it('should reject invalid ssl', () => {
process.env.DB_URL = 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid';
expect(() => getEnv()).toThrowError('Invalid ssl option: invalid');
});
it('should handle socket: URLs', () => {
process.env.DB_URL = 'socket:/run/postgresql?db=database1';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/run/postgresql',
database: 'database1',
});
});
it('should handle sockets in postgres: URLs', () => {
process.env.DB_URL = 'postgres:///database2?host=/path/to/socket';
const { database } = getEnv();
expect(database.config.kysely).toMatchObject({
host: '/path/to/socket',
database: 'database2',
expect(database.config).toMatchObject({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich',
});
});
});

View File

@@ -7,8 +7,7 @@ import { Request, Response } from 'express';
import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces';
import { join, resolve } from 'node:path';
import { parse } from 'pg-connection-string';
import { join } from 'node:path';
import { citiesFile, excludePaths, IWorker } from 'src/constants';
import { Telemetry } from 'src/decorators';
import { EnvDto } from 'src/dtos/env.dto';
@@ -22,9 +21,7 @@ import {
QueueName,
} from 'src/enum';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
import { isValidSsl, PostgresConnectionConfig } from 'src/utils/database';
import { setDifference } from 'src/utils/set';
import { PostgresConnectionOptions } from 'typeorm/driver/postgres/PostgresConnectionOptions.js';
export interface EnvData {
host?: string;
@@ -59,7 +56,7 @@ export interface EnvData {
};
database: {
config: { typeorm: PostgresConnectionOptions & DatabaseConnectionParams; kysely: PostgresConnectionConfig };
config: DatabaseConnectionParams;
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
@@ -152,14 +149,10 @@ const getEnv = (): EnvData => {
const isProd = environment === ImmichEnvironment.PRODUCTION;
const buildFolder = dto.IMMICH_BUILD_DATA || '/build';
const folders = {
// eslint-disable-next-line unicorn/prefer-module
dist: resolve(`${__dirname}/..`),
geodata: join(buildFolder, 'geodata'),
web: join(buildFolder, 'www'),
};
const databaseUrl = dto.DB_URL;
let redisConfig = {
host: dto.REDIS_HOSTNAME || 'redis',
port: dto.REDIS_PORT || 6379,
@@ -191,30 +184,16 @@ const getEnv = (): EnvData => {
}
}
const parts = {
connectionType: 'parts',
host: dto.DB_HOSTNAME || 'database',
port: dto.DB_PORT || 5432,
username: dto.DB_USERNAME || 'postgres',
password: dto.DB_PASSWORD || 'postgres',
database: dto.DB_DATABASE_NAME || 'immich',
} as const;
let parsedOptions: PostgresConnectionConfig = parts;
if (dto.DB_URL) {
const parsed = parse(dto.DB_URL);
if (!isValidSsl(parsed.ssl)) {
throw new Error(`Invalid ssl option: ${parsed.ssl}`);
}
parsedOptions = {
...parsed,
ssl: parsed.ssl,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
};
}
const databaseConnection: DatabaseConnectionParams = dto.DB_URL
? { connectionType: 'url', url: dto.DB_URL }
: {
connectionType: 'parts',
host: dto.DB_HOSTNAME || 'database',
port: dto.DB_PORT || 5432,
username: dto.DB_USERNAME || 'postgres',
password: dto.DB_PASSWORD || 'postgres',
database: dto.DB_DATABASE_NAME || 'immich',
};
return {
host: dto.IMMICH_HOST,
@@ -269,21 +248,7 @@ const getEnv = (): EnvData => {
},
database: {
config: {
typeorm: {
type: 'postgres',
entities: [],
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
subscribers: [],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(databaseUrl ? { connectionType: 'url', url: databaseUrl } : parts),
},
kysely: parsedOptions,
},
config: databaseConnection,
skipMigrations: dto.DB_SKIP_MIGRATIONS ?? false,
vectorExtension: dto.DB_VECTOR_EXTENSION === 'pgvector' ? DatabaseExtension.VECTOR : DatabaseExtension.VECTORS,
},

View File

@@ -3,7 +3,7 @@ import AsyncLock from 'async-lock';
import { FileMigrationProvider, Kysely, Migrator, sql, Transaction } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
import semver from 'semver';
import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db';
@@ -205,8 +205,29 @@ export class DatabaseRepository {
const { rows } = await tableExists.execute(this.db);
const hasTypeOrmMigrations = !!rows[0]?.result;
if (hasTypeOrmMigrations) {
// eslint-disable-next-line unicorn/prefer-module
const dist = resolve(`${__dirname}/..`);
this.logger.debug('Running typeorm migrations');
const dataSource = new DataSource(database.config.typeorm);
const dataSource = new DataSource({
type: 'postgres',
entities: [],
subscribers: [],
migrations: [`${dist}/migrations` + '/*.{js,ts}'],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds
parseInt8: true,
...(database.config.connectionType === 'url'
? { url: database.config.url }
: {
host: database.config.host,
port: database.config.port,
username: database.config.username,
password: database.config.password,
database: database.config.database,
}),
});
await dataSource.initialize();
await dataSource.runMigrations(options);
await dataSource.destroy();

View File

@@ -70,7 +70,7 @@ export class BackupService extends BaseService {
async handleBackupDatabase(): Promise<JobStatus> {
this.logger.debug(`Database Backup Started`);
const { database } = this.configRepository.getEnv();
const config = database.config.typeorm;
const config = database.config;
const isUrlConnection = config.connectionType === 'url';

View File

@@ -53,22 +53,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
kysely: {
host: 'database',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: false,
vectorExtension: extension,
@@ -292,22 +282,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
kysely: {
host: 'database',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
@@ -325,22 +305,12 @@ describe(DatabaseService.name, () => {
mockEnvData({
database: {
config: {
kysely: {
host: 'database',
port: 5432,
user: 'postgres',
password: 'postgres',
database: 'immich',
},
typeorm: {
connectionType: 'parts',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
connectionType: 'parts',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'immich',
},
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,

View File

@@ -0,0 +1,83 @@
import { asPostgresConnectionConfig } from 'src/utils/database';
describe('database utils', () => {
describe('asPostgresConnectionConfig', () => {
it('should handle sslmode=require', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=require',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=prefer', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=prefer',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-ca', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-ca',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=verify-full', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=verify-full',
}),
).toMatchObject({ ssl: {} });
});
it('should handle sslmode=no-verify', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?sslmode=no-verify',
}),
).toMatchObject({ ssl: { rejectUnauthorized: false } });
});
it('should handle ssl=true', () => {
expect(
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=true',
}),
).toMatchObject({ ssl: true });
});
it('should reject invalid ssl', () => {
expect(() =>
asPostgresConnectionConfig({
connectionType: 'url',
url: 'postgres://postgres1:postgres2@database1:54320/immich?ssl=invalid',
}),
).toThrowError('Invalid ssl option');
});
it('should handle socket: URLs', () => {
expect(
asPostgresConnectionConfig({ connectionType: 'url', url: 'socket:/run/postgresql?db=database1' }),
).toMatchObject({ host: '/run/postgresql', database: 'database1' });
});
it('should handle sockets in postgres: URLs', () => {
expect(
asPostgresConnectionConfig({ connectionType: 'url', url: 'postgres:///database2?host=/path/to/socket' }),
).toMatchObject({
host: '/path/to/socket',
database: 'database2',
});
});
});
});

View File

@@ -13,33 +13,57 @@ import {
} from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { parse } from 'pg-connection-string';
import postgres, { Notice } from 'postgres';
import { columns, Exif, Person } from 'src/database';
import { DB } from 'src/db';
import { AssetFileType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DatabaseConnectionParams } from 'src/types';
type Ssl = 'require' | 'allow' | 'prefer' | 'verify-full' | boolean | object;
export type PostgresConnectionConfig = {
host?: string;
password?: string;
user?: string;
port?: number;
database?: string;
max?: number;
client_encoding?: string;
ssl?: Ssl;
application_name?: string;
fallback_application_name?: string;
options?: string;
};
export const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
const isValidSsl = (ssl?: string | boolean | object): ssl is Ssl =>
typeof ssl !== 'string' || ssl === 'require' || ssl === 'allow' || ssl === 'prefer' || ssl === 'verify-full';
export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig => {
export const asPostgresConnectionConfig = (params: DatabaseConnectionParams) => {
if (params.connectionType === 'parts') {
return {
host: params.host,
port: params.port,
username: params.username,
password: params.password,
database: params.database,
ssl: undefined,
};
}
const { host, port, user, password, database, ...rest } = parse(params.url);
let ssl: Ssl | undefined;
if (rest.ssl) {
if (!isValidSsl(rest.ssl)) {
throw new Error(`Invalid ssl option: ${rest.ssl}`);
}
ssl = rest.ssl;
}
return {
host: host ?? undefined,
port: port ? Number(port) : undefined,
username: user,
password,
database: database ?? undefined,
ssl,
};
};
export const getKyselyConfig = (
params: DatabaseConnectionParams,
options: Partial<postgres.Options<Record<string, postgres.PostgresType>>> = {},
): KyselyConfig => {
const config = asPostgresConnectionConfig(params);
return {
dialect: new PostgresJSDialect({
postgres: postgres({
@@ -66,6 +90,12 @@ export const getKyselyConfig = (options: PostgresConnectionConfig): KyselyConfig
connection: {
TimeZone: 'UTC',
},
host: config.host,
port: config.port,
username: config.username,
password: config.password,
database: config.database,
ssl: config.ssl,
...options,
}),
}),

View File

@@ -1,5 +1,4 @@
import { Kysely } from 'kysely';
import { parse } from 'pg-connection-string';
import { DB } from 'src/db';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -37,19 +36,10 @@ const globalSetup = async () => {
const postgresPort = postgresContainer.getMappedPort(5432);
const postgresUrl = `postgres://postgres:postgres@localhost:${postgresPort}/immich`;
const parsed = parse(postgresUrl);
process.env.IMMICH_TEST_POSTGRES_URL = postgresUrl;
const db = new Kysely<DB>(
getKyselyConfig({
...parsed,
ssl: false,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
}),
);
const db = new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: postgresUrl }));
const configRepository = new ConfigRepository();
const logger = new LoggingRepository(undefined, configRepository);

View File

@@ -21,19 +21,12 @@ const envData: EnvData = {
database: {
config: {
kysely: { database: 'immich', host: 'database', port: 5432 },
typeorm: {
connectionType: 'parts',
database: 'immich',
type: 'postgres',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
name: 'immich',
synchronize: false,
migrationsRun: true,
},
connectionType: 'parts',
database: 'immich',
host: 'database',
port: 5432,
username: 'postgres',
password: 'postgres',
},
skipMigrations: false,

View File

@@ -1,9 +1,9 @@
import { ClassConstructor } from 'class-transformer';
import { Kysely, sql } from 'kysely';
import { Kysely } from 'kysely';
import { ChildProcessWithoutNullStreams } from 'node:child_process';
import { Writable } from 'node:stream';
import { parse } from 'pg-connection-string';
import { PNG } from 'pngjs';
import postgres from 'postgres';
import { DB } from 'src/db';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
@@ -49,7 +49,7 @@ import { VersionHistoryRepository } from 'src/repositories/version-history.repos
import { ViewRepository } from 'src/repositories/view-repository';
import { BaseService } from 'src/services/base.service';
import { RepositoryInterface } from 'src/types';
import { getKyselyConfig } from 'src/utils/database';
import { asPostgresConnectionConfig, getKyselyConfig } from 'src/utils/database';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
@@ -297,24 +297,20 @@ function* newPngFactory() {
const pngFactory = newPngFactory();
const withDatabase = (url: string, name: string) => url.replace('/immich', `/${name}`);
export const getKyselyDB = async (suffix?: string): Promise<Kysely<DB>> => {
const parsed = parse(process.env.IMMICH_TEST_POSTGRES_URL!);
const testUrl = process.env.IMMICH_TEST_POSTGRES_URL!;
const sql = postgres({
...asPostgresConnectionConfig({ connectionType: 'url', url: withDatabase(testUrl, 'postgres') }),
max: 1,
});
const parsedOptions = {
...parsed,
ssl: false,
host: parsed.host ?? undefined,
port: parsed.port ? Number(parsed.port) : undefined,
database: parsed.database ?? undefined,
};
const kysely = new Kysely<DB>(getKyselyConfig({ ...parsedOptions, max: 1, database: 'postgres' }));
const randomSuffix = Math.random().toString(36).slice(2, 7);
const dbName = `immich_${suffix ?? randomSuffix}`;
await sql.unsafe(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`);
await sql.raw(`CREATE DATABASE ${dbName} WITH TEMPLATE immich OWNER postgres;`).execute(kysely);
return new Kysely<DB>(getKyselyConfig({ ...parsedOptions, database: dbName }));
return new Kysely<DB>(getKyselyConfig({ connectionType: 'url', url: withDatabase(testUrl, dbName) }));
};
export const newRandomImage = () => {

6
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-web",
"version": "1.132.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"

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View 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': [];
}>();

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

View File

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

View File

@@ -1,6 +0,0 @@
import { writable } from 'svelte/store';
const selectedIdStore = writable<string | undefined>(undefined);
const optionClickCallbackStore = writable<(() => void) | undefined>(undefined);
export { optionClickCallbackStore, selectedIdStore };

View File

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

View File

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

View File

@@ -1 +0,0 @@
export const isFaceEditMode = $state({ value: false });

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

@@ -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 = () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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