From 5ab9e3d5bf80a6126f7c6a4abbe53d96e36b5ffa Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Fri, 8 May 2026 20:43:27 +0200 Subject: [PATCH] Surface mpv error details on stream load failure Subscribe to mpv log messages and capture END_FILE error code/string so load failures bubble up specific causes (HTTP 404/403, DNS failure, demuxer errors) instead of a generic 10s timeout. --- Yattee/Services/Player/MPV/MPVClient.swift | 110 ++++++++++++++++++--- Yattee/Services/Player/MPVBackend.swift | 82 +++++++++++++-- 2 files changed, 174 insertions(+), 18 deletions(-) diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index 250a70e0..5b2d4947 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -97,7 +97,7 @@ protocol MPVClientDelegate: AnyObject { func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?) func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id) func mpvClient(_ client: MPVClient, didUpdateCacheState cacheState: MPVCacheState) - func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason) + func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?) } enum MPVEndFileReason { @@ -109,6 +109,27 @@ enum MPVEndFileReason { case unknown } +/// A captured mpv log message. `level` matches mpv's level strings: "fatal", "error", "warn", "info", "v", "debug", "trace". +struct MPVLogLine { + let prefix: String + let level: String + let text: String + + var formatted: String { "[\(prefix)/\(level)] \(text)" } + + /// Numeric severity for ranking. Higher = more severe. + var severity: Int { + switch level { + case "fatal": return 5 + case "error": return 4 + case "warn": return 3 + case "info": return 2 + case "v": return 1 + default: return 0 + } + } +} + // MARK: - MPV Client /// Thread-safe wrapper around libmpv. @@ -134,6 +155,12 @@ final class MPVClient: @unchecked Sendable { /// Semaphore signaled when event loop exits private let eventLoopExitSemaphore = DispatchSemaphore(value: 0) + /// Ring buffer of recent mpv log messages (formatted "[prefix/level] text"). + /// Guarded by `logBufferLock`. Used to enrich load-error reports surfaced to the UI. + private var recentLogBuffer: [MPVLogLine] = [] + private let logBufferLock = NSLock() + private let recentLogBufferCapacity = 32 + /// Callback for render updates (called when mpv wants to redraw) var onRenderUpdate: (() -> Void)? @@ -178,6 +205,52 @@ final class MPVClient: @unchecked Sendable { } } + /// Handle MPV_EVENT_LOG_MESSAGE: trim, store in ring buffer, and forward to LoggingService. + private func handleLogMessage(_ msg: mpv_event_log_message) { + let prefix = String(cString: msg.prefix) + let level = String(cString: msg.level) + var text = String(cString: msg.text) + // mpv text usually ends with a newline; trim for tidy logs. + while text.hasSuffix("\n") || text.hasSuffix("\r") { + text.removeLast() + } + guard !text.isEmpty else { return } + + let line = MPVLogLine(prefix: prefix, level: level, text: text) + + logBufferLock.lock() + recentLogBuffer.append(line) + if recentLogBuffer.count > recentLogBufferCapacity { + recentLogBuffer.removeFirst(recentLogBuffer.count - recentLogBufferCapacity) + } + logBufferLock.unlock() + + let formatted = line.formatted + switch level { + case "fatal", "error": + Task { @MainActor in LoggingService.shared.logMPVError(formatted) } + case "warn": + Task { @MainActor in LoggingService.shared.logMPVWarning(formatted) } + default: + Task { @MainActor in LoggingService.shared.logMPV(formatted) } + } + } + + /// Snapshot the recent log buffer (most-recent-last). Thread-safe. + func recentLogLines(minimumSeverity: Int = 0) -> [MPVLogLine] { + logBufferLock.lock() + defer { logBufferLock.unlock() } + if minimumSeverity <= 0 { return recentLogBuffer } + return recentLogBuffer.filter { $0.severity >= minimumSeverity } + } + + /// Clear the recent log buffer. Call before starting a fresh load attempt. + func clearRecentLogLines() { + logBufferLock.lock() + recentLogBuffer.removeAll(keepingCapacity: true) + logBufferLock.unlock() + } + // MARK: - Lifecycle /// Initialize the MPV instance with default options. @@ -218,6 +291,14 @@ final class MPVClient: @unchecked Sendable { log("Initialized, setting up property observers...") + // Subscribe to mpv log messages so we can capture HTTP/demuxer/decoder errors + // and surface them when load fails. "warn" covers HTTP errors, demuxer/codec + // failures, and network issues without flooding on the happy path. + let logLevelResult = mpv_request_log_messages(mpv, "warn") + if logLevelResult < 0 { + logWarning("Failed to subscribe to mpv log messages: \(String(cString: mpv_error_string(logLevelResult)))") + } + // Log hwdec diagnostics #if os(tvOS) if let hwdec = mpv_get_property_string(mpv, "hwdec") { @@ -1388,25 +1469,30 @@ final class MPVClient: @unchecked Sendable { if let data = event.data { let endFile = data.assumingMemoryBound(to: mpv_event_end_file.self).pointee let reason = mapEndFileReason(endFile.reason) - - // Log detailed error information when file load fails - if endFile.reason == MPV_END_FILE_REASON_ERROR { - let errorCode = endFile.error - let errorString = String(cString: mpv_error_string(errorCode)) - logError("End file with error", details: "code=\(errorCode), message=\(errorString)") - - // Also try to get more detailed error from mpv properties + + let isErrorReason = endFile.reason == MPV_END_FILE_REASON_ERROR + let errorCode: Int32 = isErrorReason ? endFile.error : 0 + let errorString: String? = isErrorReason ? String(cString: mpv_error_string(errorCode)) : nil + + if isErrorReason, let mappedString = errorString { + logError("End file with error", details: "code=\(errorCode), message=\(mappedString)") Task { @MainActor in - LoggingService.shared.logMPVError("MPV end-file error: \(errorString) (code: \(errorCode))") + LoggingService.shared.logMPVError("MPV end-file error: \(mappedString) (code: \(errorCode))") } } - + Task { @MainActor [weak self] in guard let self else { return } - self.delegate?.mpvClientDidEndFile(self, reason: reason) + self.delegate?.mpvClientDidEndFile(self, reason: reason, errorCode: errorCode, errorString: errorString) } } + case MPV_EVENT_LOG_MESSAGE: + if let data = event.data { + let msg = data.assumingMemoryBound(to: mpv_event_log_message.self).pointee + handleLogMessage(msg) + } + case MPV_EVENT_FILE_LOADED: // Add pending external audio track after file is loaded (dispatch to mpvQueue for thread safety) mpvQueue.async { [weak self] in diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 24bb3a1f..9bb7683c 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -109,6 +109,12 @@ final class MPVBackend: PlayerBackend { private var currentLoadingID: UUID? // Tracks current load operation for cancellation private var isWaitingForExternalAudio = false // True when waiting for external audio track to load + // Captured detail from the most recent failed load attempt (mpv error string + recent log lines). + // Surfaced through BackendError.loadFailed so users see the underlying cause. + private var lastLoadErrorDetail: String? + // Set when MPV reports an END_FILE error during initial load so waitForReady can fail fast. + private var loadFailedDuringWait = false + // Buffer stall detection - triggers stream refresh when buffer stuck at 0% for too long private var bufferStallStartTime: Date? private let bufferStallTimeout: TimeInterval = 30 // Trigger refresh after 30 seconds of stall @@ -442,6 +448,9 @@ final class MPVBackend: PlayerBackend { // Reset state (but keep videoWidth/videoHeight for smooth aspect ratio transition) isReady = false isInitialLoading = true + loadFailedDuringWait = false + lastLoadErrorDetail = nil + mpvClient?.clearRecentLogLines() isSeeking = false hasDisplayedVideo = false hasStartedPlayback = false @@ -1381,6 +1390,51 @@ final class MPVBackend: PlayerBackend { } } + /// Pick the most informative recent mpv log line and combine with the END_FILE error string. + /// Used to populate `BackendError.loadFailed` so the user sees the underlying cause. + private func composeLoadErrorDetail(errorCode: Int32, errorString: String?) -> String { + let logLines = mpvClient?.recentLogLines() ?? [] + let preferred = pickMostInformativeLogLine(logLines) + if let preferred { + if let errorString, !errorString.isEmpty { + return "\(errorString): \(preferred)" + } + return preferred + } + return errorString ?? "unknown error (code \(errorCode))" + } + + /// Returns ` — ` for inclusion in a timeout message if a useful log line exists; otherwise empty. + private func logBufferDetailIfAny() -> String { + guard let mpvClient else { return "" } + if let line = pickMostInformativeLogLine(mpvClient.recentLogLines()) { + return " — \(line)" + } + return "" + } + + /// Pick the most relevant line: prefer error-level entries containing keywords like + /// "HTTP error", "Failed", "error", falling back to the most recent error/warn line. + private func pickMostInformativeLogLine(_ lines: [MPVLogLine]) -> String? { + guard !lines.isEmpty else { return nil } + let keywords = ["HTTP error", "Failed", "Cannot open", "No such", "refused", "timed out", "resolve"] + + // Search most-recent-first. + let reversed = Array(lines.reversed()) + + if let keywordHit = reversed.first(where: { line in + line.severity >= 3 && keywords.contains(where: { line.text.localizedCaseInsensitiveContains($0) }) + }) { + return keywordHit.formatted + } + + if let errorLine = reversed.first(where: { $0.severity >= 4 }) { + return errorLine.formatted + } + + return reversed.first(where: { $0.severity >= 3 })?.formatted + } + private func waitForReady(loadingID: UUID) async throws { let start = Date() let timeout = currentLoadTimeout @@ -1394,8 +1448,15 @@ final class MPVBackend: PlayerBackend { throw CancellationError() } + // Fail fast: MPV already reported an END_FILE error for this load. + if loadFailedDuringWait { + let detail = lastLoadErrorDetail ?? "MPV reported load error" + throw BackendError.loadFailed("Failed to load stream: \(detail)") + } + if Date().timeIntervalSince(start) > timeout { - throw BackendError.loadFailed("Timeout waiting for MPV to load stream (\(Int(timeout))s)") + let detail = lastLoadErrorDetail.map { " — \($0)" } ?? logBufferDetailIfAny() + throw BackendError.loadFailed("Timeout waiting for MPV to load stream (\(Int(timeout))s)\(detail)") } try await Task.sleep(for: .milliseconds(100)) @@ -1409,8 +1470,14 @@ final class MPVBackend: PlayerBackend { throw CancellationError() } + if loadFailedDuringWait { + let detail = lastLoadErrorDetail ?? "MPV reported load error" + throw BackendError.loadFailed("Failed to load audio track: \(detail)") + } + if Date().timeIntervalSince(start) > timeout { - throw BackendError.loadFailed("Timeout waiting for audio track (\(Int(timeout))s)") + let detail = lastLoadErrorDetail.map { " — \($0)" } ?? logBufferDetailIfAny() + throw BackendError.loadFailed("Timeout waiting for audio track (\(Int(timeout))s)\(detail)") } try await Task.sleep(for: .milliseconds(100)) @@ -1442,9 +1509,9 @@ extension MPVBackend: MPVClientDelegate { // Cache state is used for buffer display on seek bar - no action needed here } - nonisolated func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason) { + nonisolated func mpvClientDidEndFile(_ client: MPVClient, reason: MPVEndFileReason, errorCode: Int32, errorString: String?) { Task { @MainActor [weak self] in - self?.handleEndFile(reason: reason) + self?.handleEndFile(reason: reason, errorCode: errorCode, errorString: errorString) } } @@ -1764,7 +1831,7 @@ extension MPVBackend: MPVClientDelegate { } #endif - private func handleEndFile(reason: MPVEndFileReason) { + private func handleEndFile(reason: MPVEndFileReason, errorCode: Int32 = 0, errorString: String? = nil) { switch reason { case .eof: LoggingService.shared.debug("MPV: End of file", category: .mpv) @@ -1783,7 +1850,10 @@ extension MPVBackend: MPVClientDelegate { LoggingService.shared.logMPV("MPV: Requesting stream refresh for mid-playback error") delegate?.backend(self, didRequestStreamRefresh: currentTime) } else { - LoggingService.shared.debug("MPV: Load error (will retry)", category: .mpv) + // Capture detailed cause for waitForReady to surface in BackendError.loadFailed. + lastLoadErrorDetail = composeLoadErrorDetail(errorCode: errorCode, errorString: errorString) + loadFailedDuringWait = true + LoggingService.shared.debug("MPV: Load error (will retry) — \(lastLoadErrorDetail ?? errorString ?? "unknown")", category: .mpv) } case .stop: