Files
Kisani/Services/PhotoLibraryService.swift

150 lines
5.9 KiB
Swift
Raw Normal View History

import Foundation
import Photos
final class PhotoLibraryService: PhotoLibraryProtocol {
var authorizationStatus: PHAuthorizationStatus {
PHPhotoLibrary.authorizationStatus(for: .readWrite)
}
func requestAuthorization() async -> PHAuthorizationStatus {
await PHPhotoLibrary.requestAuthorization(for: .readWrite)
}
func fetchAssets(filter: BackupFilter) -> [PhotoAsset] {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
var subpredicates: [NSPredicate] = []
var mediaTypes: [PHAssetMediaType] = []
if filter.includePhotos { mediaTypes.append(.image) }
if filter.includeVideos { mediaTypes.append(.video) }
if !mediaTypes.isEmpty {
let typePredicate = NSPredicate(
format: "mediaType IN %@",
mediaTypes.map(\.rawValue)
)
subpredicates.append(typePredicate)
}
if !filter.includeScreenshots {
subpredicates.append(NSPredicate(
format: "NOT ((mediaSubtype & %d) != 0)",
PHAssetMediaSubtype.photoScreenshot.rawValue
))
}
if subpredicates.count > 1 {
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
} else {
options.predicate = subpredicates.first
}
let result = PHAsset.fetchAssets(with: options)
var assets: [PhotoAsset] = []
result.enumerateObjects { asset, _, _ in
autoreleasepool {
let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot)
let resources = PHAssetResource.assetResources(for: asset)
let isRAW = resources.contains { $0.type == .alternatePhoto }
if isRAW && !filter.includeRAW { return }
let filename = resources.first?.originalFilename ?? "\(asset.localIdentifier).jpg"
assets.append(PhotoAsset(
localIdentifier: asset.localIdentifier,
filename: filename,
creationDate: asset.creationDate,
mediaType: asset.mediaType,
pixelWidth: asset.pixelWidth,
pixelHeight: asset.pixelHeight,
duration: asset.duration,
isScreenshot: isScreenshot,
isRAW: isRAW
))
}
}
return assets
}
/// Returns a raw PHFetchResult for memory-safe reconciliation.
/// No PhotoAsset array is created caller enumerates in batches.
func fetchResultForReconciliation(filter: BackupFilter) -> PHFetchResult<PHAsset> {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: true)]
var subpredicates: [NSPredicate] = []
var mediaTypes: [PHAssetMediaType] = []
if filter.includePhotos { mediaTypes.append(.image) }
if filter.includeVideos { mediaTypes.append(.video) }
if !mediaTypes.isEmpty {
subpredicates.append(NSPredicate(
format: "mediaType IN %@",
mediaTypes.map(\.rawValue)
))
}
if !filter.includeScreenshots {
subpredicates.append(NSPredicate(
format: "NOT ((mediaSubtype & %d) != 0)",
PHAssetMediaSubtype.photoScreenshot.rawValue
))
}
if subpredicates.count > 1 {
options.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates)
} else {
options.predicate = subpredicates.first
}
return PHAsset.fetchAssets(with: options)
}
func exportAsset(_ asset: PhotoAsset) async throws -> URL {
let fetchResult = PHAsset.fetchAssets(
withLocalIdentifiers: [asset.localIdentifier],
options: nil
)
guard let phAsset = fetchResult.firstObject else {
throw BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -1))
}
let tmpURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension((asset.filename as NSString).pathExtension)
return try await withCheckedThrowingContinuation { continuation in
if phAsset.mediaType == .video {
let options = PHVideoRequestOptions()
options.isNetworkAccessAllowed = true
PHImageManager.default().requestAVAsset(forVideo: phAsset, options: options) { avAsset, _, _ in
guard let urlAsset = avAsset as? AVURLAsset else {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "AVExport", code: -1)))
return
}
continuation.resume(returning: urlAsset.url)
}
} else {
let options = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
options.version = .current
options.deliveryMode = .highQualityFormat
PHImageManager.default().requestImageDataAndOrientation(for: phAsset, options: options) { data, _, _, _ in
guard let data else {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: NSError(domain: "PhotoExport", code: -2)))
return
}
do {
try data.write(to: tmpURL)
continuation.resume(returning: tmpURL)
} catch {
continuation.resume(throwing: BackupError.uploadFailed(asset.filename, underlying: error))
}
}
}
}
}
}