Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
95 changes: 90 additions & 5 deletions AudioStreaming/Core/Network/NetworkingClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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?
}
Expand Down
14 changes: 7 additions & 7 deletions AudioStreaming/Streaming/Audio Source/FileAudioSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,15 +152,15 @@ 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
}

// Read moov header (8 bytes)
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
}

Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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)
Expand Down
53 changes: 46 additions & 7 deletions AudioStreaming/Streaming/Audio Source/RemoteAudioSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand Down Expand Up @@ -335,7 +350,7 @@ public class RemoteAudioSource: AudioStreamSource {

if parsedHeaderOutput == nil {
shouldTryParsingIcycastHeaders = true
checkHTTP(statusCode: httpStatusCode)
checkHTTP(response: response)
return
}

Expand All @@ -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
Expand Down
14 changes: 9 additions & 5 deletions AudioStreaming/Streaming/AudioPlayer/AudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
}
}

Expand All @@ -301,7 +301,7 @@ open class AudioPlayer {
do {
try self.startEngineIfNeeded()
} catch {
self.raiseUnexpected(error: .audioSystemError(.engineFailure))
self.raiseUnexpected(error: .audioSystemError(.engineFailure(nil)))
}
}

Expand Down Expand Up @@ -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))))
}
}
}
Expand Down Expand Up @@ -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))))
}
}

Expand Down Expand Up @@ -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) {
Expand Down
37 changes: 29 additions & 8 deletions AudioStreaming/Streaming/AudioPlayer/AudioPlayerState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)]"
}
Loading