import AppKit import CryptoKit import Foundation import os import Observation @Observable @MainActor final class NowPlayingArtworkResolver { private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Artwork") private let cacheDirectory: URL private var memoryCache: [String: NSImage] = [:] private var inFlight: Set = [] private let faviconLoader: StationArtworkLoader var onArtworkLoaded: (() -> Void)? init(cacheDirectory: URL, faviconLoader: StationArtworkLoader) { self.cacheDirectory = cacheDirectory.appendingPathComponent("now_playing_artwork") self.faviconLoader = faviconLoader } func resolve(artist: String?, title: String?, station: RadioStation?) -> NSImage { let cacheKey = artworkCacheKey(artist: artist, title: title, station: station) if let cached = memoryCache[cacheKey] { return cached } if let diskImage = NSImage(contentsOf: diskCacheURL(for: cacheKey)) { memoryCache[cacheKey] = diskImage return diskImage } if let station { let favicon = faviconLoader.image(for: station) ?? BearWaveAssets.image(named: "bearwave") ?? NSImage() memoryCache[cacheKey] = favicon return favicon } return BearWaveAssets.image(named: "bearwave") ?? NSImage() } func fetchArtwork(artist: String?, title: String?, station: RadioStation?) { logger.info("fetchArtwork called: artist='\(artist ?? "nil")', title='\(title ?? "nil")'") guard let artist, let title, !artist.isEmpty, !title.isEmpty else { logger.info("guard failed: empty artist or title") return } let cacheKey = artworkCacheKey(artist: artist, title: title, station: station) guard !inFlight.contains(cacheKey) else { logger.info("already in flight for key: \(cacheKey)") return } inFlight.insert(cacheKey) Task { defer { Task { @MainActor in self.inFlight.remove(cacheKey) } } let query = "\(artist) \(title)".trimmingCharacters(in: .whitespacesAndNewlines) logger.info("iTunes search query: '\(query)'") let searchURL = Self.buildITunesSearchURL(query: query) guard let searchURL else { logger.info("failed to build search URL") return } do { var request = URLRequest(url: searchURL) request.timeoutInterval = 8 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) else { logger.info("search request failed: \(response)") return } let decoder = JSONDecoder() let itunesResponse = try decoder.decode(ITunesResponse.self, from: data) logger.info("iTunes resultCount: \(itunesResponse.results.count)") guard let result = itunesResponse.results.first, var artworkURL = URL(string: result.artworkUrl100) else { logger.info("no artwork URL in response") return } logger.info("artwork URL: \(artworkURL.absoluteString)") artworkURL = Self.highResArtworkURL(artworkURL) var imageRequest = URLRequest(url: artworkURL) imageRequest.timeoutInterval = 8 let (imageData, imageResponse) = try await URLSession.shared.data(for: imageRequest) guard let httpImage = imageResponse as? HTTPURLResponse, (200..<300).contains(httpImage.statusCode) else { logger.info("image download failed: \(imageResponse)") return } guard let image = NSImage(data: imageData) else { logger.info("NSImage(data:) failed, data size: \(imageData.count)") return } logger.info("image loaded, size: \(String(describing: image.size))") try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) try? imageData.write(to: diskCacheURL(for: cacheKey), options: .atomic) await MainActor.run { memoryCache[cacheKey] = image logger.info("artwork cached and callback triggered") self.onArtworkLoaded?() } } catch { logger.info("error: \(error)") return } } } private func artworkCacheKey(artist: String?, title: String?, station: RadioStation?) -> String { let artistPart = artist?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let titlePart = title?.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let key = "\(artistPart)|\(titlePart)" logger.info("cacheKey: '\(key)'") return key } private func diskCacheURL(for key: String) -> URL { let digest = SHA256.hash(data: Data(key.utf8)) let hash = digest.map { String(format: "%02x", $0) }.joined() return cacheDirectory.appendingPathComponent("\(hash).image") } private static func buildITunesSearchURL(query: String) -> URL? { var components = URLComponents(string: "https://itunes.apple.com/search") components?.queryItems = [ URLQueryItem(name: "term", value: query), URLQueryItem(name: "media", value: "music"), URLQueryItem(name: "limit", value: "1") ] return components?.url } private static func highResArtworkURL(_ url: URL) -> URL { var urlString = url.absoluteString urlString = urlString.replacingOccurrences(of: "100x100", with: "600x600") return URL(string: urlString) ?? url } } private struct ITunesResponse: Codable { struct Result: Codable { let artworkUrl100: String } let results: [Result] }