Compare commits

..

6 Commits

Author SHA1 Message Date
shenlong-tanwen
d1a5ba7a66 sync adjustment timestamp and store in db 2025-09-20 19:36:26 +05:30
shenlong-tanwen
d3646b2eab feat: add adjustmentTimestamp to platformasset 2025-09-20 00:33:23 +05:30
renovate[bot]
1e0b4fac04 fix(deps): update typescript-projects (#21510)
* fix(deps): update typescript-projects

* chore: downgrade dependencies

* chore: downgrade svelte-gestures

* fix: svelte/no-navigation-without-resolve

* fix: dumb test

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Zack Pollard <zack@futo.org>
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2025-09-19 12:29:01 -04:00
Jason Rasmussen
34339ea69f fix(web): show danger/warning when taken dates overlap (#22213) 2025-09-19 12:20:09 -04:00
Jason Rasmussen
6da039780e fix: automatically remove leading/trailing whitespace from search que… (#22214)
fix: automatically remove leading/trailing whitespace from search queries
2025-09-19 12:19:26 -04:00
Jason Rasmussen
3f2e0780d5 feat: availability checks (#22185) 2025-09-19 12:18:42 -04:00
72 changed files with 9734 additions and 2051 deletions

View File

@@ -169,8 +169,6 @@ Redis (Sentinel) URL example JSON before encoding:
| `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning | | `MACHINE_LEARNING_ANN_TUNING_LEVEL` | ARM-NN GPU tuning level (1: rapid, 2: normal, 3: exhaustive) | `2` | machine learning |
| `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning | | `MACHINE_LEARNING_DEVICE_IDS`<sup>\*4</sup> | Device IDs to use in multi-GPU environments | `0` | machine learning |
| `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning | | `MACHINE_LEARNING_MAX_BATCH_SIZE__FACIAL_RECOGNITION` | Set the maximum number of faces that will be processed at once by the facial recognition model | None (`1` if using OpenVINO) | machine learning |
| `MACHINE_LEARNING_PING_TIMEOUT` | How long (ms) to wait for a PING response when checking if an ML server is available | `2000` | server |
| `MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME` | How long to ignore ML servers that are offline before trying again | `30000` | server |
| `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning | | `MACHINE_LEARNING_RKNN` | Enable RKNN hardware acceleration if supported | `True` | machine learning |
| `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning | | `MACHINE_LEARNING_RKNN_THREADS` | How many threads of RKNN runtime should be spinned up while inferencing. | `1` | machine learning |

View File

@@ -123,6 +123,13 @@
"logging_enable_description": "Enable logging", "logging_enable_description": "Enable logging",
"logging_level_description": "When enabled, what log level to use.", "logging_level_description": "When enabled, what log level to use.",
"logging_settings": "Logging", "logging_settings": "Logging",
"machine_learning_availability_checks": "Availability checks",
"machine_learning_availability_checks_description": "Automatically detect and prefer available machine learning servers",
"machine_learning_availability_checks_enabled": "Enable availability checks",
"machine_learning_availability_checks_interval": "Check interval",
"machine_learning_availability_checks_interval_description": "Interval in milliseconds between availability checks",
"machine_learning_availability_checks_timeout": "Request timeout",
"machine_learning_availability_checks_timeout_description": "Timeout in milliseconds for availability checks",
"machine_learning_clip_model": "CLIP model", "machine_learning_clip_model": "CLIP model",
"machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.", "machine_learning_clip_model_description": "The name of a CLIP model listed <link>here</link>. Note that you must re-run the 'Smart Search' job for all images upon changing a model.",
"machine_learning_duplicate_detection": "Duplicate Detection", "machine_learning_duplicate_detection": "Duplicate Detection",
@@ -1916,6 +1923,7 @@
"stacktrace": "Stacktrace", "stacktrace": "Stacktrace",
"start": "Start", "start": "Start",
"start_date": "Start date", "start_date": "Start date",
"start_date_before_end_date": "Start date must be before end date",
"state": "State", "state": "State",
"status": "Status", "status": "Status",
"stop_casting": "Stop casting", "stop_casting": "Stop casting",

View File

@@ -1,7 +1,7 @@
[tools] [tools]
node = "22.19.0" node = "22.19.0"
flutter = "3.35.4" flutter = "3.35.4"
pnpm = "10.14.0" pnpm = "10.15.1"
dart = "3.8.2" dart = "3.8.2"
[tools."github:CQLabs/homebrew-dcm"] [tools."github:CQLabs/homebrew-dcm"]

View File

@@ -3,19 +3,14 @@ package app.alextran.immich.images
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris import android.content.ContentUris
import android.content.Context import android.content.Context
import android.content.res.Resources
import android.graphics.* import android.graphics.*
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.CancellationSignal import android.os.CancellationSignal
import android.os.OperationCanceledException import android.os.OperationCanceledException
import android.provider.DocumentsContract
import android.provider.MediaStore.Images import android.provider.MediaStore.Images
import android.provider.MediaStore.Video import android.provider.MediaStore.Video
import android.system.Int64Ref
import android.util.Size import android.util.Size
import androidx.annotation.RequiresApi
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.math.* import kotlin.math.*
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -177,7 +172,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
} }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
loadThumbnail(uri, Size(targetWidth, targetHeight), signal) resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS) Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
@@ -190,7 +185,7 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
signal.throwIfCanceled() signal.throwIfCanceled()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id) val uri = ContentUris.withAppendedId(Video.Media.EXTERNAL_CONTENT_URI, id)
loadThumbnail(uri, Size(targetWidth, targetHeight), signal) resolver.loadThumbnail(uri, Size(targetWidth, targetHeight), signal)
} else { } else {
signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) } signal.setOnCancelListener { Video.Thumbnails.cancelThumbnailRequest(resolver, id) }
Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS) Video.Thumbnails.getThumbnail(resolver, id, Video.Thumbnails.MINI_KIND, OPTIONS)
@@ -220,72 +215,4 @@ class ThumbnailsImpl(context: Context) : ThumbnailApi {
ref.get() ref.get()
} }
} }
/*
* Copyright (C) 2006 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@RequiresApi(Build.VERSION_CODES.Q)
fun loadThumbnail(uri: Uri, size: Size, signal: CancellationSignal?): Bitmap {
// Convert to Point, since that's what the API is defined as
val opts = Bundle()
if (size.width < 512 && size.height < 512) {
opts.putParcelable(ContentResolver.EXTRA_SIZE, Point(size.width, size.height))
}
val orientation = Int64Ref(0)
var bitmap =
ImageDecoder.decodeBitmap(
ImageDecoder.createSource {
val afd =
resolver.openTypedAssetFile(uri, "image/*", opts, signal)
?: throw Resources.NotFoundException("Asset $uri not found")
val extras = afd.extras
orientation.value =
(extras?.getInt(DocumentsContract.EXTRA_ORIENTATION, 0) ?: 0).toLong()
afd
}
) { decoder: ImageDecoder, info: ImageDecoder.ImageInfo, _: ImageDecoder.Source ->
decoder.setAllocator(ImageDecoder.ALLOCATOR_SOFTWARE)
// One last-ditch check to see if we've been canceled.
signal?.throwIfCanceled()
// We requested a rough thumbnail size, but the remote size may have
// returned something giant, so defensively scale down as needed.
// This is modified from the original to target the smaller edge instead of the larger edge.
val widthSample = info.size.width.toDouble() / size.width
val heightSample = info.size.height.toDouble() / size.height
val sample = min(widthSample, heightSample)
if (sample > 1) {
val width = (info.size.width / sample).toInt()
val height = (info.size.height / sample).toInt()
decoder.setTargetSize(width, height)
}
}
// Transform the bitmap if requested. We use a side-channel to
// communicate the orientation, since EXIF thumbnails don't contain
// the rotation flags of the original image.
if (orientation.value != 0L) {
val width = bitmap.getWidth()
val height = bitmap.getHeight()
val m = Matrix()
m.setRotate(orientation.value.toFloat(), (width / 2).toFloat(), (height / 2).toFloat())
bitmap = Bitmap.createBitmap(bitmap, 0, 0, width, height, m, false)
}
return bitmap
}
} }

View File

@@ -89,7 +89,8 @@ data class PlatformAsset (
val height: Long? = null, val height: Long? = null,
val durationInSeconds: Long, val durationInSeconds: Long,
val orientation: Long, val orientation: Long,
val isFavorite: Boolean val isFavorite: Boolean,
val adjustmentTimestamp: Long? = null
) )
{ {
companion object { companion object {
@@ -104,7 +105,8 @@ data class PlatformAsset (
val durationInSeconds = pigeonVar_list[7] as Long val durationInSeconds = pigeonVar_list[7] as Long
val orientation = pigeonVar_list[8] as Long val orientation = pigeonVar_list[8] as Long
val isFavorite = pigeonVar_list[9] as Boolean val isFavorite = pigeonVar_list[9] as Boolean
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite) val adjustmentTimestamp = pigeonVar_list[10] as Long?
return PlatformAsset(id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite, adjustmentTimestamp)
} }
} }
fun toList(): List<Any?> { fun toList(): List<Any?> {
@@ -119,6 +121,7 @@ data class PlatformAsset (
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
adjustmentTimestamp,
) )
} }
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {

View File

@@ -138,6 +138,7 @@ open class NativeSyncApiImplBase(context: Context) {
duration, duration,
orientation.toLong(), orientation.toLong(),
isFavorite, isFavorite,
adjustmentTimestamp = null
) )
yield(AssetResult.ValidAsset(asset, bucketId)) yield(AssetResult.ValidAsset(asset, bucketId))
} }

File diff suppressed because one or more lines are too long

View File

@@ -29,11 +29,9 @@
FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; FAC6F89B2D287C890078CB2F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = FAC6F8902D287C890078CB2F /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; }; FAC6F8B72D287F120078CB2F /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC6F8B52D287F120078CB2F /* ShareViewController.swift */; };
FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; }; FAC6F8B92D287F120078CB2F /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = FAC6F8B32D287F120078CB2F /* MainInterface.storyboard */; };
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340C92E7326630050078A /* AssetResolver.swift */; }; FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */; };
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CB2E7326630050078A /* Thumbhash.swift */; }; FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */; };
FEC340D32E7326630050078A /* Request.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CF2E7326630050078A /* Request.swift */; }; FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */; };
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CC2E7326630050078A /* ThumbnailResolver.swift */; };
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEC340CD2E7326630050078A /* Thumbnails.g.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@@ -117,11 +115,9 @@
FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; }; FAC6F8B42D287F120078CB2F /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; FAC6F8B52D287F120078CB2F /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; }; FAC7416727DB9F5500C668D8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = "<group>"; };
FEC340C92E7326630050078A /* AssetResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssetResolver.swift; sourceTree = "<group>"; }; FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; };
FEC340CB2E7326630050078A /* Thumbhash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbhash.swift; sourceTree = "<group>"; }; FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailResolver.swift; sourceTree = "<group>"; }; FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailsImpl.swift; sourceTree = "<group>"; };
FEC340CD2E7326630050078A /* Thumbnails.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnails.g.swift; sourceTree = "<group>"; };
FEC340CF2E7326630050078A /* Request.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Request.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -251,7 +247,6 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FEC340D02E7326630050078A /* Resolvers */,
B25D37792E72CA15008B6CA7 /* Connectivity */, B25D37792E72CA15008B6CA7 /* Connectivity */,
B21E34A62E5AF9760031FDB9 /* Background */, B21E34A62E5AF9760031FDB9 /* Background */,
B2CF7F8C2DDE4EBB00744BF6 /* Sync */, B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
@@ -266,6 +261,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
FED3B1952E253E9B0030FD97 /* Images */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -300,34 +296,16 @@
path = ShareExtension; path = ShareExtension;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FEC340CA2E7326630050078A /* Assets */ = { FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
FEC340C92E7326630050078A /* AssetResolver.swift */, FEAFA8722E4D42F4001E47FE /* Thumbhash.swift */,
); FED3B1932E253E9B0030FD97 /* Thumbnails.g.swift */,
path = Assets; FED3B1942E253E9B0030FD97 /* ThumbnailsImpl.swift */,
sourceTree = "<group>";
};
FEC340CE2E7326630050078A /* Images */ = {
isa = PBXGroup;
children = (
FEC340CB2E7326630050078A /* Thumbhash.swift */,
FEC340CC2E7326630050078A /* ThumbnailResolver.swift */,
FEC340CD2E7326630050078A /* Thumbnails.g.swift */,
); );
path = Images; path = Images;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
FEC340D02E7326630050078A /* Resolvers */ = {
isa = PBXGroup;
children = (
FEC340CA2E7326630050078A /* Assets */,
FEC340CE2E7326630050078A /* Images */,
FEC340CF2E7326630050078A /* Request.swift */,
);
path = Resolvers;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -595,16 +573,14 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */, B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */, B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */,
FEAFA8732E4D42F4001E47FE /* Thumbhash.swift in Sources */,
B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */, B25D377C2E72CA26008B6CA7 /* ConnectivityApiImpl.swift in Sources */,
FED3B1962E253E9B0030FD97 /* ThumbnailsImpl.swift in Sources */,
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */, B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */,
FED3B1972E253E9B0030FD97 /* Thumbnails.g.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */, B2BE315F2E5E5229006EEF88 /* BackgroundWorker.g.swift in Sources */,
65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */, 65F32F33299D349D00CE9261 /* BackgroundSyncWorker.swift in Sources */,
FEC340D12E7326630050078A /* AssetResolver.swift in Sources */,
FEC340D22E7326630050078A /* Thumbhash.swift in Sources */,
FEC340D32E7326630050078A /* Request.swift in Sources */,
FEC340D42E7326630050078A /* ThumbnailResolver.swift in Sources */,
FEC340D52E7326630050078A /* Thumbnails.g.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@@ -15,7 +15,7 @@ import UIKit
) -> Bool { ) -> Bool {
// Required for flutter_local_notification // Required for flutter_local_notification
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
} }
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
@@ -53,7 +53,7 @@ import UIKit
public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) { public static func registerPlugins(binaryMessenger: FlutterBinaryMessenger) {
NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl()) NativeSyncApiSetup.setUp(binaryMessenger: binaryMessenger, api: NativeSyncApiImpl())
ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailResolver()) ThumbnailApiSetup.setUp(binaryMessenger: binaryMessenger, api: ThumbnailApiImpl())
BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: binaryMessenger, api: BackgroundWorkerApiImpl())
} }
} }

View File

@@ -0,0 +1,211 @@
import CryptoKit
import Flutter
import MobileCoreServices
import Photos
class Request {
weak var workItem: DispatchWorkItem?
var isCancelled = false
let callback: (Result<[String: Int64], any Error>) -> Void
init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) {
self.callback = callback
}
}
class ThumbnailApiImpl: ThumbnailApi {
private static let imageManager = PHImageManager.default()
private static let fetchOptions = {
let fetchOptions = PHFetchOptions()
fetchOptions.fetchLimit = 1
fetchOptions.wantsIncrementalChangeDetails = false
return fetchOptions
}()
private static let requestOptions = {
let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true
requestOptions.version = .current
return requestOptions
}()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: Request]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
return assetCache
}()
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let request = Request(callback: completion)
let item = DispatchWorkItem {
if request.isCancelled {
return completion(Self.cancelledResult)
}
Self.concurrencySemaphore.wait()
defer {
Self.concurrencySemaphore.signal()
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.removeRequest(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
if request.isCancelled {
return completion(Self.cancelledResult)
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if request.isCancelled {
return completion(Self.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
}
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if request.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
request.workItem = item
Self.addRequest(requestId: requestId, request: request)
Self.processingQueue.async(execute: item)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: Request) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
if item.isCancelled {
cancelQueue.async { request.callback(Self.cancelledResult) }
}
}
}
private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
return asset
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -1,115 +0,0 @@
import Photos
class AssetRequest: Request {
let assetId: String
var completion: (PHAsset?) -> Void
init(cancellationToken: CancellationToken, assetId: String, completion: @escaping (PHAsset?) -> Void) {
self.assetId = assetId
self.completion = completion
super.init(cancellationToken: cancellationToken)
}
}
class AssetResolver {
private let requestQueue: DispatchQueue
private let processingQueue: DispatchQueue
private var batchTimer: DispatchWorkItem?
private let batchLock = NSLock()
private let batchTimeout: TimeInterval
private let fetchOptions: PHFetchOptions
private var assetRequests = [AssetRequest]()
private let assetCache: NSCache<NSString, PHAsset>
init(
fetchOptions: PHFetchOptions,
batchTimeout: TimeInterval = 0.00025, // 250μs
cacheSize: Int = 10000,
qos: DispatchQoS = .unspecified
) {
self.fetchOptions = fetchOptions
self.batchTimeout = batchTimeout
self.assetCache = NSCache<NSString, PHAsset>()
self.assetCache.countLimit = cacheSize
self.requestQueue = DispatchQueue(label: "assets.requests", qos: qos)
self.processingQueue = DispatchQueue(label: "assets.processing", qos: qos)
}
func requestAsset(request: AssetRequest) {
requestQueue.async {
if (request.isCancelled) {
request.completion(nil)
return
}
if let cachedAsset = self.assetCache.object(forKey: request.assetId as NSString) {
request.completion(cachedAsset)
return
}
self.batchLock.lock()
if (request.isCancelled) {
self.batchLock.unlock()
request.completion(nil)
return
}
self.assetRequests.append(request)
self.batchTimer?.cancel()
let timer = DispatchWorkItem(block: self.processBatch)
self.batchTimer = timer
self.batchLock.unlock()
self.processingQueue.asyncAfter(deadline: .now() + self.batchTimeout, execute: timer)
}
}
private func processBatch() {
batchLock.lock()
if assetRequests.isEmpty {
batchTimer = nil
batchLock.unlock()
return
}
var completionMap = [String: [(PHAsset?) -> Void]]()
var activeAssetIds = [String]()
completionMap.reserveCapacity(assetRequests.count)
activeAssetIds.reserveCapacity(assetRequests.count)
for request in assetRequests {
if (request.isCancelled) {
request.completion(nil)
continue
}
if var completions = completionMap[request.assetId] {
completions.append(request.completion)
} else {
activeAssetIds.append(request.assetId)
completionMap[request.assetId] = [request.completion]
}
}
assetRequests.removeAll(keepingCapacity: true)
batchTimer = nil
batchLock.unlock()
guard !activeAssetIds.isEmpty else { return }
let assets = PHAsset.fetchAssets(withLocalIdentifiers: activeAssetIds, options: self.fetchOptions)
assets.enumerateObjects { asset, _, _ in
let assetId = asset.localIdentifier
for completion in completionMap.removeValue(forKey: assetId)! {
completion(asset)
}
self.requestQueue.async { self.assetCache.setObject(asset, forKey: assetId as NSString) }
}
for completions in completionMap.values {
for completion in completions {
completion(nil)
}
}
}
}

View File

@@ -1,198 +0,0 @@
import CryptoKit
import Flutter
import MobileCoreServices
import Photos
class ThumbnailRequest: Request {
weak var workItem: DispatchWorkItem?
let completion: (Result<[String: Int64], any Error>) -> Void
init(cancellationToken: CancellationToken, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
self.completion = completion
super.init(cancellationToken: cancellationToken)
}
}
class ThumbnailResolver: ThumbnailApi {
private static let imageManager = PHImageManager.default()
private static let assetResolver = AssetResolver(fetchOptions: {
let fetchOptions = PHFetchOptions()
fetchOptions.wantsIncrementalChangeDetails = false
return fetchOptions
}(), qos: .userInitiated)
private static let requestOptions = {
let requestOptions = PHImageRequestOptions()
requestOptions.isNetworkAccessAllowed = true
requestOptions.deliveryMode = .highQualityFormat
requestOptions.resizeMode = .fast
requestOptions.isSynchronous = true
requestOptions.version = .current
return requestOptions
}()
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent)
private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB()
private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue
private static var requests = [Int64: ThumbnailRequest]()
private static let cancelledResult = Result<[String: Int64], any Error>.success([:])
private static let thumbnailConcurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount / 2 + 1)
private static let activitySemaphore = DispatchSemaphore(value: 1)
private static let willResignActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willResignActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.suspend()
activitySemaphore.wait()
}
private static let didBecomeActiveObserver = NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil,
queue: .main
) { _ in
processingQueue.resume()
activitySemaphore.signal()
}
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
Self.processingQueue.async {
guard let data = Data(base64Encoded: thumbhash)
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
let (width, height, pointer) = thumbHashToRGBA(hash: data)
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)]))
}
}
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) {
let cancellationToken = CancellationToken()
let thumbnailRequest = ThumbnailRequest(cancellationToken: cancellationToken, completion: completion)
Self.assetResolver.requestAsset(request: AssetRequest(cancellationToken: cancellationToken, assetId: assetId) { asset in
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
let item = DispatchWorkItem {
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
guard let asset = asset else {
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
Self.removeRequest(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
Self.thumbnailConcurrencySemaphore.wait()
defer { Self.thumbnailConcurrencySemaphore.signal() }
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
var image: UIImage?
Self.imageManager.requestImage(
for: asset,
targetSize: width > 0 && height > 0 ? CGSize(width: Double(width), height: Double(height)) : PHImageManagerMaximumSize,
contentMode: .aspectFill,
options: Self.requestOptions,
resultHandler: { (_image, info) -> Void in
image = _image
}
)
if cancellationToken.isCancelled {
return completion(Self.cancelledResult)
}
guard let image = image,
let cgImage = image.cgImage else {
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
let pointer = UnsafeMutableRawPointer.allocate(
byteCount: Int(cgImage.width) * Int(cgImage.height) * 4,
alignment: MemoryLayout<UInt8>.alignment
)
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
guard let context = CGContext(
data: pointer,
width: cgImage.width,
height: cgImage.height,
bitsPerComponent: 8,
bytesPerRow: cgImage.width * 4,
space: Self.rgbColorSpace,
bitmapInfo: Self.bitmapInfo
) else {
pointer.deallocate()
Self.removeRequest(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil)))
}
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
context.interpolationQuality = .none
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height))
if cancellationToken.isCancelled {
pointer.deallocate()
return completion(Self.cancelledResult)
}
self.waitForActiveState()
completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)]))
Self.removeRequest(requestId: requestId)
}
thumbnailRequest.workItem = item
Self.processingQueue.async(execute: item)
})
Self.addRequest(requestId: requestId, request: thumbnailRequest)
}
func cancelImageRequest(requestId: Int64) {
Self.cancelRequest(requestId: requestId)
}
private static func addRequest(requestId: Int64, request: ThumbnailRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func removeRequest(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancelRequest(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let item = request.workItem else { return }
item.cancel()
if item.isCancelled {
cancelQueue.async { request.completion(Self.cancelledResult) }
}
}
}
func waitForActiveState() {
Self.activitySemaphore.wait()
Self.activitySemaphore.signal()
}
}

View File

@@ -1,20 +0,0 @@
class CancellationToken {
var isCancelled = false
}
class Request {
let cancellationToken: CancellationToken
init(cancellationToken: CancellationToken) {
self.cancellationToken = cancellationToken
}
var isCancelled: Bool {
get {
return cancellationToken.isCancelled
}
set(newValue) {
cancellationToken.isCancelled = newValue
}
}
}

View File

@@ -140,6 +140,7 @@ struct PlatformAsset: Hashable {
var durationInSeconds: Int64 var durationInSeconds: Int64
var orientation: Int64 var orientation: Int64
var isFavorite: Bool var isFavorite: Bool
var adjustmentTimestamp: Int64? = nil
// swift-format-ignore: AlwaysUseLowerCamelCase // swift-format-ignore: AlwaysUseLowerCamelCase
@@ -154,6 +155,7 @@ struct PlatformAsset: Hashable {
let durationInSeconds = pigeonVar_list[7] as! Int64 let durationInSeconds = pigeonVar_list[7] as! Int64
let orientation = pigeonVar_list[8] as! Int64 let orientation = pigeonVar_list[8] as! Int64
let isFavorite = pigeonVar_list[9] as! Bool let isFavorite = pigeonVar_list[9] as! Bool
let adjustmentTimestamp: Int64? = nilOrValue(pigeonVar_list[10])
return PlatformAsset( return PlatformAsset(
id: id, id: id,
@@ -165,7 +167,8 @@ struct PlatformAsset: Hashable {
height: height, height: height,
durationInSeconds: durationInSeconds, durationInSeconds: durationInSeconds,
orientation: orientation, orientation: orientation,
isFavorite: isFavorite isFavorite: isFavorite,
adjustmentTimestamp: adjustmentTimestamp
) )
} }
func toList() -> [Any?] { func toList() -> [Any?] {
@@ -180,6 +183,7 @@ struct PlatformAsset: Hashable {
durationInSeconds, durationInSeconds,
orientation, orientation,
isFavorite, isFavorite,
adjustmentTimestamp,
] ]
} }
static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool { static func == (lhs: PlatformAsset, rhs: PlatformAsset) -> Bool {

View File

@@ -12,7 +12,8 @@ extension PHAsset {
height: Int64(pixelHeight), height: Int64(pixelHeight),
durationInSeconds: Int64(duration), durationInSeconds: Int64(duration),
orientation: 0, orientation: 0,
isFavorite: isFavorite isFavorite: isFavorite,
adjustmentTimestamp: adjustmentTimestamp
) )
} }
@@ -23,6 +24,10 @@ extension PHAsset {
var filename: String? { var filename: String? {
return value(forKey: "filename") as? String return value(forKey: "filename") as? String
} }
var adjustmentTimestamp: Int64 {
return (value(forKey: "adjustmentTimestamp") as? Date?).map( {Int64($0?.timeIntervalSince1970 ?? 0)} ) ?? 0
}
// This method is expected to be slow as it goes through the asset resources to fetch the originalFilename // This method is expected to be slow as it goes through the asset resources to fetch the originalFilename
var originalFilename: String? { var originalFilename: String? {

View File

@@ -4,6 +4,7 @@ class LocalAsset extends BaseAsset {
final String id; final String id;
final String? remoteId; final String? remoteId;
final int orientation; final int orientation;
final int? adjustmentTimestamp;
const LocalAsset({ const LocalAsset({
required this.id, required this.id,
@@ -19,6 +20,7 @@ class LocalAsset extends BaseAsset {
super.isFavorite = false, super.isFavorite = false,
super.livePhotoVideoId, super.livePhotoVideoId,
this.orientation = 0, this.orientation = 0,
this.adjustmentTimestamp,
}); });
@override @override
@@ -41,6 +43,7 @@ class LocalAsset extends BaseAsset {
remoteId: ${remoteId ?? "<NA>"} remoteId: ${remoteId ?? "<NA>"}
isFavorite: $isFavorite, isFavorite: $isFavorite,
orientation: $orientation, orientation: $orientation,
adjustmentTimestamp: ${adjustmentTimestamp ?? "<NA>"}
}'''; }''';
} }
@@ -49,11 +52,15 @@ class LocalAsset extends BaseAsset {
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! LocalAsset) return false; if (other is! LocalAsset) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return super == other && id == other.id && orientation == other.orientation; return super == other &&
id == other.id &&
orientation == other.orientation &&
adjustmentTimestamp == other.adjustmentTimestamp;
} }
@override @override
int get hashCode => super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode; int get hashCode =>
super.hashCode ^ id.hashCode ^ remoteId.hashCode ^ orientation.hashCode ^ adjustmentTimestamp.hashCode;
LocalAsset copyWith({ LocalAsset copyWith({
String? id, String? id,
@@ -68,6 +75,7 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds, int? durationInSeconds,
bool? isFavorite, bool? isFavorite,
int? orientation, int? orientation,
int? adjustmentTimestamp,
}) { }) {
return LocalAsset( return LocalAsset(
id: id ?? this.id, id: id ?? this.id,
@@ -82,6 +90,7 @@ class LocalAsset extends BaseAsset {
durationInSeconds: durationInSeconds ?? this.durationInSeconds, durationInSeconds: durationInSeconds ?? this.durationInSeconds,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
adjustmentTimestamp: adjustmentTimestamp ?? this.adjustmentTimestamp,
); );
} }
} }

View File

@@ -303,6 +303,7 @@ extension on Iterable<PlatformAsset> {
durationInSeconds: e.durationInSeconds, durationInSeconds: e.durationInSeconds,
orientation: e.orientation, orientation: e.orientation,
isFavorite: e.isFavorite, isFavorite: e.isFavorite,
adjustmentTimestamp: e.adjustmentTimestamp,
), ),
).toList(); ).toList();
} }

View File

@@ -16,6 +16,8 @@ class LocalAssetEntity extends Table with DriftDefaultsMixin, AssetEntityMixin {
IntColumn get orientation => integer().withDefault(const Constant(0))(); IntColumn get orientation => integer().withDefault(const Constant(0))();
IntColumn get adjustmentTimestamp => integer().nullable()();
@override @override
Set<Column> get primaryKey => {id}; Set<Column> get primaryKey => {id};
} }
@@ -34,5 +36,6 @@ extension LocalAssetEntityDataDomainExtension on LocalAssetEntityData {
width: width, width: width,
remoteId: null, remoteId: null,
orientation: orientation, orientation: orientation,
adjustmentTimestamp: adjustmentTimestamp,
); );
} }

View File

@@ -21,6 +21,7 @@ typedef $$LocalAssetEntityTableCreateCompanionBuilder =
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
i0.Value<int?> adjustmentTimestamp,
}); });
typedef $$LocalAssetEntityTableUpdateCompanionBuilder = typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i1.LocalAssetEntityCompanion Function({ i1.LocalAssetEntityCompanion Function({
@@ -35,6 +36,7 @@ typedef $$LocalAssetEntityTableUpdateCompanionBuilder =
i0.Value<String?> checksum, i0.Value<String?> checksum,
i0.Value<bool> isFavorite, i0.Value<bool> isFavorite,
i0.Value<int> orientation, i0.Value<int> orientation,
i0.Value<int?> adjustmentTimestamp,
}); });
class $$LocalAssetEntityTableFilterComposer class $$LocalAssetEntityTableFilterComposer
@@ -101,6 +103,11 @@ class $$LocalAssetEntityTableFilterComposer
column: $table.orientation, column: $table.orientation,
builder: (column) => i0.ColumnFilters(column), builder: (column) => i0.ColumnFilters(column),
); );
i0.ColumnFilters<int> get adjustmentTimestamp => $composableBuilder(
column: $table.adjustmentTimestamp,
builder: (column) => i0.ColumnFilters(column),
);
} }
class $$LocalAssetEntityTableOrderingComposer class $$LocalAssetEntityTableOrderingComposer
@@ -166,6 +173,11 @@ class $$LocalAssetEntityTableOrderingComposer
column: $table.orientation, column: $table.orientation,
builder: (column) => i0.ColumnOrderings(column), builder: (column) => i0.ColumnOrderings(column),
); );
i0.ColumnOrderings<int> get adjustmentTimestamp => $composableBuilder(
column: $table.adjustmentTimestamp,
builder: (column) => i0.ColumnOrderings(column),
);
} }
class $$LocalAssetEntityTableAnnotationComposer class $$LocalAssetEntityTableAnnotationComposer
@@ -215,6 +227,11 @@ class $$LocalAssetEntityTableAnnotationComposer
column: $table.orientation, column: $table.orientation,
builder: (column) => column, builder: (column) => column,
); );
i0.GeneratedColumn<int> get adjustmentTimestamp => $composableBuilder(
column: $table.adjustmentTimestamp,
builder: (column) => column,
);
} }
class $$LocalAssetEntityTableTableManager class $$LocalAssetEntityTableTableManager
@@ -268,6 +285,7 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<int?> adjustmentTimestamp = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion( }) => i1.LocalAssetEntityCompanion(
name: name, name: name,
type: type, type: type,
@@ -280,6 +298,7 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
adjustmentTimestamp: adjustmentTimestamp,
), ),
createCompanionCallback: createCompanionCallback:
({ ({
@@ -294,6 +313,7 @@ class $$LocalAssetEntityTableTableManager
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
i0.Value<bool> isFavorite = const i0.Value.absent(), i0.Value<bool> isFavorite = const i0.Value.absent(),
i0.Value<int> orientation = const i0.Value.absent(), i0.Value<int> orientation = const i0.Value.absent(),
i0.Value<int?> adjustmentTimestamp = const i0.Value.absent(),
}) => i1.LocalAssetEntityCompanion.insert( }) => i1.LocalAssetEntityCompanion.insert(
name: name, name: name,
type: type, type: type,
@@ -306,6 +326,7 @@ class $$LocalAssetEntityTableTableManager
checksum: checksum, checksum: checksum,
isFavorite: isFavorite, isFavorite: isFavorite,
orientation: orientation, orientation: orientation,
adjustmentTimestamp: adjustmentTimestamp,
), ),
withReferenceMapper: (p0) => p0 withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e))) .map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
@@ -473,6 +494,17 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
requiredDuringInsert: false, requiredDuringInsert: false,
defaultValue: const i4.Constant(0), defaultValue: const i4.Constant(0),
); );
static const i0.VerificationMeta _adjustmentTimestampMeta =
const i0.VerificationMeta('adjustmentTimestamp');
@override
late final i0.GeneratedColumn<int> adjustmentTimestamp =
i0.GeneratedColumn<int>(
'adjustment_timestamp',
aliasedName,
true,
type: i0.DriftSqlType.int,
requiredDuringInsert: false,
);
@override @override
List<i0.GeneratedColumn> get $columns => [ List<i0.GeneratedColumn> get $columns => [
name, name,
@@ -486,6 +518,7 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
adjustmentTimestamp,
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -566,6 +599,15 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
), ),
); );
} }
if (data.containsKey('adjustment_timestamp')) {
context.handle(
_adjustmentTimestampMeta,
adjustmentTimestamp.isAcceptableOrUnknown(
data['adjustment_timestamp']!,
_adjustmentTimestampMeta,
),
);
}
return context; return context;
} }
@@ -624,6 +666,10 @@ class $LocalAssetEntityTable extends i3.LocalAssetEntity
i0.DriftSqlType.int, i0.DriftSqlType.int,
data['${effectivePrefix}orientation'], data['${effectivePrefix}orientation'],
)!, )!,
adjustmentTimestamp: attachedDatabase.typeMapping.read(
i0.DriftSqlType.int,
data['${effectivePrefix}adjustment_timestamp'],
),
); );
} }
@@ -653,6 +699,7 @@ class LocalAssetEntityData extends i0.DataClass
final String? checksum; final String? checksum;
final bool isFavorite; final bool isFavorite;
final int orientation; final int orientation;
final int? adjustmentTimestamp;
const LocalAssetEntityData({ const LocalAssetEntityData({
required this.name, required this.name,
required this.type, required this.type,
@@ -665,6 +712,7 @@ class LocalAssetEntityData extends i0.DataClass
this.checksum, this.checksum,
required this.isFavorite, required this.isFavorite,
required this.orientation, required this.orientation,
this.adjustmentTimestamp,
}); });
@override @override
Map<String, i0.Expression> toColumns(bool nullToAbsent) { Map<String, i0.Expression> toColumns(bool nullToAbsent) {
@@ -692,6 +740,9 @@ class LocalAssetEntityData extends i0.DataClass
} }
map['is_favorite'] = i0.Variable<bool>(isFavorite); map['is_favorite'] = i0.Variable<bool>(isFavorite);
map['orientation'] = i0.Variable<int>(orientation); map['orientation'] = i0.Variable<int>(orientation);
if (!nullToAbsent || adjustmentTimestamp != null) {
map['adjustment_timestamp'] = i0.Variable<int>(adjustmentTimestamp);
}
return map; return map;
} }
@@ -714,6 +765,9 @@ class LocalAssetEntityData extends i0.DataClass
checksum: serializer.fromJson<String?>(json['checksum']), checksum: serializer.fromJson<String?>(json['checksum']),
isFavorite: serializer.fromJson<bool>(json['isFavorite']), isFavorite: serializer.fromJson<bool>(json['isFavorite']),
orientation: serializer.fromJson<int>(json['orientation']), orientation: serializer.fromJson<int>(json['orientation']),
adjustmentTimestamp: serializer.fromJson<int?>(
json['adjustmentTimestamp'],
),
); );
} }
@override @override
@@ -733,6 +787,7 @@ class LocalAssetEntityData extends i0.DataClass
'checksum': serializer.toJson<String?>(checksum), 'checksum': serializer.toJson<String?>(checksum),
'isFavorite': serializer.toJson<bool>(isFavorite), 'isFavorite': serializer.toJson<bool>(isFavorite),
'orientation': serializer.toJson<int>(orientation), 'orientation': serializer.toJson<int>(orientation),
'adjustmentTimestamp': serializer.toJson<int?>(adjustmentTimestamp),
}; };
} }
@@ -748,6 +803,7 @@ class LocalAssetEntityData extends i0.DataClass
i0.Value<String?> checksum = const i0.Value.absent(), i0.Value<String?> checksum = const i0.Value.absent(),
bool? isFavorite, bool? isFavorite,
int? orientation, int? orientation,
i0.Value<int?> adjustmentTimestamp = const i0.Value.absent(),
}) => i1.LocalAssetEntityData( }) => i1.LocalAssetEntityData(
name: name ?? this.name, name: name ?? this.name,
type: type ?? this.type, type: type ?? this.type,
@@ -762,6 +818,9 @@ class LocalAssetEntityData extends i0.DataClass
checksum: checksum.present ? checksum.value : this.checksum, checksum: checksum.present ? checksum.value : this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
adjustmentTimestamp: adjustmentTimestamp.present
? adjustmentTimestamp.value
: this.adjustmentTimestamp,
); );
LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) { LocalAssetEntityData copyWithCompanion(i1.LocalAssetEntityCompanion data) {
return LocalAssetEntityData( return LocalAssetEntityData(
@@ -782,6 +841,9 @@ class LocalAssetEntityData extends i0.DataClass
orientation: data.orientation.present orientation: data.orientation.present
? data.orientation.value ? data.orientation.value
: this.orientation, : this.orientation,
adjustmentTimestamp: data.adjustmentTimestamp.present
? data.adjustmentTimestamp.value
: this.adjustmentTimestamp,
); );
} }
@@ -798,7 +860,8 @@ class LocalAssetEntityData extends i0.DataClass
..write('id: $id, ') ..write('id: $id, ')
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation') ..write('orientation: $orientation, ')
..write('adjustmentTimestamp: $adjustmentTimestamp')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -816,6 +879,7 @@ class LocalAssetEntityData extends i0.DataClass
checksum, checksum,
isFavorite, isFavorite,
orientation, orientation,
adjustmentTimestamp,
); );
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -831,7 +895,8 @@ class LocalAssetEntityData extends i0.DataClass
other.id == this.id && other.id == this.id &&
other.checksum == this.checksum && other.checksum == this.checksum &&
other.isFavorite == this.isFavorite && other.isFavorite == this.isFavorite &&
other.orientation == this.orientation); other.orientation == this.orientation &&
other.adjustmentTimestamp == this.adjustmentTimestamp);
} }
class LocalAssetEntityCompanion class LocalAssetEntityCompanion
@@ -847,6 +912,7 @@ class LocalAssetEntityCompanion
final i0.Value<String?> checksum; final i0.Value<String?> checksum;
final i0.Value<bool> isFavorite; final i0.Value<bool> isFavorite;
final i0.Value<int> orientation; final i0.Value<int> orientation;
final i0.Value<int?> adjustmentTimestamp;
const LocalAssetEntityCompanion({ const LocalAssetEntityCompanion({
this.name = const i0.Value.absent(), this.name = const i0.Value.absent(),
this.type = const i0.Value.absent(), this.type = const i0.Value.absent(),
@@ -859,6 +925,7 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
this.adjustmentTimestamp = const i0.Value.absent(),
}); });
LocalAssetEntityCompanion.insert({ LocalAssetEntityCompanion.insert({
required String name, required String name,
@@ -872,6 +939,7 @@ class LocalAssetEntityCompanion
this.checksum = const i0.Value.absent(), this.checksum = const i0.Value.absent(),
this.isFavorite = const i0.Value.absent(), this.isFavorite = const i0.Value.absent(),
this.orientation = const i0.Value.absent(), this.orientation = const i0.Value.absent(),
this.adjustmentTimestamp = const i0.Value.absent(),
}) : name = i0.Value(name), }) : name = i0.Value(name),
type = i0.Value(type), type = i0.Value(type),
id = i0.Value(id); id = i0.Value(id);
@@ -887,6 +955,7 @@ class LocalAssetEntityCompanion
i0.Expression<String>? checksum, i0.Expression<String>? checksum,
i0.Expression<bool>? isFavorite, i0.Expression<bool>? isFavorite,
i0.Expression<int>? orientation, i0.Expression<int>? orientation,
i0.Expression<int>? adjustmentTimestamp,
}) { }) {
return i0.RawValuesInsertable({ return i0.RawValuesInsertable({
if (name != null) 'name': name, if (name != null) 'name': name,
@@ -900,6 +969,8 @@ class LocalAssetEntityCompanion
if (checksum != null) 'checksum': checksum, if (checksum != null) 'checksum': checksum,
if (isFavorite != null) 'is_favorite': isFavorite, if (isFavorite != null) 'is_favorite': isFavorite,
if (orientation != null) 'orientation': orientation, if (orientation != null) 'orientation': orientation,
if (adjustmentTimestamp != null)
'adjustment_timestamp': adjustmentTimestamp,
}); });
} }
@@ -915,6 +986,7 @@ class LocalAssetEntityCompanion
i0.Value<String?>? checksum, i0.Value<String?>? checksum,
i0.Value<bool>? isFavorite, i0.Value<bool>? isFavorite,
i0.Value<int>? orientation, i0.Value<int>? orientation,
i0.Value<int?>? adjustmentTimestamp,
}) { }) {
return i1.LocalAssetEntityCompanion( return i1.LocalAssetEntityCompanion(
name: name ?? this.name, name: name ?? this.name,
@@ -928,6 +1000,7 @@ class LocalAssetEntityCompanion
checksum: checksum ?? this.checksum, checksum: checksum ?? this.checksum,
isFavorite: isFavorite ?? this.isFavorite, isFavorite: isFavorite ?? this.isFavorite,
orientation: orientation ?? this.orientation, orientation: orientation ?? this.orientation,
adjustmentTimestamp: adjustmentTimestamp ?? this.adjustmentTimestamp,
); );
} }
@@ -969,6 +1042,9 @@ class LocalAssetEntityCompanion
if (orientation.present) { if (orientation.present) {
map['orientation'] = i0.Variable<int>(orientation.value); map['orientation'] = i0.Variable<int>(orientation.value);
} }
if (adjustmentTimestamp.present) {
map['adjustment_timestamp'] = i0.Variable<int>(adjustmentTimestamp.value);
}
return map; return map;
} }
@@ -985,7 +1061,8 @@ class LocalAssetEntityCompanion
..write('id: $id, ') ..write('id: $id, ')
..write('checksum: $checksum, ') ..write('checksum: $checksum, ')
..write('isFavorite: $isFavorite, ') ..write('isFavorite: $isFavorite, ')
..write('orientation: $orientation') ..write('orientation: $orientation, ')
..write('adjustmentTimestamp: $adjustmentTimestamp')
..write(')')) ..write(')'))
.toString(); .toString();
} }

View File

@@ -93,7 +93,7 @@ class Drift extends $Drift implements IDatabaseRepository {
} }
@override @override
int get schemaVersion => 11; int get schemaVersion => 12;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -159,6 +159,9 @@ class Drift extends $Drift implements IDatabaseRepository {
from10To11: (m, v11) async { from10To11: (m, v11) async {
await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_); await m.addColumn(v11.localAlbumAssetEntity, v11.localAlbumAssetEntity.marker_);
}, },
from11To12: (m, v12) async {
await m.addColumn(v12.localAssetEntity, v12.localAssetEntity.adjustmentTimestamp);
},
), ),
); );

View File

@@ -4659,6 +4659,420 @@ class Shape22 extends i0.VersionedTable {
columnsByName['marker']! as i1.GeneratedColumn<bool>; columnsByName['marker']! as i1.GeneratedColumn<bool>;
} }
final class Schema12 extends i0.VersionedSchema {
Schema12({required super.database}) : super(version: 12);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
idxLatLng,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 localAssetEntity = Shape23(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_95,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
}
class Shape23 extends i0.VersionedTable {
Shape23({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get name =>
columnsByName['name']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get type =>
columnsByName['type']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<int> get width =>
columnsByName['width']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get height =>
columnsByName['height']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get durationInSeconds =>
columnsByName['duration_in_seconds']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get id =>
columnsByName['id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<int> get orientation =>
columnsByName['orientation']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get adjustmentTimestamp =>
columnsByName['adjustment_timestamp']! as i1.GeneratedColumn<int>;
}
i1.GeneratedColumn<int> _column_95(String aliasedName) =>
i1.GeneratedColumn<int>(
'adjustment_timestamp',
aliasedName,
true,
type: i1.DriftSqlType.int,
);
i0.MigrationStepWithVersion migrationSteps({ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2, required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3, required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@@ -4670,6 +5084,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9, required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10, required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11, required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
}) { }) {
return (currentVersion, database) async { return (currentVersion, database) async {
switch (currentVersion) { switch (currentVersion) {
@@ -4723,6 +5138,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema); final migrator = i1.Migrator(database, schema);
await from10To11(migrator, schema); await from10To11(migrator, schema);
return 11; return 11;
case 11:
final schema = Schema12(database: database);
final migrator = i1.Migrator(database, schema);
await from11To12(migrator, schema);
return 12;
default: default:
throw ArgumentError.value('Unknown migration from $currentVersion'); throw ArgumentError.value('Unknown migration from $currentVersion');
} }
@@ -4740,6 +5160,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9, required Future<void> Function(i1.Migrator m, Schema9 schema) from8To9,
required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10, required Future<void> Function(i1.Migrator m, Schema10 schema) from9To10,
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11, required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
}) => i0.VersionedSchema.stepByStepHelper( }) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps( step: migrationSteps(
from1To2: from1To2, from1To2: from1To2,
@@ -4752,5 +5173,6 @@ i1.OnUpgrade stepByStep({
from8To9: from8To9, from8To9: from8To9,
from9To10: from9To10, from9To10: from9To10,
from10To11: from10To11, from10To11: from10To11,
from11To12: from11To12,
), ),
); );

View File

@@ -263,6 +263,7 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
orientation: Value(asset.orientation), orientation: Value(asset.orientation),
checksum: const Value(null), checksum: const Value(null),
isFavorite: Value(asset.isFavorite), isFavorite: Value(asset.isFavorite),
adjustmentTimestamp: Value(asset.adjustmentTimestamp),
); );
batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>( batch.insert<$LocalAssetEntityTable, LocalAssetEntityData>(
_db.localAssetEntity, _db.localAssetEntity,

View File

@@ -41,6 +41,7 @@ class PlatformAsset {
required this.durationInSeconds, required this.durationInSeconds,
required this.orientation, required this.orientation,
required this.isFavorite, required this.isFavorite,
this.adjustmentTimestamp,
}); });
String id; String id;
@@ -63,8 +64,22 @@ class PlatformAsset {
bool isFavorite; bool isFavorite;
int? adjustmentTimestamp;
List<Object?> _toList() { List<Object?> _toList() {
return <Object?>[id, name, type, createdAt, updatedAt, width, height, durationInSeconds, orientation, isFavorite]; return <Object?>[
id,
name,
type,
createdAt,
updatedAt,
width,
height,
durationInSeconds,
orientation,
isFavorite,
adjustmentTimestamp,
];
} }
Object encode() { Object encode() {
@@ -84,6 +99,7 @@ class PlatformAsset {
durationInSeconds: result[7]! as int, durationInSeconds: result[7]! as int,
orientation: result[8]! as int, orientation: result[8]! as int,
isFavorite: result[9]! as bool, isFavorite: result[9]! as bool,
adjustmentTimestamp: result[10] as int?,
); );
} }

View File

@@ -129,6 +129,7 @@ class _AssetPropertiesSectionState extends ConsumerState<_AssetPropertiesSection
properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString())); properties.insert(4, _PropertyItem(label: 'Orientation', value: asset.orientation.toString()));
final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id); final albums = await ref.read(assetServiceProvider).getSourceAlbums(asset.id);
properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', '))); properties.add(_PropertyItem(label: 'Album', value: albums.map((a) => a.name).join(', ')));
properties.add(_PropertyItem(label: 'Adjustment Timestamp', value: asset.adjustmentTimestamp?.toString()));
} }
Future<void> _addRemoteAssetProperties(RemoteAsset asset) async { Future<void> _addRemoteAssetProperties(RemoteAsset asset) async {

View File

@@ -16,9 +16,8 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
final String id; final String id;
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
final bool exact;
LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution, this.exact = true}); LocalThumbProvider({required this.id, required this.assetType, this.size = kThumbnailResolution});
@override @override
Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) { Future<LocalThumbProvider> obtainKey(ImageConfiguration configuration) {
@@ -38,12 +37,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
} }
Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) { Stream<ImageInfo> _codec(LocalThumbProvider key, ImageDecoderCallback decode) {
final devicePixelRatio = PlatformDispatcher.instance.views.first.devicePixelRatio; final request = this.request = LocalImageRequest(localId: key.id, size: key.size, assetType: key.assetType);
final request = this.request = LocalImageRequest(
localId: key.id,
size: key.size * devicePixelRatio,
assetType: key.assetType,
);
return loadRequest(request, decode); return loadRequest(request, decode);
} }
@@ -51,7 +45,7 @@ class LocalThumbProvider extends CancellableImageProvider<LocalThumbProvider>
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
if (other is LocalThumbProvider) { if (other is LocalThumbProvider) {
return id == other.id && (!exact || size == other.size); return id == other.id;
} }
return false; return false;
} }
@@ -66,12 +60,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final Size size; final Size size;
final AssetType assetType; final AssetType assetType;
LocalFullImageProvider({ LocalFullImageProvider({required this.id, required this.assetType, required this.size});
required this.id,
required this.assetType,
required this.size,
LocalThumbProvider? initialProvider,
});
@override @override
Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) { Future<LocalFullImageProvider> obtainKey(ImageConfiguration configuration) {
@@ -82,7 +71,7 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) { ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter( return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode), _codec(key, decode),
initialImage: getInitialImage(LocalThumbProvider(id: id, assetType: assetType, exact: false)), initialImage: getInitialImage(LocalThumbProvider(id: key.id, assetType: key.assetType)),
informationCollector: () => <DiagnosticsNode>[ informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<ImageProvider>('Image provider', this), DiagnosticsProperty<ImageProvider>('Image provider', this),
DiagnosticsProperty<String>('Id', key.id), DiagnosticsProperty<String>('Id', key.id),

View File

@@ -2,7 +2,7 @@ import 'dart:ui';
const double kTimelineHeaderExtent = 80.0; const double kTimelineHeaderExtent = 80.0;
const Size kTimelineFixedTileExtent = Size.square(256); const Size kTimelineFixedTileExtent = Size.square(256);
const Size kThumbnailResolution = Size.square(128); const Size kThumbnailResolution = Size.square(320); // TODO: make the resolution vary based on actual tile size
const double kTimelineSpacing = 2.0; const double kTimelineSpacing = 2.0;
const int kTimelineColumnCount = 3; const int kTimelineColumnCount = 3;

View File

@@ -121,7 +121,6 @@ class _FixedSegmentRow extends ConsumerWidget {
} }
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) { Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
final size = Size.square(tileHeight);
return FixedTimelineRow( return FixedTimelineRow(
dimension: tileHeight, dimension: tileHeight,
spacing: spacing, spacing: spacing,
@@ -135,7 +134,6 @@ class _FixedSegmentRow extends ConsumerWidget {
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)), key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i], asset: assets[i],
assetIndex: assetIndex + i, assetIndex: assetIndex + i,
size: size,
), ),
), ),
], ],
@@ -146,9 +144,8 @@ class _FixedSegmentRow extends ConsumerWidget {
class _AssetTileWidget extends ConsumerWidget { class _AssetTileWidget extends ConsumerWidget {
final BaseAsset asset; final BaseAsset asset;
final int assetIndex; final int assetIndex;
final Size size;
const _AssetTileWidget({super.key, required this.asset, required this.assetIndex, required this.size}); const _AssetTileWidget({super.key, required this.asset, required this.assetIndex});
Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async { Future _handleOnTap(BuildContext ctx, WidgetRef ref, int assetIndex, BaseAsset asset, int? heroOffset) async {
final multiSelectState = ref.read(multiSelectProvider); final multiSelectState = ref.read(multiSelectProvider);
@@ -206,7 +203,6 @@ class _AssetTileWidget extends ConsumerWidget {
lockSelection: lockSelection, lockSelection: lockSelection,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset, heroOffset: heroOffset,
size: size,
), ),
), ),
); );

View File

@@ -393,6 +393,7 @@ Class | Method | HTTP request | Description
- [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginCredentialDto](doc//LoginCredentialDto.md)
- [LoginResponseDto](doc//LoginResponseDto.md) - [LoginResponseDto](doc//LoginResponseDto.md)
- [LogoutResponseDto](doc//LogoutResponseDto.md) - [LogoutResponseDto](doc//LogoutResponseDto.md)
- [MachineLearningAvailabilityChecksDto](doc//MachineLearningAvailabilityChecksDto.md)
- [ManualJobName](doc//ManualJobName.md) - [ManualJobName](doc//ManualJobName.md)
- [MapMarkerResponseDto](doc//MapMarkerResponseDto.md) - [MapMarkerResponseDto](doc//MapMarkerResponseDto.md)
- [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md) - [MapReverseGeocodeResponseDto](doc//MapReverseGeocodeResponseDto.md)

View File

@@ -164,6 +164,7 @@ part 'model/log_level.dart';
part 'model/login_credential_dto.dart'; part 'model/login_credential_dto.dart';
part 'model/login_response_dto.dart'; part 'model/login_response_dto.dart';
part 'model/logout_response_dto.dart'; part 'model/logout_response_dto.dart';
part 'model/machine_learning_availability_checks_dto.dart';
part 'model/manual_job_name.dart'; part 'model/manual_job_name.dart';
part 'model/map_marker_response_dto.dart'; part 'model/map_marker_response_dto.dart';
part 'model/map_reverse_geocode_response_dto.dart'; part 'model/map_reverse_geocode_response_dto.dart';

View File

@@ -382,6 +382,8 @@ class ApiClient {
return LoginResponseDto.fromJson(value); return LoginResponseDto.fromJson(value);
case 'LogoutResponseDto': case 'LogoutResponseDto':
return LogoutResponseDto.fromJson(value); return LogoutResponseDto.fromJson(value);
case 'MachineLearningAvailabilityChecksDto':
return MachineLearningAvailabilityChecksDto.fromJson(value);
case 'ManualJobName': case 'ManualJobName':
return ManualJobNameTypeTransformer().decode(value); return ManualJobNameTypeTransformer().decode(value);
case 'MapMarkerResponseDto': case 'MapMarkerResponseDto':

View File

@@ -0,0 +1,115 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class MachineLearningAvailabilityChecksDto {
/// Returns a new [MachineLearningAvailabilityChecksDto] instance.
MachineLearningAvailabilityChecksDto({
required this.enabled,
required this.interval,
required this.timeout,
});
bool enabled;
num interval;
num timeout;
@override
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
other.enabled == enabled &&
other.interval == interval &&
other.timeout == timeout;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(enabled.hashCode) +
(interval.hashCode) +
(timeout.hashCode);
@override
String toString() => 'MachineLearningAvailabilityChecksDto[enabled=$enabled, interval=$interval, timeout=$timeout]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'enabled'] = this.enabled;
json[r'interval'] = this.interval;
json[r'timeout'] = this.timeout;
return json;
}
/// Returns a new [MachineLearningAvailabilityChecksDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MachineLearningAvailabilityChecksDto? fromJson(dynamic value) {
upgradeDto(value, "MachineLearningAvailabilityChecksDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MachineLearningAvailabilityChecksDto(
enabled: mapValueOfType<bool>(json, r'enabled')!,
interval: num.parse('${json[r'interval']}'),
timeout: num.parse('${json[r'timeout']}'),
);
}
return null;
}
static List<MachineLearningAvailabilityChecksDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MachineLearningAvailabilityChecksDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MachineLearningAvailabilityChecksDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, MachineLearningAvailabilityChecksDto> mapFromJson(dynamic json) {
final map = <String, MachineLearningAvailabilityChecksDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MachineLearningAvailabilityChecksDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of MachineLearningAvailabilityChecksDto-objects as value to a dart map
static Map<String, List<MachineLearningAvailabilityChecksDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MachineLearningAvailabilityChecksDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MachineLearningAvailabilityChecksDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'enabled',
'interval',
'timeout',
};
}

View File

@@ -13,14 +13,16 @@ part of openapi.api;
class SystemConfigMachineLearningDto { class SystemConfigMachineLearningDto {
/// Returns a new [SystemConfigMachineLearningDto] instance. /// Returns a new [SystemConfigMachineLearningDto] instance.
SystemConfigMachineLearningDto({ SystemConfigMachineLearningDto({
required this.availabilityChecks,
required this.clip, required this.clip,
required this.duplicateDetection, required this.duplicateDetection,
required this.enabled, required this.enabled,
required this.facialRecognition, required this.facialRecognition,
this.url,
this.urls = const [], this.urls = const [],
}); });
MachineLearningAvailabilityChecksDto availabilityChecks;
CLIPConfig clip; CLIPConfig clip;
DuplicateDetectionConfig duplicateDetection; DuplicateDetectionConfig duplicateDetection;
@@ -29,50 +31,37 @@ class SystemConfigMachineLearningDto {
FacialRecognitionConfig facialRecognition; FacialRecognitionConfig facialRecognition;
/// This property was deprecated in v1.122.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? url;
List<String> urls; List<String> urls;
@override @override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigMachineLearningDto && bool operator ==(Object other) => identical(this, other) || other is SystemConfigMachineLearningDto &&
other.availabilityChecks == availabilityChecks &&
other.clip == clip && other.clip == clip &&
other.duplicateDetection == duplicateDetection && other.duplicateDetection == duplicateDetection &&
other.enabled == enabled && other.enabled == enabled &&
other.facialRecognition == facialRecognition && other.facialRecognition == facialRecognition &&
other.url == url &&
_deepEquality.equals(other.urls, urls); _deepEquality.equals(other.urls, urls);
@override @override
int get hashCode => int get hashCode =>
// ignore: unnecessary_parenthesis // ignore: unnecessary_parenthesis
(availabilityChecks.hashCode) +
(clip.hashCode) + (clip.hashCode) +
(duplicateDetection.hashCode) + (duplicateDetection.hashCode) +
(enabled.hashCode) + (enabled.hashCode) +
(facialRecognition.hashCode) + (facialRecognition.hashCode) +
(url == null ? 0 : url!.hashCode) +
(urls.hashCode); (urls.hashCode);
@override @override
String toString() => 'SystemConfigMachineLearningDto[clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, url=$url, urls=$urls]'; String toString() => 'SystemConfigMachineLearningDto[availabilityChecks=$availabilityChecks, clip=$clip, duplicateDetection=$duplicateDetection, enabled=$enabled, facialRecognition=$facialRecognition, urls=$urls]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
json[r'availabilityChecks'] = this.availabilityChecks;
json[r'clip'] = this.clip; json[r'clip'] = this.clip;
json[r'duplicateDetection'] = this.duplicateDetection; json[r'duplicateDetection'] = this.duplicateDetection;
json[r'enabled'] = this.enabled; json[r'enabled'] = this.enabled;
json[r'facialRecognition'] = this.facialRecognition; json[r'facialRecognition'] = this.facialRecognition;
if (this.url != null) {
json[r'url'] = this.url;
} else {
// json[r'url'] = null;
}
json[r'urls'] = this.urls; json[r'urls'] = this.urls;
return json; return json;
} }
@@ -86,11 +75,11 @@ class SystemConfigMachineLearningDto {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return SystemConfigMachineLearningDto( return SystemConfigMachineLearningDto(
availabilityChecks: MachineLearningAvailabilityChecksDto.fromJson(json[r'availabilityChecks'])!,
clip: CLIPConfig.fromJson(json[r'clip'])!, clip: CLIPConfig.fromJson(json[r'clip'])!,
duplicateDetection: DuplicateDetectionConfig.fromJson(json[r'duplicateDetection'])!, duplicateDetection: DuplicateDetectionConfig.fromJson(json[r'duplicateDetection'])!,
enabled: mapValueOfType<bool>(json, r'enabled')!, enabled: mapValueOfType<bool>(json, r'enabled')!,
facialRecognition: FacialRecognitionConfig.fromJson(json[r'facialRecognition'])!, facialRecognition: FacialRecognitionConfig.fromJson(json[r'facialRecognition'])!,
url: mapValueOfType<String>(json, r'url'),
urls: json[r'urls'] is Iterable urls: json[r'urls'] is Iterable
? (json[r'urls'] as Iterable).cast<String>().toList(growable: false) ? (json[r'urls'] as Iterable).cast<String>().toList(growable: false)
: const [], : const [],
@@ -141,6 +130,7 @@ class SystemConfigMachineLearningDto {
/// The list of required keys that must be present in a JSON. /// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{ static const requiredKeys = <String>{
'availabilityChecks',
'clip', 'clip',
'duplicateDetection', 'duplicateDetection',
'enabled', 'enabled',

View File

@@ -24,6 +24,7 @@ class PlatformAsset {
final int durationInSeconds; final int durationInSeconds;
final int orientation; final int orientation;
final bool isFavorite; final bool isFavorite;
final int? adjustmentTimestamp;
const PlatformAsset({ const PlatformAsset({
required this.id, required this.id,
@@ -36,6 +37,7 @@ class PlatformAsset {
this.durationInSeconds = 0, this.durationInSeconds = 0,
this.orientation = 0, this.orientation = 0,
this.isFavorite = false, this.isFavorite = false,
this.adjustmentTimestamp,
}); });
} }

View File

@@ -14,6 +14,7 @@ import 'schema_v8.dart' as v8;
import 'schema_v9.dart' as v9; import 'schema_v9.dart' as v9;
import 'schema_v10.dart' as v10; import 'schema_v10.dart' as v10;
import 'schema_v11.dart' as v11; import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12;
class GeneratedHelper implements SchemaInstantiationHelper { class GeneratedHelper implements SchemaInstantiationHelper {
@override @override
@@ -41,10 +42,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v10.DatabaseAtV10(db); return v10.DatabaseAtV10(db);
case 11: case 11:
return v11.DatabaseAtV11(db); return v11.DatabaseAtV11(db);
case 12:
return v12.DatabaseAtV12(db);
default: default:
throw MissingSchemaException(version, versions); throw MissingSchemaException(version, versions);
} }
} }
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
} }

File diff suppressed because it is too large Load Diff

View File

@@ -12259,6 +12259,25 @@
], ],
"type": "object" "type": "object"
}, },
"MachineLearningAvailabilityChecksDto": {
"properties": {
"enabled": {
"type": "boolean"
},
"interval": {
"type": "number"
},
"timeout": {
"type": "number"
}
},
"required": [
"enabled",
"interval",
"timeout"
],
"type": "object"
},
"ManualJobName": { "ManualJobName": {
"enum": [ "enum": [
"person-cleanup", "person-cleanup",
@@ -16395,6 +16414,9 @@
}, },
"SystemConfigMachineLearningDto": { "SystemConfigMachineLearningDto": {
"properties": { "properties": {
"availabilityChecks": {
"$ref": "#/components/schemas/MachineLearningAvailabilityChecksDto"
},
"clip": { "clip": {
"$ref": "#/components/schemas/CLIPConfig" "$ref": "#/components/schemas/CLIPConfig"
}, },
@@ -16407,11 +16429,6 @@
"facialRecognition": { "facialRecognition": {
"$ref": "#/components/schemas/FacialRecognitionConfig" "$ref": "#/components/schemas/FacialRecognitionConfig"
}, },
"url": {
"deprecated": true,
"description": "This property was deprecated in v1.122.0",
"type": "string"
},
"urls": { "urls": {
"format": "uri", "format": "uri",
"items": { "items": {
@@ -16423,6 +16440,7 @@
} }
}, },
"required": [ "required": [
"availabilityChecks",
"clip", "clip",
"duplicateDetection", "duplicateDetection",
"enabled", "enabled",

View File

@@ -1383,6 +1383,11 @@ export type SystemConfigLoggingDto = {
enabled: boolean; enabled: boolean;
level: LogLevel; level: LogLevel;
}; };
export type MachineLearningAvailabilityChecksDto = {
enabled: boolean;
interval: number;
timeout: number;
};
export type ClipConfig = { export type ClipConfig = {
enabled: boolean; enabled: boolean;
modelName: string; modelName: string;
@@ -1399,12 +1404,11 @@ export type FacialRecognitionConfig = {
modelName: string; modelName: string;
}; };
export type SystemConfigMachineLearningDto = { export type SystemConfigMachineLearningDto = {
availabilityChecks: MachineLearningAvailabilityChecksDto;
clip: ClipConfig; clip: ClipConfig;
duplicateDetection: DuplicateDetectionConfig; duplicateDetection: DuplicateDetectionConfig;
enabled: boolean; enabled: boolean;
facialRecognition: FacialRecognitionConfig; facialRecognition: FacialRecognitionConfig;
/** This property was deprecated in v1.122.0 */
url?: string;
urls: string[]; urls: string[];
}; };
export type SystemConfigMapDto = { export type SystemConfigMapDto = {

View File

@@ -3,7 +3,7 @@
"version": "0.0.1", "version": "0.0.1",
"description": "Monorepo for Immich", "description": "Monorepo for Immich",
"private": true, "private": true,
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748", "packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
} }

2602
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,14 +44,14 @@
"@nestjs/websockets": "^11.0.4", "@nestjs/websockets": "^11.0.4",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^2.0.0",
"@opentelemetry/exporter-prometheus": "^0.203.0", "@opentelemetry/exporter-prometheus": "^0.205.0",
"@opentelemetry/instrumentation-http": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.205.0",
"@opentelemetry/instrumentation-ioredis": "^0.51.0", "@opentelemetry/instrumentation-ioredis": "^0.53.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.49.0", "@opentelemetry/instrumentation-nestjs-core": "^0.51.0",
"@opentelemetry/instrumentation-pg": "^0.56.0", "@opentelemetry/instrumentation-pg": "^0.58.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^2.0.1",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^2.0.1",
"@opentelemetry/sdk-node": "^0.203.0", "@opentelemetry/sdk-node": "^0.205.0",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@react-email/components": "^0.5.0", "@react-email/components": "^0.5.0",
"@react-email/render": "^1.1.2", "@react-email/render": "^1.1.2",

View File

@@ -15,6 +15,7 @@ import { repositories } from 'src/repositories';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { SyncRepository } from 'src/repositories/sync.repository'; import { SyncRepository } from 'src/repositories/sync.repository';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { getKyselyConfig } from 'src/utils/database'; import { getKyselyConfig } from 'src/utils/database';
@@ -57,7 +58,7 @@ class SqlGenerator {
try { try {
await this.setup(); await this.setup();
for (const Repository of repositories) { for (const Repository of repositories) {
if (Repository === LoggingRepository) { if (Repository === LoggingRepository || Repository === MachineLearningRepository) {
continue; continue;
} }
await this.process(Repository); await this.process(Repository);

View File

@@ -54,6 +54,11 @@ export interface SystemConfig {
machineLearning: { machineLearning: {
enabled: boolean; enabled: boolean;
urls: string[]; urls: string[];
availabilityChecks: {
enabled: boolean;
timeout: number;
interval: number;
};
clip: { clip: {
enabled: boolean; enabled: boolean;
modelName: string; modelName: string;
@@ -176,6 +181,8 @@ export interface SystemConfig {
}; };
} }
export type MachineLearningConfig = SystemConfig['machineLearning'];
export const defaults = Object.freeze<SystemConfig>({ export const defaults = Object.freeze<SystemConfig>({
backup: { backup: {
database: { database: {
@@ -227,6 +234,11 @@ export const defaults = Object.freeze<SystemConfig>({
machineLearning: { machineLearning: {
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false', enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'], urls: [process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'],
availabilityChecks: {
enabled: true,
timeout: Number(process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT) || 2000,
interval: 30_000,
},
clip: { clip: {
enabled: true, enabled: true,
modelName: 'ViT-B-32__openai', modelName: 'ViT-B-32__openai',

View File

@@ -51,11 +51,6 @@ export const serverVersion = new SemVer(version);
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const MACHINE_LEARNING_PING_TIMEOUT = Number(process.env.MACHINE_LEARNING_PING_TIMEOUT || 2000);
export const MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME = Number(
process.env.MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME || 30_000,
);
export const citiesFile = 'cities500.txt'; export const citiesFile = 'cities500.txt';
export const reverseGeocodeMaxDistance = 25_000; export const reverseGeocodeMaxDistance = 25_000;

View File

@@ -6,7 +6,7 @@ import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto'; import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum'; import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation'; import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateString, ValidateUUID } from 'src/validation';
class BaseSearchDto { class BaseSearchDto {
@ValidateUUID({ optional: true, nullable: true }) @ValidateUUID({ optional: true, nullable: true })
@@ -144,9 +144,7 @@ export class MetadataSearchDto extends RandomSearchDto {
@Optional() @Optional()
deviceAssetId?: string; deviceAssetId?: string;
@IsString() @ValidateString({ optional: true, trim: true })
@IsNotEmpty()
@Optional()
description?: string; description?: string;
@IsString() @IsString()
@@ -154,9 +152,7 @@ export class MetadataSearchDto extends RandomSearchDto {
@Optional() @Optional()
checksum?: string; checksum?: string;
@IsString() @ValidateString({ optional: true, trim: true })
@IsNotEmpty()
@Optional()
originalFileName?: string; originalFileName?: string;
@IsString() @IsString()
@@ -190,16 +186,12 @@ export class MetadataSearchDto extends RandomSearchDto {
} }
export class StatisticsSearchDto extends BaseSearchDto { export class StatisticsSearchDto extends BaseSearchDto {
@IsString() @ValidateString({ optional: true, trim: true })
@IsNotEmpty()
@Optional()
description?: string; description?: string;
} }
export class SmartSearchDto extends BaseSearchWithResultsDto { export class SmartSearchDto extends BaseSearchWithResultsDto {
@IsString() @ValidateString({ optional: true, trim: true })
@IsNotEmpty()
@Optional()
query?: string; query?: string;
@ValidateUUID({ optional: true }) @ValidateUUID({ optional: true })

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Exclude, Transform, Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { import {
ArrayMinSize, ArrayMinSize,
IsInt, IsInt,
@@ -15,7 +15,6 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { PropertyLifecycle } from 'src/decorators';
import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto'; import { CLIPConfig, DuplicateDetectionConfig, FacialRecognitionConfig } from 'src/dtos/model-config.dto';
import { import {
AudioCodec, AudioCodec,
@@ -257,21 +256,32 @@ class SystemConfigLoggingDto {
level!: LogLevel; level!: LogLevel;
} }
class MachineLearningAvailabilityChecksDto {
@ValidateBoolean()
enabled!: boolean;
@IsInt()
timeout!: number;
@IsInt()
interval!: number;
}
class SystemConfigMachineLearningDto { class SystemConfigMachineLearningDto {
@ValidateBoolean() @ValidateBoolean()
enabled!: boolean; enabled!: boolean;
@PropertyLifecycle({ deprecatedAt: 'v1.122.0' })
@Exclude()
url?: string;
@IsUrl({ require_tld: false, allow_underscores: true }, { each: true }) @IsUrl({ require_tld: false, allow_underscores: true }, { each: true })
@ArrayMinSize(1) @ArrayMinSize(1)
@Transform(({ obj, value }) => (obj.url ? [obj.url] : value))
@ValidateIf((dto) => dto.enabled) @ValidateIf((dto) => dto.enabled)
@ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 }) @ApiProperty({ type: 'array', items: { type: 'string', format: 'uri' }, minItems: 1 })
urls!: string[]; urls!: string[];
@Type(() => MachineLearningAvailabilityChecksDto)
@ValidateNested()
@IsObject()
availabilityChecks!: MachineLearningAvailabilityChecksDto;
@Type(() => CLIPConfig) @Type(() => CLIPConfig)
@ValidateNested() @ValidateNested()
@IsObject() @IsObject()

View File

@@ -142,6 +142,10 @@ export class LoggingRepository {
this.handleMessage(LogLevel.Fatal, message, details); this.handleMessage(LogLevel.Fatal, message, details);
} }
deprecate(message: string) {
this.warn(`[Deprecated] ${message}`);
}
private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) { private handleFunction(level: LogLevel, message: LogFunction, details: LogDetails[]) {
if (this.logger.isLevelEnabled(level)) { if (this.logger.isLevelEnabled(level)) {
this.handleMessage(level, message(), details); this.handleMessage(level, message(), details);

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Duration } from 'luxon';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import { MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME, MACHINE_LEARNING_PING_TIMEOUT } from 'src/constants'; import { MachineLearningConfig } from 'src/config';
import { CLIPConfig } from 'src/dtos/model-config.dto'; import { CLIPConfig } from 'src/dtos/model-config.dto';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -57,82 +58,100 @@ export type TextEncodingOptions = ModelOptions & { language?: string };
@Injectable() @Injectable()
export class MachineLearningRepository { export class MachineLearningRepository {
// Note that deleted URL's are not removed from this map (ie: they're leaked) private healthyMap: Record<string, boolean> = {};
// Cleaning them up is low priority since there should be very few over a private interval?: ReturnType<typeof setInterval>;
// typical server uptime cycle private _config?: MachineLearningConfig;
private urlAvailability: {
[url: string]: private get config(): MachineLearningConfig {
| { if (!this._config) {
active: boolean; throw new Error('Machine learning repository not been setup');
lastChecked: number; }
}
| undefined; return this._config;
}; }
constructor(private logger: LoggingRepository) { constructor(private logger: LoggingRepository) {
this.logger.setContext(MachineLearningRepository.name); this.logger.setContext(MachineLearningRepository.name);
this.urlAvailability = {};
} }
private setUrlAvailability(url: string, active: boolean) { setup(config: MachineLearningConfig) {
const current = this.urlAvailability[url]; this._config = config;
if (current?.active !== active) { this.teardown();
this.logger.verbose(`Setting ${url} ML server to ${active ? 'active' : 'inactive'}.`);
// delete old servers
for (const url of Object.keys(this.healthyMap)) {
if (!config.urls.includes(url)) {
delete this.healthyMap[url];
}
} }
this.urlAvailability[url] = {
active, if (!config.availabilityChecks.enabled) {
lastChecked: Date.now(), return;
}; }
this.tick();
this.interval = setInterval(
() => this.tick(),
Duration.fromObject({ milliseconds: config.availabilityChecks.interval }).as('milliseconds'),
);
} }
private async checkAvailability(url: string) { teardown() {
let active = false; if (this.interval) {
clearInterval(this.interval);
}
}
private tick() {
for (const url of this.config.urls) {
void this.check(url);
}
}
private async check(url: string) {
let healthy = false;
try { try {
const response = await fetch(new URL('/ping', url), { const response = await fetch(new URL('/ping', url), {
signal: AbortSignal.timeout(MACHINE_LEARNING_PING_TIMEOUT), signal: AbortSignal.timeout(this.config.availabilityChecks.timeout),
}); });
active = response.ok; if (response.ok) {
healthy = true;
}
} catch { } catch {
// nothing to do here // nothing to do here
} }
this.setUrlAvailability(url, active);
return active; this.setHealthy(url, healthy);
} }
private async shouldSkipUrl(url: string) { private setHealthy(url: string, healthy: boolean) {
const availability = this.urlAvailability[url]; if (this.healthyMap[url] !== healthy) {
if (availability === undefined) { this.logger.log(`Machine learning server became ${healthy ? 'healthy' : 'unhealthy'} (${url}).`);
// If this is a new endpoint, then check inline and skip if it fails
if (!(await this.checkAvailability(url))) {
return true;
}
return false;
} }
if (!availability.active && Date.now() - availability.lastChecked < MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME) {
// If this is an old inactive endpoint that hasn't been checked in a this.healthyMap[url] = healthy;
// while then check but don't wait for the result, just skip it }
// This avoids delays on every search whilst allowing higher priority
// ML servers to recover over time. private isHealthy(url: string) {
void this.checkAvailability(url); if (!this.config.availabilityChecks.enabled) {
return true; return true;
} }
return false;
return this.healthyMap[url];
} }
private async predict<T>(urls: string[], payload: ModelPayload, config: MachineLearningRequest): Promise<T> { private async predict<T>(payload: ModelPayload, config: MachineLearningRequest): Promise<T> {
const formData = await this.getFormData(payload, config); const formData = await this.getFormData(payload, config);
let urlCounter = 0;
for (const url of urls) {
urlCounter++;
const isLast = urlCounter >= urls.length;
if (!isLast && (await this.shouldSkipUrl(url))) {
continue;
}
for (const url of [
// try healthy servers first
...this.config.urls.filter((url) => this.isHealthy(url)),
...this.config.urls.filter((url) => !this.isHealthy(url)),
]) {
try { try {
const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData }); const response = await fetch(new URL('/predict', url), { method: 'POST', body: formData });
if (response.ok) { if (response.ok) {
this.setUrlAvailability(url, true); this.setHealthy(url, true);
return response.json(); return response.json();
} }
@@ -144,20 +163,21 @@ export class MachineLearningRepository {
`Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`, `Machine learning request to "${url}" failed: ${error instanceof Error ? error.message : error}`,
); );
} }
this.setUrlAvailability(url, false);
this.setHealthy(url, false);
} }
throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`); throw new Error(`Machine learning request '${JSON.stringify(config)}' failed for all URLs`);
} }
async detectFaces(urls: string[], imagePath: string, { modelName, minScore }: FaceDetectionOptions) { async detectFaces(imagePath: string, { modelName, minScore }: FaceDetectionOptions) {
const request = { const request = {
[ModelTask.FACIAL_RECOGNITION]: { [ModelTask.FACIAL_RECOGNITION]: {
[ModelType.DETECTION]: { modelName, options: { minScore } }, [ModelType.DETECTION]: { modelName, options: { minScore } },
[ModelType.RECOGNITION]: { modelName }, [ModelType.RECOGNITION]: { modelName },
}, },
}; };
const response = await this.predict<FacialRecognitionResponse>(urls, { imagePath }, request); const response = await this.predict<FacialRecognitionResponse>({ imagePath }, request);
return { return {
imageHeight: response.imageHeight, imageHeight: response.imageHeight,
imageWidth: response.imageWidth, imageWidth: response.imageWidth,
@@ -165,15 +185,15 @@ export class MachineLearningRepository {
}; };
} }
async encodeImage(urls: string[], imagePath: string, { modelName }: CLIPConfig) { async encodeImage(imagePath: string, { modelName }: CLIPConfig) {
const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } }; const request = { [ModelTask.SEARCH]: { [ModelType.VISUAL]: { modelName } } };
const response = await this.predict<ClipVisualResponse>(urls, { imagePath }, request); const response = await this.predict<ClipVisualResponse>({ imagePath }, request);
return response[ModelTask.SEARCH]; return response[ModelTask.SEARCH];
} }
async encodeText(urls: string[], text: string, { language, modelName }: TextEncodingOptions) { async encodeText(text: string, { language, modelName }: TextEncodingOptions) {
const request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName, options: { language } } } }; const request = { [ModelTask.SEARCH]: { [ModelType.TEXTUAL]: { modelName, options: { language } } } };
const response = await this.predict<ClipTextualResponse>(urls, { text }, request); const response = await this.predict<ClipTextualResponse>({ text }, request);
return response[ModelTask.SEARCH]; return response[ModelTask.SEARCH];
} }

View File

@@ -729,7 +729,6 @@ describe(PersonService.name, () => {
mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] }); mocks.assetJob.getForDetectFacesJob.mockResolvedValue({ ...assetStub.image, files: [assetStub.image.files[1]] });
await sut.handleDetectFaces({ id: assetStub.image.id }); await sut.handleDetectFaces({ id: assetStub.image.id });
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith( expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }), expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
); );

View File

@@ -316,7 +316,6 @@ export class PersonService extends BaseService {
} }
const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces( const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces(
machineLearning.urls,
previewFile.path, previewFile.path,
machineLearning.facialRecognition, machineLearning.facialRecognition,
); );

View File

@@ -211,7 +211,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test' }); await sut.searchSmart(authStub.user1, { query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ modelName: expect.any(String) }), expect.objectContaining({ modelName: expect.any(String) }),
); );
@@ -225,7 +224,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 }); await sut.searchSmart(authStub.user1, { query: 'test', page: 2, size: 50 });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ modelName: expect.any(String) }), expect.objectContaining({ modelName: expect.any(String) }),
); );
@@ -243,7 +241,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test' }); await sut.searchSmart(authStub.user1, { query: 'test' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }), expect.objectContaining({ modelName: 'ViT-B-16-SigLIP__webli' }),
); );
@@ -253,7 +250,6 @@ describe(SearchService.name, () => {
await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' }); await sut.searchSmart(authStub.user1, { query: 'test', language: 'de' });
expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeText).toHaveBeenCalledWith(
[expect.any(String)],
'test', 'test',
expect.objectContaining({ language: 'de' }), expect.objectContaining({ language: 'de' }),
); );

View File

@@ -118,7 +118,7 @@ export class SearchService extends BaseService {
const key = machineLearning.clip.modelName + dto.query + dto.language; const key = machineLearning.clip.modelName + dto.query + dto.language;
embedding = this.embeddingCache.get(key); embedding = this.embeddingCache.get(key);
if (!embedding) { if (!embedding) {
embedding = await this.machineLearningRepository.encodeText(machineLearning.urls, dto.query, { embedding = await this.machineLearningRepository.encodeText(dto.query, {
modelName: machineLearning.clip.modelName, modelName: machineLearning.clip.modelName,
language: dto.language, language: dto.language,
}); });

View File

@@ -205,7 +205,6 @@ describe(SmartInfoService.name, () => {
expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success); expect(await sut.handleEncodeClip({ id: assetStub.image.id })).toEqual(JobStatus.Success);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );
@@ -242,7 +241,6 @@ describe(SmartInfoService.name, () => {
expect(mocks.database.wait).toHaveBeenCalledWith(512); expect(mocks.database.wait).toHaveBeenCalledWith(512);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith( expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
['http://immich-machine-learning:3003'],
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }), expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
); );

View File

@@ -108,11 +108,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
const embedding = await this.machineLearningRepository.encodeImage( const embedding = await this.machineLearningRepository.encodeImage(asset.files[0].path, machineLearning.clip);
machineLearning.urls,
asset.files[0].path,
machineLearning.clip,
);
if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) { if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) {
this.logger.verbose(`Waiting for CLIP dimension size to be updated`); this.logger.verbose(`Waiting for CLIP dimension size to be updated`);

View File

@@ -82,6 +82,11 @@ const updatedConfig = Object.freeze<SystemConfig>({
machineLearning: { machineLearning: {
enabled: true, enabled: true,
urls: ['http://immich-machine-learning:3003'], urls: ['http://immich-machine-learning:3003'],
availabilityChecks: {
enabled: true,
interval: 30_000,
timeout: 2000,
},
clip: { clip: {
enabled: true, enabled: true,
modelName: 'ViT-B-32__openai', modelName: 'ViT-B-32__openai',

View File

@@ -16,6 +16,20 @@ export class SystemConfigService extends BaseService {
async onBootstrap() { async onBootstrap() {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
await this.eventRepository.emit('ConfigInit', { newConfig: config }); await this.eventRepository.emit('ConfigInit', { newConfig: config });
if (
process.env.IMMICH_MACHINE_LEARNING_PING_TIMEOUT ||
process.env.IMMICH_MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME
) {
this.logger.deprecate(
'IMMICH_MACHINE_LEARNING_PING_TIMEOUT and MACHINE_LEARNING_AVAILABILITY_BACKOFF_TIME have been moved to system config(`machineLearning.availabilityChecks`) and will be removed in a future release.',
);
}
}
@OnEvent({ name: 'AppShutdown' })
onShutdown() {
this.machineLearningRepository.teardown();
} }
async getSystemConfig(): Promise<SystemConfigDto> { async getSystemConfig(): Promise<SystemConfigDto> {
@@ -28,12 +42,14 @@ export class SystemConfigService extends BaseService {
} }
@OnEvent({ name: 'ConfigInit', priority: -100 }) @OnEvent({ name: 'ConfigInit', priority: -100 })
onConfigInit({ newConfig: { logging } }: ArgOf<'ConfigInit'>) { onConfigInit({ newConfig: { logging, machineLearning } }: ArgOf<'ConfigInit'>) {
const { logLevel: envLevel } = this.configRepository.getEnv(); const { logLevel: envLevel } = this.configRepository.getEnv();
const configLevel = logging.enabled ? logging.level : false; const configLevel = logging.enabled ? logging.level : false;
const level = envLevel ?? configLevel; const level = envLevel ?? configLevel;
this.logger.setLogLevel(level); this.logger.setLogLevel(level);
this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`); this.logger.log(`LogLevel=${level} ${envLevel ? '(set via IMMICH_LOG_LEVEL)' : '(set via system config)'}`);
this.machineLearningRepository.setup(machineLearning);
} }
@OnEvent({ name: 'ConfigUpdate', server: true }) @OnEvent({ name: 'ConfigUpdate', server: true })

View File

@@ -211,6 +211,18 @@ export const ValidateDate = (options?: DateOptions & ApiPropertyOptions) => {
return applyDecorators(...decorators); return applyDecorators(...decorators);
}; };
type StringOptions = { optional?: boolean; nullable?: boolean; trim?: boolean };
export const ValidateString = (options?: StringOptions & ApiPropertyOptions) => {
const { optional, nullable, trim, ...apiPropertyOptions } = options || {};
const decorators = [ApiProperty(apiPropertyOptions), IsString(), optional ? Optional({ nullable }) : IsNotEmpty()];
if (trim) {
decorators.push(Transform(({ value }: { value: string }) => value?.trim()));
}
return applyDecorators(...decorators);
};
type BooleanOptions = { optional?: boolean; nullable?: boolean }; type BooleanOptions = { optional?: boolean; nullable?: boolean };
export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => { export const ValidateBoolean = (options?: BooleanOptions & ApiPropertyOptions) => {
const { optional, nullable, ...apiPropertyOptions } = options || {}; const { optional, nullable, ...apiPropertyOptions } = options || {};

View File

@@ -127,6 +127,7 @@ export default typescriptEslint.config(
'@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/require-await': 'error', '@typescript-eslint/require-await': 'error',
'object-shorthand': ['error', 'always'], 'object-shorthand': ['error', 'always'],
'svelte/no-navigation-without-resolve': 'off',
}, },
}, },
{ {

View File

@@ -55,7 +55,7 @@
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"simple-icons": "^15.15.0", "simple-icons": "^15.15.0",
"socket.io-client": "~4.8.0", "socket.io-client": "~4.8.0",
"svelte-gestures": "^5.1.3", "svelte-gestures": "5.1.4",
"svelte-i18n": "^4.0.1", "svelte-i18n": "^4.0.1",
"svelte-maplibre": "^1.2.0", "svelte-maplibre": "^1.2.0",
"svelte-persisted-store": "^0.12.0", "svelte-persisted-store": "^0.12.0",
@@ -70,7 +70,7 @@
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/enhanced-img": "^0.8.0", "@sveltejs/enhanced-img": "^0.8.0",
"@sveltejs/kit": "^2.27.1", "@sveltejs/kit": "^2.27.1",
"@sveltejs/vite-plugin-svelte": "6.1.2", "@sveltejs/vite-plugin-svelte": "6.2.0",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.7",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/svelte": "^5.2.8", "@testing-library/svelte": "^5.2.8",
@@ -85,7 +85,7 @@
"dotenv": "^17.0.0", "dotenv": "^17.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-p": "^0.25.0", "eslint-p": "^0.26.0",
"eslint-plugin-compat": "^6.0.2", "eslint-plugin-compat": "^6.0.2",
"eslint-plugin-svelte": "^3.9.0", "eslint-plugin-svelte": "^3.9.0",
"eslint-plugin-unicorn": "^60.0.0", "eslint-plugin-unicorn": "^60.0.0",
@@ -97,7 +97,7 @@
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
"rollup-plugin-visualizer": "^6.0.0", "rollup-plugin-visualizer": "^6.0.0",
"svelte": "5.35.5", "svelte": "5.38.10",
"svelte-check": "^4.1.5", "svelte-check": "^4.1.5",
"svelte-eslint-parser": "^1.2.0", "svelte-eslint-parser": "^1.2.0",
"tailwindcss": "^4.1.7", "tailwindcss": "^4.1.7",

View File

@@ -9,7 +9,7 @@
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import type { SystemConfigDto } from '@immich/sdk'; import type { SystemConfigDto } from '@immich/sdk';
import { Button, IconButton } from '@immich/ui'; import { Button, IconButton } from '@immich/ui';
import { mdiMinusCircle } from '@mdi/js'; import { mdiPlus, mdiTrashCanOutline } from '@mdi/js';
import { isEqual } from 'lodash-es'; import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
@@ -46,19 +46,6 @@
<div> <div>
{#each config.machineLearning.urls as _, i (i)} {#each config.machineLearning.urls as _, i (i)}
{#snippet removeButton()}
{#if config.machineLearning.urls.length > 1}
<IconButton
size="large"
shape="round"
color="danger"
aria-label=""
onclick={() => config.machineLearning.urls.splice(i, 1)}
icon={mdiMinusCircle}
/>
{/if}
{/snippet}
<SettingInputField <SettingInputField
inputType={SettingInputFieldType.TEXT} inputType={SettingInputFieldType.TEXT}
label={i === 0 ? $t('url') : undefined} label={i === 0 ? $t('url') : undefined}
@@ -67,20 +54,69 @@
required={i === 0} required={i === 0}
disabled={disabled || !config.machineLearning.enabled} disabled={disabled || !config.machineLearning.enabled}
isEdited={i === 0 && !isEqual(config.machineLearning.urls, savedConfig.machineLearning.urls)} isEdited={i === 0 && !isEqual(config.machineLearning.urls, savedConfig.machineLearning.urls)}
trailingSnippet={removeButton} >
/> {#snippet trailingSnippet()}
{#if config.machineLearning.urls.length > 1}
<IconButton
aria-label=""
onclick={() => config.machineLearning.urls.splice(i, 1)}
icon={mdiTrashCanOutline}
color="danger"
/>
{/if}
{/snippet}
</SettingInputField>
{/each} {/each}
</div> </div>
<Button <div class="flex justify-end">
class="mb-2" <Button
size="small" class="mb-2"
shape="round" size="small"
onclick={() => config.machineLearning.urls.splice(0, 0, '')} shape="round"
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button leadingIcon={mdiPlus}
> onclick={() => config.machineLearning.urls.push('')}
disabled={disabled || !config.machineLearning.enabled}>{$t('add_url')}</Button
>
</div>
</div> </div>
<SettingAccordion
key="availability-checks"
title={$t('admin.machine_learning_availability_checks')}
subtitle={$t('admin.machine_learning_availability_checks_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<SettingSwitch
title={$t('admin.machine_learning_availability_checks_enabled')}
bind:checked={config.machineLearning.availabilityChecks.enabled}
disabled={disabled || !config.machineLearning.enabled}
/>
<hr />
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_availability_checks_interval')}
bind:value={config.machineLearning.availabilityChecks.interval}
description={$t('admin.machine_learning_availability_checks_interval_description')}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.availabilityChecks.enabled}
isEdited={config.machineLearning.availabilityChecks.interval !==
savedConfig.machineLearning.availabilityChecks.interval}
/>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label={$t('admin.machine_learning_availability_checks_timeout')}
bind:value={config.machineLearning.availabilityChecks.timeout}
description={$t('admin.machine_learning_availability_checks_timeout_description')}
disabled={disabled || !config.machineLearning.enabled || !config.machineLearning.availabilityChecks.enabled}
isEdited={config.machineLearning.availabilityChecks.timeout !==
savedConfig.machineLearning.availabilityChecks.timeout}
/>
</div>
</SettingAccordion>
<SettingAccordion <SettingAccordion
key="smart-search" key="smart-search"
title={$t('admin.machine_learning_smart_search')} title={$t('admin.machine_learning_smart_search')}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte'; import SupportedDatetimePanel from '$lib/components/admin-settings/SupportedDatetimePanel.svelte';
import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte'; import SupportedVariablesPanel from '$lib/components/admin-settings/SupportedVariablesPanel.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte'; import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
@@ -262,7 +263,7 @@
values={{ job: $t('admin.storage_template_migration_job') }} values={{ job: $t('admin.storage_template_migration_job') }}
> >
{#snippet children({ message })} {#snippet children({ message })}
<a href={AppRoute.ADMIN_JOBS} class="text-primary"> <a href={resolve(AppRoute.ADMIN_JOBS)} class="text-primary">
{message} {message}
</a> </a>
{/snippet} {/snippet}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import AlbumCard from '$lib/components/album-page/album-card.svelte'; import AlbumCard from '$lib/components/album-page/album-card.svelte';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { albumViewSettings } from '$lib/stores/preferences.store'; import { albumViewSettings } from '$lib/stores/preferences.store';
@@ -65,7 +66,7 @@
{#each albums as album, index (album.id)} {#each albums as album, index (album.id)}
<a <a
data-sveltekit-preload-data="hover" data-sveltekit-preload-data="hover"
href="{AppRoute.ALBUMS}/{album.id}" href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}
animate:flip={{ duration: 400 }} animate:flip={{ duration: 400 }}
oncontextmenu={(event) => oncontextmenu(event, album)} oncontextmenu={(event) => oncontextmenu(event, album)}
> >

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte'; import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import AlbumsTable from '$lib/components/album-page/albums-table.svelte'; import AlbumsTable from '$lib/components/album-page/albums-table.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
@@ -315,7 +316,7 @@
button: { button: {
text: $t('view_album'), text: $t('view_album'),
onClick() { onClick() {
return goto(`${AppRoute.ALBUMS}/${album.id}`); return goto(resolve(`${AppRoute.ALBUMS}/${album.id}`));
}, },
}, },
}); });

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { AppRoute, dateFormats } from '$lib/constants'; import { AppRoute, dateFormats } from '$lib/constants';
import { locale } from '$lib/stores/preferences.store'; import { locale } from '$lib/stores/preferences.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
@@ -32,7 +33,7 @@
<tr <tr
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5" class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
onclick={() => goto(`${AppRoute.ALBUMS}/${album.id}`)} onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
{oncontextmenu} {oncontextmenu}
> >
<td class="text-md text-ellipsis text-start w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center"> <td class="text-md text-ellipsis text-start w-8/12 sm:w-4/12 md:w-4/12 xl:w-[30%] 2xl:w-[40%] items-center">

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { autoGrowHeight } from '$lib/actions/autogrow'; import { autoGrowHeight } from '$lib/actions/autogrow';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte'; import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
@@ -146,7 +147,10 @@
<div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div> <div class="w-full leading-4 overflow-hidden self-center break-words text-sm">{reaction.comment}</div>
{#if assetId === undefined && reaction.assetId} {#if assetId === undefined && reaction.assetId}
<a class="aspect-square w-[75px] h-[75px]" href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}"> <a
class="aspect-square w-[75px] h-[75px]"
href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetId}`)}
>
<img <img
class="rounded-lg w-[75px] h-[75px] object-cover" class="rounded-lg w-[75px] h-[75px] object-cover"
src={getAssetThumbnailUrl(reaction.assetId)} src={getAssetThumbnailUrl(reaction.assetId)}
@@ -198,7 +202,7 @@
{#if assetId === undefined && reaction.assetId} {#if assetId === undefined && reaction.assetId}
<a <a
class="aspect-square w-[75px] h-[75px]" class="aspect-square w-[75px] h-[75px]"
href="{AppRoute.ALBUMS}/{albumId}/photos/{reaction.assetId}" href={resolve(`${AppRoute.ALBUMS}/${albumId}/photos/${reaction.assetId}`)}
> >
<img <img
class="rounded-lg w-[75px] h-[75px] object-cover" class="rounded-lg w-[75px] h-[75px] object-cover"

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import CastButton from '$lib/cast/cast-button.svelte'; import CastButton from '$lib/cast/cast-button.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action'; import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte'; import AddToAlbumAction from '$lib/components/asset-viewer/actions/add-to-album-action.svelte';
@@ -224,14 +225,15 @@
{#if !asset.isArchived && !asset.isTrashed} {#if !asset.isArchived && !asset.isTrashed}
<MenuOption <MenuOption
icon={mdiImageSearch} icon={mdiImageSearch}
onClick={() => goto(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`)} onClick={() => goto(resolve(`${AppRoute.PHOTOS}?at=${stack?.primaryAssetId ?? asset.id}`))}
text={$t('view_in_timeline')} text={$t('view_in_timeline')}
/> />
{/if} {/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled} {#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption <MenuOption
icon={mdiCompare} icon={mdiCompare}
onClick={() => goto(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`)} onClick={() =>
goto(resolve(`${AppRoute.SEARCH}?query={"queryAssetId":"${stack?.primaryAssetId ?? asset.id}"}`))}
text={$t('view_similar_photos')} text={$t('view_similar_photos')}
/> />
{/if} {/if}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import { AppRoute } from '$lib/constants'; import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
@@ -45,7 +46,7 @@
<div class="flex group transition-all"> <div class="flex group transition-all">
<a <a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all" class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
href={encodeURI(`${AppRoute.TAGS}/?path=${tag.value}`)} href={resolve(`${AppRoute.TAGS}/?path=${encodeURI(tag.value)}`)}
> >
<p class="text-sm"> <p class="text-sm">
{tag.value} {tag.value}

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte'; import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte'; import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte'; import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
@@ -208,9 +209,11 @@
{#if showingHiddenPeople || !person.isHidden} {#if showingHiddenPeople || !person.isHidden}
<a <a
class="w-[90px]" class="w-[90px]"
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={currentAlbum?.id href={resolve(
? `${AppRoute.ALBUMS}/${currentAlbum?.id}` `${AppRoute.PEOPLE}/${person.id}?${QueryParameter.PREVIOUS_ROUTE}=${
: AppRoute.PHOTOS}" currentAlbum?.id ? `${AppRoute.ALBUMS}/${currentAlbum?.id}` : AppRoute.PHOTOS
}`,
)}
onfocus={() => ($boundingBoxesArray = people[index].faces)} onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])} onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)} onmouseover={() => ($boundingBoxesArray = people[index].faces)}
@@ -362,6 +365,7 @@
</p> </p>
{#if showAssetPath} {#if showAssetPath}
<p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}> <p class="text-xs opacity-50 break-all pb-2 hover:text-primary" transition:slide={{ duration: 250 }}>
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve this is supposed to be treated as an absolute/external link -->
<a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap"> <a href={getAssetFolderHref(asset)} title={$t('go_to_folder')} class="whitespace-pre-wrap">
{asset.originalPath} {asset.originalPath}
</a> </a>
@@ -394,10 +398,12 @@
{#if asset.exifInfo?.make || asset.exifInfo?.model} {#if asset.exifInfo?.make || asset.exifInfo?.model}
<p> <p>
<a <a
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ href={resolve(
...(asset.exifInfo?.make ? { make: asset.exifInfo.make } : {}), `${AppRoute.SEARCH}?${getMetadataSearchQuery({
...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}), ...(asset.exifInfo?.make ? { make: asset.exifInfo.make } : {}),
})}" ...(asset.exifInfo?.model ? { model: asset.exifInfo.model } : {}),
})}`,
)}
title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}" title="{$t('search_for')} {asset.exifInfo.make || ''} {asset.exifInfo.model || ''}"
class="hover:text-primary" class="hover:text-primary"
> >
@@ -411,7 +417,9 @@
<div class="flex gap-2 text-sm"> <div class="flex gap-2 text-sm">
<p> <p>
<a <a
href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}" href={resolve(
`${AppRoute.SEARCH}?${getMetadataSearchQuery({ lensModel: asset.exifInfo.lensModel })}`,
)}
title="{$t('search_for')} {asset.exifInfo.lensModel}" title="{$t('search_for')} {asset.exifInfo.lensModel}"
class="hover:text-primary line-clamp-1" class="hover:text-primary line-clamp-1"
> >
@@ -475,7 +483,7 @@
simplified simplified
useLocationPin useLocationPin
showSimpleControls={!showEditFaces} showSimpleControls={!showEditFaces}
onOpenInMapView={() => goto(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`)} onOpenInMapView={() => goto(resolve(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`))}
> >
{#snippet popup({ marker })} {#snippet popup({ marker })}
{@const { lat, lon } = marker} {@const { lat, lon } = marker}
@@ -516,7 +524,7 @@
<section class="px-6 pt-6 dark:text-immich-dark-fg"> <section class="px-6 pt-6 dark:text-immich-dark-fg">
<p class="uppercase pb-4 text-sm">{$t('appears_in')}</p> <p class="uppercase pb-4 text-sm">{$t('appears_in')}</p>
{#each albums as album (album.id)} {#each albums as album (album.id)}
<a href="{AppRoute.ALBUMS}/{album.id}"> <a href={resolve(`${AppRoute.ALBUMS}/${album.id}`)}>
<div class="flex gap-4 pt-2 hover:cursor-pointer items-center"> <div class="flex gap-4 pt-2 hover:cursor-pointer items-center">
<div> <div>
<img <img

View File

@@ -7,6 +7,7 @@
<script lang="ts"> <script lang="ts">
import DateInput from '$lib/elements/DateInput.svelte'; import DateInput from '$lib/elements/DateInput.svelte';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
interface Props { interface Props {
@@ -14,31 +15,27 @@
} }
let { filters = $bindable() }: Props = $props(); let { filters = $bindable() }: Props = $props();
let invalid = $derived(filters.takenAfter && filters.takenBefore && filters.takenAfter > filters.takenBefore);
const inputClasses = $derived(
`immich-form-input w-full mt-1 hover:cursor-pointer ${invalid ? 'border border-danger' : ''}`,
);
</script> </script>
<div id="date-range-selection" class="grid grid-auto-fit-40 gap-5"> <div class="flex flex-col gap-1">
<label class="immich-form-label" for="start-date"> <div id="date-range-selection" class="grid grid-auto-fit-40 gap-5">
<span class="uppercase">{$t('start_date')}</span> <label class="immich-form-label" for="start-date">
<DateInput <span class="uppercase">{$t('start_date')}</span>
class="immich-form-input w-full mt-1 hover:cursor-pointer" <DateInput class={inputClasses} type="date" id="start-date" name="start-date" bind:value={filters.takenAfter} />
type="date" </label>
id="start-date"
name="start-date"
max={filters.takenBefore}
bind:value={filters.takenAfter}
/>
</label>
<label class="immich-form-label" for="end-date"> <label class="immich-form-label" for="end-date">
<span class="uppercase">{$t('end_date')}</span> <span class="uppercase">{$t('end_date')}</span>
<DateInput <DateInput class={inputClasses} type="date" id="end-date" name="end-date" bind:value={filters.takenBefore} />
class="immich-form-input w-full mt-1 hover:cursor-pointer" </label>
type="date" </div>
id="end-date" {#if invalid}
name="end-date" <Text color="danger">{$t('start_date_before_end_date')}</Text>
placeholder="" {/if}
min={filters.takenAfter}
bind:value={filters.takenBefore}
/>
</label>
</div> </div>

View File

@@ -105,7 +105,7 @@
{/if} {/if}
{#if inputType !== SettingInputFieldType.PASSWORD} {#if inputType !== SettingInputFieldType.PASSWORD}
<div class="flex place-items-center place-content-center"> <div class="flex place-items-center place-content-center gap-2">
{#if inputType === SettingInputFieldType.COLOR} {#if inputType === SettingInputFieldType.COLOR}
<input <input
bind:this={input} bind:this={input}

View File

@@ -17,6 +17,9 @@ describe('RecentAlbums component', () => {
render(RecentAlbums); render(RecentAlbums);
expect(sdkMock.getAllAlbums).toBeCalledTimes(1); expect(sdkMock.getAllAlbums).toBeCalledTimes(1);
// wtf
await tick();
await tick(); await tick();
const links = screen.getAllByRole('link'); const links = screen.getAllByRole('link');