Create BearWave macOS radio-first app
This commit is contained in:
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 {}
|
||||
Reference in New Issue
Block a user