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 = [] 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") } }