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]
|
||||
}
|
||||
466
Sources/BearWaveCore/Services/PlaybackController.swift
Normal file
466
Sources/BearWaveCore/Services/PlaybackController.swift
Normal file
@@ -0,0 +1,466 @@
|
||||
import AppKit
|
||||
import AVFoundation
|
||||
import Combine
|
||||
@preconcurrency import MediaPlayer
|
||||
import Observation
|
||||
import os
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
public final class PlaybackController {
|
||||
private let logger = Logger(subsystem: "com.spalencsar.BearWave", category: "Playback")
|
||||
private let player = AVPlayer()
|
||||
private let artworkProvider: (RadioStation?, String?, String?) -> NSImage
|
||||
var onMetadataChanged: ((String?, String?, RadioStation?) -> Void)?
|
||||
private var retryTask: Task<Void, Never>?
|
||||
private var retryAttempts = 0
|
||||
private var lastURL: URL?
|
||||
private var lastStation: RadioStation?
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
private var itemCancellables: Set<AnyCancellable> = []
|
||||
private var metadataHandler: MetadataHandler?
|
||||
|
||||
var state: PlaybackStatus = .stopped
|
||||
public var isPlaying: Bool { state.isPlaying }
|
||||
var currentStation: RadioStation?
|
||||
var currentTrackTitle = ""
|
||||
var currentTrackArtist = ""
|
||||
var currentArtwork: NSImage = BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
var currentTime: TimeInterval = 0
|
||||
var duration: TimeInterval = 0
|
||||
var volume: Double = 0.55 {
|
||||
didSet {
|
||||
player.volume = Float(min(max(volume, 0), 1))
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
init(artworkProvider: @escaping (RadioStation?, String?, String?) -> NSImage = { station, _, _ in
|
||||
BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
}) {
|
||||
self.artworkProvider = artworkProvider
|
||||
player.volume = Float(volume)
|
||||
configureRemoteCommands()
|
||||
observePlaybackNotifications()
|
||||
observePlayer()
|
||||
configureTimeObserver()
|
||||
}
|
||||
|
||||
func play(_ station: RadioStation) {
|
||||
guard let url = URL(string: station.playbackURLString) else { return }
|
||||
retryTask?.cancel()
|
||||
retryAttempts = 0
|
||||
lastURL = url
|
||||
lastStation = station
|
||||
currentStation = station
|
||||
currentTrackTitle = ""
|
||||
currentTrackArtist = ""
|
||||
state = .connecting
|
||||
itemCancellables.removeAll()
|
||||
|
||||
let item = AVPlayerItem(url: url)
|
||||
|
||||
let metadataOutput = AVPlayerItemMetadataOutput(identifiers: nil)
|
||||
let handler = MetadataHandler { [weak self] artist, title in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
var changed = false
|
||||
if let title, title != self.currentTrackTitle {
|
||||
self.currentTrackTitle = title
|
||||
changed = true
|
||||
}
|
||||
if let artist, artist != self.currentTrackArtist {
|
||||
self.currentTrackArtist = artist
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
self.onMetadataChanged?(self.currentTrackArtist, self.currentTrackTitle, self.currentStation)
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
metadataOutput.setDelegate(handler, queue: .main)
|
||||
item.add(metadataOutput)
|
||||
metadataHandler = handler
|
||||
|
||||
observeItem(item)
|
||||
player.replaceCurrentItem(with: item)
|
||||
player.play()
|
||||
setInitialArtwork()
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func resume() {
|
||||
if let station = lastStation {
|
||||
play(station)
|
||||
} else {
|
||||
player.play()
|
||||
state = .connecting
|
||||
updateNowPlaying()
|
||||
}
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player.pause()
|
||||
state = .paused
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
retryTask?.cancel()
|
||||
player.pause()
|
||||
player.replaceCurrentItem(with: nil)
|
||||
state = .stopped
|
||||
currentStation = nil
|
||||
currentTrackTitle = ""
|
||||
currentTrackArtist = ""
|
||||
currentArtwork = BearWaveAssets.image(named: "bearwave") ?? NSImage()
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
itemCancellables.removeAll()
|
||||
metadataHandler = nil
|
||||
onMetadataChanged?(nil, nil, nil)
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
||||
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
||||
}
|
||||
|
||||
public func togglePlayPause() {
|
||||
isPlaying ? pause() : resume()
|
||||
}
|
||||
|
||||
private var timeObserverToken: Any?
|
||||
|
||||
func configureTimeObserver() {
|
||||
let interval = CMTime(seconds: 1, preferredTimescale: 1)
|
||||
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let currentSeconds = time.seconds
|
||||
guard currentSeconds.isFinite, currentSeconds >= 0 else { return }
|
||||
|
||||
self.currentTime = currentSeconds
|
||||
let itemDuration = self.player.currentItem?.duration
|
||||
if let itemDuration, itemDuration.timescale > 0, itemDuration.value > 0 {
|
||||
let dur = CMTimeGetSeconds(itemDuration)
|
||||
if dur.isFinite, dur > 0 {
|
||||
self.duration = dur
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observePlaybackNotifications() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] notification in
|
||||
let error = (notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError)
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
let resolvedError = error ?? self.player.currentItem?.error as NSError?
|
||||
let playbackError = Self.classifyError(resolvedError)
|
||||
self.logger.info("playback failed: \(playbackError.title) – \(playbackError.detail)")
|
||||
self.state = .failed(playbackError)
|
||||
if playbackError.isRetryable {
|
||||
self.scheduleRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemPlaybackStalled,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.state = .buffering
|
||||
self.scheduleRetry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observePlayer() {
|
||||
player.publisher(for: \.timeControlStatus)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] status in
|
||||
Task { @MainActor in self?.handleTimeControlStatus(status) }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func observeItem(_ item: AVPlayerItem) {
|
||||
item.publisher(for: \.status)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self, weak item] status in
|
||||
Task { @MainActor in self?.handleItemStatus(status, item: item) }
|
||||
}
|
||||
.store(in: &itemCancellables)
|
||||
}
|
||||
|
||||
private func handleItemStatus(_ status: AVPlayerItem.Status, item: AVPlayerItem?) {
|
||||
switch status {
|
||||
case .unknown:
|
||||
state = .connecting
|
||||
case .readyToPlay:
|
||||
if player.timeControlStatus == .playing {
|
||||
state = .playing
|
||||
} else {
|
||||
state = .buffering
|
||||
}
|
||||
case .failed:
|
||||
let error = Self.classifyError(item?.error as NSError?)
|
||||
logger.info("item failed: \(error.title) – \(error.detail)")
|
||||
state = .failed(error)
|
||||
if error.isRetryable {
|
||||
scheduleRetry()
|
||||
}
|
||||
@unknown default:
|
||||
state = .failed(.unknown(String(localized: "Unsupported stream state")))
|
||||
}
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func handleTimeControlStatus(_ status: AVPlayer.TimeControlStatus) {
|
||||
guard currentStation != nil else { return }
|
||||
switch status {
|
||||
case .paused:
|
||||
if state != .stopped, !isFailed {
|
||||
state = .paused
|
||||
}
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
if !isFailed {
|
||||
state = player.currentItem?.status == .unknown ? .connecting : .buffering
|
||||
}
|
||||
case .playing:
|
||||
state = .playing
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func scheduleRetry() {
|
||||
guard retryAttempts < 2, let url = lastURL else { return }
|
||||
if case let .failed(error) = state, !error.isRetryable {
|
||||
logger.info("skipping retry: error is not retryable")
|
||||
return
|
||||
}
|
||||
retryAttempts += 1
|
||||
logger.info("scheduling retry attempt \(self.retryAttempts)")
|
||||
retryTask?.cancel()
|
||||
retryTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .milliseconds(1200))
|
||||
await MainActor.run {
|
||||
guard let self else { return }
|
||||
let item = AVPlayerItem(url: url)
|
||||
self.itemCancellables.removeAll()
|
||||
self.observeItem(item)
|
||||
self.state = .connecting
|
||||
self.player.replaceCurrentItem(with: item)
|
||||
self.player.play()
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureRemoteCommands() {
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.resume() }
|
||||
return .success
|
||||
}
|
||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.pause() }
|
||||
return .success
|
||||
}
|
||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.togglePlayPause() }
|
||||
return .success
|
||||
}
|
||||
commandCenter.stopCommand.addTarget { [weak self] _ in
|
||||
Task { @MainActor in self?.stop() }
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
func refreshNowPlaying() {
|
||||
logger.info("refreshNowPlaying called")
|
||||
currentArtwork = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
public func updateNowPlayingExternally() {
|
||||
updateNowPlaying()
|
||||
}
|
||||
|
||||
private func setInitialArtwork() {
|
||||
currentArtwork = artworkProvider(currentStation, nil, nil)
|
||||
}
|
||||
|
||||
private func updateNowPlaying() {
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo()
|
||||
MPNowPlayingInfoCenter.default().playbackState = state == .playing ? .playing : .paused
|
||||
}
|
||||
|
||||
private func nowPlayingInfo() -> [String: Any] {
|
||||
var info: [String: Any] = [
|
||||
MPMediaItemPropertyTitle: currentTrackTitle.isEmpty ? currentStation?.name ?? "BearWave" : currentTrackTitle,
|
||||
MPMediaItemPropertyAlbumTitle: currentStation?.name ?? "BearWave",
|
||||
MPNowPlayingInfoPropertyPlaybackRate: state == .playing ? 1.0 : 0.0
|
||||
]
|
||||
if !currentTrackArtist.isEmpty {
|
||||
info[MPMediaItemPropertyArtist] = currentTrackArtist
|
||||
}
|
||||
if let artwork = nowPlayingArtwork() {
|
||||
info[MPMediaItemPropertyArtwork] = artwork
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
private func nowPlayingArtwork() -> MPMediaItemArtwork? {
|
||||
let image = artworkProvider(currentStation, currentTrackArtist.isEmpty ? nil : currentTrackArtist, currentTrackTitle.isEmpty ? nil : currentTrackTitle)
|
||||
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return nil }
|
||||
let size = image.size
|
||||
return MPMediaItemArtwork(boundsSize: size, requestHandler: { @Sendable requestedSize in
|
||||
NSImage(cgImage: cgImage, size: requestedSize)
|
||||
})
|
||||
}
|
||||
|
||||
private var isFailed: Bool {
|
||||
if case .failed = state { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private static func classifyError(_ error: NSError?) -> PlaybackError {
|
||||
guard let error else { return .unknown(String(localized: "Stream failed")) }
|
||||
|
||||
let domain = error.domain
|
||||
let code = error.code
|
||||
|
||||
if domain == NSURLErrorDomain {
|
||||
switch code {
|
||||
case NSURLErrorTimedOut:
|
||||
return .connectionTimeout
|
||||
case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed:
|
||||
return .dnsResolutionFailed
|
||||
case NSURLErrorCannotConnectToHost, NSURLErrorNetworkConnectionLost:
|
||||
return .stationOffline
|
||||
case NSURLErrorUnsupportedURL, NSURLErrorFileDoesNotExist:
|
||||
return .codecNotSupported
|
||||
default:
|
||||
if (500...599).contains(code) {
|
||||
return .httpError(code)
|
||||
}
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
if domain == "CoreMediaErrorDomain" {
|
||||
switch code {
|
||||
case -12785:
|
||||
return .streamInterrupted
|
||||
case -12935, -16042, -15697:
|
||||
return .codecNotSupported
|
||||
default:
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
if domain == "AVFoundationErrorDomain" {
|
||||
switch code {
|
||||
case -11800, -11828, -11838:
|
||||
return .codecNotSupported
|
||||
default:
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
if (400...599).contains(code) {
|
||||
return .httpError(code)
|
||||
}
|
||||
|
||||
return .unknown(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
final class MetadataHandler: NSObject, AVPlayerItemMetadataOutputPushDelegate {
|
||||
private let onMetadata: @Sendable (String?, String?) -> Void
|
||||
|
||||
init(onMetadata: @escaping @Sendable (String?, String?) -> Void) {
|
||||
self.onMetadata = onMetadata
|
||||
}
|
||||
|
||||
func metadataOutput(_ output: AVPlayerItemMetadataOutput, didOutputTimedMetadataGroups groups: [AVTimedMetadataGroup], from track: AVPlayerItemTrack?) {
|
||||
guard let items = groups.first?.items else { return }
|
||||
let parsed = parseMetadataItems(items)
|
||||
onMetadata(parsed.artist, parsed.title)
|
||||
}
|
||||
}
|
||||
|
||||
private func parseMetadataItems(_ items: [AVMetadataItem]) -> (artist: String?, title: String?) {
|
||||
var artist: String?
|
||||
var title: String?
|
||||
|
||||
for item in items {
|
||||
let key = metadataKey(for: item)
|
||||
let value = metadataStringValue(for: item)
|
||||
guard let value, !value.isEmpty else { continue }
|
||||
|
||||
if key.contains("artist") || key.contains("author") {
|
||||
artist = value
|
||||
} else if key.contains("title") || key.contains("song") {
|
||||
if let split = splitArtistTitle(value) {
|
||||
artist = artist ?? split.artist
|
||||
title = split.title
|
||||
} else {
|
||||
title = value
|
||||
}
|
||||
} else if artist == nil && title == nil, let split = splitArtistTitle(value) {
|
||||
artist = split.artist
|
||||
title = split.title
|
||||
}
|
||||
}
|
||||
|
||||
return (artist, title)
|
||||
}
|
||||
|
||||
private func metadataKey(for item: AVMetadataItem) -> String {
|
||||
let common = item.commonKey?.rawValue ?? ""
|
||||
let identifier = item.identifier?.rawValue ?? ""
|
||||
let key = item.key as? String ?? ""
|
||||
return [common, identifier, key].joined(separator: " ").lowercased()
|
||||
}
|
||||
|
||||
private func metadataStringValue(for item: AVMetadataItem) -> String? {
|
||||
if let stringValue = item.stringValue {
|
||||
return cleanMetadataString(stringValue)
|
||||
}
|
||||
if let value = item.value as? String {
|
||||
return cleanMetadataString(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func cleanMetadataString(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "StreamTitle=", with: "")
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: " ';\""))
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func splitArtistTitle(_ value: String) -> (artist: String, title: String)? {
|
||||
let separators = [" - ", " – ", " — "]
|
||||
for separator in separators {
|
||||
let parts = value.components(separatedBy: separator)
|
||||
guard parts.count >= 2 else { continue }
|
||||
let artist = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let title = parts.dropFirst().joined(separator: separator).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !artist.isEmpty, !title.isEmpty {
|
||||
return (artist, title)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
204
Sources/BearWaveCore/Services/RadioBrowserClient.swift
Normal file
204
Sources/BearWaveCore/Services/RadioBrowserClient.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
|
||||
struct RadioBrowserClient: Sendable {
|
||||
var baseURL: URL
|
||||
var cacheDirectory: URL
|
||||
var session: URLSessionProtocol
|
||||
|
||||
init(
|
||||
baseURL: URL = URL(string: "https://de1.api.radio-browser.info/json")!,
|
||||
cacheDirectory: URL,
|
||||
session: URLSessionProtocol = URLSession.shared
|
||||
) {
|
||||
self.baseURL = baseURL
|
||||
self.cacheDirectory = cacheDirectory
|
||||
self.session = session
|
||||
}
|
||||
|
||||
func topStations(count: Int = 100) async throws -> [RadioStation] {
|
||||
try await request("/stations/topvote/\(count)")
|
||||
}
|
||||
|
||||
func germanStations() async throws -> [RadioStation] {
|
||||
try await request("/stations/bycountrycodeexact/DE?limit=50&order=votes&reverse=true")
|
||||
}
|
||||
|
||||
func dutchStations() async throws -> [RadioStation] {
|
||||
try await request("/stations/bycountrycodeexact/NL?limit=50&order=votes&reverse=true")
|
||||
}
|
||||
|
||||
func worldStations(count: Int = 200) async throws -> [RadioStation] {
|
||||
try await request("/stations?hidebroken=true&limit=\(count)&order=votes&reverse=true")
|
||||
}
|
||||
|
||||
func stations(tag: String) async throws -> [RadioStation] {
|
||||
try await request("/stations/bytag/\(Self.percentEncodePathComponent(tag))")
|
||||
}
|
||||
|
||||
func stations(countryCode: String) async throws -> [RadioStation] {
|
||||
try await request("/stations/bycountrycodeexact/\(countryCode.trimmingCharacters(in: .whitespacesAndNewlines).uppercased())")
|
||||
}
|
||||
|
||||
func search(_ query: String) async throws -> [RadioStation] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let candidates = searchCandidates(for: trimmed)
|
||||
var firstError: Error?
|
||||
var combined: [RadioStation] = []
|
||||
var seenIDs = Set<String>()
|
||||
|
||||
for candidate in candidates {
|
||||
do {
|
||||
let stations = try await request(searchEndpoint(name: candidate))
|
||||
for station in stations {
|
||||
if seenIDs.insert(station.id).inserted {
|
||||
combined.append(station)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if firstError == nil {
|
||||
firstError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ranked = rankSearchResults(combined, query: trimmed)
|
||||
if !ranked.isEmpty {
|
||||
return ranked
|
||||
}
|
||||
|
||||
if let firstError {
|
||||
throw firstError
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
func request(_ endpoint: String) async throws -> [RadioStation] {
|
||||
let cacheFile = cacheURL(for: endpoint)
|
||||
let requestURL = URL(string: baseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + endpoint)!
|
||||
|
||||
do {
|
||||
var request = URLRequest(url: requestURL)
|
||||
request.setValue("BearWave-macOS/1.0", forHTTPHeaderField: "User-Agent")
|
||||
let (data, response) = try await session.data(for: request)
|
||||
if let httpResponse = response as? HTTPURLResponse, !(200..<300).contains(httpResponse.statusCode) {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
let stations = try Self.decodeStations(from: data)
|
||||
if !stations.isEmpty {
|
||||
try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
try data.write(to: cacheFile, options: .atomic)
|
||||
}
|
||||
return stations
|
||||
} catch {
|
||||
if let cachedData = try? Data(contentsOf: cacheFile) {
|
||||
return try Self.decodeStations(from: cachedData)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func cacheURL(for endpoint: String) -> URL {
|
||||
let digest = Insecure.MD5.hash(data: Data(endpoint.utf8))
|
||||
let hash = digest.map { String(format: "%02x", $0) }.joined()
|
||||
return cacheDirectory.appendingPathComponent("\(hash).json")
|
||||
}
|
||||
|
||||
static func decodeStations(from data: Data) throws -> [RadioStation] {
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode([RadioStation].self, from: data)
|
||||
.filter { !$0.name.isEmpty && !$0.playbackURLString.isEmpty }
|
||||
}
|
||||
|
||||
static func percentEncodePathComponent(_ value: String) -> String {
|
||||
var allowed = CharacterSet.urlPathAllowed
|
||||
allowed.remove(charactersIn: "/?#[]@!$&'()*+,;=")
|
||||
return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
|
||||
}
|
||||
|
||||
private func searchEndpoint(name: String) -> String {
|
||||
var components = URLComponents()
|
||||
components.path = "/stations/search"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "name", value: name),
|
||||
URLQueryItem(name: "hidebroken", value: "true"),
|
||||
URLQueryItem(name: "limit", value: "200"),
|
||||
URLQueryItem(name: "order", value: "votes"),
|
||||
URLQueryItem(name: "reverse", value: "true")
|
||||
]
|
||||
return components.string ?? "/stations/byname/\(Self.percentEncodePathComponent(name))"
|
||||
}
|
||||
|
||||
private func searchCandidates(for query: String) -> [String] {
|
||||
guard !query.isEmpty else { return [] }
|
||||
|
||||
var candidates = [query]
|
||||
let punctuation = CharacterSet.punctuationCharacters
|
||||
if query.rangeOfCharacter(from: punctuation) == nil {
|
||||
candidates.insert("\(query)!", at: 0)
|
||||
candidates.append("\(query).")
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
return candidates.filter { candidate in
|
||||
let key = candidate.lowercased()
|
||||
if seen.contains(key) { return false }
|
||||
seen.insert(key)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private func rankSearchResults(_ stations: [RadioStation], query: String) -> [RadioStation] {
|
||||
let normalizedQuery = Self.normalizedSearchText(query)
|
||||
guard !normalizedQuery.isEmpty else { return stations }
|
||||
|
||||
return stations
|
||||
.map { station -> (RadioStation, Int) in
|
||||
let normalizedName = Self.normalizedSearchText(station.name)
|
||||
let nameTokens = Self.normalizedSearchTokens(station.name)
|
||||
let allTokens = Self.normalizedSearchTokens(station.searchableText)
|
||||
let score: Int
|
||||
if normalizedName == normalizedQuery {
|
||||
score = 0
|
||||
} else if nameTokens.contains(normalizedQuery) {
|
||||
score = 1
|
||||
} else if nameTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
|
||||
score = 2
|
||||
} else if allTokens.contains(normalizedQuery) {
|
||||
score = 3
|
||||
} else if allTokens.contains(where: { $0.hasPrefix(normalizedQuery) }) {
|
||||
score = 4
|
||||
} else {
|
||||
score = 5
|
||||
}
|
||||
return (station, score)
|
||||
}
|
||||
.filter { $0.1 < 5 }
|
||||
.sorted {
|
||||
if $0.1 != $1.1 { return $0.1 < $1.1 }
|
||||
if $0.0.votes != $1.0.votes { return $0.0.votes > $1.0.votes }
|
||||
return $0.0.name.localizedCaseInsensitiveCompare($1.0.name) == .orderedAscending
|
||||
}
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
static func normalizedSearchText(_ value: String) -> String {
|
||||
value
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.joined()
|
||||
}
|
||||
|
||||
static func normalizedSearchTokens(_ value: String) -> [String] {
|
||||
value
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
}
|
||||
|
||||
protocol URLSessionProtocol: Sendable {
|
||||
func data(for request: URLRequest) async throws -> (Data, URLResponse)
|
||||
}
|
||||
|
||||
extension URLSession: URLSessionProtocol {}
|
||||
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