Packages/MoonriseKit/Sources/Feeds/MarsImages.swift
99 lines · 4427 bytes
1import Foundation
2
3/// Latest raw images from the Mars rovers, from NASA's public raw image feeds.
4public struct MarsImage: Sendable, Identifiable, Hashable {
5 public var id: String
6 public var rover: String
7 public var sol: Int
8 public var camera: String
9 public var taken: Date
10 public var thumbnail: URL
11 public var image: URL
12 public var credit: String
13
14 public static let perseveranceURL = URL(string: "https://mars.nasa.gov/rss/api/?feed=raw_images&category=mars2020&feedtype=json&num=100&page=0&order=sol+desc")!
15 public static let curiosityURL = URL(string: "https://mars.nasa.gov/api/v1/raw_image_items/?order=sol%20desc,date_taken%20desc&per_page=100&page=0&condition_1=msl:mission")!
16
17 /// Newest full frames from both rovers, `limit` per rover. A rover whose feed fails is left out.
18 public static func latest(limit: Int = 12, session: URLSession = .shared) async throws -> [MarsImage] {
19 async let p = try? decodePerseverance(await session.data(from: perseveranceURL).0)
20 async let c = try? decodeCuriosity(await session.data(from: curiosityURL).0)
21 let (pi, ci) = await (p, c)
22 guard pi != nil || ci != nil else { throw URLError(.cannotLoadFromNetwork) }
23 return Array((pi ?? []).prefix(limit)) + Array((ci ?? []).prefix(limit))
24 }
25
26 // MARK: Perseverance
27
28 private struct M2020: Decodable {
29 struct Image: Decodable {
30 struct Files: Decodable { var medium: URL; var large: URL }
31 struct Camera: Decodable { var instrument: String }
32 var imageid: String
33 var sol: Int
34 var sample_type: String
35 var date_taken_utc: String
36 var image_files: Files
37 var camera: Camera
38 var credit: String
39 }
40 var images: [Image]
41 }
42
43 static func decodePerseverance(_ data: Data) throws -> [MarsImage] {
44 try JSONDecoder().decode(M2020.self, from: data).images
45 .filter { $0.sample_type.lowercased() == "full" && $0.camera.instrument != "SKYCAM" && !$0.camera.instrument.hasSuffix("_RIGHT") }
46 .compactMap { i in
47 guard let taken = parseUTC(i.date_taken_utc) else { return nil }
48 return MarsImage(id: i.imageid, rover: "Perseverance", sol: i.sol, camera: cameraName(i.camera.instrument), taken: taken, thumbnail: i.image_files.medium, image: i.image_files.large, credit: i.credit)
49 }
50 .sorted { $0.taken > $1.taken }
51 }
52
53 // MARK: Curiosity
54
55 private struct MSL: Decodable {
56 struct Item: Decodable {
57 struct Extended: Decodable { var sample_type: String }
58 var imageid: String
59 var sol: Int
60 var instrument: String
61 var url: URL
62 var date_taken: String
63 var extended: Extended
64 }
65 var items: [Item]
66 }
67
68 static func decodeCuriosity(_ data: Data) throws -> [MarsImage] {
69 try JSONDecoder().decode(MSL.self, from: data).items
70 .filter { $0.extended.sample_type == "full" }
71 .compactMap { i in
72 guard let taken = parseUTC(i.date_taken) else { return nil }
73 return MarsImage(id: i.imageid, rover: "Curiosity", sol: i.sol, camera: cameraName(i.instrument), taken: taken, thumbnail: i.url, image: i.url, credit: "NASA/JPL-Caltech")
74 }
75 .sorted { $0.taken > $1.taken }
76 }
77
78 /// ISO 8601 with or without fractional seconds and zone; a missing zone is UTC.
79 static func parseUTC(_ s: String) -> Date? {
80 var s = s
81 if !s.hasSuffix("Z"), !s.contains("+") { s += "Z" }
82 if let d = try? Date(s, strategy: .iso8601) { return d }
83 return try? Date(s, strategy: Date.ISO8601FormatStyle(includingFractionalSeconds: true))
84 }
85
86 static func cameraName(_ code: String) -> String {
87 switch code {
88 case let c where c.hasPrefix("MCZ"): "Mastcam-Z"
89 case let c where c.hasPrefix("NAVCAM"), let c where c.hasPrefix("NAV_"): "Navcam"
90 case let c where c.contains("HAZ"): c.hasPrefix("R") ? "Rear Hazcam" : "Front Hazcam"
91 case "MAHLI": "MAHLI"
92 case let c where c.hasPrefix("MAST"): "Mastcam"
93 case "CHEMCAM_RMI": "ChemCam"
94 case let c where c.hasPrefix("SHERLOC"): "SHERLOC"
95 case let c where c.hasPrefix("SUPERCAM"): "SuperCam"
96 default: code
97 }
98 }
99}