import CryptoKit import Foundation import Network import Observation struct HttpError: LocalizedError { let code: Int let message: String var errorDescription: String? { message } } /// A thin URLSession wrapper. With [offlineCache] set, successful GETs are kept on disk and /// answered from there when the network fails, so the library still browses with the server away. final class Http: @unchecked Sendable { let session: URLSession private let cacheDir: URL? /// When true, GETs never touch the network: cached body or HTTP 504. var forceCache: (() -> Bool)? init(connectTimeout: TimeInterval, readTimeout: TimeInterval, offlineCache: URL? = nil, memoryMb: Int = 0) { let c = URLSessionConfiguration.default c.timeoutIntervalForRequest = max(connectTimeout, readTimeout) c.timeoutIntervalForResource = 3600 c.requestCachePolicy = .reloadIgnoringLocalCacheData c.urlCache = memoryMb > 0 ? URLCache(memoryCapacity: memoryMb << 20, diskCapacity: 0) : nil c.waitsForConnectivity = false c.httpMaximumConnectionsPerHost = 8 session = URLSession(configuration: c) cacheDir = offlineCache if let d = offlineCache { try? FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) } } private func cacheFile(_ req: URLRequest) -> URL? { guard let dir = cacheDir, req.httpMethod ?? "GET" == "GET", let url = req.url?.absoluteString else { return nil } var key = "GET " + url for h in ["X-Plex-Container-Start", "X-Plex-Container-Size"] { if let v = req.value(forHTTPHeaderField: h) { key += "|\(h)=\(v)" } } let hash = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined() return dir.appendingPathComponent(hash) } /// Returns the body of a 2xx response, or throws [HttpError] / the URL error. func data(_ req: URLRequest) async throws -> Data { let file = cacheFile(req) if let file, forceCache?() == true { if let d = try? Data(contentsOf: file) { return d } throw HttpError(code: 504, message: "Offline, and not cached") } do { let (data, resp) = try await session.data(for: req) let code = (resp as? HTTPURLResponse)?.statusCode ?? 0 guard (200..<300).contains(code) else { throw HttpError(code: code, message: "HTTP \(code) from \(req.url?.host ?? "server")") } if let file { try? data.write(to: file, options: .atomic) } return data } catch let e as URLError { if e.code != .cancelled, let file, let d = try? Data(contentsOf: file) { return d } throw e } } func string(_ req: URLRequest) async throws -> String { String(decoding: try await data(req), as: UTF8.self) } /// Status code only; network errors become false. func ok(_ req: URLRequest) async -> Bool { guard let (_, resp) = try? await session.data(for: req) else { return false } return ((resp as? HTTPURLResponse)?.statusCode).map { (200..<300).contains($0) } ?? false } } extension URL { /// Appends query items, skipping nil values. func with(_ params: [(String, Any?)]) -> URL { guard var c = URLComponents(url: self, resolvingAgainstBaseURL: false) else { return self } var items = c.queryItems ?? [] for (k, v) in params { if let v { items.append(URLQueryItem(name: k, value: "\(v)")) } } c.queryItems = items.isEmpty ? nil : items // URLComponents leaves '+' alone, which servers read as a space. c.percentEncodedQuery = c.percentEncodedQuery?.replacingOccurrences(of: "+", with: "%2B") return c.url ?? self } } /// Form-style percent-encoding for a single query value. func urlEncode(_ s: String) -> String { var allowed = CharacterSet.alphanumerics allowed.insert(charactersIn: "-._~") return s.addingPercentEncoding(withAllowedCharacters: allowed) ?? s } /// Online / Wi-Fi state, for offline fallbacks and Wi-Fi-only downloads. @Observable final class Connectivity: @unchecked Sendable { private(set) var online = true private(set) var unmetered = true @ObservationIgnored private let monitor = NWPathMonitor() @ObservationIgnored private var listeners: [() -> Void] = [] init() { monitor.pathUpdateHandler = { [weak self] path in DispatchQueue.main.async { guard let self else { return } let on = path.status == .satisfied let un = on && !path.isExpensive && !path.isConstrained if on != self.online || un != self.unmetered { self.online = on self.unmetered = un self.listeners.forEach { $0() } } } } monitor.start(queue: DispatchQueue(label: "connectivity")) } /// Main-thread callback whenever online or unmetered changes. func onChange(_ f: @escaping () -> Void) { listeners.append(f) } }