Create BearWave macOS radio-first app
This commit is contained in:
158
Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift
Normal file
158
Sources/BearWaveCore/Services/NowPlayingArtworkResolver.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
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<String> = []
|
||||
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]
|
||||
}
|
||||
Reference in New Issue
Block a user