mirror of
https://github.com/immich-app/immich.git
synced 2025-12-23 07:29:28 -08:00
background upload plugin
add schemas sync variants formatting initial implementation use existing db, wip move to separate folder fix table definitions wip wiring it up repository pattern
This commit is contained in:
@@ -90,18 +90,19 @@ enum class UploadApiErrorCode(val raw: Int) {
|
|||||||
NETWORK_ERROR(8),
|
NETWORK_ERROR(8),
|
||||||
PHOTOS_INTERNAL_ERROR(9),
|
PHOTOS_INTERNAL_ERROR(9),
|
||||||
PHOTOS_UNKNOWN_ERROR(10),
|
PHOTOS_UNKNOWN_ERROR(10),
|
||||||
NO_SERVER_URL(11),
|
INTERRUPTED(11),
|
||||||
NO_DEVICE_ID(12),
|
CANCELLED(12),
|
||||||
NO_ACCESS_TOKEN(13),
|
DOWNLOAD_STALLED(13),
|
||||||
INTERRUPTED(14),
|
FORCE_QUIT(14),
|
||||||
CANCELLED(15),
|
OUT_OF_RESOURCES(15),
|
||||||
DOWNLOAD_STALLED(16),
|
BACKGROUND_UPDATES_DISABLED(16),
|
||||||
FORCE_QUIT(17),
|
UPLOAD_TIMEOUT(17),
|
||||||
OUT_OF_RESOURCES(18),
|
I_CLOUD_RATE_LIMIT(18),
|
||||||
BACKGROUND_UPDATES_DISABLED(19),
|
I_CLOUD_THROTTLED(19),
|
||||||
UPLOAD_TIMEOUT(20),
|
INVALID_RESPONSE(20),
|
||||||
I_CLOUD_RATE_LIMIT(21),
|
BAD_REQUEST(21),
|
||||||
I_CLOUD_THROTTLED(22);
|
INTERNAL_SERVER_ERROR(22),
|
||||||
|
UNAUTHORIZED(23);
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun ofRaw(raw: Int): UploadApiErrorCode? {
|
fun ofRaw(raw: Int): UploadApiErrorCode? {
|
||||||
@@ -262,6 +263,7 @@ interface UploadApi {
|
|||||||
fun cancelAll(callback: (Result<Unit>) -> Unit)
|
fun cancelAll(callback: (Result<Unit>) -> Unit)
|
||||||
fun enqueueAssets(localIds: List<String>, callback: (Result<Unit>) -> Unit)
|
fun enqueueAssets(localIds: List<String>, callback: (Result<Unit>) -> Unit)
|
||||||
fun enqueueFiles(paths: List<String>, callback: (Result<Unit>) -> Unit)
|
fun enqueueFiles(paths: List<String>, callback: (Result<Unit>) -> Unit)
|
||||||
|
fun onConfigChange(key: Long, callback: (Result<Unit>) -> Unit)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** The codec used by UploadApi. */
|
/** The codec used by UploadApi. */
|
||||||
@@ -361,6 +363,25 @@ interface UploadApi {
|
|||||||
channel.setMessageHandler(null)
|
channel.setMessageHandler(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
run {
|
||||||
|
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.immich_mobile.UploadApi.onConfigChange$separatedMessageChannelSuffix", codec)
|
||||||
|
if (api != null) {
|
||||||
|
channel.setMessageHandler { message, reply ->
|
||||||
|
val args = message as List<Any?>
|
||||||
|
val keyArg = args[0] as Long
|
||||||
|
api.onConfigChange(keyArg) { result: Result<Unit> ->
|
||||||
|
val error = result.exceptionOrNull()
|
||||||
|
if (error != null) {
|
||||||
|
reply.reply(UploadTaskPigeonUtils.wrapError(error))
|
||||||
|
} else {
|
||||||
|
reply.reply(UploadTaskPigeonUtils.wrapResult(null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
channel.setMessageHandler(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,11 @@
|
|||||||
path = Upload;
|
path = Upload;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
FEA74CE22ED223690014C832 /* Repositories */ = {
|
||||||
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
|
path = Repositories;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
FEB3BA112EBD52860081A5EB /* Schemas */ = {
|
FEB3BA112EBD52860081A5EB /* Schemas */ = {
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
isa = PBXFileSystemSynchronizedRootGroup;
|
||||||
path = Schemas;
|
path = Schemas;
|
||||||
@@ -281,6 +286,7 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
97C146F01CF9000F007C117D /* Runner */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
FEA74CE22ED223690014C832 /* Repositories */,
|
||||||
FE14355D2EC446E90009D5AC /* Upload */,
|
FE14355D2EC446E90009D5AC /* Upload */,
|
||||||
FEE084F22EC172080045228E /* Schemas */,
|
FEE084F22EC172080045228E /* Schemas */,
|
||||||
FEB3BA112EBD52860081A5EB /* Schemas */,
|
FEB3BA112EBD52860081A5EB /* Schemas */,
|
||||||
@@ -373,6 +379,7 @@
|
|||||||
B231F52D2E93A44A00BC45D1 /* Core */,
|
B231F52D2E93A44A00BC45D1 /* Core */,
|
||||||
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
B2CF7F8C2DDE4EBB00744BF6 /* Sync */,
|
||||||
FE14355D2EC446E90009D5AC /* Upload */,
|
FE14355D2EC446E90009D5AC /* Upload */,
|
||||||
|
FEA74CE22ED223690014C832 /* Repositories */,
|
||||||
FEB3BA112EBD52860081A5EB /* Schemas */,
|
FEB3BA112EBD52860081A5EB /* Schemas */,
|
||||||
FEE084F22EC172080045228E /* Schemas */,
|
FEE084F22EC172080045228E /* Schemas */,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import BackgroundTasks
|
import BackgroundTasks
|
||||||
import Flutter
|
import Flutter
|
||||||
|
import SQLiteData
|
||||||
import UIKit
|
import UIKit
|
||||||
import network_info_plus
|
import network_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
@@ -9,6 +10,8 @@ import shared_preferences_foundation
|
|||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
private var backgroundCompletionHandlers: [String: () -> Void] = [:]
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
@@ -53,6 +56,18 @@ import shared_preferences_foundation
|
|||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
|
handleEventsForBackgroundURLSession identifier: String,
|
||||||
|
completionHandler: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
backgroundCompletionHandlers[identifier] = completionHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
func completionHandler(forSession identifier: String) -> (() -> Void)? {
|
||||||
|
return backgroundCompletionHandlers.removeValue(forKey: identifier)
|
||||||
|
}
|
||||||
|
|
||||||
public static func registerPlugins(with engine: FlutterEngine) {
|
public static func registerPlugins(with engine: FlutterEngine) {
|
||||||
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
NativeSyncApiImpl.register(with: engine.registrar(forPlugin: NativeSyncApiImpl.name)!)
|
||||||
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
ThumbnailApiSetup.setUp(binaryMessenger: engine.binaryMessenger, api: ThumbnailApiImpl())
|
||||||
@@ -62,9 +77,25 @@ import shared_preferences_foundation
|
|||||||
StreamStatusStreamHandler.register(with: engine.binaryMessenger, streamHandler: statusListener)
|
StreamStatusStreamHandler.register(with: engine.binaryMessenger, streamHandler: statusListener)
|
||||||
let progressListener = ProgressEventListener()
|
let progressListener = ProgressEventListener()
|
||||||
StreamProgressStreamHandler.register(with: engine.binaryMessenger, streamHandler: progressListener)
|
StreamProgressStreamHandler.register(with: engine.binaryMessenger, streamHandler: progressListener)
|
||||||
|
|
||||||
|
let dbUrl = try! FileManager.default.url(
|
||||||
|
for: .documentDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
).appendingPathComponent("immich.sqlite")
|
||||||
|
let db = try! DatabasePool(path: dbUrl.path)
|
||||||
|
let storeRepository = StoreRepository(db: db)
|
||||||
|
let taskRepository = TaskRepository(db: db)
|
||||||
|
|
||||||
UploadApiSetup.setUp(
|
UploadApiSetup.setUp(
|
||||||
binaryMessenger: engine.binaryMessenger,
|
binaryMessenger: engine.binaryMessenger,
|
||||||
api: UploadApiImpl(statusListener: statusListener, progressListener: progressListener)
|
api: UploadApiImpl(
|
||||||
|
storeRepository: storeRepository,
|
||||||
|
taskRepository: taskRepository,
|
||||||
|
statusListener: statusListener,
|
||||||
|
progressListener: progressListener
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,15 +29,15 @@ class BackgroundWorkerApiImpl: BackgroundWorkerFgHostApi {
|
|||||||
public static func registerBackgroundWorkers() {
|
public static func registerBackgroundWorkers() {
|
||||||
BGTaskScheduler.shared.register(
|
BGTaskScheduler.shared.register(
|
||||||
forTaskWithIdentifier: processingTaskID, using: nil) { task in
|
forTaskWithIdentifier: processingTaskID, using: nil) { task in
|
||||||
if task is BGProcessingTask {
|
if case let task as BGProcessingTask = task {
|
||||||
handleBackgroundProcessing(task: task as! BGProcessingTask)
|
handleBackgroundProcessing(task: task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BGTaskScheduler.shared.register(
|
BGTaskScheduler.shared.register(
|
||||||
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
|
forTaskWithIdentifier: refreshTaskID, using: nil) { task in
|
||||||
if task is BGAppRefreshTask {
|
if case let task as BGAppRefreshTask = task {
|
||||||
handleBackgroundRefresh(task: task as! BGAppRefreshTask)
|
handleBackgroundRefresh(task: task)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,86 @@
|
|||||||
import SQLiteData
|
import SQLiteData
|
||||||
|
|
||||||
|
protocol StoreProtocol {
|
||||||
|
func get<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>) throws -> T?
|
||||||
|
func get<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>) throws -> T?
|
||||||
|
func set<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>, value: T) throws
|
||||||
|
func set<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>, value: T) throws
|
||||||
|
func invalidateCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol StoreConvertible<StorageType> {
|
||||||
|
associatedtype StorageType
|
||||||
|
static var cacheKeyPath: ReferenceWritableKeyPath<StoreCache, [StoreKey: Self]> { get }
|
||||||
|
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
|
||||||
|
static func toValue(_ value: Self) throws(StoreError) -> StorageType
|
||||||
|
}
|
||||||
|
|
||||||
|
final class StoreRepository: StoreProtocol {
|
||||||
|
private let db: DatabasePool
|
||||||
|
private static let cache = StoreCache()
|
||||||
|
private static var lock = os_unfair_lock()
|
||||||
|
|
||||||
|
init(db: DatabasePool) {
|
||||||
|
self.db = db
|
||||||
|
}
|
||||||
|
|
||||||
|
func get<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>) throws -> T? {
|
||||||
|
os_unfair_lock_lock(&Self.lock)
|
||||||
|
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||||
|
let cached = Self.cache.get(key)
|
||||||
|
if _fastPath(cached != nil) { return cached! }
|
||||||
|
return try db.read { conn in
|
||||||
|
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
||||||
|
if let value = try query.fetchOne(conn) ?? nil {
|
||||||
|
let converted = try T.fromValue(value)
|
||||||
|
Self.cache.set(key, value: converted)
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func get<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>) throws -> T? {
|
||||||
|
os_unfair_lock_lock(&Self.lock)
|
||||||
|
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||||
|
let cached = Self.cache.get(key)
|
||||||
|
if _fastPath(cached != nil) { return cached! }
|
||||||
|
return try db.read { conn in
|
||||||
|
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
||||||
|
if let value = try query.fetchOne(conn) ?? nil {
|
||||||
|
let converted = try T.fromValue(value)
|
||||||
|
Self.cache.set(key, value: converted)
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T: StoreConvertible<Int>>(_ key: StoreKey.Typed<T>, value: T) throws {
|
||||||
|
os_unfair_lock_lock(&Self.lock)
|
||||||
|
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||||
|
let converted = try T.toValue(value)
|
||||||
|
try db.write { conn in
|
||||||
|
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: converted) }.execute(conn)
|
||||||
|
}
|
||||||
|
Self.cache.set(key, value: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func set<T: StoreConvertible<String>>(_ key: StoreKey.Typed<T>, value: T) throws {
|
||||||
|
os_unfair_lock_lock(&Self.lock)
|
||||||
|
defer { os_unfair_lock_unlock(&Self.lock) }
|
||||||
|
let converted = try T.toValue(value)
|
||||||
|
try db.write { conn in
|
||||||
|
try Store.upsert { Store(id: key.rawValue, stringValue: converted, intValue: nil) }.execute(conn)
|
||||||
|
}
|
||||||
|
Self.cache.set(key, value: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateCache() {
|
||||||
|
Self.cache.reset()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum StoreError: Error {
|
enum StoreError: Error {
|
||||||
case invalidJSON(String)
|
case invalidJSON(String)
|
||||||
case invalidURL(String)
|
case invalidURL(String)
|
||||||
@@ -7,29 +88,21 @@ enum StoreError: Error {
|
|||||||
case notFound
|
case notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol StoreConvertible {
|
|
||||||
static var cacheKeyPath: ReferenceWritableKeyPath<StoreCache, [StoreKey: Self]> { get }
|
|
||||||
associatedtype StorageType
|
|
||||||
static func fromValue(_ value: StorageType) throws(StoreError) -> Self
|
|
||||||
static func toValue(_ value: Self) throws(StoreError) -> StorageType
|
|
||||||
}
|
|
||||||
|
|
||||||
extension StoreConvertible {
|
extension StoreConvertible {
|
||||||
static func get(_ cache: StoreCache, key: StoreKey) -> Self? {
|
fileprivate static func get(_ cache: StoreCache, key: StoreKey) -> Self? {
|
||||||
os_unfair_lock_lock(&cache.lock)
|
|
||||||
defer { os_unfair_lock_unlock(&cache.lock) }
|
|
||||||
return cache[keyPath: cacheKeyPath][key]
|
return cache[keyPath: cacheKeyPath][key]
|
||||||
}
|
}
|
||||||
|
|
||||||
static func set(_ cache: StoreCache, key: StoreKey, value: Self?) {
|
fileprivate static func set(_ cache: StoreCache, key: StoreKey, value: Self?) {
|
||||||
os_unfair_lock_lock(&cache.lock)
|
|
||||||
defer { os_unfair_lock_unlock(&cache.lock) }
|
|
||||||
cache[keyPath: cacheKeyPath][key] = value
|
cache[keyPath: cacheKeyPath][key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fileprivate static func reset(_ cache: StoreCache) {
|
||||||
|
cache.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class StoreCache {
|
final class StoreCache {
|
||||||
fileprivate var lock = os_unfair_lock()
|
|
||||||
fileprivate var intCache: [StoreKey: Int] = [:]
|
fileprivate var intCache: [StoreKey: Int] = [:]
|
||||||
fileprivate var boolCache: [StoreKey: Bool] = [:]
|
fileprivate var boolCache: [StoreKey: Bool] = [:]
|
||||||
fileprivate var dateCache: [StoreKey: Date] = [:]
|
fileprivate var dateCache: [StoreKey: Date] = [:]
|
||||||
@@ -39,11 +112,21 @@ final class StoreCache {
|
|||||||
fileprivate var stringDictCache: [StoreKey: [String: String]] = [:]
|
fileprivate var stringDictCache: [StoreKey: [String: String]] = [:]
|
||||||
|
|
||||||
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) -> T? {
|
func get<T: StoreConvertible>(_ key: StoreKey.Typed<T>) -> T? {
|
||||||
T.get(self, key: key.rawValue)
|
return T.get(self, key: key.rawValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T?) {
|
func set<T: StoreConvertible>(_ key: StoreKey.Typed<T>, value: T?) {
|
||||||
T.set(self, key: key.rawValue, value: value)
|
return T.set(self, key: key.rawValue, value: value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
intCache.removeAll(keepingCapacity: true)
|
||||||
|
boolCache.removeAll(keepingCapacity: true)
|
||||||
|
dateCache.removeAll(keepingCapacity: true)
|
||||||
|
stringCache.removeAll(keepingCapacity: true)
|
||||||
|
urlCache.removeAll(keepingCapacity: true)
|
||||||
|
endpointArrayCache.removeAll(keepingCapacity: true)
|
||||||
|
stringDictCache.removeAll(keepingCapacity: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +165,7 @@ extension URL: StoreConvertible {
|
|||||||
static func toValue(_ value: URL) -> String { value.absoluteString }
|
static func toValue(_ value: URL) -> String { value.absoluteString }
|
||||||
}
|
}
|
||||||
|
|
||||||
extension StoreConvertible where Self: Codable, StorageType == String {
|
extension StoreConvertible<String> where Self: Codable {
|
||||||
static var jsonDecoder: JSONDecoder { JSONDecoder() }
|
static var jsonDecoder: JSONDecoder { JSONDecoder() }
|
||||||
static var jsonEncoder: JSONEncoder { JSONEncoder() }
|
static var jsonEncoder: JSONEncoder { JSONEncoder() }
|
||||||
|
|
||||||
@@ -118,43 +201,3 @@ extension Dictionary: StoreConvertible where Key == String, Value == String {
|
|||||||
static let cacheKeyPath = \StoreCache.stringDictCache
|
static let cacheKeyPath = \StoreCache.stringDictCache
|
||||||
typealias StorageType = String
|
typealias StorageType = String
|
||||||
}
|
}
|
||||||
|
|
||||||
extension Store {
|
|
||||||
static let cache = StoreCache()
|
|
||||||
|
|
||||||
static func get<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>) throws -> T?
|
|
||||||
where T.StorageType == Int {
|
|
||||||
if let cached = cache.get(key) { return cached }
|
|
||||||
let query = Store.select(\.intValue).where { $0.id.eq(key.rawValue) }
|
|
||||||
if let value = try query.fetchOne(conn) ?? nil {
|
|
||||||
let converted = try T.fromValue(value)
|
|
||||||
cache.set(key, value: converted)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func get<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>) throws -> T?
|
|
||||||
where T.StorageType == String {
|
|
||||||
if let cached = cache.get(key) { return cached }
|
|
||||||
let query = Store.select(\.stringValue).where { $0.id.eq(key.rawValue) }
|
|
||||||
if let value = try query.fetchOne(conn) ?? nil {
|
|
||||||
let converted = try T.fromValue(value)
|
|
||||||
cache.set(key, value: converted)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func set<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>, value: T) throws
|
|
||||||
where T.StorageType == Int {
|
|
||||||
let converted = try T.toValue(value)
|
|
||||||
try Store.upsert { Store(id: key.rawValue, stringValue: nil, intValue: converted) }.execute(conn)
|
|
||||||
cache.set(key, value: value)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func set<T: StoreConvertible>(_ conn: Database, _ key: StoreKey.Typed<T>, value: T) throws
|
|
||||||
where T.StorageType == String {
|
|
||||||
let converted = try T.toValue(value)
|
|
||||||
try Store.upsert { Store(id: key.rawValue, stringValue: converted, intValue: nil) }.execute(conn)
|
|
||||||
cache.set(key, value: value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
279
mobile/ios/Runner/Repositories/TaskRepository.swift
Normal file
279
mobile/ios/Runner/Repositories/TaskRepository.swift
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import SQLiteData
|
||||||
|
|
||||||
|
protocol TaskProtocol {
|
||||||
|
func getTaskIds(status: TaskStatus) async throws -> [Int64]
|
||||||
|
func getBackupCandidates() async throws -> [LocalAssetCandidate]
|
||||||
|
func getBackupCandidates(ids: [String]) async throws -> [LocalAssetCandidate]
|
||||||
|
func getDownloadTasks() async throws -> [LocalAssetDownloadData]
|
||||||
|
func getUploadTasks() async throws -> [LocalAssetUploadData]
|
||||||
|
func markOrphansPending(ids: [Int64]) async throws
|
||||||
|
func markDownloadQueued(taskId: Int64, isLivePhoto: Bool, filePath: URL) async throws
|
||||||
|
func markUploadQueued(taskId: Int64) async throws
|
||||||
|
func markDownloadComplete(taskId: Int64, localId: String, hash: String?) async throws -> TaskStatus
|
||||||
|
func markUploadSuccess(taskId: Int64, livePhotoVideoId: String?) async throws
|
||||||
|
func retryOrFail(taskId: Int64, code: UploadErrorCode, status: TaskStatus) async throws
|
||||||
|
func enqueue(assets: [LocalAssetCandidate], imagePriority: Float, videoPriority: Float) async throws
|
||||||
|
func enqueue(files: [String]) async throws
|
||||||
|
func resolveError(code: UploadErrorCode) async throws
|
||||||
|
func getFilename(taskId: Int64) async throws -> String?
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TaskRepository: TaskProtocol {
|
||||||
|
private let db: DatabasePool
|
||||||
|
|
||||||
|
init(db: DatabasePool) {
|
||||||
|
self.db = db
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTaskIds(status: TaskStatus) async throws -> [Int64] {
|
||||||
|
return try await db.read { conn in
|
||||||
|
try UploadTask.select(\.id).where { $0.status.eq(status) }.fetchAll(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBackupCandidates() async throws -> [LocalAssetCandidate] {
|
||||||
|
return try await db.read { conn in
|
||||||
|
return try LocalAsset.backupCandidates.fetchAll(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBackupCandidates(ids: [String]) async throws -> [LocalAssetCandidate] {
|
||||||
|
return try await db.read { conn in
|
||||||
|
return try LocalAsset.backupCandidates.where { $0.id.in(ids) }.fetchAll(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDownloadTasks() async throws -> [LocalAssetDownloadData] {
|
||||||
|
return try await db.read({ conn in
|
||||||
|
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
||||||
|
.where { task, _ in task.canRetry && task.noFatalError && LocalAsset.withChecksum.exists() }
|
||||||
|
.select { task, asset in
|
||||||
|
LocalAssetDownloadData.Columns(
|
||||||
|
checksum: asset.checksum,
|
||||||
|
createdAt: asset.createdAt,
|
||||||
|
filename: asset.name,
|
||||||
|
livePhotoVideoId: task.livePhotoVideoId,
|
||||||
|
localId: asset.id,
|
||||||
|
taskId: task.id,
|
||||||
|
updatedAt: asset.updatedAt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
||||||
|
.limit { _, _ in UploadTaskStat.availableDownloadSlots }
|
||||||
|
.fetchAll(conn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUploadTasks() async throws -> [LocalAssetUploadData] {
|
||||||
|
return try await db.read({ conn in
|
||||||
|
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
||||||
|
.where { task, _ in task.canRetry && task.noFatalError && LocalAsset.withChecksum.exists() }
|
||||||
|
.select { task, asset in
|
||||||
|
LocalAssetUploadData.Columns(
|
||||||
|
filename: asset.name,
|
||||||
|
filePath: task.filePath.unwrapped,
|
||||||
|
priority: task.priority,
|
||||||
|
taskId: task.id,
|
||||||
|
type: asset.type
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
||||||
|
.limit { task, _ in UploadTaskStat.availableUploadSlots }
|
||||||
|
.fetchAll(conn)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func markOrphansPending(ids: [Int64]) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
try UploadTask.update {
|
||||||
|
$0.filePath = nil
|
||||||
|
$0.status = .downloadPending
|
||||||
|
}
|
||||||
|
.where { row in row.status.in([TaskStatus.downloadQueued, TaskStatus.uploadPending]) || row.id.in(ids) }
|
||||||
|
.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markDownloadQueued(taskId: Int64, isLivePhoto: Bool, filePath: URL) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
try UploadTask.update {
|
||||||
|
$0.status = .downloadQueued
|
||||||
|
$0.isLivePhoto = isLivePhoto
|
||||||
|
$0.filePath = filePath
|
||||||
|
}
|
||||||
|
.where { $0.id.eq(taskId) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markUploadQueued(taskId: Int64) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
try UploadTask.update { row in
|
||||||
|
row.status = .uploadQueued
|
||||||
|
row.filePath = nil
|
||||||
|
}
|
||||||
|
.where { $0.id.eq(taskId) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markDownloadComplete(taskId: Int64, localId: String, hash: String?) async throws -> TaskStatus {
|
||||||
|
return try await db.write { conn in
|
||||||
|
if let hash {
|
||||||
|
try LocalAsset.update { $0.checksum = hash }.where { $0.id.eq(localId) }.execute(conn)
|
||||||
|
}
|
||||||
|
let status =
|
||||||
|
if let hash, try RemoteAsset.select(\.rowid).where({ $0.checksum.eq(hash) }).fetchOne(conn) != nil {
|
||||||
|
TaskStatus.uploadSkipped
|
||||||
|
} else {
|
||||||
|
TaskStatus.uploadPending
|
||||||
|
}
|
||||||
|
try UploadTask.update { $0.status = status }.where { $0.id.eq(taskId) }.execute(conn)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markUploadSuccess(taskId: Int64, livePhotoVideoId: String?) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
let task =
|
||||||
|
try UploadTask
|
||||||
|
.update { $0.status = .uploadComplete }
|
||||||
|
.where { $0.id.eq(taskId) }
|
||||||
|
.returning(\.self)
|
||||||
|
.fetchOne(conn)
|
||||||
|
guard let task, let localId = task.localId, let isLivePhoto = task.isLivePhoto, isLivePhoto,
|
||||||
|
task.livePhotoVideoId == nil
|
||||||
|
else { return }
|
||||||
|
try UploadTask.insert {
|
||||||
|
UploadTask.Draft(
|
||||||
|
attempts: 0,
|
||||||
|
createdAt: Date(),
|
||||||
|
filePath: nil,
|
||||||
|
isLivePhoto: true,
|
||||||
|
lastError: nil,
|
||||||
|
livePhotoVideoId: livePhotoVideoId,
|
||||||
|
localId: localId,
|
||||||
|
method: .multipart,
|
||||||
|
priority: 0.7,
|
||||||
|
retryAfter: nil,
|
||||||
|
status: .downloadPending,
|
||||||
|
)
|
||||||
|
}.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryOrFail(taskId: Int64, code: UploadErrorCode, status: TaskStatus) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
try UploadTask.update { row in
|
||||||
|
let retryOffset =
|
||||||
|
switch code {
|
||||||
|
case .iCloudThrottled, .iCloudRateLimit, .notEnoughSpace: 3000
|
||||||
|
default: 0
|
||||||
|
}
|
||||||
|
row.status = Case()
|
||||||
|
.when(row.localId.is(nil) && row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.uploadPending)
|
||||||
|
.when(row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.downloadPending)
|
||||||
|
.else(status)
|
||||||
|
row.attempts += 1
|
||||||
|
row.lastError = code
|
||||||
|
row.retryAfter = #sql("unixepoch('now') + (\(4 << row.attempts)) + \(retryOffset)")
|
||||||
|
}
|
||||||
|
.where { $0.id.eq(taskId) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(assets: [LocalAssetCandidate], imagePriority: Float, videoPriority: Float) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
var draft = draftStub
|
||||||
|
for candidate in assets {
|
||||||
|
draft.localId = candidate.id
|
||||||
|
draft.priority = candidate.type == .image ? imagePriority : videoPriority
|
||||||
|
try UploadTask.insert {
|
||||||
|
draft
|
||||||
|
} onConflict: {
|
||||||
|
($0.localId, $0.livePhotoVideoId)
|
||||||
|
}
|
||||||
|
.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(files: [String]) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
var draft = draftStub
|
||||||
|
draft.priority = 1.0
|
||||||
|
draft.status = .uploadPending
|
||||||
|
for file in files {
|
||||||
|
draft.filePath = URL(fileURLWithPath: file, isDirectory: false)
|
||||||
|
try UploadTask.insert { draft }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveError(code: UploadErrorCode) async throws {
|
||||||
|
try await db.write { conn in
|
||||||
|
try UploadTask.update { $0.lastError = nil }.where { $0.lastError.unwrapped.eq(code) }.execute(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFilename(taskId: Int64) async throws -> String? {
|
||||||
|
try await db.read { conn in
|
||||||
|
try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }.select(\.1.name).fetchOne(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var draftStub: UploadTask.Draft {
|
||||||
|
.init(
|
||||||
|
attempts: 0,
|
||||||
|
createdAt: Date(),
|
||||||
|
filePath: nil,
|
||||||
|
isLivePhoto: nil,
|
||||||
|
lastError: nil,
|
||||||
|
livePhotoVideoId: nil,
|
||||||
|
localId: nil,
|
||||||
|
method: .multipart,
|
||||||
|
priority: 0.5,
|
||||||
|
retryAfter: nil,
|
||||||
|
status: .downloadPending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UploadTask.TableColumns {
|
||||||
|
var noFatalError: some QueryExpression<Bool> { lastError.is(nil) || !lastError.unwrapped.in(UploadErrorCode.fatal) }
|
||||||
|
var canRetry: some QueryExpression<Bool> {
|
||||||
|
attempts.lte(TaskConfig.maxRetries) && (retryAfter.is(nil) || retryAfter.unwrapped <= Date().unixTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalAlbum {
|
||||||
|
static let selected = Self.where { $0.backupSelection.eq(BackupSelection.selected) }
|
||||||
|
static let excluded = Self.where { $0.backupSelection.eq(BackupSelection.excluded) }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalAlbumAsset {
|
||||||
|
static let selected = Self.where {
|
||||||
|
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.selected.select(\.id))
|
||||||
|
}
|
||||||
|
static let excluded = Self.where {
|
||||||
|
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.excluded.select(\.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension RemoteAsset {
|
||||||
|
static let currentUser = Self.where { _ in
|
||||||
|
ownerId.eq(Store.select(\.stringValue).where { $0.id.eq(StoreKey.currentUser.rawValue) }.unwrapped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LocalAsset {
|
||||||
|
static let withChecksum = Self.where { $0.checksum.isNot(nil) }
|
||||||
|
static let shouldBackup = Self.where { _ in LocalAlbumAsset.selected.exists() && !LocalAlbumAsset.excluded.exists() }
|
||||||
|
static let notBackedUp = Self.where { local in
|
||||||
|
!RemoteAsset.currentUser.where { remote in local.checksum.eq(remote.checksum) }.exists()
|
||||||
|
}
|
||||||
|
static let backupCandidates = Self
|
||||||
|
.shouldBackup
|
||||||
|
.notBackedUp
|
||||||
|
.where { local in !UploadTask.where { $0.localId.eq(local.id) }.exists() }
|
||||||
|
.select { LocalAssetCandidate.Columns(id: $0.id, type: $0.type) }
|
||||||
|
.limit { _ in UploadTaskStat.availableSlots }
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import SQLiteData
|
|||||||
|
|
||||||
extension Notification.Name {
|
extension Notification.Name {
|
||||||
static let networkDidConnect = Notification.Name("networkDidConnect")
|
static let networkDidConnect = Notification.Name("networkDidConnect")
|
||||||
|
static let downloadTaskDidComplete = Notification.Name("downloadTaskDidComplete")
|
||||||
|
static let uploadTaskDidComplete = Notification.Name("uploadTaskDidComplete")
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TaskConfig {
|
enum TaskConfig {
|
||||||
@@ -12,6 +14,7 @@ enum TaskConfig {
|
|||||||
static let sessionId = "app.mertalev.immich.upload"
|
static let sessionId = "app.mertalev.immich.upload"
|
||||||
static let downloadCheckIntervalNs: UInt64 = 30_000_000_000 // 30 seconds
|
static let downloadCheckIntervalNs: UInt64 = 30_000_000_000 // 30 seconds
|
||||||
static let downloadTimeoutS = TimeInterval(60)
|
static let downloadTimeoutS = TimeInterval(60)
|
||||||
|
static let progressThrottleInterval = TimeInterval(0.1)
|
||||||
static let transferSpeedAlpha = 0.4
|
static let transferSpeedAlpha = 0.4
|
||||||
static let originalsDir = FileManager.default.temporaryDirectory.appendingPathComponent(
|
static let originalsDir = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
"originals",
|
"originals",
|
||||||
@@ -214,6 +217,18 @@ enum UploadError: Error {
|
|||||||
case fileCreationFailed
|
case fileCreationFailed
|
||||||
case iCloudError(UploadErrorCode)
|
case iCloudError(UploadErrorCode)
|
||||||
case photosError(UploadErrorCode)
|
case photosError(UploadErrorCode)
|
||||||
|
case unknown
|
||||||
|
|
||||||
|
var code: UploadErrorCode {
|
||||||
|
switch self {
|
||||||
|
case .iCloudError(let code), .photosError(let code):
|
||||||
|
return code
|
||||||
|
case .unknown:
|
||||||
|
return .unknown
|
||||||
|
case .fileCreationFailed:
|
||||||
|
return .writeFailed
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UploadErrorCode: Int, QueryBindable {
|
enum UploadErrorCode: Int, QueryBindable {
|
||||||
@@ -228,9 +243,6 @@ enum UploadErrorCode: Int, QueryBindable {
|
|||||||
case networkError
|
case networkError
|
||||||
case photosInternalError
|
case photosInternalError
|
||||||
case photosUnknownError
|
case photosUnknownError
|
||||||
case noServerUrl
|
|
||||||
case noDeviceId
|
|
||||||
case noAccessToken
|
|
||||||
case interrupted
|
case interrupted
|
||||||
case cancelled
|
case cancelled
|
||||||
case downloadStalled
|
case downloadStalled
|
||||||
@@ -243,6 +255,9 @@ enum UploadErrorCode: Int, QueryBindable {
|
|||||||
case invalidResponse
|
case invalidResponse
|
||||||
case badRequest
|
case badRequest
|
||||||
case internalServerError
|
case internalServerError
|
||||||
|
case unauthorized
|
||||||
|
|
||||||
|
static let fatal: [UploadErrorCode] = [.assetNotFound, .resourceNotFound, .invalidResource, .badRequest, .unauthorized]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AssetType: Int, QueryBindable {
|
enum AssetType: Int, QueryBindable {
|
||||||
|
|||||||
@@ -73,11 +73,6 @@ struct LocalAlbum: Identifiable {
|
|||||||
let updatedAt: Date
|
let updatedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
extension LocalAlbum {
|
|
||||||
static let selected = Self.where { $0.backupSelection.eq(BackupSelection.selected) }
|
|
||||||
static let excluded = Self.where { $0.backupSelection.eq(BackupSelection.excluded) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@Table("local_album_asset_entity")
|
@Table("local_album_asset_entity")
|
||||||
struct LocalAlbumAsset {
|
struct LocalAlbumAsset {
|
||||||
let id: ID
|
let id: ID
|
||||||
@@ -93,15 +88,6 @@ struct LocalAlbumAsset {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension LocalAlbumAsset {
|
|
||||||
static let selected = Self.where {
|
|
||||||
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.selected.select(\.id))
|
|
||||||
}
|
|
||||||
static let excluded = Self.where {
|
|
||||||
$0.id.assetId.eq(LocalAsset.columns.id) && $0.id.albumId.in(LocalAlbum.excluded.select(\.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Table("local_asset_entity")
|
@Table("local_asset_entity")
|
||||||
struct LocalAsset: Identifiable {
|
struct LocalAsset: Identifiable {
|
||||||
let id: String
|
let id: String
|
||||||
@@ -119,18 +105,6 @@ struct LocalAsset: Identifiable {
|
|||||||
@Column("updated_at")
|
@Column("updated_at")
|
||||||
let updatedAt: String
|
let updatedAt: String
|
||||||
let width: Int?
|
let width: Int?
|
||||||
|
|
||||||
static func getCandidates() -> Where<LocalAsset> {
|
|
||||||
return Self.where { local in
|
|
||||||
LocalAlbumAsset.selected.exists()
|
|
||||||
&& !LocalAlbumAsset.excluded.exists()
|
|
||||||
&& !RemoteAsset.where {
|
|
||||||
local.checksum.eq($0.checksum)
|
|
||||||
&& $0.ownerId.eq(Store.select(\.stringValue).where { $0.id.eq(StoreKey.currentUser.rawValue) }.unwrapped)
|
|
||||||
}.exists()
|
|
||||||
&& !UploadTask.where { $0.localId.eq(local.id) }.exists()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Selection
|
@Selection
|
||||||
@@ -143,6 +117,7 @@ struct LocalAssetCandidate {
|
|||||||
struct LocalAssetDownloadData {
|
struct LocalAssetDownloadData {
|
||||||
let checksum: String?
|
let checksum: String?
|
||||||
let createdAt: String
|
let createdAt: String
|
||||||
|
let filename: String
|
||||||
let livePhotoVideoId: RemoteAsset.ID?
|
let livePhotoVideoId: RemoteAsset.ID?
|
||||||
let localId: LocalAsset.ID
|
let localId: LocalAsset.ID
|
||||||
let taskId: UploadTask.ID
|
let taskId: UploadTask.ID
|
||||||
@@ -151,6 +126,7 @@ struct LocalAssetDownloadData {
|
|||||||
|
|
||||||
@Selection
|
@Selection
|
||||||
struct LocalAssetUploadData {
|
struct LocalAssetUploadData {
|
||||||
|
let filename: String
|
||||||
let filePath: URL
|
let filePath: URL
|
||||||
let priority: Float
|
let priority: Float
|
||||||
let taskId: UploadTask.ID
|
let taskId: UploadTask.ID
|
||||||
@@ -375,16 +351,7 @@ struct UploadTask: Identifiable {
|
|||||||
var priority: Float
|
var priority: Float
|
||||||
@Column("retry_after", as: Date?.UnixTimeRepresentation.self)
|
@Column("retry_after", as: Date?.UnixTimeRepresentation.self)
|
||||||
let retryAfter: Date?
|
let retryAfter: Date?
|
||||||
let status: TaskStatus
|
var status: TaskStatus
|
||||||
|
|
||||||
static func retryOrFail(code: UploadErrorCode, status: TaskStatus) -> Update<UploadTask, ()> {
|
|
||||||
return Self.update { row in
|
|
||||||
row.status = Case().when(row.attempts.lte(TaskConfig.maxRetries), then: TaskStatus.downloadPending).else(status)
|
|
||||||
row.attempts += 1
|
|
||||||
row.lastError = code
|
|
||||||
row.retryAfter = #sql("unixepoch('now') + (\(4 << row.attempts))")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Table("upload_task_stats")
|
@Table("upload_task_stats")
|
||||||
|
|||||||
62
mobile/ios/Runner/Upload/AssetData.swift
Normal file
62
mobile/ios/Runner/Upload/AssetData.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import StructuredFieldValues
|
||||||
|
|
||||||
|
struct AssetData: StructuredFieldValue {
|
||||||
|
static let structuredFieldType: StructuredFieldType = .dictionary
|
||||||
|
|
||||||
|
let deviceAssetId: String
|
||||||
|
let deviceId: String
|
||||||
|
let fileCreatedAt: String
|
||||||
|
let fileModifiedAt: String
|
||||||
|
let fileName: String
|
||||||
|
let isFavorite: Bool
|
||||||
|
let livePhotoVideoId: String?
|
||||||
|
|
||||||
|
static let boundary = "Boundary-\(UUID().uuidString)"
|
||||||
|
static let deviceAssetIdField = "--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceAssetId\"\r\n\r\n"
|
||||||
|
.data(using: .utf8)!
|
||||||
|
static let deviceIdField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceId\"\r\n\r\n"
|
||||||
|
.data(using: .utf8)!
|
||||||
|
static let fileCreatedAtField =
|
||||||
|
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileCreatedAt\"\r\n\r\n"
|
||||||
|
.data(using: .utf8)!
|
||||||
|
static let fileModifiedAtField =
|
||||||
|
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileModifiedAt\"\r\n\r\n"
|
||||||
|
.data(using: .utf8)!
|
||||||
|
static let isFavoriteField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"isFavorite\"\r\n\r\n"
|
||||||
|
.data(using: .utf8)!
|
||||||
|
static let livePhotoVideoIdField =
|
||||||
|
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"livePhotoVideoId\"\r\n\r\n"
|
||||||
|
.data(using: .utf8)!
|
||||||
|
static let trueData = "true".data(using: .utf8)!
|
||||||
|
static let falseData = "false".data(using: .utf8)!
|
||||||
|
static let footer = "\r\n--\(boundary)--\r\n".data(using: .utf8)!
|
||||||
|
static let contentType = "multipart/form-data; boundary=\(boundary)"
|
||||||
|
|
||||||
|
func multipart() -> (Data, Data) {
|
||||||
|
var header = Data()
|
||||||
|
header.append(Self.deviceAssetIdField)
|
||||||
|
header.append(deviceAssetId.data(using: .utf8)!)
|
||||||
|
|
||||||
|
header.append(Self.deviceIdField)
|
||||||
|
header.append(deviceId.data(using: .utf8)!)
|
||||||
|
|
||||||
|
header.append(Self.fileCreatedAtField)
|
||||||
|
header.append(fileCreatedAt.data(using: .utf8)!)
|
||||||
|
|
||||||
|
header.append(Self.fileModifiedAtField)
|
||||||
|
header.append(fileModifiedAt.data(using: .utf8)!)
|
||||||
|
|
||||||
|
header.append(Self.isFavoriteField)
|
||||||
|
header.append(isFavorite ? Self.trueData : Self.falseData)
|
||||||
|
|
||||||
|
if let livePhotoVideoId {
|
||||||
|
header.append(Self.livePhotoVideoIdField)
|
||||||
|
header.append(livePhotoVideoId.data(using: .utf8)!)
|
||||||
|
}
|
||||||
|
header.append(
|
||||||
|
"\r\n--\(Self.boundary)\r\nContent-Disposition: form-data; name=\"assetData\"; filename=\"\(fileName)\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||||
|
.data(using: .utf8)!
|
||||||
|
)
|
||||||
|
return (header, Self.footer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,33 @@
|
|||||||
import SQLiteData
|
import SQLiteData
|
||||||
|
|
||||||
class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
|
private let stateLock = NSLock()
|
||||||
private static let stateLock = NSLock()
|
private var transferStates: [Int64: NetworkTransferState] = [:]
|
||||||
private static var transferStates: [Int64: NetworkTransferState] = [:]
|
private var responseData: [Int64: Data] = [:]
|
||||||
private static var responseData: [Int64: Data] = [:]
|
private let jsonDecoder = JSONDecoder()
|
||||||
private static let jsonDecoder = JSONDecoder()
|
|
||||||
|
|
||||||
private let db: DatabasePool
|
private class NetworkTransferState {
|
||||||
private let statusListener: StatusEventListener
|
var lastUpdateTime: Date
|
||||||
private let progressListener: ProgressEventListener
|
var totalBytesTransferred: Int64
|
||||||
weak var downloadQueue: DownloadQueue?
|
var currentSpeed: Double?
|
||||||
weak var uploadQueue: UploadQueue?
|
|
||||||
|
|
||||||
init(db: DatabasePool, statusListener: StatusEventListener, progressListener: ProgressEventListener) {
|
init(lastUpdateTime: Date, totalBytesTransferred: Int64, currentSpeed: Double?) {
|
||||||
self.db = db
|
self.lastUpdateTime = lastUpdateTime
|
||||||
|
self.totalBytesTransferred = totalBytesTransferred
|
||||||
|
self.currentSpeed = currentSpeed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UploadApiDelegate<
|
||||||
|
TaskRepo: TaskProtocol,
|
||||||
|
StatusListener: TaskStatusListener,
|
||||||
|
ProgressListener: TaskProgressListener
|
||||||
|
>: NSObject, URLSessionDataDelegate, URLSessionTaskDelegate {
|
||||||
|
private let taskRepository: TaskRepo
|
||||||
|
private let statusListener: StatusListener
|
||||||
|
private let progressListener: ProgressListener
|
||||||
|
|
||||||
|
init(taskRepository: TaskRepo, statusListener: StatusListener, progressListener: ProgressListener) {
|
||||||
|
self.taskRepository = taskRepository
|
||||||
self.statusListener = statusListener
|
self.statusListener = statusListener
|
||||||
self.progressListener = progressListener
|
self.progressListener = progressListener
|
||||||
}
|
}
|
||||||
@@ -30,11 +44,11 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
let taskId = Int64(taskIdStr)
|
let taskId = Int64(taskIdStr)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
Self.stateLock.withLock {
|
stateLock.withLock {
|
||||||
if var response = Self.responseData[taskId] {
|
if var response = responseData[taskId] {
|
||||||
response.append(data)
|
response.append(data)
|
||||||
} else {
|
} else {
|
||||||
Self.responseData[taskId] = data
|
responseData[taskId] = data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,8 +56,7 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||||
Task {
|
Task {
|
||||||
defer {
|
defer {
|
||||||
downloadQueue?.startQueueProcessing()
|
NotificationCenter.default.post(name: .uploadTaskDidComplete, object: nil)
|
||||||
uploadQueue?.startQueueProcessing()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let taskDescriptionId = task.taskDescription,
|
guard let taskDescriptionId = task.taskDescription,
|
||||||
@@ -53,25 +66,27 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
}
|
}
|
||||||
|
|
||||||
defer {
|
defer {
|
||||||
Self.stateLock.withLock { let _ = Self.transferStates.removeValue(forKey: taskId) }
|
stateLock.withLock { let _ = transferStates.removeValue(forKey: taskId) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if let responseData = Self.stateLock.withLock({ Self.responseData.removeValue(forKey: taskId) }),
|
if let body = stateLock.withLock({ responseData.removeValue(forKey: taskId) }),
|
||||||
let httpResponse = task.response as? HTTPURLResponse
|
let response = task.response as? HTTPURLResponse
|
||||||
{
|
{
|
||||||
switch httpResponse.statusCode {
|
switch response.statusCode {
|
||||||
case 200, 201:
|
case 200, 201:
|
||||||
do {
|
do {
|
||||||
let response = try Self.jsonDecoder.decode(UploadSuccessResponse.self, from: responseData)
|
let response = try jsonDecoder.decode(UploadSuccessResponse.self, from: body)
|
||||||
return await handleSuccess(taskId: taskId, response: response)
|
return await handleSuccess(taskId: taskId, response: response)
|
||||||
} catch {
|
} catch {
|
||||||
return await handleFailure(taskId: taskId, code: .invalidResponse)
|
return await handleFailure(taskId: taskId, code: .invalidResponse)
|
||||||
}
|
}
|
||||||
|
case 401: return await handleFailure(taskId: taskId, code: .unauthorized)
|
||||||
case 400..<500:
|
case 400..<500:
|
||||||
dPrint(
|
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
||||||
"Response \(httpResponse.statusCode): \(String(data: responseData, encoding: .utf8) ?? "No response body")"
|
|
||||||
)
|
|
||||||
return await handleFailure(taskId: taskId, code: .badRequest)
|
return await handleFailure(taskId: taskId, code: .badRequest)
|
||||||
|
case 500..<600:
|
||||||
|
dPrint("Response \(response.statusCode): \(String(data: body, encoding: .utf8) ?? "No response body")")
|
||||||
|
return await handleFailure(taskId: taskId, code: .internalServerError)
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -111,8 +126,8 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
) {
|
) {
|
||||||
guard let sessionTaskId = task.taskDescription, let taskId = Int64(sessionTaskId) else { return }
|
guard let sessionTaskId = task.taskDescription, let taskId = Int64(sessionTaskId) else { return }
|
||||||
let currentTime = Date()
|
let currentTime = Date()
|
||||||
let state = Self.stateLock.withLock {
|
let state = stateLock.withLock {
|
||||||
if let existing = Self.transferStates[taskId] {
|
if let existing = transferStates[taskId] {
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
let new = NetworkTransferState(
|
let new = NetworkTransferState(
|
||||||
@@ -120,7 +135,7 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
totalBytesTransferred: totalBytesSent,
|
totalBytesTransferred: totalBytesSent,
|
||||||
currentSpeed: nil
|
currentSpeed: nil
|
||||||
)
|
)
|
||||||
Self.transferStates[taskId] = new
|
transferStates[taskId] = new
|
||||||
return new
|
return new
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,30 +162,29 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
|
||||||
|
dPrint("All background events delivered for session: \(session.configuration.identifier ?? "unknown")")
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let identifier = session.configuration.identifier,
|
||||||
|
let appDelegate = UIApplication.shared.delegate as? AppDelegate,
|
||||||
|
let completionHandler = appDelegate.completionHandler(forSession: identifier)
|
||||||
|
{
|
||||||
|
completionHandler()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func handleSuccess(taskId: Int64, response: UploadSuccessResponse) async {
|
private func handleSuccess(taskId: Int64, response: UploadSuccessResponse) async {
|
||||||
dPrint("Upload succeeded for task \(taskId), server ID: \(response.id)")
|
dPrint("Upload succeeded for task \(taskId), server ID: \(response.id)")
|
||||||
do {
|
do {
|
||||||
try await db.write { conn in
|
try await taskRepository.markUploadSuccess(taskId: taskId, livePhotoVideoId: response.id)
|
||||||
let task = try UploadTask.update { $0.status = .uploadComplete }.where({ $0.id.eq(taskId) })
|
statusListener.onTaskStatus(
|
||||||
.returning(\.self).fetchOne(conn)
|
UploadApiTaskStatus(
|
||||||
guard let task, let isLivePhoto = task.isLivePhoto, isLivePhoto, task.livePhotoVideoId == nil else { return }
|
id: String(taskId),
|
||||||
try UploadTask.insert {
|
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
||||||
UploadTask.Draft(
|
status: .uploadComplete
|
||||||
attempts: 0,
|
)
|
||||||
createdAt: Date(),
|
|
||||||
filePath: nil,
|
|
||||||
isLivePhoto: true,
|
|
||||||
lastError: nil,
|
|
||||||
livePhotoVideoId: response.id,
|
|
||||||
localId: task.localId,
|
|
||||||
method: .multipart,
|
|
||||||
priority: 0.7,
|
|
||||||
retryAfter: nil,
|
|
||||||
status: .downloadPending,
|
|
||||||
)
|
)
|
||||||
}.execute(conn)
|
|
||||||
}
|
|
||||||
dPrint("Updated upload success status for session task \(taskId)")
|
|
||||||
} catch {
|
} catch {
|
||||||
dPrint(
|
dPrint(
|
||||||
"Failed to update upload success status for session task \(taskId): \(error.localizedDescription)"
|
"Failed to update upload success status for session task \(taskId): \(error.localizedDescription)"
|
||||||
@@ -180,10 +194,14 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
|
|
||||||
private func handleFailure(taskId: Int64, code: UploadErrorCode = .unknown) async {
|
private func handleFailure(taskId: Int64, code: UploadErrorCode = .unknown) async {
|
||||||
dPrint("Upload failed for task \(taskId) with code \(code)")
|
dPrint("Upload failed for task \(taskId) with code \(code)")
|
||||||
try? await db.write { conn in
|
try? await taskRepository.retryOrFail(taskId: taskId, code: code, status: .uploadFailed)
|
||||||
try UploadTask.retryOrFail(code: code, status: .uploadFailed).where { $0.id.eq(taskId) }
|
statusListener.onTaskStatus(
|
||||||
.execute(conn)
|
UploadApiTaskStatus(
|
||||||
}
|
id: String(taskId),
|
||||||
|
filename: (try? await taskRepository.getFilename(taskId: taskId)) ?? "",
|
||||||
|
status: .uploadFailed
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS 17, *)
|
@available(iOS 17, *)
|
||||||
@@ -193,16 +211,4 @@ class UploadApiDelegate: NSObject, URLSessionDataDelegate, URLSessionTaskDelegat
|
|||||||
resumeTask.taskDescription = taskDescriptionId
|
resumeTask.taskDescription = taskDescriptionId
|
||||||
resumeTask.resume()
|
resumeTask.resume()
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NetworkTransferState {
|
|
||||||
var lastUpdateTime: Date
|
|
||||||
var totalBytesTransferred: Int64
|
|
||||||
var currentSpeed: Double?
|
|
||||||
|
|
||||||
init(lastUpdateTime: Date, totalBytesTransferred: Int64, currentSpeed: Double?) {
|
|
||||||
self.lastUpdateTime = lastUpdateTime
|
|
||||||
self.totalBytesTransferred = totalBytesTransferred
|
|
||||||
self.currentSpeed = currentSpeed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,131 +1,105 @@
|
|||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Photos
|
import Photos
|
||||||
import SQLiteData
|
|
||||||
|
|
||||||
class DownloadQueue {
|
private var queueProcessingTask: Task<Void, Never>?
|
||||||
private static let resourceManager = PHAssetResourceManager.default()
|
private var queueProcessingLock = NSLock()
|
||||||
private static var queueProcessingTask: Task<Void, Never>?
|
private let resourceManager = PHAssetResourceManager.default()
|
||||||
private static var queueProcessingLock = NSLock()
|
|
||||||
|
|
||||||
private let db: DatabasePool
|
private final class RequestRef {
|
||||||
private let uploadQueue: UploadQueue
|
var id: PHAssetResourceDataRequestID?
|
||||||
private let statusListener: StatusEventListener
|
var lastProgressTime = Date()
|
||||||
private let progressListener: ProgressEventListener
|
var didStall = false
|
||||||
|
}
|
||||||
|
|
||||||
|
final class DownloadQueue<
|
||||||
|
StoreRepo: StoreProtocol,
|
||||||
|
TaskRepo: TaskProtocol,
|
||||||
|
StatusListener: TaskStatusListener,
|
||||||
|
ProgressListener: TaskProgressListener
|
||||||
|
> {
|
||||||
|
private let storeRepository: StoreRepo
|
||||||
|
private let taskRepository: TaskRepo
|
||||||
|
private let statusListener: StatusListener
|
||||||
|
private let progressListener: ProgressListener
|
||||||
|
private var uploadObserver: NSObjectProtocol?
|
||||||
|
private var networkObserver: NSObjectProtocol?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
db: DatabasePool,
|
storeRepository: StoreRepo,
|
||||||
uploadQueue: UploadQueue,
|
taskRepository: TaskRepo,
|
||||||
statusListener: StatusEventListener,
|
statusListener: StatusListener,
|
||||||
progressListener: ProgressEventListener
|
progressListener: ProgressListener
|
||||||
) {
|
) {
|
||||||
self.db = db
|
self.storeRepository = storeRepository
|
||||||
self.uploadQueue = uploadQueue
|
self.taskRepository = taskRepository
|
||||||
self.statusListener = statusListener
|
self.statusListener = statusListener
|
||||||
self.progressListener = progressListener
|
self.progressListener = progressListener
|
||||||
NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) { [weak self] _ in
|
uploadObserver = NotificationCenter.default.addObserver(forName: .uploadTaskDidComplete, object: nil, queue: nil) {
|
||||||
|
[weak self] _ in
|
||||||
|
self?.startQueueProcessing()
|
||||||
|
}
|
||||||
|
networkObserver = NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) {
|
||||||
|
[weak self] _ in
|
||||||
dPrint("Network connected")
|
dPrint("Network connected")
|
||||||
self?.startQueueProcessing()
|
self?.startQueueProcessing()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
uploadObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||||
|
networkObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||||
|
}
|
||||||
|
|
||||||
func enqueueAssets(localIds: [String]) async throws {
|
func enqueueAssets(localIds: [String]) async throws {
|
||||||
guard !localIds.isEmpty else { return dPrint("No assets to enqueue") }
|
guard !localIds.isEmpty else { return dPrint("No assets to enqueue") }
|
||||||
|
|
||||||
defer { startQueueProcessing() }
|
defer { startQueueProcessing() }
|
||||||
let candidates = try await db.read { conn in
|
let candidates = try await taskRepository.getBackupCandidates(ids: localIds)
|
||||||
return try LocalAsset.all
|
|
||||||
.where { asset in asset.id.in(localIds) }
|
|
||||||
.select { LocalAssetCandidate.Columns(id: $0.id, type: $0.type) }
|
|
||||||
.limit { _ in UploadTaskStat.availableSlots }
|
|
||||||
.fetchAll(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard !candidates.isEmpty else { return dPrint("No candidates to enqueue") }
|
guard !candidates.isEmpty else { return dPrint("No candidates to enqueue") }
|
||||||
|
|
||||||
try await db.write { conn in
|
try await taskRepository.enqueue(assets: candidates, imagePriority: 0.9, videoPriority: 0.8)
|
||||||
var draft = UploadTask.Draft(
|
|
||||||
attempts: 0,
|
|
||||||
createdAt: Date(),
|
|
||||||
filePath: nil,
|
|
||||||
isLivePhoto: nil,
|
|
||||||
lastError: nil,
|
|
||||||
livePhotoVideoId: nil,
|
|
||||||
localId: "",
|
|
||||||
method: .multipart,
|
|
||||||
priority: 0.5,
|
|
||||||
retryAfter: nil,
|
|
||||||
status: .downloadPending,
|
|
||||||
)
|
|
||||||
for candidate in candidates {
|
|
||||||
draft.localId = candidate.id
|
|
||||||
draft.priority = candidate.type == .image ? 0.9 : 0.8
|
|
||||||
try UploadTask.insert {
|
|
||||||
draft
|
|
||||||
} onConflict: {
|
|
||||||
($0.localId, $0.livePhotoVideoId)
|
|
||||||
}.execute(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dPrint("Enqueued \(candidates.count) assets for upload")
|
dPrint("Enqueued \(candidates.count) assets for upload")
|
||||||
}
|
}
|
||||||
|
|
||||||
func startQueueProcessing() {
|
func startQueueProcessing() {
|
||||||
dPrint("Starting download queue processing")
|
dPrint("Starting download queue processing")
|
||||||
Self.queueProcessingLock.withLock {
|
queueProcessingLock.withLock {
|
||||||
guard Self.queueProcessingTask == nil else { return }
|
guard queueProcessingTask == nil else { return }
|
||||||
Self.queueProcessingTask = Task {
|
queueProcessingTask = Task {
|
||||||
await startDownloads()
|
await startDownloads()
|
||||||
Self.queueProcessingLock.withLock { Self.queueProcessingTask = nil }
|
queueProcessingLock.withLock { queueProcessingTask = nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startDownloads() async {
|
private func startDownloads() async {
|
||||||
dPrint("Processing download queue")
|
dPrint("Processing download queue")
|
||||||
guard NetworkMonitor.shared.isConnected else {
|
|
||||||
return dPrint("Download queue paused: network disconnected")
|
guard await UIApplication.shared.applicationState != .background else {
|
||||||
|
return dPrint("Not processing downloads in background") // TODO: run in processing tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard NetworkMonitor.shared.isConnected,
|
||||||
|
let backupEnabled = try? storeRepository.get(StoreKey.enableBackup), backupEnabled,
|
||||||
|
let deviceId = try? storeRepository.get(StoreKey.deviceId)
|
||||||
|
else { return dPrint("Download queue paused: missing preconditions") }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let tasks: [LocalAssetDownloadData] = try await db.read({ conn in
|
let tasks = try await taskRepository.getDownloadTasks()
|
||||||
guard let backupEnabled = try Store.get(conn, StoreKey.enableBackup), backupEnabled else { return [] }
|
|
||||||
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
|
||||||
.where { task, asset in
|
|
||||||
asset.checksum.isNot(nil) && task.status.eq(TaskStatus.downloadPending)
|
|
||||||
&& task.attempts < TaskConfig.maxRetries
|
|
||||||
&& (task.retryAfter.is(nil) || task.retryAfter.unwrapped <= Date().unixTime)
|
|
||||||
&& (task.lastError.is(nil)
|
|
||||||
|| !task.lastError.unwrapped.in([
|
|
||||||
UploadErrorCode.assetNotFound, UploadErrorCode.resourceNotFound, UploadErrorCode.invalidResource,
|
|
||||||
]))
|
|
||||||
}
|
|
||||||
.select { task, asset in
|
|
||||||
LocalAssetDownloadData.Columns(
|
|
||||||
checksum: asset.checksum,
|
|
||||||
createdAt: asset.createdAt,
|
|
||||||
livePhotoVideoId: task.livePhotoVideoId,
|
|
||||||
localId: asset.id,
|
|
||||||
taskId: task.id,
|
|
||||||
updatedAt: asset.updatedAt
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
|
||||||
.limit { _, _ in UploadTaskStat.availableDownloadSlots }
|
|
||||||
.fetchAll(conn)
|
|
||||||
})
|
|
||||||
if tasks.isEmpty { return dPrint("No download tasks to process") }
|
if tasks.isEmpty { return dPrint("No download tasks to process") }
|
||||||
|
|
||||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||||
var iterator = tasks.makeIterator()
|
var iterator = tasks.makeIterator()
|
||||||
for _ in 0..<min(TaskConfig.maxActiveDownloads, tasks.count) {
|
for _ in 0..<min(TaskConfig.maxActiveDownloads, tasks.count) {
|
||||||
if let task = iterator.next() {
|
if let task = iterator.next() {
|
||||||
group.addTask { await self.downloadAndQueue(task) }
|
group.addTask { await self.downloadAndQueue(task, deviceId: deviceId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
while try await group.next() != nil {
|
while try await group.next() != nil {
|
||||||
if let task = iterator.next() {
|
if let task = iterator.next() {
|
||||||
group.addTask { await self.downloadAndQueue(task) }
|
group.addTask { await self.downloadAndQueue(task, deviceId: deviceId) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,26 +108,21 @@ class DownloadQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func downloadAndQueue(_ task: LocalAssetDownloadData) async {
|
private func downloadAndQueue(_ task: LocalAssetDownloadData, deviceId: String) async {
|
||||||
defer { startQueueProcessing() }
|
defer { startQueueProcessing() }
|
||||||
dPrint("Starting download for task \(task.taskId)")
|
dPrint("Starting download for task \(task.taskId)")
|
||||||
|
|
||||||
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [task.localId], options: nil).firstObject
|
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [task.localId], options: nil).firstObject
|
||||||
else {
|
else {
|
||||||
dPrint("Asset not found")
|
dPrint("Asset not found")
|
||||||
return handleFailure(task: task, code: .assetNotFound)
|
return await handleFailure(task: task, code: .assetNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
let isLivePhoto = asset.mediaSubtypes.contains(.photoLive)
|
let isLivePhoto = asset.mediaSubtypes.contains(.photoLive)
|
||||||
let isMotion = isLivePhoto && task.livePhotoVideoId != nil
|
let isMotion = isLivePhoto && task.livePhotoVideoId != nil
|
||||||
guard let resource = isMotion ? asset.getLivePhotoResource() : asset.getResource() else {
|
guard let resource = isMotion ? asset.getLivePhotoResource() : asset.getResource() else {
|
||||||
dPrint("Resource not found")
|
dPrint("Resource not found")
|
||||||
return handleFailure(task: task, code: .resourceNotFound)
|
return await handleFailure(task: task, code: .resourceNotFound)
|
||||||
}
|
|
||||||
|
|
||||||
guard let deviceId = (try? await db.read { conn in try Store.get(conn, StoreKey.deviceId) }) else {
|
|
||||||
dPrint("Device ID not found")
|
|
||||||
return handleFailure(task: task, code: .noDeviceId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileDir = TaskConfig.originalsDir
|
let fileDir = TaskConfig.originalsDir
|
||||||
@@ -167,17 +136,11 @@ class DownloadQueue {
|
|||||||
)
|
)
|
||||||
} catch {
|
} catch {
|
||||||
dPrint("Failed to create directory for download task \(task.taskId): \(error)")
|
dPrint("Failed to create directory for download task \(task.taskId): \(error)")
|
||||||
return handleFailure(task: task, code: .writeFailed, filePath: filePath)
|
return await handleFailure(task: task, code: .writeFailed, filePath: filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try await db.write { conn in
|
try await taskRepository.markDownloadQueued(taskId: task.taskId, isLivePhoto: isLivePhoto, filePath: filePath)
|
||||||
try UploadTask.update {
|
|
||||||
$0.status = .downloadQueued
|
|
||||||
$0.isLivePhoto = isLivePhoto
|
|
||||||
$0.filePath = filePath
|
|
||||||
}.where { $0.id.eq(task.taskId) }.execute(conn)
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
return dPrint("Failed to set file path for download task \(task.taskId): \(error)")
|
return dPrint("Failed to set file path for download task \(task.taskId): \(error)")
|
||||||
}
|
}
|
||||||
@@ -187,28 +150,21 @@ class DownloadQueue {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let hash = try await download(task: task, asset: asset, resource: resource, to: filePath, deviceId: deviceId)
|
let hash = try await download(task: task, asset: asset, resource: resource, to: filePath, deviceId: deviceId)
|
||||||
let status = try await db.write { conn in
|
let status = try await taskRepository.markDownloadComplete(taskId: task.taskId, localId: task.localId, hash: hash)
|
||||||
if let hash { try LocalAsset.update { $0.checksum = hash }.where { $0.id.eq(task.localId) }.execute(conn) }
|
|
||||||
let status =
|
|
||||||
if let hash, try RemoteAsset.select(\.rowid).where({ $0.checksum.eq(hash) }).fetchOne(conn) != nil {
|
|
||||||
TaskStatus.uploadSkipped
|
|
||||||
} else {
|
|
||||||
TaskStatus.uploadPending
|
|
||||||
}
|
|
||||||
try UploadTask.update { $0.status = .uploadPending }.where { $0.id.eq(task.taskId) }.execute(conn)
|
|
||||||
return status
|
|
||||||
}
|
|
||||||
statusListener.onTaskStatus(
|
statusListener.onTaskStatus(
|
||||||
UploadApiTaskStatus(
|
UploadApiTaskStatus(
|
||||||
id: String(task.taskId),
|
id: String(task.taskId),
|
||||||
filename: filePath.path,
|
filename: task.filename,
|
||||||
status: UploadApiStatus(rawValue: status.rawValue)!
|
status: UploadApiStatus(rawValue: status.rawValue)!
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
uploadQueue.startQueueProcessing()
|
NotificationCenter.default.post(name: .downloadTaskDidComplete, object: nil)
|
||||||
|
} catch let error as UploadError {
|
||||||
|
dPrint("Download failed for task \(task.taskId): \(error)")
|
||||||
|
await handleFailure(task: task, code: error.code, filePath: filePath)
|
||||||
} catch {
|
} catch {
|
||||||
dPrint("Download failed for task \(task.taskId): \(error)")
|
dPrint("Download failed for task \(task.taskId): \(error)")
|
||||||
handleFailure(task: task, code: .writeFailed, filePath: filePath)
|
await handleFailure(task: task, code: .unknown, filePath: filePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,18 +196,11 @@ class DownloadQueue {
|
|||||||
}
|
}
|
||||||
try fileHandle.write(contentsOf: header)
|
try fileHandle.write(contentsOf: header)
|
||||||
|
|
||||||
class RequestRef {
|
|
||||||
var id: PHAssetResourceDataRequestID?
|
|
||||||
var lastProgressTime = Date()
|
var lastProgressTime = Date()
|
||||||
var didStall = false
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastProgressTime = Date()
|
|
||||||
nonisolated(unsafe) let progressListener = self.progressListener
|
|
||||||
let taskIdStr = String(task.taskId)
|
let taskIdStr = String(task.taskId)
|
||||||
options.progressHandler = { progress in
|
options.progressHandler = { progress in
|
||||||
lastProgressTime = Date()
|
lastProgressTime = Date()
|
||||||
progressListener.onTaskProgress(UploadApiTaskProgress(id: taskIdStr, progress: progress))
|
self.progressListener.onTaskProgress(UploadApiTaskProgress(id: taskIdStr, progress: progress))
|
||||||
}
|
}
|
||||||
|
|
||||||
let request = RequestRef()
|
let request = RequestRef()
|
||||||
@@ -261,7 +210,7 @@ class DownloadQueue {
|
|||||||
request.didStall = Date().timeIntervalSince(lastProgressTime) > TaskConfig.downloadTimeoutS
|
request.didStall = Date().timeIntervalSince(lastProgressTime) > TaskConfig.downloadTimeoutS
|
||||||
if request.didStall {
|
if request.didStall {
|
||||||
if let requestId = request.id {
|
if let requestId = request.id {
|
||||||
Self.resourceManager.cancelDataRequest(requestId)
|
resourceManager.cancelDataRequest(requestId)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -271,7 +220,7 @@ class DownloadQueue {
|
|||||||
return try await withTaskCancellationHandler {
|
return try await withTaskCancellationHandler {
|
||||||
try await withCheckedThrowingContinuation { continuation in
|
try await withCheckedThrowingContinuation { continuation in
|
||||||
var hasher = task.checksum == nil && task.livePhotoVideoId == nil ? Insecure.SHA1() : nil
|
var hasher = task.checksum == nil && task.livePhotoVideoId == nil ? Insecure.SHA1() : nil
|
||||||
request.id = Self.resourceManager.requestData(
|
request.id = resourceManager.requestData(
|
||||||
for: resource,
|
for: resource,
|
||||||
options: options,
|
options: options,
|
||||||
dataReceivedHandler: { data in
|
dataReceivedHandler: { data in
|
||||||
@@ -281,7 +230,7 @@ class DownloadQueue {
|
|||||||
try fileHandle.write(contentsOf: data)
|
try fileHandle.write(contentsOf: data)
|
||||||
} catch {
|
} catch {
|
||||||
request.id = nil
|
request.id = nil
|
||||||
Self.resourceManager.cancelDataRequest(requestId)
|
resourceManager.cancelDataRequest(requestId)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
completionHandler: { error in
|
completionHandler: { error in
|
||||||
@@ -295,7 +244,7 @@ class DownloadQueue {
|
|||||||
case 81: .iCloudThrottled
|
case 81: .iCloudThrottled
|
||||||
default: .photosUnknownError
|
default: .photosUnknownError
|
||||||
}
|
}
|
||||||
self.handleFailure(task: task, code: code, filePath: filePath)
|
continuation.resume(throwing: UploadError.iCloudError(code))
|
||||||
case let e as PHPhotosError:
|
case let e as PHPhotosError:
|
||||||
dPrint("Photos error during download: \(e)")
|
dPrint("Photos error during download: \(e)")
|
||||||
let code: UploadErrorCode =
|
let code: UploadErrorCode =
|
||||||
@@ -310,10 +259,10 @@ class DownloadQueue {
|
|||||||
case .userCancelled: .cancelled
|
case .userCancelled: .cancelled
|
||||||
default: .photosUnknownError
|
default: .photosUnknownError
|
||||||
}
|
}
|
||||||
self.handleFailure(task: task, code: code, filePath: filePath)
|
continuation.resume(throwing: UploadError.photosError(code))
|
||||||
case .some:
|
case .some:
|
||||||
dPrint("Unknown error during download: \(String(describing: error))")
|
dPrint("Unknown error during download: \(String(describing: error))")
|
||||||
self.handleFailure(task: task, code: .unknown, filePath: filePath)
|
continuation.resume(throwing: UploadError.unknown)
|
||||||
case .none:
|
case .none:
|
||||||
dPrint("Download completed for task \(task.taskId)")
|
dPrint("Download completed for task \(task.taskId)")
|
||||||
do {
|
do {
|
||||||
@@ -321,7 +270,7 @@ class DownloadQueue {
|
|||||||
continuation.resume(returning: hasher.map { hasher in Data(hasher.finalize()).base64EncodedString() })
|
continuation.resume(returning: hasher.map { hasher in Data(hasher.finalize()).base64EncodedString() })
|
||||||
} catch {
|
} catch {
|
||||||
try? FileManager.default.removeItem(at: filePath)
|
try? FileManager.default.removeItem(at: filePath)
|
||||||
continuation.resume(throwing: error)
|
continuation.resume(throwing: UploadError.fileCreationFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -329,21 +278,26 @@ class DownloadQueue {
|
|||||||
}
|
}
|
||||||
} onCancel: {
|
} onCancel: {
|
||||||
if let requestId = request.id {
|
if let requestId = request.id {
|
||||||
Self.resourceManager.cancelDataRequest(requestId)
|
resourceManager.cancelDataRequest(requestId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFailure(task: LocalAssetDownloadData, code: UploadErrorCode, filePath: URL? = nil) {
|
private func handleFailure(task: LocalAssetDownloadData, code: UploadErrorCode, filePath: URL? = nil) async {
|
||||||
dPrint("Handling failure for task \(task.taskId) with code \(code.rawValue)")
|
dPrint("Handling failure for task \(task.taskId) with code \(code.rawValue)")
|
||||||
do {
|
do {
|
||||||
if let filePath {
|
if let filePath {
|
||||||
try? FileManager.default.removeItem(at: filePath)
|
try? FileManager.default.removeItem(at: filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
try db.write { conn in
|
try await taskRepository.retryOrFail(taskId: task.taskId, code: code, status: .downloadFailed)
|
||||||
try UploadTask.retryOrFail(code: code, status: .downloadFailed).where { $0.id.eq(task.taskId) }.execute(conn)
|
statusListener.onTaskStatus(
|
||||||
}
|
UploadApiTaskStatus(
|
||||||
|
id: String(task.taskId),
|
||||||
|
filename: task.filename,
|
||||||
|
status: .downloadFailed
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
dPrint("Failed to update download failure status for task \(task.taskId): \(error)")
|
dPrint("Failed to update download failure status for task \(task.taskId): \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
class StatusEventListener: StreamStatusStreamHandler {
|
protocol TaskProgressListener {
|
||||||
|
func onTaskProgress(_ event: UploadApiTaskProgress)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol TaskStatusListener {
|
||||||
|
func onTaskStatus(_ event: UploadApiTaskStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class StatusEventListener: StreamStatusStreamHandler, TaskStatusListener, @unchecked Sendable {
|
||||||
var eventSink: PigeonEventSink<UploadApiTaskStatus>?
|
var eventSink: PigeonEventSink<UploadApiTaskStatus>?
|
||||||
|
|
||||||
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskStatus>) {
|
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskStatus>) {
|
||||||
@@ -6,26 +14,7 @@ class StatusEventListener: StreamStatusStreamHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func onTaskStatus(_ event: UploadApiTaskStatus) {
|
func onTaskStatus(_ event: UploadApiTaskStatus) {
|
||||||
if let eventSink = eventSink {
|
if let eventSink {
|
||||||
eventSink.success(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func onEventsDone() {
|
|
||||||
eventSink?.endOfStream()
|
|
||||||
eventSink = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ProgressEventListener: StreamProgressStreamHandler {
|
|
||||||
var eventSink: PigeonEventSink<UploadApiTaskProgress>?
|
|
||||||
|
|
||||||
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskProgress>) {
|
|
||||||
eventSink = sink
|
|
||||||
}
|
|
||||||
|
|
||||||
func onTaskProgress(_ event: UploadApiTaskProgress) {
|
|
||||||
if let eventSink = eventSink {
|
|
||||||
DispatchQueue.main.async { eventSink.success(event) }
|
DispatchQueue.main.async { eventSink.success(event) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,3 +26,40 @@ class ProgressEventListener: StreamProgressStreamHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final class ProgressEventListener: StreamProgressStreamHandler, TaskProgressListener, @unchecked Sendable {
|
||||||
|
var eventSink: PigeonEventSink<UploadApiTaskProgress>?
|
||||||
|
private var lastReportTimes: [String: Date] = [:]
|
||||||
|
private let lock = NSLock()
|
||||||
|
|
||||||
|
override func onListen(withArguments arguments: Any?, sink: PigeonEventSink<UploadApiTaskProgress>) {
|
||||||
|
eventSink = sink
|
||||||
|
}
|
||||||
|
|
||||||
|
func onTaskProgress(_ event: UploadApiTaskProgress) {
|
||||||
|
guard let eventSink,
|
||||||
|
lock.withLock({
|
||||||
|
let now = Date()
|
||||||
|
if let lastReport = lastReportTimes[event.id] {
|
||||||
|
guard now.timeIntervalSince(lastReport) >= TaskConfig.progressThrottleInterval else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastReportTimes[event.id] = now
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
DispatchQueue.main.async { eventSink.success(event) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func onEventsDone() {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.eventSink?.endOfStream()
|
||||||
|
self.eventSink = nil
|
||||||
|
self.lock.withLock {
|
||||||
|
self.lastReportTimes.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Network
|
import Network
|
||||||
|
|
||||||
class NetworkMonitor {
|
final class NetworkMonitor {
|
||||||
static let shared = NetworkMonitor()
|
static let shared = NetworkMonitor()
|
||||||
private let monitor = NWPathMonitor()
|
private let monitor = NWPathMonitor()
|
||||||
private(set) var isConnected = false
|
private(set) var isConnected = false
|
||||||
@@ -17,6 +17,6 @@ class NetworkMonitor {
|
|||||||
NotificationCenter.default.post(name: .networkDidConnect, object: nil)
|
NotificationCenter.default.post(name: .networkDidConnect, object: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
monitor.start(queue: .global(qos: .utility))
|
monitor.start(queue: .global(qos: .default))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,57 @@
|
|||||||
import SQLiteData
|
private var queueProcessingTask: Task<Void, Never>?
|
||||||
import StructuredFieldValues
|
private var queueProcessingLock = NSLock()
|
||||||
|
|
||||||
class UploadQueue {
|
final class UploadQueue<StoreRepo: StoreProtocol, TaskRepo: TaskProtocol, StatusListener: TaskStatusListener> {
|
||||||
private static let structuredEncoder = StructuredFieldValueEncoder()
|
private let storeRepository: StoreRepo
|
||||||
private static var queueProcessingTask: Task<Void, Never>?
|
private let taskRepository: TaskRepo
|
||||||
private static var queueProcessingLock = NSLock()
|
private let statusListener: StatusListener
|
||||||
|
|
||||||
private let db: DatabasePool
|
|
||||||
private let cellularSession: URLSession
|
private let cellularSession: URLSession
|
||||||
private let wifiOnlySession: URLSession
|
private let wifiOnlySession: URLSession
|
||||||
private let statusListener: StatusEventListener
|
private var uploadObserver: NSObjectProtocol?
|
||||||
|
private var downloadObserver: NSObjectProtocol?
|
||||||
|
private var networkObserver: NSObjectProtocol?
|
||||||
|
|
||||||
init(db: DatabasePool, cellularSession: URLSession, wifiOnlySession: URLSession, statusListener: StatusEventListener)
|
init(
|
||||||
{
|
storeRepository: StoreRepo,
|
||||||
self.db = db
|
taskRepository: TaskRepo,
|
||||||
|
statusListener: StatusListener,
|
||||||
|
cellularSession: URLSession,
|
||||||
|
wifiOnlySession: URLSession
|
||||||
|
) {
|
||||||
|
self.storeRepository = storeRepository
|
||||||
|
self.taskRepository = taskRepository
|
||||||
self.cellularSession = cellularSession
|
self.cellularSession = cellularSession
|
||||||
self.wifiOnlySession = wifiOnlySession
|
self.wifiOnlySession = wifiOnlySession
|
||||||
self.statusListener = statusListener
|
self.statusListener = statusListener
|
||||||
|
|
||||||
|
uploadObserver = NotificationCenter.default.addObserver(forName: .uploadTaskDidComplete, object: nil, queue: nil) {
|
||||||
|
[weak self] _ in
|
||||||
|
self?.startQueueProcessing()
|
||||||
|
}
|
||||||
|
downloadObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .downloadTaskDidComplete,
|
||||||
|
object: nil,
|
||||||
|
queue: nil
|
||||||
|
) { [weak self] _ in
|
||||||
|
self?.startQueueProcessing()
|
||||||
|
}
|
||||||
|
networkObserver = NotificationCenter.default.addObserver(forName: .networkDidConnect, object: nil, queue: nil) {
|
||||||
|
[weak self] _ in
|
||||||
|
self?.startQueueProcessing()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
uploadObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||||
|
downloadObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||||
|
networkObserver.map(NotificationCenter.default.removeObserver(_:))
|
||||||
}
|
}
|
||||||
|
|
||||||
func enqueueFiles(paths: [String]) async throws {
|
func enqueueFiles(paths: [String]) async throws {
|
||||||
guard !paths.isEmpty else { return dPrint("No paths to enqueue") }
|
guard !paths.isEmpty else { return dPrint("No paths to enqueue") }
|
||||||
|
|
||||||
guard let deviceId = (try? await db.read { conn in try Store.get(conn, StoreKey.deviceId) }) else {
|
guard let deviceId = try? storeRepository.get(StoreKey.deviceId) else {
|
||||||
throw StoreError.notFound
|
throw StoreError.notFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,72 +104,36 @@ class UploadQueue {
|
|||||||
try await group.waitForAll()
|
try await group.waitForAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
try await db.write { conn in
|
try await taskRepository.enqueue(files: paths)
|
||||||
var draft = UploadTask.Draft(
|
|
||||||
attempts: 0,
|
|
||||||
createdAt: Date(),
|
|
||||||
filePath: nil,
|
|
||||||
isLivePhoto: nil,
|
|
||||||
lastError: nil,
|
|
||||||
livePhotoVideoId: nil,
|
|
||||||
localId: "",
|
|
||||||
method: .multipart,
|
|
||||||
priority: 0.5,
|
|
||||||
retryAfter: nil,
|
|
||||||
status: .downloadPending,
|
|
||||||
)
|
|
||||||
for path in paths {
|
|
||||||
draft.filePath = URL(fileURLWithPath: path, isDirectory: false)
|
|
||||||
try UploadTask.insert { draft }.execute(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dPrint("Enqueued \(paths.count) assets for upload")
|
dPrint("Enqueued \(paths.count) assets for upload")
|
||||||
}
|
}
|
||||||
|
|
||||||
func startQueueProcessing() {
|
func startQueueProcessing() {
|
||||||
dPrint("Starting upload queue processing")
|
dPrint("Starting upload queue processing")
|
||||||
Self.queueProcessingLock.withLock {
|
queueProcessingLock.withLock {
|
||||||
guard Self.queueProcessingTask == nil else { return }
|
guard queueProcessingTask == nil else { return }
|
||||||
Self.queueProcessingTask = Task {
|
queueProcessingTask = Task {
|
||||||
await startUploads()
|
await startUploads()
|
||||||
Self.queueProcessingLock.withLock { Self.queueProcessingTask = nil }
|
queueProcessingLock.withLock { queueProcessingTask = nil }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startUploads() async {
|
private func startUploads() async {
|
||||||
dPrint("Processing download queue")
|
dPrint("Processing upload queue")
|
||||||
guard NetworkMonitor.shared.isConnected,
|
guard NetworkMonitor.shared.isConnected,
|
||||||
let backupEnabled = try? await db.read({ conn in try Store.get(conn, StoreKey.enableBackup) }),
|
let backupEnabled = try? storeRepository.get(StoreKey.enableBackup), backupEnabled,
|
||||||
backupEnabled
|
let url = try? storeRepository.get(StoreKey.serverEndpoint),
|
||||||
else { return dPrint("Download queue paused: network disconnected or backup disabled") }
|
let accessToken = try? storeRepository.get(StoreKey.accessToken)
|
||||||
|
else { return dPrint("Upload queue paused: missing preconditions") }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let tasks: [LocalAssetUploadData] = try await db.read({ conn in
|
let tasks = try await taskRepository.getUploadTasks()
|
||||||
guard let backupEnabled = try Store.get(conn, StoreKey.enableBackup), backupEnabled else { return [] }
|
|
||||||
return try UploadTask.join(LocalAsset.all) { task, asset in task.localId.eq(asset.id) }
|
|
||||||
.where { task, asset in
|
|
||||||
asset.checksum.isNot(nil) && task.status.eq(TaskStatus.uploadPending)
|
|
||||||
&& task.attempts < TaskConfig.maxRetries
|
|
||||||
&& task.filePath.isNot(nil)
|
|
||||||
}
|
|
||||||
.select { task, asset in
|
|
||||||
LocalAssetUploadData.Columns(
|
|
||||||
filePath: task.filePath.unwrapped,
|
|
||||||
priority: task.priority,
|
|
||||||
taskId: task.id,
|
|
||||||
type: asset.type
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.limit { task, _ in UploadTaskStat.availableUploadSlots }
|
|
||||||
.order { task, asset in (task.priority.desc(), task.createdAt) }
|
|
||||||
.fetchAll(conn)
|
|
||||||
})
|
|
||||||
if tasks.isEmpty { return dPrint("No upload tasks to process") }
|
if tasks.isEmpty { return dPrint("No upload tasks to process") }
|
||||||
|
|
||||||
await withTaskGroup(of: Void.self) { group in
|
await withTaskGroup(of: Void.self) { group in
|
||||||
for task in tasks {
|
for task in tasks {
|
||||||
group.addTask { await self.startUpload(multipart: task) }
|
group.addTask { await self.startUpload(multipart: task, url: url, accessToken: accessToken) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -148,33 +141,18 @@ class UploadQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startUpload(multipart task: LocalAssetUploadData) async {
|
private func startUpload(multipart task: LocalAssetUploadData, url: URL, accessToken: String) async {
|
||||||
dPrint("Uploading asset resource at \(task.filePath) of task \(task.taskId)")
|
dPrint("Uploading asset resource at \(task.filePath) of task \(task.taskId)")
|
||||||
defer { startQueueProcessing() }
|
defer { startQueueProcessing() }
|
||||||
|
|
||||||
let (url, accessToken, session): (URL, String, URLSession)
|
|
||||||
do {
|
|
||||||
(url, accessToken, session) = try await db.read { conn in
|
|
||||||
guard let url = try Store.get(conn, StoreKey.serverEndpoint),
|
|
||||||
let accessToken = try Store.get(conn, StoreKey.accessToken)
|
|
||||||
else {
|
|
||||||
throw StoreError.notFound
|
|
||||||
}
|
|
||||||
|
|
||||||
let session =
|
let session =
|
||||||
switch task.type {
|
switch task.type {
|
||||||
case .image:
|
case .image:
|
||||||
(try? Store.get(conn, StoreKey.useWifiForUploadPhotos)) ?? false ? cellularSession : wifiOnlySession
|
(try? storeRepository.get(StoreKey.useWifiForUploadPhotos)) ?? false ? wifiOnlySession : cellularSession
|
||||||
case .video:
|
case .video:
|
||||||
(try? Store.get(conn, StoreKey.useWifiForUploadVideos)) ?? false ? cellularSession : wifiOnlySession
|
(try? storeRepository.get(StoreKey.useWifiForUploadVideos)) ?? false ? wifiOnlySession : cellularSession
|
||||||
default: wifiOnlySession
|
default: wifiOnlySession
|
||||||
}
|
}
|
||||||
return (url, accessToken, session)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
dPrint("Upload failed for \(task.taskId), could not retrieve server URL or access token: \(error)")
|
|
||||||
return handleFailure(task: task, code: .noServerUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: url.appendingPathComponent("/assets"))
|
var request = URLRequest(url: url.appendingPathComponent("/assets"))
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
@@ -186,18 +164,11 @@ class UploadQueue {
|
|||||||
sessionTask.priority = task.priority
|
sessionTask.priority = task.priority
|
||||||
do {
|
do {
|
||||||
try? FileManager.default.removeItem(at: task.filePath) // upload task already copied the file
|
try? FileManager.default.removeItem(at: task.filePath) // upload task already copied the file
|
||||||
try await db.write { conn in
|
try await taskRepository.markUploadQueued(taskId: task.taskId)
|
||||||
try UploadTask.update { row in
|
|
||||||
row.status = .uploadQueued
|
|
||||||
row.filePath = nil
|
|
||||||
}
|
|
||||||
.where { $0.id.eq(task.taskId) }
|
|
||||||
.execute(conn)
|
|
||||||
}
|
|
||||||
statusListener.onTaskStatus(
|
statusListener.onTaskStatus(
|
||||||
UploadApiTaskStatus(
|
UploadApiTaskStatus(
|
||||||
id: String(task.taskId),
|
id: String(task.taskId),
|
||||||
filename: task.filePath.lastPathComponent,
|
filename: task.filename,
|
||||||
status: .uploadQueued,
|
status: .uploadQueued,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -209,11 +180,16 @@ class UploadQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleFailure(task: LocalAssetUploadData, code: UploadErrorCode) {
|
private func handleFailure(task: LocalAssetUploadData, code: UploadErrorCode) async {
|
||||||
do {
|
do {
|
||||||
try db.write { conn in
|
try await taskRepository.retryOrFail(taskId: task.taskId, code: code, status: .uploadFailed)
|
||||||
try UploadTask.retryOrFail(code: code, status: .uploadFailed).where { $0.id.eq(task.taskId) }.execute(conn)
|
statusListener.onTaskStatus(
|
||||||
}
|
UploadApiTaskStatus(
|
||||||
|
id: String(task.taskId),
|
||||||
|
filename: task.filename,
|
||||||
|
status: .uploadFailed
|
||||||
|
)
|
||||||
|
)
|
||||||
} catch {
|
} catch {
|
||||||
dPrint("Failed to update upload failure status for task \(task.taskId): \(error)")
|
dPrint("Failed to update upload failure status for task \(task.taskId): \(error)")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,18 +122,19 @@ enum UploadApiErrorCode: Int {
|
|||||||
case networkError = 8
|
case networkError = 8
|
||||||
case photosInternalError = 9
|
case photosInternalError = 9
|
||||||
case photosUnknownError = 10
|
case photosUnknownError = 10
|
||||||
case noServerUrl = 11
|
case interrupted = 11
|
||||||
case noDeviceId = 12
|
case cancelled = 12
|
||||||
case noAccessToken = 13
|
case downloadStalled = 13
|
||||||
case interrupted = 14
|
case forceQuit = 14
|
||||||
case cancelled = 15
|
case outOfResources = 15
|
||||||
case downloadStalled = 16
|
case backgroundUpdatesDisabled = 16
|
||||||
case forceQuit = 17
|
case uploadTimeout = 17
|
||||||
case outOfResources = 18
|
case iCloudRateLimit = 18
|
||||||
case backgroundUpdatesDisabled = 19
|
case iCloudThrottled = 19
|
||||||
case uploadTimeout = 20
|
case invalidResponse = 20
|
||||||
case iCloudRateLimit = 21
|
case badRequest = 21
|
||||||
case iCloudThrottled = 22
|
case internalServerError = 22
|
||||||
|
case unauthorized = 23
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UploadApiStatus: Int {
|
enum UploadApiStatus: Int {
|
||||||
@@ -294,6 +295,7 @@ protocol UploadApi {
|
|||||||
func cancelAll(completion: @escaping (Result<Void, Error>) -> Void)
|
func cancelAll(completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
func enqueueAssets(localIds: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
func enqueueFiles(paths: [String], completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
|
func onConfigChange(key: Int64, completion: @escaping (Result<Void, Error>) -> Void)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
|
||||||
@@ -381,6 +383,23 @@ class UploadApiSetup {
|
|||||||
} else {
|
} else {
|
||||||
enqueueFilesChannel.setMessageHandler(nil)
|
enqueueFilesChannel.setMessageHandler(nil)
|
||||||
}
|
}
|
||||||
|
let onConfigChangeChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.UploadApi.onConfigChange\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
|
||||||
|
if let api = api {
|
||||||
|
onConfigChangeChannel.setMessageHandler { message, reply in
|
||||||
|
let args = message as! [Any?]
|
||||||
|
let keyArg = args[0] as! Int64
|
||||||
|
api.onConfigChange(key: keyArg) { result in
|
||||||
|
switch result {
|
||||||
|
case .success:
|
||||||
|
reply(wrapResult(nil))
|
||||||
|
case .failure(let error):
|
||||||
|
reply(wrapError(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onConfigChangeChannel.setMessageHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import SQLiteData
|
import SQLiteData
|
||||||
import StructuredFieldValues
|
|
||||||
|
|
||||||
extension FileHandle {
|
extension FileHandle {
|
||||||
static func createOrOverwrite(atPath path: String) throws -> FileHandle {
|
static func createOrOverwrite(atPath path: String) throws -> FileHandle {
|
||||||
@@ -11,33 +10,41 @@ extension FileHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadApiImpl: ImmichPlugin, UploadApi {
|
final class UploadApiImpl<
|
||||||
private let db: DatabasePool
|
StoreRepo: StoreProtocol,
|
||||||
private let downloadQueue: DownloadQueue
|
TaskRepo: TaskProtocol,
|
||||||
private let uploadQueue: UploadQueue
|
StatusListener: TaskStatusListener,
|
||||||
|
ProgressListener: TaskProgressListener
|
||||||
|
>: ImmichPlugin, UploadApi {
|
||||||
|
private let storeRepository: StoreRepo
|
||||||
|
private let taskRepository: TaskRepo
|
||||||
|
private let downloadQueue: DownloadQueue<StoreRepo, TaskRepo, StatusListener, ProgressListener>
|
||||||
|
private let uploadQueue: UploadQueue<StoreRepo, TaskRepo, StatusListener>
|
||||||
|
|
||||||
private var isInitialized = false
|
private var isInitialized = false
|
||||||
private let initLock = NSLock()
|
private let initLock = NSLock()
|
||||||
|
|
||||||
private var backupTask: Task<Void, Never>?
|
private var backupTask: Task<Void, Never>?
|
||||||
private let backupLock = NSLock()
|
private let backupLock = NSLock()
|
||||||
|
|
||||||
private let cellularSession: URLSession
|
private let cellularSession: URLSession
|
||||||
private let wifiOnlySession: URLSession
|
private let wifiOnlySession: URLSession
|
||||||
|
|
||||||
init(statusListener: StatusEventListener, progressListener: ProgressEventListener) {
|
init(
|
||||||
let dbUrl = try! FileManager.default.url(
|
storeRepository: StoreRepo,
|
||||||
for: .documentDirectory,
|
taskRepository: TaskRepo,
|
||||||
in: .userDomainMask,
|
statusListener: StatusListener,
|
||||||
appropriateFor: nil,
|
progressListener: ProgressListener
|
||||||
create: true
|
) {
|
||||||
).appendingPathComponent("immich.sqlite")
|
self.taskRepository = taskRepository
|
||||||
|
let delegate = UploadApiDelegate(
|
||||||
self.db = try! DatabasePool(path: dbUrl.path)
|
taskRepository: taskRepository,
|
||||||
|
statusListener: statusListener,
|
||||||
|
progressListener: progressListener
|
||||||
|
)
|
||||||
let cellularConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).cellular")
|
let cellularConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).cellular")
|
||||||
cellularConfig.allowsCellularAccess = true
|
cellularConfig.allowsCellularAccess = true
|
||||||
cellularConfig.waitsForConnectivity = true
|
cellularConfig.waitsForConnectivity = true
|
||||||
let delegate = UploadApiDelegate(db: db, statusListener: statusListener, progressListener: progressListener)
|
|
||||||
self.cellularSession = URLSession(configuration: cellularConfig, delegate: delegate, delegateQueue: nil)
|
self.cellularSession = URLSession(configuration: cellularConfig, delegate: delegate, delegateQueue: nil)
|
||||||
|
|
||||||
let wifiOnlyConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).wifi")
|
let wifiOnlyConfig = URLSessionConfiguration.background(withIdentifier: "\(TaskConfig.sessionId).wifi")
|
||||||
@@ -45,28 +52,26 @@ class UploadApiImpl: ImmichPlugin, UploadApi {
|
|||||||
wifiOnlyConfig.waitsForConnectivity = true
|
wifiOnlyConfig.waitsForConnectivity = true
|
||||||
self.wifiOnlySession = URLSession(configuration: wifiOnlyConfig, delegate: delegate, delegateQueue: nil)
|
self.wifiOnlySession = URLSession(configuration: wifiOnlyConfig, delegate: delegate, delegateQueue: nil)
|
||||||
|
|
||||||
|
self.storeRepository = storeRepository
|
||||||
self.uploadQueue = UploadQueue(
|
self.uploadQueue = UploadQueue(
|
||||||
db: db,
|
storeRepository: storeRepository,
|
||||||
|
taskRepository: taskRepository,
|
||||||
|
statusListener: statusListener,
|
||||||
cellularSession: cellularSession,
|
cellularSession: cellularSession,
|
||||||
wifiOnlySession: wifiOnlySession,
|
wifiOnlySession: wifiOnlySession
|
||||||
statusListener: statusListener
|
|
||||||
)
|
)
|
||||||
self.downloadQueue = DownloadQueue(
|
self.downloadQueue = DownloadQueue(
|
||||||
db: db,
|
storeRepository: storeRepository,
|
||||||
uploadQueue: uploadQueue,
|
taskRepository: taskRepository,
|
||||||
statusListener: statusListener,
|
statusListener: statusListener,
|
||||||
progressListener: progressListener
|
progressListener: progressListener
|
||||||
)
|
)
|
||||||
delegate.downloadQueue = downloadQueue
|
|
||||||
delegate.uploadQueue = uploadQueue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func initialize(completion: @escaping (Result<Void, any Error>) -> Void) {
|
func initialize(completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
Task(priority: .high) {
|
Task(priority: .high) {
|
||||||
do {
|
do {
|
||||||
async let dbIds = db.read { conn in
|
async let dbIds = taskRepository.getTaskIds(status: .uploadQueued)
|
||||||
try UploadTask.select(\.id).where { $0.status.eq(TaskStatus.uploadQueued) }.fetchAll(conn)
|
|
||||||
}
|
|
||||||
async let cellularTasks = cellularSession.allTasks
|
async let cellularTasks = cellularSession.allTasks
|
||||||
async let wifiTasks = wifiOnlySession.allTasks
|
async let wifiTasks = wifiOnlySession.allTasks
|
||||||
|
|
||||||
@@ -84,15 +89,7 @@ class UploadApiImpl: ImmichPlugin, UploadApi {
|
|||||||
validateTasks(await cellularTasks)
|
validateTasks(await cellularTasks)
|
||||||
validateTasks(await wifiTasks)
|
validateTasks(await wifiTasks)
|
||||||
|
|
||||||
let orphanIds = Array(dbTaskIds)
|
try await taskRepository.markOrphansPending(ids: Array(dbTaskIds))
|
||||||
try await db.write { conn in
|
|
||||||
try UploadTask.update {
|
|
||||||
$0.filePath = nil
|
|
||||||
$0.status = .downloadPending
|
|
||||||
}
|
|
||||||
.where { row in row.status.in([TaskStatus.downloadQueued, TaskStatus.uploadPending]) || row.id.in(orphanIds) }
|
|
||||||
.execute(conn)
|
|
||||||
}
|
|
||||||
|
|
||||||
try? FileManager.default.removeItem(at: TaskConfig.originalsDir)
|
try? FileManager.default.removeItem(at: TaskConfig.originalsDir)
|
||||||
initLock.withLock { isInitialized = true }
|
initLock.withLock { isInitialized = true }
|
||||||
@@ -139,9 +136,9 @@ class UploadApiImpl: ImmichPlugin, UploadApi {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await downloadQueue.enqueueAssets(localIds: localIds)
|
try await downloadQueue.enqueueAssets(localIds: localIds)
|
||||||
completion(.success(()))
|
self.completeWhenActive(for: completion, with: .success(()))
|
||||||
} catch {
|
} catch {
|
||||||
completion(.failure(error))
|
self.completeWhenActive(for: completion, with: .failure(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,13 +147,24 @@ class UploadApiImpl: ImmichPlugin, UploadApi {
|
|||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
try await uploadQueue.enqueueFiles(paths: paths)
|
try await uploadQueue.enqueueFiles(paths: paths)
|
||||||
completion(.success(()))
|
self.completeWhenActive(for: completion, with: .success(()))
|
||||||
} catch {
|
} catch {
|
||||||
completion(.failure(error))
|
self.completeWhenActive(for: completion, with: .failure(error))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func onConfigChange(key: Int64, completion: @escaping (Result<Void, any Error>) -> Void) {
|
||||||
|
storeRepository.invalidateCache()
|
||||||
|
Task {
|
||||||
|
if let key = StoreKey(rawValue: Int(key)), key == ._accessToken {
|
||||||
|
try? await taskRepository.resolveError(code: .unauthorized)
|
||||||
|
}
|
||||||
|
startBackup()
|
||||||
|
self.completeWhenActive(for: completion, with: .success(()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func cancelSessionTasks(_ tasks: [URLSessionTask]) {
|
private func cancelSessionTasks(_ tasks: [URLSessionTask]) {
|
||||||
dPrint("Canceling \(tasks.count) tasks")
|
dPrint("Canceling \(tasks.count) tasks")
|
||||||
for task in tasks {
|
for task in tasks {
|
||||||
@@ -165,107 +173,20 @@ class UploadApiImpl: ImmichPlugin, UploadApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func _startBackup() async {
|
private func _startBackup() async {
|
||||||
defer { downloadQueue.startQueueProcessing() }
|
defer {
|
||||||
do {
|
downloadQueue.startQueueProcessing()
|
||||||
let candidates = try await db.read { conn in
|
uploadQueue.startQueueProcessing()
|
||||||
return try LocalAsset.getCandidates()
|
|
||||||
.where { asset in !UploadTask.where { task in task.localId.eq(asset.id) }.exists() }
|
|
||||||
.select { LocalAssetCandidate.Columns(id: $0.id, type: $0.type) }
|
|
||||||
.limit { _ in UploadTaskStat.availableSlots }
|
|
||||||
.fetchAll(conn)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let candidates = try await taskRepository.getBackupCandidates()
|
||||||
|
|
||||||
guard !candidates.isEmpty else { return dPrint("No candidates for backup") }
|
guard !candidates.isEmpty else { return dPrint("No candidates for backup") }
|
||||||
|
|
||||||
try await db.write { conn in
|
try await taskRepository.enqueue(assets: candidates, imagePriority: 0.5, videoPriority: 0.3)
|
||||||
var draft = UploadTask.Draft(
|
|
||||||
attempts: 0,
|
|
||||||
createdAt: Date(),
|
|
||||||
filePath: nil,
|
|
||||||
isLivePhoto: nil,
|
|
||||||
lastError: nil,
|
|
||||||
livePhotoVideoId: nil,
|
|
||||||
localId: "",
|
|
||||||
method: .multipart,
|
|
||||||
priority: 0.5,
|
|
||||||
retryAfter: nil,
|
|
||||||
status: .downloadPending,
|
|
||||||
)
|
|
||||||
for candidate in candidates {
|
|
||||||
draft.localId = candidate.id
|
|
||||||
draft.priority = candidate.type == .image ? 0.5 : 0.3
|
|
||||||
try UploadTask.insert {
|
|
||||||
draft
|
|
||||||
} onConflict: {
|
|
||||||
($0.localId, $0.livePhotoVideoId)
|
|
||||||
}
|
|
||||||
.execute(conn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dPrint("Backup enqueued \(candidates.count) assets for upload")
|
dPrint("Backup enqueued \(candidates.count) assets for upload")
|
||||||
} catch {
|
} catch {
|
||||||
print("Backup queue error: \(error)")
|
print("Backup queue error: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AssetData: StructuredFieldValue {
|
|
||||||
static let structuredFieldType: StructuredFieldType = .dictionary
|
|
||||||
|
|
||||||
let deviceAssetId: String
|
|
||||||
let deviceId: String
|
|
||||||
let fileCreatedAt: String
|
|
||||||
let fileModifiedAt: String
|
|
||||||
let fileName: String
|
|
||||||
let isFavorite: Bool
|
|
||||||
let livePhotoVideoId: String?
|
|
||||||
|
|
||||||
static let boundary = "Boundary-\(UUID().uuidString)"
|
|
||||||
static let deviceAssetIdField = "--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceAssetId\"\r\n\r\n"
|
|
||||||
.data(using: .utf8)!
|
|
||||||
static let deviceIdField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"deviceId\"\r\n\r\n"
|
|
||||||
.data(using: .utf8)!
|
|
||||||
static let fileCreatedAtField =
|
|
||||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileCreatedAt\"\r\n\r\n"
|
|
||||||
.data(using: .utf8)!
|
|
||||||
static let fileModifiedAtField =
|
|
||||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"fileModifiedAt\"\r\n\r\n"
|
|
||||||
.data(using: .utf8)!
|
|
||||||
static let isFavoriteField = "\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"isFavorite\"\r\n\r\n"
|
|
||||||
.data(using: .utf8)!
|
|
||||||
static let livePhotoVideoIdField =
|
|
||||||
"\r\n--\(boundary)\r\nContent-Disposition: form-data; name=\"livePhotoVideoId\"\r\n\r\n"
|
|
||||||
.data(using: .utf8)!
|
|
||||||
static let trueData = "true".data(using: .utf8)!
|
|
||||||
static let falseData = "false".data(using: .utf8)!
|
|
||||||
static let footer = "\r\n--\(boundary)--\r\n".data(using: .utf8)!
|
|
||||||
static let contentType = "multipart/form-data; boundary=\(boundary)"
|
|
||||||
|
|
||||||
func multipart() -> (Data, Data) {
|
|
||||||
var header = Data()
|
|
||||||
header.append(Self.deviceAssetIdField)
|
|
||||||
header.append(deviceAssetId.data(using: .utf8)!)
|
|
||||||
|
|
||||||
header.append(Self.deviceIdField)
|
|
||||||
header.append(deviceId.data(using: .utf8)!)
|
|
||||||
|
|
||||||
header.append(Self.fileCreatedAtField)
|
|
||||||
header.append(fileCreatedAt.data(using: .utf8)!)
|
|
||||||
|
|
||||||
header.append(Self.fileModifiedAtField)
|
|
||||||
header.append(fileModifiedAt.data(using: .utf8)!)
|
|
||||||
|
|
||||||
header.append(Self.isFavoriteField)
|
|
||||||
header.append(isFavorite ? Self.trueData : Self.falseData)
|
|
||||||
|
|
||||||
if let livePhotoVideoId {
|
|
||||||
header.append(Self.livePhotoVideoIdField)
|
|
||||||
header.append(livePhotoVideoId.data(using: .utf8)!)
|
|
||||||
}
|
|
||||||
header.append(
|
|
||||||
"\r\n--\(Self.boundary)\r\nContent-Disposition: form-data; name=\"assetData\"; filename=\"\(fileName)\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
|
||||||
.data(using: .utf8)!
|
|
||||||
)
|
|
||||||
return (header, Self.footer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:immich_mobile/infrastructure/entities/store.entity.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
|
||||||
// Temporary interface until Isar is removed to make the service work with both Isar and Sqlite
|
// Temporary interface until Isar is removed to make the service work with both Isar and Sqlite
|
||||||
@@ -141,6 +142,7 @@ class DriftStoreRepository extends DriftDatabaseRepository implements IStoreRepo
|
|||||||
@override
|
@override
|
||||||
Future<bool> upsert<T>(StoreKey<T> key, T value) async {
|
Future<bool> upsert<T>(StoreKey<T> key, T value) async {
|
||||||
await _db.storeEntity.insertOnConflictUpdate(await _fromValue(key, value));
|
await _db.storeEntity.insertOnConflictUpdate(await _fromValue(key, value));
|
||||||
|
await uploadApi.onConfigChange(key.id);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
30
mobile/lib/platform/upload_api.g.dart
generated
30
mobile/lib/platform/upload_api.g.dart
generated
@@ -41,9 +41,6 @@ enum UploadApiErrorCode {
|
|||||||
networkError,
|
networkError,
|
||||||
photosInternalError,
|
photosInternalError,
|
||||||
photosUnknownError,
|
photosUnknownError,
|
||||||
noServerUrl,
|
|
||||||
noDeviceId,
|
|
||||||
noAccessToken,
|
|
||||||
interrupted,
|
interrupted,
|
||||||
cancelled,
|
cancelled,
|
||||||
downloadStalled,
|
downloadStalled,
|
||||||
@@ -53,6 +50,10 @@ enum UploadApiErrorCode {
|
|||||||
uploadTimeout,
|
uploadTimeout,
|
||||||
iCloudRateLimit,
|
iCloudRateLimit,
|
||||||
iCloudThrottled,
|
iCloudThrottled,
|
||||||
|
invalidResponse,
|
||||||
|
badRequest,
|
||||||
|
internalServerError,
|
||||||
|
unauthorized,
|
||||||
}
|
}
|
||||||
|
|
||||||
enum UploadApiStatus {
|
enum UploadApiStatus {
|
||||||
@@ -339,6 +340,29 @@ class UploadApi {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> onConfigChange(int key) async {
|
||||||
|
final String pigeonVar_channelName =
|
||||||
|
'dev.flutter.pigeon.immich_mobile.UploadApi.onConfigChange$pigeonVar_messageChannelSuffix';
|
||||||
|
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
|
||||||
|
pigeonVar_channelName,
|
||||||
|
pigeonChannelCodec,
|
||||||
|
binaryMessenger: pigeonVar_binaryMessenger,
|
||||||
|
);
|
||||||
|
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[key]);
|
||||||
|
final List<Object?>? pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
|
||||||
|
if (pigeonVar_replyList == null) {
|
||||||
|
throw _createConnectionError(pigeonVar_channelName);
|
||||||
|
} else if (pigeonVar_replyList.length > 1) {
|
||||||
|
throw PlatformException(
|
||||||
|
code: pigeonVar_replyList[0]! as String,
|
||||||
|
message: pigeonVar_replyList[1] as String?,
|
||||||
|
details: pigeonVar_replyList[2],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Stream<UploadApiTaskStatus> streamStatus({String instanceName = ''}) {
|
Stream<UploadApiTaskStatus> streamStatus({String instanceName = ''}) {
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ enum UploadApiErrorCode {
|
|||||||
networkError("Network error"),
|
networkError("Network error"),
|
||||||
photosInternalError("Apple Photos internal error"),
|
photosInternalError("Apple Photos internal error"),
|
||||||
photosUnknownError("Apple Photos unknown error"),
|
photosUnknownError("Apple Photos unknown error"),
|
||||||
noServerUrl("Server URL is not set"),
|
|
||||||
noDeviceId("Device ID is not set"),
|
|
||||||
noAccessToken("Access token is not set"),
|
|
||||||
interrupted("Upload interrupted"),
|
interrupted("Upload interrupted"),
|
||||||
cancelled("Upload cancelled"),
|
cancelled("Upload cancelled"),
|
||||||
downloadStalled("Download stalled"),
|
downloadStalled("Download stalled"),
|
||||||
@@ -23,7 +20,11 @@ enum UploadApiErrorCode {
|
|||||||
backgroundUpdatesDisabled("Background updates are disabled"),
|
backgroundUpdatesDisabled("Background updates are disabled"),
|
||||||
uploadTimeout("Upload timed out"),
|
uploadTimeout("Upload timed out"),
|
||||||
iCloudRateLimit("iCloud rate limit reached"),
|
iCloudRateLimit("iCloud rate limit reached"),
|
||||||
iCloudThrottled("iCloud requests are being throttled");
|
iCloudThrottled("iCloud requests are being throttled"),
|
||||||
|
invalidResponse("Invalid response from server"),
|
||||||
|
badRequest("Server rejected the upload request"),
|
||||||
|
internalServerError("Internal server error"),
|
||||||
|
unauthorized("Unauthorized access");
|
||||||
|
|
||||||
final String message;
|
final String message;
|
||||||
|
|
||||||
@@ -87,6 +88,9 @@ abstract class UploadApi {
|
|||||||
|
|
||||||
@async
|
@async
|
||||||
void enqueueFiles(List<String> paths);
|
void enqueueFiles(List<String> paths);
|
||||||
|
|
||||||
|
@async
|
||||||
|
void onConfigChange(int key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventChannelApi()
|
@EventChannelApi()
|
||||||
|
|||||||
Reference in New Issue
Block a user