mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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.
This commit is contained in:
@@ -97,7 +97,7 @@ protocol MPVClientDelegate: AnyObject {
|
|||||||
func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?)
|
func mpvClient(_ client: MPVClient, didUpdateProperty property: String, value: Any?)
|
||||||
func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id)
|
func mpvClient(_ client: MPVClient, didReceiveEvent event: mpv_event_id)
|
||||||
func mpvClient(_ client: MPVClient, didUpdateCacheState cacheState: MPVCacheState)
|
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 {
|
enum MPVEndFileReason {
|
||||||
@@ -109,6 +109,27 @@ enum MPVEndFileReason {
|
|||||||
case unknown
|
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
|
// MARK: - MPV Client
|
||||||
|
|
||||||
/// Thread-safe wrapper around libmpv.
|
/// Thread-safe wrapper around libmpv.
|
||||||
@@ -134,6 +155,12 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
/// Semaphore signaled when event loop exits
|
/// Semaphore signaled when event loop exits
|
||||||
private let eventLoopExitSemaphore = DispatchSemaphore(value: 0)
|
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)
|
/// Callback for render updates (called when mpv wants to redraw)
|
||||||
var onRenderUpdate: (() -> Void)?
|
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
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
/// Initialize the MPV instance with default options.
|
/// Initialize the MPV instance with default options.
|
||||||
@@ -218,6 +291,14 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
|
|
||||||
log("Initialized, setting up property observers...")
|
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
|
// Log hwdec diagnostics
|
||||||
#if os(tvOS)
|
#if os(tvOS)
|
||||||
if let hwdec = mpv_get_property_string(mpv, "hwdec") {
|
if let hwdec = mpv_get_property_string(mpv, "hwdec") {
|
||||||
@@ -1389,24 +1470,29 @@ final class MPVClient: @unchecked Sendable {
|
|||||||
let endFile = data.assumingMemoryBound(to: mpv_event_end_file.self).pointee
|
let endFile = data.assumingMemoryBound(to: mpv_event_end_file.self).pointee
|
||||||
let reason = mapEndFileReason(endFile.reason)
|
let reason = mapEndFileReason(endFile.reason)
|
||||||
|
|
||||||
// Log detailed error information when file load fails
|
let isErrorReason = endFile.reason == MPV_END_FILE_REASON_ERROR
|
||||||
if endFile.reason == MPV_END_FILE_REASON_ERROR {
|
let errorCode: Int32 = isErrorReason ? endFile.error : 0
|
||||||
let errorCode = endFile.error
|
let errorString: String? = isErrorReason ? String(cString: mpv_error_string(errorCode)) : nil
|
||||||
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
|
if isErrorReason, let mappedString = errorString {
|
||||||
|
logError("End file with error", details: "code=\(errorCode), message=\(mappedString)")
|
||||||
Task { @MainActor in
|
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
|
Task { @MainActor [weak self] in
|
||||||
guard let self else { return }
|
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:
|
case MPV_EVENT_FILE_LOADED:
|
||||||
// Add pending external audio track after file is loaded (dispatch to mpvQueue for thread safety)
|
// Add pending external audio track after file is loaded (dispatch to mpvQueue for thread safety)
|
||||||
mpvQueue.async { [weak self] in
|
mpvQueue.async { [weak self] in
|
||||||
|
|||||||
@@ -109,6 +109,12 @@ final class MPVBackend: PlayerBackend {
|
|||||||
private var currentLoadingID: UUID? // Tracks current load operation for cancellation
|
private var currentLoadingID: UUID? // Tracks current load operation for cancellation
|
||||||
private var isWaitingForExternalAudio = false // True when waiting for external audio track to load
|
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
|
// Buffer stall detection - triggers stream refresh when buffer stuck at 0% for too long
|
||||||
private var bufferStallStartTime: Date?
|
private var bufferStallStartTime: Date?
|
||||||
private let bufferStallTimeout: TimeInterval = 30 // Trigger refresh after 30 seconds of stall
|
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)
|
// Reset state (but keep videoWidth/videoHeight for smooth aspect ratio transition)
|
||||||
isReady = false
|
isReady = false
|
||||||
isInitialLoading = true
|
isInitialLoading = true
|
||||||
|
loadFailedDuringWait = false
|
||||||
|
lastLoadErrorDetail = nil
|
||||||
|
mpvClient?.clearRecentLogLines()
|
||||||
isSeeking = false
|
isSeeking = false
|
||||||
hasDisplayedVideo = false
|
hasDisplayedVideo = false
|
||||||
hasStartedPlayback = 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 ` — <line>` 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 {
|
private func waitForReady(loadingID: UUID) async throws {
|
||||||
let start = Date()
|
let start = Date()
|
||||||
let timeout = currentLoadTimeout
|
let timeout = currentLoadTimeout
|
||||||
@@ -1394,8 +1448,15 @@ final class MPVBackend: PlayerBackend {
|
|||||||
throw CancellationError()
|
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 {
|
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))
|
try await Task.sleep(for: .milliseconds(100))
|
||||||
@@ -1409,8 +1470,14 @@ final class MPVBackend: PlayerBackend {
|
|||||||
throw CancellationError()
|
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 {
|
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))
|
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
|
// 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
|
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
|
#endif
|
||||||
|
|
||||||
private func handleEndFile(reason: MPVEndFileReason) {
|
private func handleEndFile(reason: MPVEndFileReason, errorCode: Int32 = 0, errorString: String? = nil) {
|
||||||
switch reason {
|
switch reason {
|
||||||
case .eof:
|
case .eof:
|
||||||
LoggingService.shared.debug("MPV: End of file", category: .mpv)
|
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")
|
LoggingService.shared.logMPV("MPV: Requesting stream refresh for mid-playback error")
|
||||||
delegate?.backend(self, didRequestStreamRefresh: currentTime)
|
delegate?.backend(self, didRequestStreamRefresh: currentTime)
|
||||||
} else {
|
} 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:
|
case .stop:
|
||||||
|
|||||||
Reference in New Issue
Block a user