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