Files
bearwave-mac/Sources/BearWaveCore/Services/RadioBrowserClient.swift
2026-06-25 09:21:42 +02:00

205 lines
7.7 KiB
Swift

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 {}