diff --git a/AudioStreaming/Core/Extensions/AVAudioUnit+Convenience.swift b/AudioStreaming/Core/Extensions/AVAudioUnit+Convenience.swift index 33604f8..e8b99e7 100644 --- a/AudioStreaming/Core/Extensions/AVAudioUnit+Convenience.swift +++ b/AudioStreaming/Core/Extensions/AVAudioUnit+Convenience.swift @@ -21,7 +21,7 @@ extension AVAudioUnit { completion(.failure(error)) return } - completion(.failure(AudioPlayerError.audioSystemError(.playerNotFound))) + completion(.failure(AudioPlayerError.audioSystemError(.playerNotFound(nil)))) return } completion(.success(audioUnit)) diff --git a/AudioStreaming/Core/Network/NetworkingClient.swift b/AudioStreaming/Core/Network/NetworkingClient.swift index 49926e4..2f22063 100644 --- a/AudioStreaming/Core/Network/NetworkingClient.swift +++ b/AudioStreaming/Core/Network/NetworkingClient.swift @@ -12,14 +12,15 @@ enum DataStreamError: Error { public enum NetworkError: Error, Equatable { case failure(Error) - case serverError + case serverError(HTTPResponseDetails) case missingData + public static func == (lhs: NetworkError, rhs: NetworkError) -> Bool { switch (lhs, rhs) { - case (.failure, failure): - return true - case (.serverError, .serverError): - return true + case let (.failure(lhsError), .failure(rhsError)): + return compareErrors(lhsError, rhsError) + case let (.serverError(lhsDetails), .serverError(rhsDetails)): + return lhsDetails == rhsDetails case (.missingData, .missingData): return true default: @@ -28,6 +29,90 @@ public enum NetworkError: Error, Equatable { } } +extension NetworkError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .failure(error): + let nsError = error as NSError + return "\(error.localizedDescription) [\(nsError.domain):\(nsError.code)]" + case let .serverError(details): + return details.localizedDescription + case .missingData: + return "Missing audio data from network stream" + } + } +} + +public struct HTTPResponseDetails: Equatable, Sendable { + public let statusCode: Int + public let url: String? + public let contentType: String? + public let headers: [String: String] + public let bodySnippet: String? + + init(response: HTTPURLResponse, bodySnippet: String? = nil) { + statusCode = response.statusCode + url = response.url?.absoluteString + contentType = response.value(forHTTPHeaderField: "Content-Type") + headers = response.allHeaderFields.reduce(into: [:]) { result, entry in + result[String(describing: entry.key)] = String(describing: entry.value) + } + self.bodySnippet = bodySnippet + } + + func appendingBodySnippet(_ bodySnippet: String?) -> HTTPResponseDetails { + HTTPResponseDetails( + statusCode: statusCode, + url: url, + contentType: contentType, + headers: headers, + bodySnippet: bodySnippet ?? self.bodySnippet + ) + } + + private init( + statusCode: Int, + url: String?, + contentType: String?, + headers: [String: String], + bodySnippet: String? + ) { + self.statusCode = statusCode + self.url = url + self.contentType = contentType + self.headers = headers + self.bodySnippet = bodySnippet + } + + public var localizedDescription: String { + var parts = ["HTTP server error \(statusCode)"] + if let contentType, !contentType.isEmpty { + parts.append("contentType=\(contentType)") + } + if let url, !url.isEmpty { + parts.append("url=\(url)") + } + if let bodySnippet, !bodySnippet.isEmpty { + parts.append("bodySnippet=\(bodySnippet)") + } + return parts.joined(separator: " ") + } +} + +private func compareErrors(_ lhs: Error?, _ rhs: Error?) -> Bool { + switch (lhs, rhs) { + case (nil, nil): + return true + case let (lhs?, rhs?): + let lhsNSError = lhs as NSError + let rhsNSError = rhs as NSError + return lhsNSError.domain == rhsNSError.domain && + lhsNSError.code == rhsNSError.code + default: + return false + } +} + protocol StreamTaskProvider: AnyObject { func dataStream(for request: URLSessionTask) -> NetworkDataStream? } diff --git a/AudioStreaming/Streaming/Audio Source/FileAudioSource.swift b/AudioStreaming/Streaming/Audio Source/FileAudioSource.swift index 4a356b3..7f773cb 100644 --- a/AudioStreaming/Streaming/Audio Source/FileAudioSource.swift +++ b/AudioStreaming/Streaming/Audio Source/FileAudioSource.swift @@ -152,7 +152,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource { func performMp4Restructure(inputStream: InputStream, moovOffset: Int) throws { let offsetAccepted = inputStream.setProperty(moovOffset, forKey: .fileCurrentOffsetKey) if !offsetAccepted { - delegate?.errorOccurred(source: self, error: inputStream.streamError ?? AudioSystemError.playerStartError) + delegate?.errorOccurred(source: self, error: inputStream.streamError ?? AudioSystemError.playerStartError(nil)) return } @@ -160,7 +160,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource { var header = [UInt8](repeating: 0, count: 8) let headerRead = inputStream.read(&header, maxLength: 8) guard headerRead == 8 else { - delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError) + delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError(nil)) return } @@ -180,7 +180,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource { var ext = [UInt8](repeating: 0, count: 8) let extRead = inputStream.read(&ext, maxLength: 8) guard extRead == 8 else { - delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError) + delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError(nil)) return } let ext64 = Data(ext).withUnsafeBytes { $0.load(as: UInt64.self) }.bigEndian @@ -190,7 +190,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource { let remaining = moovSize - moovData.count if remaining < 0 { - delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError) + delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError(nil)) return } if remaining > 0 { @@ -202,7 +202,7 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource { return inputStream.read(base, maxLength: remaining - total) } guard readBytes > 0 else { - delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError) + delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError(nil)) return } total += readBytes @@ -213,13 +213,13 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource { let moovResult = try mp4Restructure.restructureMoov(data: moovData) delegate?.dataAvailable(source: self, data: moovResult.initialData) if !inputStream.setProperty(moovResult.mdatOffset, forKey: .fileCurrentOffsetKey) { - delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError) + delegate?.errorOccurred(source: self, error: AudioSystemError.playerStartError(nil)) } } private func open() throws { guard let inputStream = InputStream(url: url) else { - throw AudioSystemError.playerStartError + throw AudioSystemError.playerStartError(nil) } self.inputStream = inputStream CFReadStreamSetDispatchQueue(inputStream, underlyingQueue) diff --git a/AudioStreaming/Streaming/Audio Source/RemoteAudioSource.swift b/AudioStreaming/Streaming/Audio Source/RemoteAudioSource.swift index 522a6c2..dd2aeed 100644 --- a/AudioStreaming/Streaming/Audio Source/RemoteAudioSource.swift +++ b/AudioStreaming/Streaming/Audio Source/RemoteAudioSource.swift @@ -36,11 +36,13 @@ public class RemoteAudioSource: AudioStreamSource { private var relativePosition: Int private var seekOffset: Int private var supportsSeek: Bool + private var pendingHTTPError: HTTPResponseDetails? var metadataStreamProcessor: MetadataStreamSource private var shouldTryParsingIcycastHeaders: Bool = false private let icycastHeadersProcessor: IcycastHeadersProcessor + private static let maxErrorBodySnippetLength = 256 public var audioFileHint: AudioFileTypeID { guard let output = parsedHeaderOutput, output.typeId != 0 else { @@ -269,6 +271,9 @@ public class RemoteAudioSource: AudioStreamSource { case let .complete(event): if let error = event.error { delegate?.errorOccurred(source: self, error: error) + } else if let pendingHTTPError { + self.pendingHTTPError = nil + delegate?.errorOccurred(source: self, error: NetworkError.serverError(pendingHTTPError)) } else { addCompletionOperation { [weak self] in guard let self = self else { return } @@ -279,6 +284,16 @@ public class RemoteAudioSource: AudioStreamSource { } private func handleSuccessfulStreamEvent(response: NetworkDataStream.Response) { + if let pendingHTTPError { + let details = pendingHTTPError.appendingBodySnippet( + extractTextBodySnippet(data: response.data, contentType: pendingHTTPError.contentType) + ) + self.pendingHTTPError = nil + close() + delegate?.errorOccurred(source: self, error: NetworkError.serverError(details)) + return + } + guard let audioData = response.data else { delegate?.errorOccurred(source: self, error: NetworkError.missingData) return @@ -335,7 +350,7 @@ public class RemoteAudioSource: AudioStreamSource { if parsedHeaderOutput == nil { shouldTryParsingIcycastHeaders = true - checkHTTP(statusCode: httpStatusCode) + checkHTTP(response: response) return } @@ -349,22 +364,46 @@ public class RemoteAudioSource: AudioStreamSource { if let metadataStep = parsedHeaderOutput?.metadataStep { metadataStreamProcessor.metadataAvailable(step: metadataStep) } - checkHTTP(statusCode: httpStatusCode) + checkHTTP(response: response) } - private func checkHTTP(statusCode: Int) { + private func checkHTTP(response: HTTPURLResponse) { + let statusCode = response.statusCode // check for error if statusCode == 416 { // range not satisfied error if length >= 0 { seekOffset = length } delegate?.endOfFileOccurred(source: self) } else if statusCode >= 300 { - delegate?.errorOccurred( - source: self, - error: NetworkError.serverError - ) + pendingHTTPError = .init(response: response) } } + private func extractTextBodySnippet(data: Data?, contentType: String?) -> String? { + guard let data, !data.isEmpty else { return nil } + guard isTextualContentType(contentType) else { return nil } + + let prefixData = data.prefix(Self.maxErrorBodySnippetLength) + let decoded = String(data: prefixData, encoding: .utf8) + ?? String(data: prefixData, encoding: .isoLatin1) + guard let decoded else { return nil } + + let normalized = decoded + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .split(whereSeparator: \.isWhitespace) + .joined(separator: " ") + + return normalized.isEmpty ? nil : normalized + } + + private func isTextualContentType(_ contentType: String?) -> Bool { + guard let contentType = contentType?.lowercased() else { return false } + return contentType.hasPrefix("text/") + || contentType.contains("json") + || contentType.contains("xml") + || contentType.contains("javascript") + } + private func buildUrlRequest(with url: URL, seekIfNeeded seekOffset: Int) -> URLRequest { var urlRequest = URLRequest(url: url) urlRequest.networkServiceType = .avStreaming diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift index 1987109..ff54ff9 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift @@ -282,7 +282,7 @@ open class AudioPlayer { do { try self.startEngineIfNeeded() } catch { - self.raiseUnexpected(error: .audioSystemError(.engineFailure)) + self.raiseUnexpected(error: .audioSystemError(.engineFailure(.init(error: error)))) } } @@ -301,7 +301,7 @@ open class AudioPlayer { do { try self.startEngineIfNeeded() } catch { - self.raiseUnexpected(error: .audioSystemError(.engineFailure)) + self.raiseUnexpected(error: .audioSystemError(.engineFailure(nil))) } } @@ -578,7 +578,7 @@ open class AudioPlayer { self.playerRenderProcessor.attachCallback(on: unit, audioFormat: self.outputAudioFormat) case let .failure(error): assertionFailure("couldn't create player unit: \(error)") - self.raiseUnexpected(error: .audioSystemError(.playerNotFound)) + self.raiseUnexpected(error: .audioSystemError(.playerNotFound(.init(error: error)))) } } } @@ -705,7 +705,7 @@ open class AudioPlayer { try player.auAudioUnit.startHardware() } catch { stopEngine(reason: .error) - raiseUnexpected(error: .audioSystemError(.playerStartError)) + raiseUnexpected(error: .audioSystemError(.playerStartError(.init(error: error)))) } } @@ -1059,7 +1059,11 @@ extension AudioPlayer: AudioStreamSourceDelegate { public func errorOccurred(source: CoreAudioStreamSource, error: Error) { guard let entry = playerContext.audioReadingEntry, entry.has(same: source) else { return } - raiseUnexpected(error: .networkError(.failure(error))) + if let networkError = error as? NetworkError { + raiseUnexpected(error: .networkError(networkError)) + } else { + raiseUnexpected(error: .networkError(.failure(error))) + } } public func endOfFileOccurred(source: CoreAudioStreamSource) { diff --git a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerState.swift b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerState.swift index fb0d86b..9d59c80 100644 --- a/AudioStreaming/Streaming/AudioPlayer/AudioPlayerState.swift +++ b/AudioStreaming/Streaming/AudioPlayer/AudioPlayerState.swift @@ -100,25 +100,46 @@ public enum AudioPlayerError: LocalizedError, Equatable, Sendable { } } +public struct AudioSystemErrorDetails: Equatable, Sendable { + public let description: String + public let domain: String + public let code: Int + + init(error: Error) { + let nsError = error as NSError + description = error.localizedDescription + domain = nsError.domain + code = nsError.code + } +} + public enum AudioSystemError: LocalizedError, Equatable, Sendable { - case engineFailure - case playerNotFound - case playerStartError + case engineFailure(AudioSystemErrorDetails?) + case playerNotFound(AudioSystemErrorDetails?) + case playerStartError(AudioSystemErrorDetails?) case fileStreamError(AudioFileStreamError) case converterError(AudioConverterError) case codecError public var errorDescription: String? { switch self { - case .engineFailure: return "Audio engine couldn't start" - case .playerNotFound: return "Player not found" - case .playerStartError: return "Player couldn't start" + case let .engineFailure(error): + return detailedDescription(prefix: "Audio engine couldn't start", error: error) + case let .playerNotFound(error): + return detailedDescription(prefix: "Player not found", error: error) + case let .playerStartError(error): + return detailedDescription(prefix: "Player couldn't start", error: error) case let .fileStreamError(error): - return "Audio file stream error'd: \(error)" + return "Audio file stream errored: \(error)" case let .converterError(error): - return "Audio converter error'd: \(error)" + return "Audio converter errored: \(error)" case .codecError: return "Audio codec error" } } } + +private func detailedDescription(prefix: String, error: AudioSystemErrorDetails?) -> String { + guard let error else { return prefix } + return "\(prefix): \(error.description) [\(error.domain):\(error.code)]" +} diff --git a/AudioStreamingTests/Core/Network/ErrorDiagnosticsTests.swift b/AudioStreamingTests/Core/Network/ErrorDiagnosticsTests.swift new file mode 100644 index 0000000..247fff9 --- /dev/null +++ b/AudioStreamingTests/Core/Network/ErrorDiagnosticsTests.swift @@ -0,0 +1,122 @@ +import Foundation +import XCTest + +@testable import AudioStreaming + +final class NetworkErrorTests: XCTestCase { + func testFailureEqualityUsesNSErrorIdentity() { + XCTAssertEqual( + NetworkError.failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)), + NetworkError.failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)) + ) + XCTAssertNotEqual( + NetworkError.failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut)), + NetworkError.failure(NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost)) + ) + } + + func testServerErrorDescriptionIncludesStatusCode() { + let details403 = makeResponseDetails(statusCode: 403, contentType: "text/html", url: "https://example.com/stream") + let details404 = makeResponseDetails(statusCode: 404, contentType: nil, url: nil) + let details500 = makeResponseDetails(statusCode: 500, contentType: "application/json", url: "https://example.com/api") + + XCTAssertEqual( + NetworkError.serverError(details403).localizedDescription, + "HTTP server error 403 contentType=text/html url=https://example.com/stream" + ) + XCTAssertEqual( + NetworkError.serverError(details404).localizedDescription, + "HTTP server error 404 url=https://example.com" + ) + XCTAssertEqual( + NetworkError.serverError(details500).localizedDescription, + "HTTP server error 500 contentType=application/json url=https://example.com/api" + ) + + let details403WithBody = details403.appendingBodySnippet("Access denied by origin") + XCTAssertEqual( + NetworkError.serverError(details403WithBody).localizedDescription, + "HTTP server error 403 contentType=text/html url=https://example.com/stream bodySnippet=Access denied by origin" + ) + } + + func testServerErrorPreservesStructuredResponseDetails() { + let details = makeResponseDetails( + statusCode: 403, + contentType: "text/html; charset=utf-8", + url: "https://worldwide-fm.radiocult.fm/stream", + headers: [ + "cf-ray": "12345", + "Server": "cloudflare" + ] + ) + + let error = NetworkError.serverError(details) + + guard case let .serverError(responseDetails) = error else { + return XCTFail("Expected serverError with response details.") + } + + XCTAssertEqual(responseDetails.statusCode, 403) + XCTAssertEqual(responseDetails.contentType, "text/html; charset=utf-8") + XCTAssertEqual(responseDetails.url, "https://worldwide-fm.radiocult.fm/stream") + XCTAssertEqual(responseDetails.headers["cf-ray"], "12345") + XCTAssertEqual(responseDetails.headers["Server"], "cloudflare") + XCTAssertNil(responseDetails.bodySnippet) + + let detailsWithBody = responseDetails.appendingBodySnippet("Forbidden") + XCTAssertEqual(detailsWithBody.bodySnippet, "Forbidden") + } + + func testMissingDataDescription() { + XCTAssertEqual(NetworkError.missingData.localizedDescription, "Missing audio data from network stream") + } + + func testEngineFailureDescriptionIncludesUnderlyingNSErrorDetails() { + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorCannotConnectToHost) + let description = AudioSystemError.engineFailure(.init(error: error)).localizedDescription + + XCTAssertTrue(description.contains("Audio engine couldn't start")) + XCTAssertTrue(description.contains(NSURLErrorDomain)) + XCTAssertTrue(description.contains("\(NSURLErrorCannotConnectToHost)")) + } + + func testNilUnderlyingErrorUsesPlainPrefix() { + XCTAssertEqual(AudioSystemError.engineFailure(nil).localizedDescription, "Audio engine couldn't start") + XCTAssertEqual(AudioSystemError.playerNotFound(nil).localizedDescription, "Player not found") + XCTAssertEqual(AudioSystemError.playerStartError(nil).localizedDescription, "Player couldn't start") + } + + func testUnderlyingAudioSystemErrorsIncludePrefixAndNSErrorDetails() { + let playerNotFoundDescription = + AudioSystemError.playerNotFound(.init(error: NSError(domain: "AudioUnit", code: -50))).localizedDescription + XCTAssertTrue(playerNotFoundDescription.contains("Player not found")) + XCTAssertTrue(playerNotFoundDescription.contains("AudioUnit")) + XCTAssertTrue(playerNotFoundDescription.contains("-50")) + + let playerStartDescription = + AudioSystemError.playerStartError(.init(error: NSError(domain: NSOSStatusErrorDomain, code: -10875))).localizedDescription + XCTAssertTrue(playerStartDescription.contains("Player couldn't start")) + XCTAssertTrue(playerStartDescription.contains(NSOSStatusErrorDomain)) + XCTAssertTrue(playerStartDescription.contains("-10875")) + } + + private func makeResponseDetails( + statusCode: Int, + contentType: String?, + url: String?, + headers: [String: String] = [:] + ) -> HTTPResponseDetails { + var headerFields = headers + if let contentType { + headerFields["Content-Type"] = contentType + } + let response = HTTPURLResponse( + url: URL(string: url ?? "https://example.com")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: headerFields + )! + return HTTPResponseDetails(response: response) + } +}