205 lines
7.7 KiB
Swift
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 {}
|