Initial scaffold — NASBackup iOS app
Full project scaffold: XcodeGen project.yml, Core models/protocols/errors, SMBService (kishikawakatsumi/SMBClient), SFTPService (Citadel 0.9.2), PhotoLibraryService, LANMonitor, BackupEngine, BackgroundTaskManager, all feature UIs (Login, Connect, Browse, Backup, History, Settings), Shared components and theme. Builds clean with zero errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
115
Services/PhotoLibraryService.swift
Normal file
115
Services/PhotoLibraryService.swift
Normal file
@@ -0,0 +1,115 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user