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.

Add asset discovery API to LadderKit

Extends PhotoLibrary protocol with enumerateAssets() and
totalAssetCount() for full library scanning. Adds AssetInfo,
AssetKind, and AlbumInfo types that capture PhotoKit metadata
needed for backup decisions and S3 metadata JSON.

The PhotoKit implementation detects edits via asset resource
types and builds an album membership map across all user and
smart albums.

+322 -2
+66
Sources/LadderKit/AssetInfo.swift
··· 1 + import Foundation 2 + 3 + /// Lightweight metadata about a photo or video asset, populated from PhotoKit. 4 + /// 5 + /// This struct is used for asset discovery and filtering — it carries enough 6 + /// information to decide whether an asset needs backup and to build the 7 + /// metadata JSON, without loading the asset's pixel data. 8 + public struct AssetInfo: Sendable { 9 + public let identifier: String 10 + public let creationDate: Date? 11 + public let kind: AssetKind 12 + public let pixelWidth: Int 13 + public let pixelHeight: Int 14 + public let latitude: Double? 15 + public let longitude: Double? 16 + public let isFavorite: Bool 17 + public let originalFilename: String? 18 + public let uniformTypeIdentifier: String? 19 + public let hasEdit: Bool 20 + public let albums: [AlbumInfo] 21 + 22 + public init( 23 + identifier: String, 24 + creationDate: Date?, 25 + kind: AssetKind, 26 + pixelWidth: Int, 27 + pixelHeight: Int, 28 + latitude: Double?, 29 + longitude: Double?, 30 + isFavorite: Bool, 31 + originalFilename: String?, 32 + uniformTypeIdentifier: String?, 33 + hasEdit: Bool, 34 + albums: [AlbumInfo] 35 + ) { 36 + self.identifier = identifier 37 + self.creationDate = creationDate 38 + self.kind = kind 39 + self.pixelWidth = pixelWidth 40 + self.pixelHeight = pixelHeight 41 + self.latitude = latitude 42 + self.longitude = longitude 43 + self.isFavorite = isFavorite 44 + self.originalFilename = originalFilename 45 + self.uniformTypeIdentifier = uniformTypeIdentifier 46 + self.hasEdit = hasEdit 47 + self.albums = albums 48 + } 49 + } 50 + 51 + /// The type of media asset. 52 + public enum AssetKind: Int, Sendable, Codable { 53 + case photo = 0 54 + case video = 1 55 + } 56 + 57 + /// Reference to an album containing the asset. 58 + public struct AlbumInfo: Sendable, Codable, Equatable { 59 + public let identifier: String 60 + public let title: String 61 + 62 + public init(identifier: String, title: String) { 63 + self.identifier = identifier 64 + self.title = title 65 + } 66 + }
+109 -2
Sources/LadderKit/PhotoLibrary.swift
··· 3 3 4 4 /// Abstraction over PhotoKit for testability. 5 5 public protocol PhotoLibrary: Sendable { 6 - /// Fetch assets by their local identifiers. 6 + /// Fetch assets by their local identifiers (for export). 7 7 func fetchAssets(identifiers: [String]) -> [String: AssetHandle] 8 + 9 + /// Return the total number of non-trashed assets in the library. 10 + func totalAssetCount() -> Int 11 + 12 + /// Enumerate all non-trashed assets with their metadata. 13 + /// 14 + /// Returns assets sorted by creation date (newest first). 15 + /// Each asset includes album membership and edit detection. 16 + func enumerateAssets() -> [AssetInfo] 8 17 } 9 18 10 19 /// Abstraction over a single asset's exportable resource. ··· 23 32 } 24 33 25 34 /// Real PhotoKit implementation. 26 - public struct PhotoKitLibrary: PhotoLibrary { 35 + public struct PhotoKitLibrary: PhotoLibrary, @unchecked Sendable { 27 36 public init() {} 28 37 29 38 public func fetchAssets(identifiers: [String]) -> [String: AssetHandle] { ··· 41 50 result[asset.localIdentifier] = PhotoKitAssetHandle(resource: resource) 42 51 } 43 52 return result 53 + } 54 + 55 + public func totalAssetCount() -> Int { 56 + let options = PHFetchOptions() 57 + options.includeHiddenAssets = false 58 + options.includeAllBurstAssets = false 59 + let result = PHAsset.fetchAssets(with: options) 60 + return result.count 61 + } 62 + 63 + public func enumerateAssets() -> [AssetInfo] { 64 + let albumMap = buildAlbumMap() 65 + 66 + let options = PHFetchOptions() 67 + options.includeHiddenAssets = false 68 + options.includeAllBurstAssets = false 69 + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] 70 + 71 + let fetchResult = PHAsset.fetchAssets(with: options) 72 + var assets: [AssetInfo] = [] 73 + assets.reserveCapacity(fetchResult.count) 74 + 75 + fetchResult.enumerateObjects { phAsset, _, _ in 76 + let info = self.assetInfo(from: phAsset, albumMap: albumMap) 77 + assets.append(info) 78 + } 79 + 80 + return assets 81 + } 82 + 83 + // MARK: - Private 84 + 85 + private func assetInfo( 86 + from phAsset: PHAsset, 87 + albumMap: [String: [AlbumInfo]] 88 + ) -> AssetInfo { 89 + let resources = PHAssetResource.assetResources(for: phAsset) 90 + let primaryResource = resources.first(where: { $0.type == .photo || $0.type == .video }) 91 + ?? resources.first 92 + 93 + let hasEdit = resources.contains { $0.type == .adjustmentData } 94 + && resources.contains { 95 + $0.type == .fullSizePhoto || $0.type == .fullSizeVideo 96 + } 97 + 98 + let kind: AssetKind = phAsset.mediaType == .video ? .video : .photo 99 + 100 + return AssetInfo( 101 + identifier: phAsset.localIdentifier, 102 + creationDate: phAsset.creationDate, 103 + kind: kind, 104 + pixelWidth: phAsset.pixelWidth, 105 + pixelHeight: phAsset.pixelHeight, 106 + latitude: phAsset.location?.coordinate.latitude, 107 + longitude: phAsset.location?.coordinate.longitude, 108 + isFavorite: phAsset.isFavorite, 109 + originalFilename: primaryResource?.originalFilename, 110 + uniformTypeIdentifier: primaryResource?.uniformTypeIdentifier, 111 + hasEdit: hasEdit, 112 + albums: albumMap[phAsset.localIdentifier] ?? [] 113 + ) 114 + } 115 + 116 + /// Build a map of asset identifier → albums it belongs to. 117 + /// 118 + /// Fetches all user-created albums and smart albums, then for each album 119 + /// fetches its assets and records the membership. 120 + private func buildAlbumMap() -> [String: [AlbumInfo]] { 121 + var map: [String: [AlbumInfo]] = [:] 122 + 123 + let albumTypes: [(PHAssetCollectionType, PHAssetCollectionSubtype)] = [ 124 + (.album, .any), 125 + (.smartAlbum, .any), 126 + ] 127 + 128 + for (type, subtype) in albumTypes { 129 + let collections = PHAssetCollection.fetchAssetCollections( 130 + with: type, 131 + subtype: subtype, 132 + options: nil 133 + ) 134 + 135 + collections.enumerateObjects { collection, _, _ in 136 + guard let title = collection.localizedTitle else { return } 137 + 138 + let albumInfo = AlbumInfo( 139 + identifier: collection.localIdentifier, 140 + title: title 141 + ) 142 + 143 + let assets = PHAsset.fetchAssets(in: collection, options: nil) 144 + assets.enumerateObjects { asset, _, _ in 145 + map[asset.localIdentifier, default: []].append(albumInfo) 146 + } 147 + } 148 + } 149 + 150 + return map 44 151 } 45 152 } 46 153
+138
Tests/AssetInfoTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import LadderKit 5 + 6 + @Suite("AssetInfo") 7 + struct AssetInfoTests { 8 + @Test("creates photo asset with all fields") 9 + func createPhotoAsset() { 10 + let date = Date(timeIntervalSince1970: 1_700_000_000) 11 + let album = AlbumInfo(identifier: "album-1", title: "Vacation") 12 + let info = AssetInfo( 13 + identifier: "ABC-123", 14 + creationDate: date, 15 + kind: .photo, 16 + pixelWidth: 4032, 17 + pixelHeight: 3024, 18 + latitude: 52.3676, 19 + longitude: 4.9041, 20 + isFavorite: true, 21 + originalFilename: "IMG_0001.HEIC", 22 + uniformTypeIdentifier: "public.heic", 23 + hasEdit: false, 24 + albums: [album] 25 + ) 26 + 27 + #expect(info.identifier == "ABC-123") 28 + #expect(info.creationDate == date) 29 + #expect(info.kind == .photo) 30 + #expect(info.pixelWidth == 4032) 31 + #expect(info.pixelHeight == 3024) 32 + #expect(info.latitude == 52.3676) 33 + #expect(info.longitude == 4.9041) 34 + #expect(info.isFavorite == true) 35 + #expect(info.originalFilename == "IMG_0001.HEIC") 36 + #expect(info.uniformTypeIdentifier == "public.heic") 37 + #expect(info.hasEdit == false) 38 + #expect(info.albums.count == 1) 39 + #expect(info.albums[0].title == "Vacation") 40 + } 41 + 42 + @Test("creates video asset with nil optional fields") 43 + func createVideoAssetMinimal() { 44 + let info = AssetInfo( 45 + identifier: "VID-456", 46 + creationDate: nil, 47 + kind: .video, 48 + pixelWidth: 1920, 49 + pixelHeight: 1080, 50 + latitude: nil, 51 + longitude: nil, 52 + isFavorite: false, 53 + originalFilename: nil, 54 + uniformTypeIdentifier: nil, 55 + hasEdit: false, 56 + albums: [] 57 + ) 58 + 59 + #expect(info.identifier == "VID-456") 60 + #expect(info.creationDate == nil) 61 + #expect(info.kind == .video) 62 + #expect(info.latitude == nil) 63 + #expect(info.originalFilename == nil) 64 + #expect(info.albums.isEmpty) 65 + } 66 + 67 + @Test("asset kind raw values match CLI constants") 68 + func assetKindRawValues() { 69 + #expect(AssetKind.photo.rawValue == 0) 70 + #expect(AssetKind.video.rawValue == 1) 71 + } 72 + 73 + @Test("album info equality") 74 + func albumInfoEquality() { 75 + let a = AlbumInfo(identifier: "id-1", title: "Photos") 76 + let b = AlbumInfo(identifier: "id-1", title: "Photos") 77 + let c = AlbumInfo(identifier: "id-2", title: "Videos") 78 + 79 + #expect(a == b) 80 + #expect(a != c) 81 + } 82 + } 83 + 84 + @Suite("MockPhotoLibrary Discovery") 85 + struct MockPhotoLibraryDiscoveryTests { 86 + @Test("enumerateAssets returns configured assets") 87 + func enumerateAssets() { 88 + let infos = [ 89 + AssetInfo( 90 + identifier: "asset-1", 91 + creationDate: Date(), 92 + kind: .photo, 93 + pixelWidth: 100, 94 + pixelHeight: 100, 95 + latitude: nil, 96 + longitude: nil, 97 + isFavorite: false, 98 + originalFilename: "photo.jpg", 99 + uniformTypeIdentifier: "public.jpeg", 100 + hasEdit: false, 101 + albums: [] 102 + ), 103 + AssetInfo( 104 + identifier: "asset-2", 105 + creationDate: nil, 106 + kind: .video, 107 + pixelWidth: 1920, 108 + pixelHeight: 1080, 109 + latitude: nil, 110 + longitude: nil, 111 + isFavorite: true, 112 + originalFilename: "video.mov", 113 + uniformTypeIdentifier: "com.apple.quicktime-movie", 114 + hasEdit: true, 115 + albums: [AlbumInfo(identifier: "a1", title: "Favorites")] 116 + ), 117 + ] 118 + 119 + let library = MockPhotoLibrary(assets: [:], assetInfos: infos) 120 + 121 + #expect(library.totalAssetCount() == 2) 122 + 123 + let enumerated = library.enumerateAssets() 124 + #expect(enumerated.count == 2) 125 + #expect(enumerated[0].identifier == "asset-1") 126 + #expect(enumerated[1].identifier == "asset-2") 127 + #expect(enumerated[1].isFavorite == true) 128 + #expect(enumerated[1].hasEdit == true) 129 + #expect(enumerated[1].albums.count == 1) 130 + } 131 + 132 + @Test("totalAssetCount returns zero for empty library") 133 + func emptyLibrary() { 134 + let library = MockPhotoLibrary(assets: [:]) 135 + #expect(library.totalAssetCount() == 0) 136 + #expect(library.enumerateAssets().isEmpty) 137 + } 138 + }
+9
Tests/PhotoExporterTests.swift
··· 7 7 /// Mock photo library for testing export logic without PhotoKit. 8 8 struct MockPhotoLibrary: PhotoLibrary { 9 9 let assets: [String: AssetHandle] 10 + var assetInfos: [AssetInfo] = [] 10 11 11 12 func fetchAssets(identifiers: [String]) -> [String: AssetHandle] { 12 13 var result: [String: AssetHandle] = [:] ··· 16 17 } 17 18 } 18 19 return result 20 + } 21 + 22 + func totalAssetCount() -> Int { 23 + assetInfos.count 24 + } 25 + 26 + func enumerateAssets() -> [AssetInfo] { 27 + assetInfos 19 28 } 20 29 } 21 30