macsync/@packages/iphoto/Sources/IPhotoSync/APIClient.swift

252 lines
9.7 KiB
Swift

import Alamofire
import Foundation
import LilithAgentCore
import LilithLogging
import MacSyncShared
import SwiftyJSON
private let log = AppLogger.logger(for: "IPhoto.API")
// MARK: - Payload Types
public struct SyncPhotoPayload {
public let localIdentifier: String
public let mediaType: String
public let width: Int
public let height: Int
public let fileSize: Int?
public let durationSeconds: TimeInterval?
public let capturedAt: String
public let modifiedAt: String?
public let originalFilename: String?
public let latitude: Double?
public let longitude: Double?
public let isFavorite: Bool
public let isHidden: Bool
public let isScreenshot: Bool
public let isSelfie: Bool
public let isBurst: Bool
public let burstIdentifier: String?
public init(
localIdentifier: String, mediaType: String, width: Int, height: Int,
fileSize: Int?, durationSeconds: TimeInterval?, capturedAt: String,
modifiedAt: String?, originalFilename: String?,
latitude: Double?, longitude: Double?,
isFavorite: Bool, isHidden: Bool, isScreenshot: Bool,
isSelfie: Bool, isBurst: Bool, burstIdentifier: String?
) {
self.localIdentifier = localIdentifier
self.mediaType = mediaType
self.width = width
self.height = height
self.fileSize = fileSize
self.durationSeconds = durationSeconds
self.capturedAt = capturedAt
self.modifiedAt = modifiedAt
self.originalFilename = originalFilename
self.latitude = latitude
self.longitude = longitude
self.isFavorite = isFavorite
self.isHidden = isHidden
self.isScreenshot = isScreenshot
self.isSelfie = isSelfie
self.isBurst = isBurst
self.burstIdentifier = burstIdentifier
}
var dictionary: [String: Any?] {
[
"localIdentifier": localIdentifier,
"mediaType": mediaType,
"width": width,
"height": height,
"fileSize": fileSize,
"durationSeconds": durationSeconds,
"capturedAt": capturedAt,
"modifiedAt": modifiedAt,
"originalFilename": originalFilename,
"latitude": latitude,
"longitude": longitude,
"isFavorite": isFavorite,
"isHidden": isHidden,
"isScreenshot": isScreenshot,
"isSelfie": isSelfie,
"isBurst": isBurst,
"burstIdentifier": burstIdentifier,
]
}
}
public struct SyncAlbumPayload {
public let localIdentifier: String
public let title: String
public let albumType: String
public let photoLocalIdentifiers: [String]
public let startDate: String?
public let endDate: String?
public init(
localIdentifier: String, title: String, albumType: String,
photoLocalIdentifiers: [String], startDate: String?, endDate: String?
) {
self.localIdentifier = localIdentifier
self.title = title
self.albumType = albumType
self.photoLocalIdentifiers = photoLocalIdentifiers
self.startDate = startDate
self.endDate = endDate
}
var dictionary: [String: Any?] {
[
"localIdentifier": localIdentifier,
"title": title,
"albumType": albumType,
"photoLocalIdentifiers": photoLocalIdentifiers,
"startDate": startDate,
"endDate": endDate,
]
}
}
public struct SyncPhotosResponse {
public let needsUpload: [String]
public let synced: Int
}
public struct IPhotoStatsResponse {
public let totalPhotos: Int
public let uploadedPhotos: Int
public let pendingUpload: Int
public let totalAlbums: Int
}
// MARK: - Protocol
public protocol IPhotoAPIClientProtocol: AnyObject {
var isAuthenticated: Bool { get }
func syncPhotos(_ payloads: [SyncPhotoPayload]) async throws -> SyncPhotosResponse
func syncAlbums(_ payloads: [SyncAlbumPayload]) async throws -> Int
func getPendingUploads() async throws -> [String]
func uploadPhoto(localIdentifier: String, data: Data, mimeType: String) async throws -> Bool
func uploadPhotoFromURL(localIdentifier: String, fileURL: URL, mimeType: String) async throws -> Bool
func getStats() async throws -> IPhotoStatsResponse
}
// MARK: - APIClient
public final class APIClient: BaseAPIClient, IPhotoAPIClientProtocol, @unchecked Sendable {
public static let shared: APIClient = {
APIClient(baseURL: macSyncResolveServerURL(), keychain: macSyncSharedKeychain)
}()
public var isAuthenticated: Bool {
(try? getAuthToken()) != nil
}
// MARK: - Photo Sync
public func syncPhotos(_ payloads: [SyncPhotoPayload]) async throws -> SyncPhotosResponse {
let params: [String: Any] = ["photos": payloads.map { $0.dictionary }]
let data = try await authenticatedRequest("/client/iphoto/sync", method: .post, parameters: params)
let json = JSON(data)
guard json["success"].boolValue else {
let msg = json["error"]["message"].stringValue
throw APIError.serverError(statusCode: json["statusCode"].intValue,
message: msg.isEmpty ? "Server error" : msg)
}
let needsUpload = json["data"]["needsUpload"].arrayValue.compactMap { $0.string }
let synced = json["data"]["synced"].intValue
log.info("syncPhotos success synced=\(synced) needsUpload=\(needsUpload.count)")
return SyncPhotosResponse(needsUpload: needsUpload, synced: synced)
}
public func syncAlbums(_ payloads: [SyncAlbumPayload]) async throws -> Int {
let params: [String: Any] = ["albums": payloads.map { $0.dictionary }]
let data = try await authenticatedRequest("/client/iphoto/albums", method: .post, parameters: params)
let json = JSON(data)
guard json["success"].boolValue else {
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
}
return json["data"]["synced"].intValue
}
public func getPendingUploads() async throws -> [String] {
let data = try await authenticatedRequest("/client/iphoto/upload/pending", method: .get)
let json = JSON(data)
guard json["success"].boolValue else {
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
}
return json["data"]["localIdentifiers"].arrayValue.compactMap { $0.string }
}
// MARK: - Binary Upload
public func uploadPhoto(localIdentifier: String, data: Data, mimeType: String) async throws -> Bool {
// Encode / as %2F so UUID/L0/001 is treated as one path segment, not three.
let slashFreeSet = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
let encodedId = localIdentifier.addingPercentEncoding(withAllowedCharacters: slashFreeSet) ?? localIdentifier
let url = "\(baseURL)/client/iphoto/upload/\(encodedId)"
guard let token = try? getAuthToken() else { throw APIError.missingAuthToken }
let headers = HTTPHeaders([
"Authorization": "Bearer \(token)",
"Content-Type": mimeType,
"X-Filename": localIdentifier,
])
let response = await AF.upload(data, to: url, method: .post, headers: headers)
.validate()
.serializingData()
.response
switch response.result {
case .success(let responseData):
let json = JSON(responseData)
log.info("uploadPhoto \(localIdentifier) success=\(json["success"].boolValue)")
return json["success"].boolValue
case .failure(let error):
log.warning("uploadPhoto \(localIdentifier) failed: \(error.localizedDescription)")
return false
}
}
public func uploadPhotoFromURL(localIdentifier: String, fileURL: URL, mimeType: String) async throws -> Bool {
let slashFreeSet = CharacterSet.urlPathAllowed.subtracting(CharacterSet(charactersIn: "/"))
let encodedId = localIdentifier.addingPercentEncoding(withAllowedCharacters: slashFreeSet) ?? localIdentifier
let url = "\(baseURL)/client/iphoto/upload/\(encodedId)"
guard let token = try? getAuthToken() else { throw APIError.missingAuthToken }
let headers = HTTPHeaders([
"Authorization": "Bearer \(token)",
"Content-Type": mimeType,
"X-Filename": fileURL.lastPathComponent,
])
let response = await AF.upload(fileURL, to: url, method: .post, headers: headers)
.validate()
.serializingData()
.response
switch response.result {
case .success(let responseData):
let json = JSON(responseData)
log.info("uploadPhotoFromURL \(localIdentifier) success=\(json["success"].boolValue)")
return json["success"].boolValue
case .failure(let error):
log.warning("uploadPhotoFromURL \(localIdentifier) failed: \(error.localizedDescription)")
return false
}
}
// MARK: - Stats
public func getStats() async throws -> IPhotoStatsResponse {
let data = try await authenticatedRequest("/client/iphoto/stats", method: .get)
let json = JSON(data)
guard json["success"].boolValue else {
throw APIError.serverError(statusCode: 0, message: json["error"]["message"].stringValue)
}
return IPhotoStatsResponse(
totalPhotos: json["data"]["totalPhotos"].intValue,
uploadedPhotos: json["data"]["uploadedPhotos"].intValue,
pendingUpload: json["data"]["pendingUpload"].intValue,
totalAlbums: json["data"]["totalAlbums"].intValue
)
}
}