Create BearWave macOS radio-first app
This commit is contained in:
78
Sources/BearWaveCore/Services/StationArtworkLoader.swift
Normal file
78
Sources/BearWaveCore/Services/StationArtworkLoader.swift
Normal file
@@ -0,0 +1,78 @@
|
||||
import AppKit
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class StationArtworkLoader {
|
||||
private let cacheDirectory: URL
|
||||
private var memoryCache: [String: NSImage] = [:]
|
||||
private var inFlight: Set<String> = []
|
||||
|
||||
init(cacheDirectory: URL) {
|
||||
self.cacheDirectory = cacheDirectory.appendingPathComponent("station_artwork")
|
||||
}
|
||||
|
||||
func image(for station: RadioStation?) -> NSImage? {
|
||||
guard let urlString = normalizedArtworkURLString(for: station) else {
|
||||
return BearWaveAssets.image(named: "bearwave")
|
||||
}
|
||||
if let cached = memoryCache[urlString] {
|
||||
return cached
|
||||
}
|
||||
if let diskImage = NSImage(contentsOf: cacheURL(for: urlString)) {
|
||||
memoryCache[urlString] = diskImage
|
||||
return diskImage
|
||||
}
|
||||
load(urlString)
|
||||
return BearWaveAssets.image(named: "bearwave")
|
||||
}
|
||||
|
||||
private func load(_ urlString: String) {
|
||||
guard !inFlight.contains(urlString), let url = URL(string: urlString) else { return }
|
||||
inFlight.insert(urlString)
|
||||
|
||||
Task {
|
||||
defer {
|
||||
Task { @MainActor in self.inFlight.remove(urlString) }
|
||||
}
|
||||
do {
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 10
|
||||
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode),
|
||||
let image = NSImage(data: data) else { return }
|
||||
|
||||
try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
try? data.write(to: cacheURL(for: urlString), options: .atomic)
|
||||
|
||||
await MainActor.run {
|
||||
memoryCache[urlString] = image
|
||||
}
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func normalizedArtworkURLString(for station: RadioStation?) -> String? {
|
||||
guard let station else { return nil }
|
||||
let raw = station.favicon.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !raw.isEmpty else { return nil }
|
||||
if raw.hasPrefix("//") {
|
||||
return "https:\(raw)"
|
||||
}
|
||||
if raw.hasPrefix("http://") || raw.hasPrefix("https://") {
|
||||
return raw
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cacheURL(for urlString: String) -> URL {
|
||||
let digest = SHA256.hash(data: Data(urlString.utf8))
|
||||
let hash = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return cacheDirectory.appendingPathComponent("\(hash).image")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user