A macOS CLI tool that exports original photos and videos from the macOS Photos library using PhotoKit.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: classification-aware AppleScript fallback and UUID key preservation (0.5.1)

- AppleScriptError.assetUnavailable for -1728 errors, mapped to
.permanentlyUnavailable so the adaptive controller doesn't throttle on
genuinely-dead shared-album assets
- ScriptFailure carries classification end-to-end
- PhotoKitLibrary.fetchAssets preserves caller-provided identifier keys
(bare UUID vs "UUID/L0/001") instead of always returning PhotoKit's form
- PhotoKitLibrary.loadEnrichedAssets and
PhotosDatabaseLocalAvailability.fromLibrary convenience helpers

+93 -4
+24
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## 0.5.1 4 + 5 + Review-driven fixes on top of 0.5.0. No API removals; additive only. 6 + 7 + - `AppleScriptError.assetUnavailable(uuid, message)` — new case raised when 8 + Photos.app reports `-1728 "Can't get media item"`. Retrying is pointless for 9 + these (typically shared-album assets whose derivative is gone server-side). 10 + `PhotoExporter` maps this to `ExportClassification.permanentlyUnavailable` 11 + and reports `.permanentFailure` to the adaptive controller, so the iCloud 12 + lane doesn't throttle down on genuinely-dead assets. 13 + - `ScriptFailure` now carries a `classification`, so per-asset classification 14 + flows through the AppleScript fallback path instead of being flattened to 15 + `.transientCloud`. 16 + - `PhotoKitLibrary.fetchAssets(identifiers:)` now keys the returned dict by 17 + whatever the caller passed in (bare UUID or full `"UUID/L0/001"` local 18 + identifier). Previously it always returned PhotoKit's full identifier, and 19 + callers had to split the string. Makes the UUID contract explicit at the 20 + library boundary. 21 + - `PhotoKitLibrary.loadEnrichedAssets(libraryURL:)` — static convenience that 22 + enumerates assets and applies Photos.sqlite enrichment in one call. 23 + - `PhotosDatabaseLocalAvailability.fromLibrary(at:)` — static convenience that 24 + locates the library's `Photos.sqlite` and loads the locally-available UUID 25 + set in one call. 26 + 3 27 ## 0.5.0 4 28 5 29 Adaptive export: partition a batch into local vs. iCloud work, and let the
+15
Sources/LadderKit/AppleScriptExporter.swift
··· 69 69 if result.timedOut { 70 70 throw AppleScriptError.timeout(bareUUID, timeout) 71 71 } 72 + // -1728 = "Can't get media item" — asset is not retrievable from 73 + // Photos.app at all (typical for shared-album assets whose 74 + // derivative has gone missing server-side). Retrying just waits 75 + // another 5-10 minutes for the same error. 76 + if result.stderr.contains("-1728") { 77 + throw AppleScriptError.assetUnavailable( 78 + bareUUID, 79 + result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) 80 + ) 81 + } 72 82 throw AppleScriptError.scriptFailed( 73 83 bareUUID, 74 84 result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) ··· 233 243 case timeout(String, TimeInterval) 234 244 case scriptFailed(String, String) 235 245 case noFileProduced(String) 246 + /// Photos.app reported the asset cannot be retrieved (error -1728). Retrying 247 + /// is pointless — typically a shared-album asset whose derivative is gone. 248 + case assetUnavailable(String, String) 236 249 237 250 public var errorDescription: String? { 238 251 switch self { ··· 245 258 return "AppleScript export failed for asset \(uuid): \(message)" 246 259 case .noFileProduced(let uuid): 247 260 return "AppleScript export produced no file for asset \(uuid)" 261 + case .assetUnavailable(let uuid, let message): 262 + return "Asset \(uuid) is unavailable from iCloud: \(message)" 248 263 } 249 264 } 250 265 }
+11
Sources/LadderKit/LocalAvailability.swift
··· 25 25 public func isLocallyAvailable(uuid: String) -> Bool { 26 26 localUUIDs.contains(uuid) 27 27 } 28 + 29 + /// Convenience: load availability directly from a Photos library bundle. 30 + /// Returns `nil` if the library's `Photos.sqlite` can't be located. 31 + public static func fromLibrary(at libraryURL: URL) -> PhotosDatabaseLocalAvailability? { 32 + guard let dbPath = PhotosLibraryPath.databasePath(for: libraryURL) else { 33 + return nil 34 + } 35 + return PhotosDatabaseLocalAvailability( 36 + localUUIDs: PhotosDatabase.localAvailableUUIDs(dbPath: dbPath) 37 + ) 38 + } 28 39 }
+23 -3
Sources/LadderKit/PhotoExporter.swift
··· 212 212 errors.append(ExportError( 213 213 uuid: fail.uuid, 214 214 message: fail.message, 215 - classification: .transientCloud 215 + classification: fail.classification 216 216 )) 217 - await controller?.record(.transientFailure) 217 + await controller?.record( 218 + fail.classification == .permanentlyUnavailable 219 + ? .permanentFailure : .transientFailure 220 + ) 218 221 } 219 222 220 223 if Task.isCancelled { ··· 268 271 size: size, 269 272 sha256: sha256 270 273 )) 274 + } catch let err as AppleScriptError { 275 + let classification: ExportClassification 276 + if case .assetUnavailable = err { 277 + classification = .permanentlyUnavailable 278 + } else { 279 + classification = .transientCloud 280 + } 281 + return .failure(ScriptFailure( 282 + uuid: uuid, 283 + message: err.localizedDescription, 284 + classification: classification 285 + )) 271 286 } catch { 272 - return .failure(ScriptFailure(uuid: uuid, message: error.localizedDescription)) 287 + return .failure(ScriptFailure( 288 + uuid: uuid, 289 + message: error.localizedDescription, 290 + classification: .transientCloud 291 + )) 273 292 } 274 293 } 275 294 ··· 331 350 private struct ScriptFailure: Error, Sendable { 332 351 let uuid: String 333 352 let message: String 353 + let classification: ExportClassification 334 354 } 335 355 336 356 public enum ExportFailure: LocalizedError {
+20 -1
Sources/LadderKit/PhotoLibrary.swift
··· 40 40 public struct PhotoKitLibrary: PhotoLibrary, @unchecked Sendable { 41 41 public init() {} 42 42 43 + /// Enumerate all non-trashed assets and enrich them from Photos.sqlite 44 + /// (filenames, albums, keywords, people, descriptions, edits). If the 45 + /// library's database can't be located, returns the un-enriched list. 46 + public static func loadEnrichedAssets(libraryURL: URL) -> [AssetInfo] { 47 + var assets = PhotoKitLibrary().enumerateAssets() 48 + if let dbPath = PhotosLibraryPath.databasePath(for: libraryURL) { 49 + let enrichment = PhotosDatabase.readEnrichment(dbPath: dbPath) 50 + PhotosDatabase.enrich(&assets, with: enrichment) 51 + } 52 + return assets 53 + } 54 + 43 55 public func fetchAssets(identifiers: [String]) -> [String: AssetHandle] { 44 56 let fetchResult = PHAsset.fetchAssets( 45 57 withLocalIdentifiers: identifiers, 46 58 options: nil 47 59 ) 48 60 61 + // PhotoKit returns assets keyed by their full localIdentifier 62 + // ("UUID/L0/001"). Callers may pass bare UUIDs or full identifiers; 63 + // match each fetched asset back to the caller's input string so the 64 + // returned dict is keyed by whatever the caller asked for. 49 65 var result: [String: AssetHandle] = [:] 50 66 fetchResult.enumerateObjects { asset, _, _ in 51 67 let resources = PHAssetResource.assetResources(for: asset) 52 68 guard let resource = resources.first(where: { $0.type == .photo || $0.type == .video }) 53 69 ?? resources.first 54 70 else { return } 55 - result[asset.localIdentifier] = PhotoKitAssetHandle( 71 + let key = identifiers.first(where: { id in 72 + asset.localIdentifier == id || asset.localIdentifier.hasPrefix(id + "/") 73 + }) ?? asset.localIdentifier 74 + result[key] = PhotoKitAssetHandle( 56 75 resource: resource, 57 76 isShared: asset.sourceType == .typeCloudShared, 58 77 )