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 let isScreenshot = asset.mediaSubtypes.contains(.photoScreenshot) let resources = PHAssetResource.assetResources(for: asset) // RAW files are identified by an alternate photo resource (RAW+JPEG pairs) 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 } 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)) } } } } } }