Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -3281,6 +3281,7 @@ class PlayerViewModel @JvmOverloads internal constructor(
ffmpegVideoArgs = emptyList(),
seekable = mpv.seekable ?: stableLocalFile,
audioStreamIndex = null,
isExternalAudio = true,
)
} else {
null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.window.DialogProperties
import chimahon.anki.AnkiMediaWarning
import chimahon.anki.AnkiSentenceAudioFailure
import chimahon.anki.AnkiSentenceAudioInputSource
import chimahon.anki.AnkiSentenceAudioPlayableFallback
import eu.kanade.tachiyomi.ui.player.scene.PlayerSceneMiningProgress
import eu.kanade.tachiyomi.util.system.toast
import tachiyomi.i18n.kmk.KMR
Expand Down Expand Up @@ -47,6 +50,118 @@ internal fun Context.showPlayerAnkiMediaWarnings(warnings: List<AnkiMediaWarning
AnkiMediaWarning.SceneGenerationFailed -> KMR.strings.anki_scene_fallback_generation
AnkiMediaWarning.AnimatedStorageFailed -> KMR.strings.anki_scene_fallback_storage
AnkiMediaWarning.StillStorageFailed -> KMR.strings.anki_scene_still_storage_failed
is AnkiMediaWarning.SentenceAudioGenerationFailed -> when (warning.failure) {
AnkiSentenceAudioFailure.TRACK_MAPPING_UNAVAILABLE -> {
KMR.strings.anki_sentence_audio_track_mapping_unavailable
}
AnkiSentenceAudioFailure.SOURCE_UNAVAILABLE -> {
KMR.strings.anki_sentence_audio_source_unavailable
}
AnkiSentenceAudioFailure.TIMING_UNAVAILABLE -> {
KMR.strings.anki_sentence_audio_timing_unavailable
}
AnkiSentenceAudioFailure.AUDIO_PROBE_FAILED -> {
when (warning.diagnostic?.inputSource) {
AnkiSentenceAudioInputSource.ORIGINAL_VIDEO -> {
KMR.strings.anki_sentence_audio_probe_failed_original
}
AnkiSentenceAudioInputSource.MPV_PLAYABLE_VIDEO -> {
KMR.strings.anki_sentence_audio_probe_failed_playable
}
AnkiSentenceAudioInputSource.MPV_EXTERNAL_AUDIO,
null -> KMR.strings.anki_sentence_audio_probe_failed
}
}
AnkiSentenceAudioFailure.AUDIO_STREAMS_NOT_FOUND -> {
when (warning.diagnostic?.inputSource) {
AnkiSentenceAudioInputSource.ORIGINAL_VIDEO -> {
when (warning.diagnostic?.playableFallback) {
AnkiSentenceAudioPlayableFallback.MISSING -> {
KMR.strings
.anki_sentence_audio_streams_not_found_original_playable_missing
}
AnkiSentenceAudioPlayableFallback.SAME_AS_ORIGINAL -> {
KMR.strings
.anki_sentence_audio_streams_not_found_original_playable_same
}
AnkiSentenceAudioPlayableFallback.UNAVAILABLE -> {
KMR.strings
.anki_sentence_audio_streams_not_found_original_playable_unavailable
}
null -> KMR.strings.anki_sentence_audio_streams_not_found_original
}
}
AnkiSentenceAudioInputSource.MPV_PLAYABLE_VIDEO -> {
KMR.strings.anki_sentence_audio_streams_not_found_playable
}
AnkiSentenceAudioInputSource.MPV_EXTERNAL_AUDIO -> {
KMR.strings.anki_sentence_audio_streams_not_found_external
}
null -> KMR.strings.anki_sentence_audio_streams_not_found
}
}
AnkiSentenceAudioFailure.AUDIO_CODEC_RESTRICTED -> {
when (warning.diagnostic?.inputSource) {
AnkiSentenceAudioInputSource.ORIGINAL_VIDEO -> {
KMR.strings.anki_sentence_audio_codec_restricted_original
}
AnkiSentenceAudioInputSource.MPV_PLAYABLE_VIDEO -> {
KMR.strings.anki_sentence_audio_codec_restricted_playable
}
AnkiSentenceAudioInputSource.MPV_EXTERNAL_AUDIO -> {
KMR.strings.anki_sentence_audio_codec_restricted_external
}
null -> KMR.strings.anki_sentence_audio_codec_restricted
}
}
AnkiSentenceAudioFailure.AUDIO_STREAM_INDEX_UNAVAILABLE -> {
KMR.strings.anki_sentence_audio_stream_index_unavailable
}
AnkiSentenceAudioFailure.AUDIO_STREAM_NOT_AUDIO -> {
KMR.strings.anki_sentence_audio_stream_not_audio
}
AnkiSentenceAudioFailure.AUDIO_STREAM_PROTECTED -> {
KMR.strings.anki_sentence_audio_stream_protected
}
AnkiSentenceAudioFailure.AUDIO_STREAM_UNREADABLE -> {
KMR.strings.anki_sentence_audio_stream_unreadable
}
AnkiSentenceAudioFailure.EXTRACTION_FAILED -> {
KMR.strings.anki_sentence_audio_extraction_failed
}
AnkiSentenceAudioFailure.EXTRACTION_OUTPUT_MISSING -> {
KMR.strings.anki_sentence_audio_extraction_output_missing
}
AnkiSentenceAudioFailure.EXTRACTION_OUTPUT_READ_FAILED -> {
KMR.strings.anki_sentence_audio_extraction_output_read_failed
}
AnkiSentenceAudioFailure.EXTRACTION_STREAM_MAPPING_FAILED -> {
KMR.strings.anki_sentence_audio_extraction_stream_mapping_failed
}
AnkiSentenceAudioFailure.EXTRACTION_SOURCE_READ_FAILED -> {
when (warning.diagnostic?.inputSource) {
AnkiSentenceAudioInputSource.ORIGINAL_VIDEO -> {
KMR.strings.anki_sentence_audio_extraction_source_read_failed_original
}
AnkiSentenceAudioInputSource.MPV_PLAYABLE_VIDEO -> {
KMR.strings.anki_sentence_audio_extraction_source_read_failed_playable
}
AnkiSentenceAudioInputSource.MPV_EXTERNAL_AUDIO,
null -> KMR.strings.anki_sentence_audio_extraction_source_read_failed
}
}
AnkiSentenceAudioFailure.EXTRACTION_SEEK_FAILED -> {
KMR.strings.anki_sentence_audio_extraction_seek_failed
}
AnkiSentenceAudioFailure.EXTRACTION_OUTPUT_WRITE_FAILED -> {
KMR.strings.anki_sentence_audio_extraction_output_write_failed
}
AnkiSentenceAudioFailure.EXTRACTION_TIMED_OUT -> {
KMR.strings.anki_sentence_audio_extraction_timed_out
}
AnkiSentenceAudioFailure.UNKNOWN -> KMR.strings.anki_sentence_audio_generation_failed
}
AnkiMediaWarning.SentenceAudioStorageFailed -> KMR.strings.anki_sentence_audio_storage_failed
},
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@ internal class AndroidSceneCaptureService private constructor(
outputCleanup.nativeFinished()
}
when (result) {
SceneCommandResult.Failed -> {
SceneCommandResult.Failed,
is SceneCommandResult.FfmpegFailed -> {
return@withContext AnkiScreenshotPreparation.Failed(stillFallback = null)
}
is SceneCommandResult.Success -> Unit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,75 @@ import kotlin.coroutines.resume
internal sealed interface SceneCommandResult {
data class Success(val output: String = "") : SceneCommandResult

data class FfmpegFailed(
val failure: SceneFfmpegFailure,
val nativeDiagnostics: SceneNativeFailureDiagnostics? = null,
) : SceneCommandResult

data object Failed : SceneCommandResult
}

/**
* Raw native details remain in-memory until the temporary, app-private sentence-audio journal
* redacts them. They must never be passed to UI warnings or regular application logs.
*/
internal data class SceneNativeFailureDiagnostics(
val returnCode: Int?,
val failStackTrace: String?,
val logs: String?,
)

internal enum class SceneFfmpegFailure {
STREAM_MAPPING,
SOURCE_READ,
SEEK,
OUTPUT_WRITE,
UNKNOWN,
}

/**
* Converts untrusted native diagnostics into a closed, user-safe category. Raw logs may include
* URLs or request headers, so they are never exposed to the UI; the temporary sentence-audio
* journal is responsible for redacting any diagnostic detail it writes.
*/
internal fun classifySceneFfmpegFailure(
failStackTrace: String?,
logs: String?,
): SceneFfmpegFailure {
val detail = sequenceOf(failStackTrace, logs)
.filterNotNull()
.joinToString(separator = "\n")
.lowercase()

return when {
"stream map" in detail && "matches no streams" in detail -> {
SceneFfmpegFailure.STREAM_MAPPING
}
"could not seek" in detail || "failed to seek" in detail || "invalid seek" in detail -> {
SceneFfmpegFailure.SEEK
}
"could not write header for output" in detail ||
"error opening output" in detail ||
"failed to avio_open" in detail ||
"error writing trailer" in detail ||
"error muxing a packet" in detail -> {
SceneFfmpegFailure.OUTPUT_WRITE
}
"http error" in detail ||
"server returned" in detail ||
"failed to open segment" in detail ||
"error when loading first segment" in detail ||
"unable to open resource" in detail ||
"connection refused" in detail ||
"connection reset by peer" in detail ||
"network is unreachable" in detail ||
"connection timed out" in detail -> {
SceneFfmpegFailure.SOURCE_READ
}
else -> SceneFfmpegFailure.UNKNOWN
}
}

internal interface SceneCommandExecutor {
suspend fun executeFfmpeg(
arguments: Array<String>,
Expand Down Expand Up @@ -79,7 +145,18 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor {
if (ReturnCode.isSuccess(session.returnCode)) {
SceneCommandResult.Success()
} else {
SceneCommandResult.Failed
val diagnostics = SceneNativeFailureDiagnostics(
returnCode = session.returnCode?.getValue(),
failStackTrace = session.failStackTrace,
logs = session.getAllLogsAsString(FAILURE_LOG_WAIT_MILLIS),
)
SceneCommandResult.FfmpegFailed(
classifySceneFfmpegFailure(
failStackTrace = diagnostics.failStackTrace,
logs = diagnostics.logs,
),
nativeDiagnostics = diagnostics,
)
}
},
onNativeFinished = onNativeFinished,
Expand All @@ -105,7 +182,18 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor {
if (ReturnCode.isSuccess(session.returnCode)) {
SceneCommandResult.Success(session.output.orEmpty())
} else {
SceneCommandResult.Failed
val diagnostics = SceneNativeFailureDiagnostics(
returnCode = session.returnCode?.getValue(),
failStackTrace = session.failStackTrace,
logs = session.getAllLogsAsString(FAILURE_LOG_WAIT_MILLIS),
)
SceneCommandResult.FfmpegFailed(
classifySceneFfmpegFailure(
failStackTrace = diagnostics.failStackTrace,
logs = diagnostics.logs,
),
nativeDiagnostics = diagnostics,
)
}
},
onNativeFinished = onNativeFinished,
Expand Down Expand Up @@ -201,6 +289,8 @@ internal class FfmpegKitSceneCommandExecutor : SceneCommandExecutor {
}

private companion object {
// Temporary diagnostic window: only native failures wait for late FFmpeg/FFprobe lines.
const val FAILURE_LOG_WAIT_MILLIS = 1_000
val DISCARD_LOG_CALLBACK = LogCallback {}
val DISCARD_STATISTICS_CALLBACK = StatisticsCallback {}
}
Expand Down
Loading
Loading