mirror of
https://github.com/immich-app/immich.git
synced 2026-04-29 04:28:48 -07:00
Compare commits
12 Commits
uhthomas/f
...
refactor-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
206a07d8db | ||
|
|
0a93963041 | ||
|
|
95c1f0efeb | ||
|
|
fbe631fe91 | ||
|
|
2143a0c935 | ||
|
|
136bd1e2eb | ||
|
|
564065a3ed | ||
|
|
9bcce59719 | ||
|
|
cd86a83c33 | ||
|
|
f29c06799f | ||
|
|
6fcf651d76 | ||
|
|
196307bca5 |
@@ -28,17 +28,17 @@ For the full list, refer to the [Immich source code](https://github.com/immich-a
|
||||
|
||||
## Video formats
|
||||
|
||||
| Format | Extension(s) | Supported? | Notes |
|
||||
| :---------- | :-------------------- | :----------------: | :---- |
|
||||
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
||||
| `AVI` | `.avi` | :white_check_mark: | |
|
||||
| `FLV` | `.flv` | :white_check_mark: | |
|
||||
| `M4V` | `.m4v` | :white_check_mark: | |
|
||||
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
||||
| `MP2T` | `.mts` `.m2ts` `.m2t` | :white_check_mark: | |
|
||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||
| Format | Extension(s) | Supported? | Notes |
|
||||
| :---------- | :-------------------------- | :----------------: | :---- |
|
||||
| `3GPP` | `.3gp` `.3gpp` | :white_check_mark: | |
|
||||
| `AVI` | `.avi` | :white_check_mark: | |
|
||||
| `FLV` | `.flv` | :white_check_mark: | |
|
||||
| `M4V` | `.m4v` | :white_check_mark: | |
|
||||
| `MATROSKA` | `.mkv` | :white_check_mark: | |
|
||||
| `MP2T` | `.mts` `.m2ts` `.m2t` `.ts` | :white_check_mark: | |
|
||||
| `MP4` | `.mp4` `.insv` | :white_check_mark: | |
|
||||
| `MPEG` | `.mpg` `.mpe` `.mpeg` | :white_check_mark: | |
|
||||
| `MXF` | `.mxf` | :white_check_mark: | |
|
||||
| `QUICKTIME` | `.mov` | :white_check_mark: | |
|
||||
| `WEBM` | `.webm` | :white_check_mark: | |
|
||||
| `WMV` | `.wmv` | :white_check_mark: | |
|
||||
|
||||
@@ -173,6 +173,7 @@ export const setupBaseMockApiRoutes = async (context: BrowserContext, adminUserI
|
||||
'.mpeg',
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.ts',
|
||||
'.vob',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
generateTimelineData,
|
||||
TimelineAssetConfig,
|
||||
TimelineData,
|
||||
toAssetResponseDto,
|
||||
} from 'src/ui/generators/timeline';
|
||||
import { setupBaseMockApiRoutes } from 'src/ui/mock-network/base-network';
|
||||
import { setupTimelineMockApiRoutes, TimelineTestContext } from 'src/ui/mock-network/timeline-network';
|
||||
@@ -30,6 +31,10 @@ test.describe('search gallery-viewer', () => {
|
||||
};
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.fail(
|
||||
process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS !== '1',
|
||||
'This test requires env var: PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1',
|
||||
);
|
||||
adminUserId = faker.string.uuid();
|
||||
testContext.adminId = adminUserId;
|
||||
timelineRestData = generateTimelineData({ ...createDefaultTimelineConfig(), ownerId: adminUserId });
|
||||
@@ -44,7 +49,10 @@ test.describe('search gallery-viewer', () => {
|
||||
|
||||
await context.route('**/api/search/metadata', async (route, request) => {
|
||||
if (request.method() === 'POST') {
|
||||
const searchAssets = assets.slice(0, 5).filter((asset) => !changes.assetDeletions.includes(asset.id));
|
||||
const searchAssets = assets
|
||||
.slice(0, 5)
|
||||
.filter((asset) => !changes.assetDeletions.includes(asset.id))
|
||||
.map((asset) => toAssetResponseDto(asset));
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -16,6 +16,7 @@
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; };
|
||||
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
|
||||
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
|
||||
@@ -102,6 +103,7 @@
|
||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
|
||||
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
|
||||
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
|
||||
@@ -137,20 +139,23 @@
|
||||
);
|
||||
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
|
||||
};
|
||||
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Mutex.swift,
|
||||
);
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
B231F52D2E93A44A00BC45D1 /* Core */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Core;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
);
|
||||
path = Sync;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -162,10 +167,16 @@
|
||||
path = WidgetExtension;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
FE1BB4562F8319560087DBF9 /* Utility */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */,
|
||||
);
|
||||
path = Utility;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FEE084F22EC172080045228E /* Schemas */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = Schemas;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -273,6 +284,7 @@
|
||||
97C146F01CF9000F007C117D /* Runner */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FE1BB4562F8319560087DBF9 /* Utility */,
|
||||
FEE084F22EC172080045228E /* Schemas */,
|
||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||
B25D37792E72CA15008B6CA7 /* Connectivity */,
|
||||
@@ -327,6 +339,7 @@
|
||||
FED3B1952E253E9B0030FD97 /* Images */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */,
|
||||
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
|
||||
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
|
||||
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
|
||||
@@ -558,10 +571,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
@@ -590,10 +607,14 @@
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
@@ -608,6 +629,7 @@
|
||||
files = (
|
||||
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
|
||||
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
|
||||
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
|
||||
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum ImageProcessing {
|
||||
static let queue = DispatchQueue(label: "thumbnail.processing", qos: .userInitiated, attributes: .concurrent)
|
||||
static let semaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2)
|
||||
static let queue = {
|
||||
let q = OperationQueue()
|
||||
q.name = "thumbnail.processing"
|
||||
q.qualityOfService = .userInitiated
|
||||
q.maxConcurrentOperationCount = ProcessInfo.processInfo.activeProcessorCount * 2
|
||||
return q
|
||||
}()
|
||||
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
|
||||
}
|
||||
|
||||
14
mobile/ios/Runner/Images/ImageRequest.swift
Normal file
14
mobile/ios/Runner/Images/ImageRequest.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
struct RequestRegistry<T: AnyObject & Sendable>: ~Copyable, Sendable {
|
||||
private let requests = Mutex<[Int64: T]>([:])
|
||||
|
||||
func add(requestId: Int64, request: T) {
|
||||
requests.withLock { $0[requestId] = request }
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func remove(requestId: Int64) -> T? {
|
||||
requests.withLock { $0.removeValue(forKey: requestId) }
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,18 @@ import MobileCoreServices
|
||||
import Photos
|
||||
|
||||
class LocalImageRequest {
|
||||
weak var workItem: DispatchWorkItem?
|
||||
weak var operation: Operation?
|
||||
var isCancelled = false
|
||||
let callback: (Result<[String: Int64]?, any Error>) -> Void
|
||||
|
||||
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
self.callback = callback
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
isCancelled = true
|
||||
operation?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
class LocalImageApiImpl: LocalImageApi {
|
||||
@@ -31,9 +36,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
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 registry = RequestRegistry<LocalImageRequest>()
|
||||
|
||||
private static var rgbaFormat = vImage_CGImageFormat(
|
||||
bitsPerComponent: 8,
|
||||
@@ -42,7 +45,6 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
|
||||
renderingIntent: .defaultIntent
|
||||
)!
|
||||
private static var requests = [Int64: LocalImageRequest]()
|
||||
private static let assetCache = {
|
||||
let assetCache = NSCache<NSString, PHAsset>()
|
||||
assetCache.countLimit = 10000
|
||||
@@ -50,7 +52,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
}()
|
||||
|
||||
func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) {
|
||||
ImageProcessing.queue.async {
|
||||
ImageProcessing.queue.addOperation {
|
||||
guard let data = Data(base64Encoded: thumbhash)
|
||||
else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))}
|
||||
|
||||
@@ -66,23 +68,14 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, preferEncoded: Bool, completion: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
|
||||
let request = LocalImageRequest(callback: completion)
|
||||
let item = DispatchWorkItem {
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
ImageProcessing.semaphore.wait()
|
||||
defer {
|
||||
ImageProcessing.semaphore.signal()
|
||||
}
|
||||
|
||||
let operation = BlockOperation {
|
||||
if request.isCancelled {
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let asset = Self.requestAsset(assetId: assetId)
|
||||
else {
|
||||
Self.remove(requestId: requestId)
|
||||
Self.registry.remove(requestId: requestId)
|
||||
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
|
||||
return
|
||||
}
|
||||
@@ -107,12 +100,11 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
)
|
||||
|
||||
if request.isCancelled {
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
guard let data = imageData else {
|
||||
Self.remove(requestId: requestId)
|
||||
Self.registry.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
@@ -122,7 +114,6 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
if request.isCancelled {
|
||||
free(pointer)
|
||||
Self.remove(requestId: requestId)
|
||||
return completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
|
||||
@@ -130,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
"pointer": Int64(Int(bitPattern: pointer)),
|
||||
"length": Int64(length),
|
||||
]))
|
||||
Self.remove(requestId: requestId)
|
||||
Self.registry.remove(requestId: requestId)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -151,7 +142,7 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
|
||||
guard let image = image,
|
||||
let cgImage = image.cgImage else {
|
||||
Self.remove(requestId: requestId)
|
||||
Self.registry.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
|
||||
}
|
||||
|
||||
@@ -171,51 +162,32 @@ class LocalImageApiImpl: LocalImageApi {
|
||||
"pointer": Int64(Int(bitPattern: buffer.data)),
|
||||
"width": Int64(buffer.width),
|
||||
"height": Int64(buffer.height),
|
||||
"rowBytes": Int64(buffer.rowBytes)
|
||||
"rowBytes": Int64(buffer.rowBytes),
|
||||
]))
|
||||
Self.remove(requestId: requestId)
|
||||
Self.registry.remove(requestId: requestId)
|
||||
} catch {
|
||||
Self.remove(requestId: requestId)
|
||||
Self.registry.remove(requestId: requestId)
|
||||
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
|
||||
}
|
||||
}
|
||||
|
||||
request.workItem = item
|
||||
Self.add(requestId: requestId, request: request)
|
||||
ImageProcessing.queue.async(execute: item)
|
||||
request.operation = operation
|
||||
Self.registry.add(requestId: requestId, request: request)
|
||||
ImageProcessing.queue.addOperation(operation)
|
||||
}
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
Self.cancel(requestId: requestId)
|
||||
}
|
||||
|
||||
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
|
||||
requestQueue.sync { requests[requestId] = request }
|
||||
}
|
||||
|
||||
private static func remove(requestId: Int64) -> Void {
|
||||
requestQueue.sync { requests[requestId] = nil }
|
||||
}
|
||||
|
||||
private static func cancel(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(ImageProcessing.cancelledResult) }
|
||||
}
|
||||
}
|
||||
Self.registry.remove(requestId: requestId)?.cancel()
|
||||
}
|
||||
|
||||
private static func requestAsset(assetId: String) -> PHAsset? {
|
||||
var asset: PHAsset?
|
||||
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
|
||||
if asset != nil { return asset }
|
||||
if let cached = assetCache.object(forKey: assetId as NSString) {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
|
||||
else { return nil }
|
||||
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
|
||||
assetCache.setObject(asset, forKey: assetId as NSString)
|
||||
return asset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,15 @@ class RemoteImageRequest {
|
||||
self.task = task
|
||||
self.completion = completion
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
isCancelled = true
|
||||
task?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
private static var lock = os_unfair_lock()
|
||||
private static var requests = [Int64: RemoteImageRequest]()
|
||||
private static let registry = RequestRegistry<RemoteImageRequest>()
|
||||
private static var rgbaFormat = vImage_CGImageFormat(
|
||||
bitsPerComponent: 8,
|
||||
bitsPerPixel: 32,
|
||||
@@ -43,20 +47,15 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
|
||||
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
|
||||
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
Self.requests[requestId] = request
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
Self.registry.add(requestId: requestId, request: request)
|
||||
|
||||
task.resume()
|
||||
}
|
||||
|
||||
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
guard let request = requests[requestId] else {
|
||||
return os_unfair_lock_unlock(&Self.lock)
|
||||
guard let request = registry.remove(requestId: requestId) else {
|
||||
return
|
||||
}
|
||||
requests[requestId] = nil
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
if let error = error {
|
||||
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
|
||||
@@ -73,10 +72,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
return request.completion(.failure(PigeonError(code: "", message: "No data received", details: nil)))
|
||||
}
|
||||
|
||||
ImageProcessing.queue.async {
|
||||
ImageProcessing.semaphore.wait()
|
||||
defer { ImageProcessing.semaphore.signal() }
|
||||
|
||||
ImageProcessing.queue.addOperation {
|
||||
if request.isCancelled {
|
||||
return request.completion(ImageProcessing.cancelledResult)
|
||||
}
|
||||
@@ -130,13 +126,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
|
||||
}
|
||||
|
||||
func cancelRequest(requestId: Int64) {
|
||||
os_unfair_lock_lock(&Self.lock)
|
||||
let request = Self.requests[requestId]
|
||||
os_unfair_lock_unlock(&Self.lock)
|
||||
|
||||
guard let request = request else { return }
|
||||
request.isCancelled = true
|
||||
request.task?.cancel()
|
||||
Self.registry.remove(requestId: requestId)?.cancel()
|
||||
}
|
||||
|
||||
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {
|
||||
|
||||
54
mobile/ios/Runner/Utility/Mutex.swift
Normal file
54
mobile/ios/Runner/Utility/Mutex.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Darwin
|
||||
|
||||
// Can be replaced with std Mutex when the deployment target is iOS 18+
|
||||
struct Mutex<Value: ~Copyable>: ~Copyable, @unchecked Sendable {
|
||||
struct _Buffer: ~Copyable {
|
||||
var lock: os_unfair_lock = .init()
|
||||
var value: Value
|
||||
|
||||
init(value: consuming Value) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
deinit {}
|
||||
}
|
||||
|
||||
let _buffer: UnsafeMutablePointer<_Buffer>
|
||||
|
||||
init(_ initialValue: consuming sending Value) {
|
||||
_buffer = .allocate(capacity: 1)
|
||||
_buffer.initialize(to: _Buffer(value: initialValue))
|
||||
}
|
||||
|
||||
deinit {
|
||||
_buffer.deinitialize(count: 1)
|
||||
_buffer.deallocate()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
borrowing func withLock<Result: ~Copyable, E: Error>(
|
||||
_ body: (inout sending Value) throws(E) -> sending Result
|
||||
) throws(E) -> sending Result {
|
||||
os_unfair_lock_lock(&_buffer.pointee.lock)
|
||||
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
|
||||
return try body(&_buffer.pointee.value)
|
||||
}
|
||||
}
|
||||
|
||||
// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+
|
||||
typealias UnfairLock = Mutex<Void>
|
||||
|
||||
extension Mutex where Value == Void {
|
||||
init() {
|
||||
self.init(())
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
borrowing func withLock<Result: ~Copyable, E: Error>(
|
||||
_ body: () throws(E) -> sending Result
|
||||
) throws(E) -> sending Result {
|
||||
os_unfair_lock_lock(&_buffer.pointee.lock)
|
||||
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
|
||||
return try body()
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,19 @@ class TrashBottomBar extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SizedBox(
|
||||
height: 64,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
),
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: context.themeData.canvasColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: const SafeArea(
|
||||
top: false,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
DeleteTrashActionButton(source: ActionSource.timeline),
|
||||
RestoreTrashActionButton(source: ActionSource.timeline),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
RestartableTimer useTimer(Duration duration, VoidCallback callback) {
|
||||
final latest = useRef(callback);
|
||||
latest.value = callback;
|
||||
|
||||
final timer = useMemoized(
|
||||
() => RestartableTimer(duration, () => latest.value()),
|
||||
[duration],
|
||||
);
|
||||
|
||||
useEffect(() => timer.cancel, [timer]);
|
||||
|
||||
return timer;
|
||||
RestartableTimer useTimer(Duration duration, void Function() callback) {
|
||||
return use(_TimerHook(duration: duration, callback: callback));
|
||||
}
|
||||
|
||||
class _TimerHook extends Hook<RestartableTimer> {
|
||||
final Duration duration;
|
||||
final void Function() callback;
|
||||
|
||||
const _TimerHook({required this.duration, required this.callback});
|
||||
@override
|
||||
HookState<RestartableTimer, Hook<RestartableTimer>> createState() => _TimerHookState();
|
||||
}
|
||||
|
||||
class _TimerHookState extends HookState<RestartableTimer, _TimerHook> {
|
||||
late RestartableTimer timer;
|
||||
@override
|
||||
void initHook() {
|
||||
super.initHook();
|
||||
timer = RestartableTimer(hook.duration, hook.callback);
|
||||
}
|
||||
|
||||
@override
|
||||
RestartableTimer build(BuildContext context) {
|
||||
return timer;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/constants/colors.dart';
|
||||
@@ -7,26 +8,63 @@ import 'package:immich_mobile/models/cast/cast_manager_state.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
|
||||
import 'package:immich_mobile/providers/cast.provider.dart';
|
||||
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
|
||||
|
||||
class VideoControls extends HookConsumerWidget {
|
||||
class VideoControls extends ConsumerStatefulWidget {
|
||||
final String videoPlayerName;
|
||||
|
||||
static const List<Shadow> _controlShadows = [Shadow(color: Colors.black87, blurRadius: 6, offset: Offset(0, 1))];
|
||||
|
||||
const VideoControls({super.key, required this.videoPlayerName});
|
||||
|
||||
void _toggle(WidgetRef ref, bool isCasting) {
|
||||
if (isCasting) {
|
||||
ref.read(castProvider.notifier).toggle();
|
||||
} else {
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).toggle();
|
||||
@override
|
||||
ConsumerState<VideoControls> createState() => _VideoControlsState();
|
||||
}
|
||||
|
||||
class _VideoControlsState extends ConsumerState<VideoControls> {
|
||||
late final RestartableTimer _hideTimer;
|
||||
|
||||
AutoDisposeStateNotifierProvider<VideoPlayerNotifier, VideoPlayerState> get _provider =>
|
||||
videoPlayerProvider(widget.videoPlayerName);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hideTimer = RestartableTimer(const Duration(seconds: 5), _onHideTimer);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant VideoControls oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.videoPlayerName != widget.videoPlayerName) {
|
||||
_hideTimer.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void _onSeek(WidgetRef ref, bool isCasting, double value) {
|
||||
@override
|
||||
void dispose() {
|
||||
_hideTimer.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onHideTimer() {
|
||||
if (!mounted) return;
|
||||
if (ref.read(_provider).status == VideoPlaybackStatus.playing) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggle(bool isCasting) {
|
||||
if (isCasting) {
|
||||
ref.read(castProvider.notifier).toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(_provider.notifier).toggle();
|
||||
}
|
||||
|
||||
void _onSeek(bool isCasting, double value) {
|
||||
final seekTo = Duration(microseconds: value.toInt());
|
||||
|
||||
if (isCasting) {
|
||||
@@ -34,38 +72,30 @@ class VideoControls extends HookConsumerWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(videoPlayerProvider(videoPlayerName).notifier).seekTo(seekTo);
|
||||
ref.read(_provider.notifier).seekTo(seekTo);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final provider = videoPlayerProvider(videoPlayerName);
|
||||
Widget build(BuildContext context) {
|
||||
final cast = ref.watch(castProvider);
|
||||
final isCasting = cast.isCasting;
|
||||
|
||||
final (position, duration) = isCasting
|
||||
? ref.watch(castProvider.select((c) => (c.currentTime, c.duration)))
|
||||
: ref.watch(provider.select((v) => (v.position, v.duration)));
|
||||
: ref.watch(_provider.select((v) => (v.position, v.duration)));
|
||||
|
||||
final videoStatus = ref.watch(provider.select((v) => v.status));
|
||||
final videoStatus = ref.watch(_provider.select((v) => v.status));
|
||||
final isPlaying = isCasting
|
||||
? cast.castState == CastState.playing
|
||||
: videoStatus == VideoPlaybackStatus.playing || videoStatus == VideoPlaybackStatus.buffering;
|
||||
final isFinished = !isCasting && videoStatus == VideoPlaybackStatus.completed;
|
||||
|
||||
final hideTimer = useTimer(const Duration(seconds: 5), () {
|
||||
if (!context.mounted) return;
|
||||
if (ref.read(provider).status == VideoPlaybackStatus.playing) {
|
||||
ref.read(assetViewerProvider.notifier).setControls(false);
|
||||
}
|
||||
});
|
||||
|
||||
ref.listen(assetViewerProvider.select((v) => v.showingControls), (prev, showing) {
|
||||
if (showing && prev != showing) hideTimer.reset();
|
||||
if (showing && prev != showing) _hideTimer.reset();
|
||||
});
|
||||
ref.listen(provider.select((v) => v.status), (_, __) => hideTimer.reset());
|
||||
ref.listen(_provider.select((v) => v.status), (_, __) => _hideTimer.reset());
|
||||
|
||||
final notifier = ref.read(provider.notifier);
|
||||
final notifier = ref.read(_provider.notifier);
|
||||
final isLoaded = duration != Duration.zero;
|
||||
|
||||
return Padding(
|
||||
@@ -80,9 +110,13 @@ class VideoControls extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
constraints: const BoxConstraints(),
|
||||
icon: isFinished
|
||||
? const Icon(Icons.replay, color: Colors.white, shadows: _controlShadows)
|
||||
: AnimatedPlayPause(color: Colors.white, playing: isPlaying, shadows: _controlShadows),
|
||||
onPressed: () => _toggle(ref, isCasting),
|
||||
? const Icon(Icons.replay, color: Colors.white, shadows: VideoControls._controlShadows)
|
||||
: AnimatedPlayPause(
|
||||
color: Colors.white,
|
||||
playing: isPlaying,
|
||||
shadows: VideoControls._controlShadows,
|
||||
),
|
||||
onPressed: () => _toggle(isCasting),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
@@ -91,7 +125,7 @@ class VideoControls extends HookConsumerWidget {
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontFeatures: [FontFeature.tabularFigures()],
|
||||
shadows: _controlShadows,
|
||||
shadows: VideoControls._controlShadows,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -107,7 +141,7 @@ class VideoControls extends HookConsumerWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
onChangeStart: (_) => notifier.hold(),
|
||||
onChangeEnd: (_) => notifier.release(),
|
||||
onChanged: isLoaded ? (value) => _onSeek(ref, isCasting, value) : null,
|
||||
onChanged: isLoaded ? (value) => _onSeek(isCasting, value) : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
14
mobile/openapi/lib/model/database_backup_dto.dart
generated
14
mobile/openapi/lib/model/database_backup_dto.dart
generated
@@ -15,30 +15,36 @@ class DatabaseBackupDto {
|
||||
DatabaseBackupDto({
|
||||
required this.filename,
|
||||
required this.filesize,
|
||||
required this.timezone,
|
||||
});
|
||||
|
||||
String filename;
|
||||
|
||||
num filesize;
|
||||
|
||||
String timezone;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupDto &&
|
||||
other.filename == filename &&
|
||||
other.filesize == filesize;
|
||||
other.filesize == filesize &&
|
||||
other.timezone == timezone;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(filename.hashCode) +
|
||||
(filesize.hashCode);
|
||||
(filesize.hashCode) +
|
||||
(timezone.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize]';
|
||||
String toString() => 'DatabaseBackupDto[filename=$filename, filesize=$filesize, timezone=$timezone]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'filename'] = this.filename;
|
||||
json[r'filesize'] = this.filesize;
|
||||
json[r'timezone'] = this.timezone;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -53,6 +59,7 @@ class DatabaseBackupDto {
|
||||
return DatabaseBackupDto(
|
||||
filename: mapValueOfType<String>(json, r'filename')!,
|
||||
filesize: num.parse('${json[r'filesize']}'),
|
||||
timezone: mapValueOfType<String>(json, r'timezone')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -102,6 +109,7 @@ class DatabaseBackupDto {
|
||||
static const requiredKeys = <String>{
|
||||
'filename',
|
||||
'filesize',
|
||||
'timezone',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17721,11 +17721,15 @@
|
||||
},
|
||||
"filesize": {
|
||||
"type": "number"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filename",
|
||||
"filesize"
|
||||
"filesize",
|
||||
"timezone"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -63,6 +63,7 @@ export type DatabaseBackupDeleteDto = {
|
||||
export type DatabaseBackupDto = {
|
||||
filename: string;
|
||||
filesize: number;
|
||||
timezone: string;
|
||||
};
|
||||
export type DatabaseBackupListResponseDto = {
|
||||
backups: DatabaseBackupDto[];
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IsString } from 'class-validator';
|
||||
export class DatabaseBackupDto {
|
||||
filename!: string;
|
||||
filesize!: number;
|
||||
timezone!: string;
|
||||
}
|
||||
|
||||
export class DatabaseBackupListResponseDto {
|
||||
|
||||
@@ -75,6 +75,10 @@ export interface EnvData {
|
||||
server: string;
|
||||
};
|
||||
|
||||
versionCheck: {
|
||||
url: string;
|
||||
};
|
||||
|
||||
network: {
|
||||
trustedProxies: string[];
|
||||
};
|
||||
@@ -320,6 +324,10 @@ const getEnv = (): EnvData => {
|
||||
|
||||
licensePublicKey: isProd ? productionKeys : stagingKeys,
|
||||
|
||||
versionCheck: {
|
||||
url: isProd ? 'https://version.immich.cloud/version' : 'https://version.dev.immich.cloud/version',
|
||||
},
|
||||
|
||||
network: {
|
||||
trustedProxies: dto.IMMICH_TRUSTED_PROXIES ?? ['linklocal', 'uniquelocal'],
|
||||
},
|
||||
|
||||
@@ -66,7 +66,8 @@ export class ServerInfoRepository {
|
||||
|
||||
async getLatestRelease(): Promise<VersionResponse> {
|
||||
try {
|
||||
const response = await fetch('https://version.immich.cloud/version');
|
||||
const { versionCheck } = this.configRepository.getEnv();
|
||||
const response = await fetch(versionCheck.url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Version check request failed with status ${response.status}: ${await response.text()}`);
|
||||
|
||||
@@ -111,6 +111,7 @@ const validVideos = [
|
||||
'.mpg',
|
||||
'.mts',
|
||||
'.mxf',
|
||||
'.ts',
|
||||
'.vob',
|
||||
'.webm',
|
||||
'.wmv',
|
||||
|
||||
@@ -283,6 +283,7 @@ export class DatabaseBackupService {
|
||||
async listBackups(): Promise<DatabaseBackupListResponseDto> {
|
||||
const backupsFolder = StorageCore.getBaseFolder(StorageFolder.Backups);
|
||||
const files = await this.storageRepository.readdir(backupsFolder);
|
||||
const timezone = DateTime.local().zoneName;
|
||||
|
||||
const validFiles = files
|
||||
.filter((fn) => isValidDatabaseBackupName(fn))
|
||||
@@ -292,7 +293,7 @@ export class DatabaseBackupService {
|
||||
const backups = await Promise.all(
|
||||
validFiles.map(async (filename) => {
|
||||
const stats = await this.storageRepository.stat(path.join(backupsFolder, filename));
|
||||
return { filename, filesize: stats.size };
|
||||
return { filename, filesize: stats.size, timezone };
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@ import { DateTime } from 'luxon';
|
||||
import { SemVer } from 'semver';
|
||||
import { defaults } from 'src/config';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { ImmichEnvironment, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { JobName, JobStatus, SystemMetadataKey } from 'src/enum';
|
||||
import { VersionService } from 'src/services/version.service';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@@ -73,15 +72,6 @@ describe(VersionService.name, () => {
|
||||
});
|
||||
|
||||
describe('handVersionCheck', () => {
|
||||
beforeEach(() => {
|
||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Production }));
|
||||
});
|
||||
|
||||
it('should not run in dev mode', async () => {
|
||||
mocks.config.getEnv.mockReturnValue(mockEnvData({ environment: ImmichEnvironment.Development }));
|
||||
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
|
||||
});
|
||||
|
||||
it('should not run if the last check was < 60 minutes ago', async () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
|
||||
|
||||
@@ -4,7 +4,7 @@ import semver, { SemVer } from 'semver';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
|
||||
import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { VersionCheckMetadata } from 'src/types';
|
||||
@@ -71,11 +71,6 @@ export class VersionService extends BaseService {
|
||||
try {
|
||||
this.logger.debug('Running version check');
|
||||
|
||||
const { environment } = this.configRepository.getEnv();
|
||||
if (environment === ImmichEnvironment.Development) {
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const { newVersionCheck } = await this.getConfig({ withCache: true });
|
||||
if (!newVersionCheck.enabled) {
|
||||
return JobStatus.Skipped;
|
||||
|
||||
@@ -83,6 +83,7 @@ describe('mimeTypes', () => {
|
||||
{ mimetype: 'video/mp2t', extension: '.m2t' },
|
||||
{ mimetype: 'video/mp2t', extension: '.m2ts' },
|
||||
{ mimetype: 'video/mp2t', extension: '.mts' },
|
||||
{ mimetype: 'video/mp2t', extension: '.ts' },
|
||||
{ mimetype: 'video/mp4', extension: '.mp4' },
|
||||
{ mimetype: 'video/mpeg', extension: '.mpe' },
|
||||
{ mimetype: 'video/mpeg', extension: '.mpeg' },
|
||||
|
||||
@@ -114,6 +114,7 @@ const video: Record<string, string[]> = {
|
||||
'.mpg': ['video/mpeg'],
|
||||
'.mts': ['video/mp2t'],
|
||||
'.mxf': ['application/mxf'],
|
||||
'.ts': ['video/mp2t'],
|
||||
'.vob': ['video/mpeg'],
|
||||
'.webm': ['video/webm'],
|
||||
'.wmv': ['video/x-ms-wmv'],
|
||||
|
||||
@@ -44,6 +44,10 @@ const envData: EnvData = {
|
||||
server: 'server-public-key',
|
||||
},
|
||||
|
||||
versionCheck: {
|
||||
url: 'https://version.immich.cloud/version',
|
||||
},
|
||||
|
||||
network: {
|
||||
trustedProxies: [],
|
||||
},
|
||||
|
||||
@@ -276,7 +276,6 @@
|
||||
const handleStopSlideshow = async () => {
|
||||
try {
|
||||
if (document.fullscreenElement) {
|
||||
document.body.style.cursor = '';
|
||||
await document.exitFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -338,7 +337,7 @@
|
||||
onAction?.(action);
|
||||
};
|
||||
|
||||
let isFullScreen = $derived(fullscreenElement !== null);
|
||||
let isFullScreen = $derived(!!fullscreenElement);
|
||||
|
||||
$effect(() => {
|
||||
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
setCursorStyle('');
|
||||
if (unsubscribeRestart) {
|
||||
unsubscribeRestart();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { screen } from '@testing-library/svelte';
|
||||
import { DateTime } from 'luxon';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MaintenanceBackupEntry from './MaintenanceBackupEntry.svelte';
|
||||
|
||||
vi.mock('$lib/services/database-backups.service', () => ({
|
||||
getDatabaseBackupActions: () => ({
|
||||
Download: { type: 'command', title: 'Download', onAction: vi.fn() },
|
||||
Delete: { type: 'command', title: 'Delete', onAction: vi.fn() },
|
||||
}),
|
||||
handleRestoreDatabaseBackup: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('MaintenanceBackupEntry', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-24T12:00:00Z'));
|
||||
locale.set('en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders relative backup time using the user timezone instead of UTC', () => {
|
||||
const backupTimestamp = '20260324T110000';
|
||||
|
||||
const expectedRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
|
||||
zone: 'Asia/Tokyo',
|
||||
})
|
||||
.toLocal()
|
||||
.toRelative({ locale: 'en' });
|
||||
|
||||
const utcRelativeTime = DateTime.fromFormat(backupTimestamp, "yyyyMMdd'T'HHmmss", {
|
||||
zone: 'UTC',
|
||||
})
|
||||
.toLocal()
|
||||
.toRelative({ locale: 'en' });
|
||||
|
||||
expect(expectedRelativeTime).toBeTruthy();
|
||||
expect(expectedRelativeTime).not.toEqual(utcRelativeTime);
|
||||
|
||||
renderWithTooltips(MaintenanceBackupEntry, {
|
||||
expectedVersion: '1.2.3',
|
||||
filename: 'immich-db-backup-20260324T110000-v1.2.3-snapshot.sql.gz',
|
||||
filesize: 1024,
|
||||
timezone: 'Asia/Tokyo',
|
||||
});
|
||||
|
||||
expect(screen.getByText(expectedRelativeTime!)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -13,16 +13,17 @@
|
||||
filename: string;
|
||||
filesize: number;
|
||||
expectedVersion: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
const { filename, filesize, expectedVersion }: Props = $props();
|
||||
const { filename, filesize, expectedVersion, timezone }: Props = $props();
|
||||
|
||||
const filesizeText = $derived(getBytesWithUnit(filesize, 1));
|
||||
|
||||
const backupDateTime = $derived.by(() => {
|
||||
const dateMatch = filename.match(/\d+T\d+/);
|
||||
if (dateMatch) {
|
||||
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' }).toLocal();
|
||||
return DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone }).toLocal();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -51,12 +51,13 @@
|
||||
const unknownDateKey = $t('unknown_date');
|
||||
|
||||
for (const backup of backups) {
|
||||
const timezone = backup.timezone;
|
||||
const dateMatch = backup.filename.match(/\d+T\d+/);
|
||||
let dateKey: string;
|
||||
let dt: DateTime;
|
||||
|
||||
if (dateMatch) {
|
||||
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: 'utc' });
|
||||
dt = DateTime.fromFormat(dateMatch[0], "yyyyMMdd'T'HHmmss", { zone: timezone });
|
||||
dateKey = dt.toFormat('LLLL d, yyyy');
|
||||
} else {
|
||||
dt = DateTime.fromMillis(0);
|
||||
@@ -128,6 +129,7 @@
|
||||
filename={backup.filename}
|
||||
filesize={backup.filesize}
|
||||
expectedVersion={props.expectedVersion}
|
||||
timezone={backup.timezone}
|
||||
/>
|
||||
{/each}
|
||||
</Stack>
|
||||
|
||||
@@ -145,7 +145,7 @@ describe('TimelineManager', () => {
|
||||
it('cancels month loading', async () => {
|
||||
const month = getTimelineMonthByDate(timelineManager, { year: 2024, month: 1 })!;
|
||||
void timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
const abortSpy = vi.spyOn(month!.loader!.cancelToken!, 'abort');
|
||||
const abortSpy = vi.spyOn(month!.loader!.abortController!, 'abort');
|
||||
month?.cancel();
|
||||
expect(abortSpy).toBeCalledTimes(1);
|
||||
await timelineManager.loadTimelineMonth({ year: 2024, month: 1 });
|
||||
@@ -638,12 +638,8 @@ describe('TimelineManager', () => {
|
||||
const previousMonth = getTimelineMonthByDate(timelineManager, { year: 2024, month: 3 });
|
||||
const a = month!.getFirstAsset();
|
||||
const b = previousMonth!.getFirstAsset();
|
||||
const loadTimelineMonthSpy = vi.spyOn(month!.loader!, 'execute');
|
||||
const previousMonthSpy = vi.spyOn(previousMonth!.loader!, 'execute');
|
||||
const previous = await timelineManager.getLaterAsset(a);
|
||||
expect(previous).toEqual(b);
|
||||
expect(loadTimelineMonthSpy).toBeCalledTimes(0);
|
||||
expect(previousMonthSpy).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('skips removed assets', async () => {
|
||||
|
||||
@@ -307,8 +307,8 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.initTask.executed) {
|
||||
await (this.initTask.loading ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
if (!this.initTask.succeeded) {
|
||||
await (this.initTask.running ? this.initTask.waitUntilCompletion() : this.#init(this.#options));
|
||||
}
|
||||
|
||||
const changedWidth = viewport.width !== this.viewportWidth;
|
||||
@@ -351,14 +351,10 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
return;
|
||||
}
|
||||
|
||||
if (timelineMonth.loader?.executed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const executionStatus = await timelineMonth.loader?.execute(async (signal: AbortSignal) => {
|
||||
await loadFromTimeBuckets(this, timelineMonth, this.#options, signal);
|
||||
}, cancelable);
|
||||
if (executionStatus === 'LOADED') {
|
||||
if (executionStatus === 'SUCCESS') {
|
||||
updateGeometry(this, timelineMonth, { invalidateHeight: false });
|
||||
this.updateViewportProximities();
|
||||
}
|
||||
@@ -372,7 +368,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||
|
||||
async findTimelineMonthForAsset(asset: AssetDescriptor | AssetResponseDto) {
|
||||
if (!this.isInitialized) {
|
||||
await this.initTask.waitUntilExecution();
|
||||
await this.initTask.waitUntilSucceeded();
|
||||
}
|
||||
|
||||
const { id } = asset;
|
||||
|
||||
@@ -2,39 +2,39 @@ import { CancellableTask } from '$lib/utils/cancellable-task';
|
||||
|
||||
describe('CancellableTask', () => {
|
||||
describe('execute', () => {
|
||||
it('should execute task successfully and return LOADED', async () => {
|
||||
it('should execute task successfully and return SUCCESS', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async (_: AbortSignal) => {
|
||||
const taskFunction = vi.fn(async (_: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
});
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFunction, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(task.loading).toBe(false);
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
expect(result).toBe('SUCCESS');
|
||||
expect(task.succeeded).toBe(true);
|
||||
expect(task.running).toBe(false);
|
||||
expect(taskFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call loadedCallback when task completes successfully', async () => {
|
||||
const loadedCallback = vi.fn();
|
||||
const task = new CancellableTask(loadedCallback);
|
||||
const taskFn = vi.fn(async () => {});
|
||||
it('should call succeededCallback when task completes successfully', async () => {
|
||||
const succeededCallback = vi.fn();
|
||||
const task = new CancellableTask(succeededCallback);
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
|
||||
expect(loadedCallback).toHaveBeenCalledTimes(1);
|
||||
expect(succeededCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
const result = await task.execute(taskFunction, true);
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
expect(taskFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should wait if task is already running', async () => {
|
||||
@@ -43,42 +43,42 @@ describe('CancellableTask', () => {
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
const taskFunction = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, true);
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
const promise1 = task.execute(taskFunction, true);
|
||||
const promise2 = task.execute(taskFunction, true);
|
||||
|
||||
expect(task.loading).toBe(true);
|
||||
expect(task.running).toBe(true);
|
||||
resolveTask!();
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
|
||||
expect(result1).toBe('LOADED');
|
||||
expect(result1).toBe('SUCCESS');
|
||||
expect(result2).toBe('WAITED');
|
||||
expect(taskFn).toHaveBeenCalledTimes(1);
|
||||
expect(taskFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should pass AbortSignal to task function', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
await Promise.resolve();
|
||||
capturedSignal = signal;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it('should set cancellable flag correctly', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
expect(task.cancellable).toBe(true);
|
||||
const promise = task.execute(taskFn, false);
|
||||
const promise = task.execute(taskFunction, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
await promise;
|
||||
});
|
||||
@@ -89,14 +89,14 @@ describe('CancellableTask', () => {
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = vi.fn(async () => {
|
||||
const taskFunction = vi.fn(async () => {
|
||||
await taskPromise;
|
||||
});
|
||||
|
||||
const promise1 = task.execute(taskFn, false);
|
||||
const promise1 = task.execute(taskFunction, false);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
const promise2 = task.execute(taskFn, true);
|
||||
const promise2 = task.execute(taskFunction, true);
|
||||
expect(task.cancellable).toBe(false);
|
||||
|
||||
resolveTask!();
|
||||
@@ -108,7 +108,7 @@ describe('CancellableTask', () => {
|
||||
it('should cancel a running task', async () => {
|
||||
const task = new CancellableTask();
|
||||
let taskStarted = false;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
taskStarted = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
@@ -116,9 +116,7 @@ describe('CancellableTask', () => {
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
const promise = task.execute(taskFunction, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(taskStarted).toBe(true);
|
||||
|
||||
@@ -126,20 +124,20 @@ describe('CancellableTask', () => {
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.succeeded).toBe(false);
|
||||
});
|
||||
|
||||
it('should call canceledCallback when task is canceled', async () => {
|
||||
const canceledCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, canceledCallback);
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
const promise = task.execute(taskFunction, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
@@ -149,55 +147,79 @@ describe('CancellableTask', () => {
|
||||
|
||||
it('should not cancel if task is not cancellable', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {
|
||||
const taskFunction = vi.fn(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
const promise = task.execute(taskFn, false);
|
||||
const promise = task.execute(taskFunction, false);
|
||||
task.cancel();
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(result).toBe('SUCCESS');
|
||||
expect(task.succeeded).toBe(true);
|
||||
});
|
||||
|
||||
it('should return CANCELED when concurrent caller is waiting and task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
await taskPromise;
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise1 = task.execute(taskFunction, true);
|
||||
const promise2 = task.execute(taskFunction, true);
|
||||
|
||||
task.cancel();
|
||||
resolveTask!();
|
||||
|
||||
const [result1, result2] = await Promise.all([promise1, promise2]);
|
||||
expect(result1).toBe('CANCELED');
|
||||
expect(result2).toBe('CANCELED');
|
||||
});
|
||||
|
||||
it('should not cancel if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
await task.execute(taskFunction, true);
|
||||
expect(task.succeeded).toBe(true);
|
||||
|
||||
task.cancel();
|
||||
expect(task.executed).toBe(true);
|
||||
expect(task.succeeded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset', () => {
|
||||
it('should reset task to initial state', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
expect(task.executed).toBe(true);
|
||||
await task.execute(taskFunction, true);
|
||||
expect(task.succeeded).toBe(true);
|
||||
|
||||
await task.reset();
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.cancelToken).toBe(null);
|
||||
expect(task.loading).toBe(false);
|
||||
expect(task.succeeded).toBe(false);
|
||||
expect(task.abortController).toBe(null);
|
||||
expect(task.running).toBe(false);
|
||||
});
|
||||
|
||||
it('should cancel running task before resetting', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
const promise = task.execute(taskFunction, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const resetPromise = task.reset();
|
||||
@@ -205,30 +227,30 @@ describe('CancellableTask', () => {
|
||||
await promise;
|
||||
await resetPromise;
|
||||
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.loading).toBe(false);
|
||||
expect(task.succeeded).toBe(false);
|
||||
expect(task.running).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after reset', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
await task.reset();
|
||||
const result = await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFunction, true);
|
||||
|
||||
expect(result).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
expect(taskFn).toHaveBeenCalledTimes(2);
|
||||
expect(result).toBe('SUCCESS');
|
||||
expect(task.succeeded).toBe(true);
|
||||
expect(taskFunction).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilCompletion', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
const result = await task.waitUntilCompletion();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
@@ -240,11 +262,11 @@ describe('CancellableTask', () => {
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
const taskFunction = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const executePromise = task.execute(taskFunction, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
resolveTask!();
|
||||
@@ -256,14 +278,14 @@ describe('CancellableTask', () => {
|
||||
|
||||
it('should return CANCELED if task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const executePromise = task.execute(taskFunction, true);
|
||||
const waitPromise = task.waitUntilCompletion();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
@@ -275,13 +297,13 @@ describe('CancellableTask', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitUntilExecution', () => {
|
||||
describe('waitUntilSucceeded', () => {
|
||||
it('should return DONE if task is already executed', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
const result = await task.waitUntilExecution();
|
||||
await task.execute(taskFunction, true);
|
||||
const result = await task.waitUntilSucceeded();
|
||||
|
||||
expect(result).toBe('DONE');
|
||||
});
|
||||
@@ -292,12 +314,12 @@ describe('CancellableTask', () => {
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
const taskFunction = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
const executePromise = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
const executePromise = task.execute(taskFunction, true);
|
||||
const waitPromise = task.waitUntilSucceeded();
|
||||
|
||||
resolveTask!();
|
||||
|
||||
@@ -311,7 +333,7 @@ describe('CancellableTask', () => {
|
||||
|
||||
const task = new CancellableTask();
|
||||
let attempt = 0;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
attempt++;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted && attempt === 1) {
|
||||
@@ -320,8 +342,8 @@ describe('CancellableTask', () => {
|
||||
};
|
||||
|
||||
// Start first execution
|
||||
const executePromise1 = task.execute(taskFn, true);
|
||||
const waitPromise = task.waitUntilExecution();
|
||||
const executePromise1 = task.execute(taskFunction, true);
|
||||
const waitPromise = task.waitUntilSucceeded();
|
||||
|
||||
// Cancel the first execution
|
||||
vi.advanceTimersByTime(10);
|
||||
@@ -330,12 +352,12 @@ describe('CancellableTask', () => {
|
||||
await executePromise1;
|
||||
|
||||
// Start second execution
|
||||
const executePromise2 = task.execute(taskFn, true);
|
||||
const executePromise2 = task.execute(taskFunction, true);
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
const [executeResult, waitResult] = await Promise.all([executePromise2, waitPromise]);
|
||||
|
||||
expect(executeResult).toBe('LOADED');
|
||||
expect(executeResult).toBe('SUCCESS');
|
||||
expect(waitResult).toBe('WAITED');
|
||||
expect(attempt).toBe(2);
|
||||
|
||||
@@ -347,98 +369,98 @@ describe('CancellableTask', () => {
|
||||
it('should return ERRORED when task throws non-abort error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
const taskFunction = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFunction, true);
|
||||
|
||||
expect(result).toBe('ERRORED');
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.succeeded).toBe(false);
|
||||
});
|
||||
|
||||
it('should call errorCallback when task throws non-abort error', async () => {
|
||||
const errorCallback = vi.fn();
|
||||
const task = new CancellableTask(undefined, undefined, errorCallback);
|
||||
const error = new Error('Task failed');
|
||||
const taskFn = async () => {
|
||||
const taskFunction = async () => {
|
||||
await Promise.resolve();
|
||||
throw error;
|
||||
};
|
||||
|
||||
await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
|
||||
expect(errorCallback).toHaveBeenCalledTimes(1);
|
||||
expect(errorCallback).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should return CANCELED when task throws AbortError', async () => {
|
||||
it('should return ERRORED when task throws AbortError without signal being aborted', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
const taskFunction = async () => {
|
||||
await Promise.resolve();
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFunction, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
expect(result).toBe('ERRORED');
|
||||
expect(task.succeeded).toBe(false);
|
||||
});
|
||||
|
||||
it('should allow re-execution after error', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn1 = async () => {
|
||||
const taskFunction1 = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
const taskFn2 = vi.fn(async () => {});
|
||||
const taskFunction2 = vi.fn(async () => {});
|
||||
|
||||
const result1 = await task.execute(taskFn1, true);
|
||||
const result1 = await task.execute(taskFunction1, true);
|
||||
expect(result1).toBe('ERRORED');
|
||||
|
||||
const result2 = await task.execute(taskFn2, true);
|
||||
expect(result2).toBe('LOADED');
|
||||
expect(task.executed).toBe(true);
|
||||
const result2 = await task.execute(taskFunction2, true);
|
||||
expect(result2).toBe('SUCCESS');
|
||||
expect(task.succeeded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loading property', () => {
|
||||
describe('running property', () => {
|
||||
it('should return true when task is running', async () => {
|
||||
const task = new CancellableTask();
|
||||
let resolveTask: () => void;
|
||||
const taskPromise = new Promise<void>((resolve) => {
|
||||
resolveTask = resolve;
|
||||
});
|
||||
const taskFn = async () => {
|
||||
const taskFunction = async () => {
|
||||
await taskPromise;
|
||||
};
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
expect(task.running).toBe(false);
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
expect(task.loading).toBe(true);
|
||||
const promise = task.execute(taskFunction, true);
|
||||
expect(task.running).toBe(true);
|
||||
|
||||
resolveTask!();
|
||||
await promise;
|
||||
|
||||
expect(task.loading).toBe(false);
|
||||
expect(task.running).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete promise', () => {
|
||||
it('should resolve when task completes successfully', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = vi.fn(async () => {});
|
||||
const taskFunction = vi.fn(async () => {});
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
await expect(completePromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
@@ -446,7 +468,7 @@ describe('CancellableTask', () => {
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
const promise = task.execute(taskFn, true);
|
||||
const promise = task.execute(taskFunction, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
await promise;
|
||||
@@ -456,13 +478,13 @@ describe('CancellableTask', () => {
|
||||
|
||||
it('should reject when task errors', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async () => {
|
||||
const taskFunction = async () => {
|
||||
await Promise.resolve();
|
||||
throw new Error('Failed');
|
||||
};
|
||||
|
||||
const completePromise = task.complete;
|
||||
await task.execute(taskFn, true);
|
||||
await task.execute(taskFunction, true);
|
||||
|
||||
await expect(completePromise).rejects.toBeUndefined();
|
||||
});
|
||||
@@ -472,27 +494,22 @@ describe('CancellableTask', () => {
|
||||
it('should automatically call abort() on signal when task is canceled', async () => {
|
||||
const task = new CancellableTask();
|
||||
let capturedSignal: AbortSignal | null = null;
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
capturedSignal = signal;
|
||||
// Simulate a long-running task
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (signal.aborted) {
|
||||
throw new DOMException('Aborted', 'AbortError');
|
||||
}
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
|
||||
// Wait a bit to ensure task has started
|
||||
const promise = task.execute(taskFunction, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(capturedSignal).not.toBeNull();
|
||||
expect(capturedSignal!.aborted).toBe(false);
|
||||
|
||||
// Cancel the task
|
||||
task.cancel();
|
||||
|
||||
// Verify the signal was aborted
|
||||
expect(capturedSignal!.aborted).toBe(true);
|
||||
|
||||
const result = await promise;
|
||||
@@ -502,25 +519,22 @@ describe('CancellableTask', () => {
|
||||
it('should detect if signal was aborted after task completes', async () => {
|
||||
const task = new CancellableTask();
|
||||
let controller: AbortController | null = null;
|
||||
const taskFn = async (_: AbortSignal) => {
|
||||
// Capture the controller to abort it externally
|
||||
controller = task.cancelToken;
|
||||
// Simulate some work
|
||||
const taskFunction = async (_: AbortSignal) => {
|
||||
// Capture the controller to abort it externally before the function returns
|
||||
controller = task.abortController;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
// Now abort before the function returns
|
||||
controller?.abort();
|
||||
};
|
||||
|
||||
const result = await task.execute(taskFn, true);
|
||||
const result = await task.execute(taskFunction, true);
|
||||
|
||||
expect(result).toBe('CANCELED');
|
||||
expect(task.executed).toBe(false);
|
||||
expect(task.succeeded).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle abort signal in async operations', async () => {
|
||||
const task = new CancellableTask();
|
||||
const taskFn = async (signal: AbortSignal) => {
|
||||
// Simulate listening to abort signal during async operation
|
||||
const taskFunction = async (signal: AbortSignal) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
reject(new DOMException('Aborted', 'AbortError'));
|
||||
@@ -529,7 +543,7 @@ describe('CancellableTask', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const promise = task.execute(taskFn, true);
|
||||
const promise = task.execute(taskFunction, true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
task.cancel();
|
||||
|
||||
|
||||
@@ -1,47 +1,60 @@
|
||||
/**
|
||||
* A one-shot async task with cancellation support via AbortController/AbortSignal.
|
||||
*
|
||||
* State machine:
|
||||
*
|
||||
* IDLE ──execute()──▶ RUNNING ──task succeeds──▶ SUCCEEDED (terminal)
|
||||
* │
|
||||
* ├──cancel()/abort──▶ CANCELED ──▶ IDLE
|
||||
* └──task throws─────▶ ERRORED ──▶ IDLE
|
||||
*
|
||||
* SUCCEEDED is terminal — further execute() calls return 'DONE'.
|
||||
* Call reset() to move from SUCCEEDED back to IDLE for re-execution.
|
||||
*
|
||||
* execute() return values: 'SUCCESS' | 'DONE' | 'WAITED' | 'CANCELED' | 'ERRORED'
|
||||
*/
|
||||
export class CancellableTask {
|
||||
cancelToken: AbortController | null = null;
|
||||
abortController: AbortController | null = null;
|
||||
cancellable: boolean = true;
|
||||
/**
|
||||
* A promise that resolves once the bucket is loaded, and rejects if bucket is canceled.
|
||||
* A promise that resolves once the task completes, and rejects if the task is canceled or errored.
|
||||
*/
|
||||
complete!: Promise<unknown>;
|
||||
executed: boolean = false;
|
||||
succeeded: boolean = false;
|
||||
|
||||
private loadedSignal: (() => void) | undefined;
|
||||
private canceledSignal: (() => void) | undefined;
|
||||
private completeResolve: (() => void) | undefined;
|
||||
private completeReject: (() => void) | undefined;
|
||||
|
||||
constructor(
|
||||
private loadedCallback?: () => void,
|
||||
private succeededCallback?: () => void,
|
||||
private canceledCallback?: () => void,
|
||||
private errorCallback?: (error: unknown) => void,
|
||||
) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return !!this.cancelToken;
|
||||
get running() {
|
||||
return !!this.abortController;
|
||||
}
|
||||
|
||||
async waitUntilCompletion() {
|
||||
if (this.executed) {
|
||||
if (this.succeeded) {
|
||||
return 'DONE';
|
||||
}
|
||||
// The `complete` promise resolves when executed, rejects when canceled/errored.
|
||||
try {
|
||||
const complete = this.complete;
|
||||
await complete;
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
// ignore
|
||||
// expected when canceled
|
||||
}
|
||||
return 'CANCELED';
|
||||
}
|
||||
|
||||
async waitUntilExecution() {
|
||||
async waitUntilSucceeded() {
|
||||
// Keep retrying until the task completes successfully (not canceled)
|
||||
for (;;) {
|
||||
try {
|
||||
if (this.executed) {
|
||||
if (this.succeeded) {
|
||||
return 'DONE';
|
||||
}
|
||||
await this.complete;
|
||||
@@ -52,59 +65,60 @@ export class CancellableTask {
|
||||
}
|
||||
}
|
||||
|
||||
async execute<F extends (abortSignal: AbortSignal) => Promise<void>>(f: F, cancellable: boolean) {
|
||||
if (this.executed) {
|
||||
async execute(task: (abortSignal: AbortSignal) => Promise<void>, cancellable: boolean) {
|
||||
if (this.succeeded) {
|
||||
return 'DONE';
|
||||
}
|
||||
|
||||
// if promise is pending, wait on previous request instead.
|
||||
if (this.cancelToken) {
|
||||
// if promise is pending, and preventCancel is requested,
|
||||
// do not allow transition from prevent cancel to allow cancel.
|
||||
if (this.cancellable && !cancellable) {
|
||||
this.cancellable = cancellable;
|
||||
if (this.abortController) {
|
||||
if (!cancellable) {
|
||||
this.cancellable = false;
|
||||
}
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
}
|
||||
this.cancellable = cancellable;
|
||||
const cancelToken = (this.cancelToken = new AbortController());
|
||||
|
||||
try {
|
||||
await f(cancelToken.signal);
|
||||
if (cancelToken.signal.aborted) {
|
||||
try {
|
||||
await this.complete;
|
||||
return 'WAITED';
|
||||
} catch {
|
||||
return 'CANCELED';
|
||||
}
|
||||
this.#transitionToExecuted();
|
||||
return 'LOADED';
|
||||
}
|
||||
this.cancellable = cancellable;
|
||||
const abortController = (this.abortController = new AbortController());
|
||||
|
||||
try {
|
||||
await task(abortController.signal);
|
||||
if (abortController.signal.aborted) {
|
||||
return 'CANCELED';
|
||||
}
|
||||
this.#transitionToSucceeded();
|
||||
return 'SUCCESS';
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((error as any).name === 'AbortError') {
|
||||
// abort error is not treated as an error, but as a cancellation.
|
||||
if (abortController.signal.aborted) {
|
||||
return 'CANCELED';
|
||||
}
|
||||
this.#transitionToErrored(error);
|
||||
return 'ERRORED';
|
||||
} finally {
|
||||
this.cancelToken = null;
|
||||
if (this.abortController === abortController) {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.abortController = null;
|
||||
this.succeeded = false;
|
||||
this.complete = new Promise<void>((resolve, reject) => {
|
||||
this.cancelToken = null;
|
||||
this.executed = false;
|
||||
this.loadedSignal = resolve;
|
||||
this.canceledSignal = reject;
|
||||
this.completeResolve = resolve;
|
||||
this.completeReject = reject;
|
||||
});
|
||||
// Suppress unhandled rejection warning
|
||||
this.complete.catch(() => {});
|
||||
}
|
||||
|
||||
// will reset this job back to the initial state (isLoaded=false, no errors, etc)
|
||||
async reset() {
|
||||
this.#transitionToCancelled();
|
||||
if (this.cancelToken) {
|
||||
if (this.abortController) {
|
||||
await this.waitUntilCompletion();
|
||||
}
|
||||
this.init();
|
||||
@@ -115,27 +129,26 @@ export class CancellableTask {
|
||||
}
|
||||
|
||||
#transitionToCancelled() {
|
||||
if (this.executed) {
|
||||
if (this.succeeded) {
|
||||
return;
|
||||
}
|
||||
if (!this.cancellable) {
|
||||
return;
|
||||
}
|
||||
this.cancelToken?.abort();
|
||||
this.canceledSignal?.();
|
||||
this.abortController?.abort();
|
||||
this.completeReject?.();
|
||||
this.init();
|
||||
this.canceledCallback?.();
|
||||
}
|
||||
|
||||
#transitionToExecuted() {
|
||||
this.executed = true;
|
||||
this.loadedSignal?.();
|
||||
this.loadedCallback?.();
|
||||
#transitionToSucceeded() {
|
||||
this.succeeded = true;
|
||||
this.completeResolve?.();
|
||||
this.succeededCallback?.();
|
||||
}
|
||||
|
||||
#transitionToErrored(error: unknown) {
|
||||
this.cancelToken = null;
|
||||
this.canceledSignal?.();
|
||||
this.completeReject?.();
|
||||
this.init();
|
||||
this.errorCallback?.(error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user