fix: FileInfo.Stream.Data — data-потоки (tmcd/субтитры) не ломают ffprobe-парсинг
Build Media Mirror Worker / Build and publish (release) Successful in 1m35s

- подкласс Stream.Data с @SerialName(data), Subtitle.startTime nullable
- JobExecutor не трогался: filterIsInstance игнорирует Data
- тесты: JSON-фикстура с data-потоком, реальный .mov с tmcd-треком
This commit is contained in:
Hermes Agent
2026-08-11 00:00:39 +03:00
parent 72aa501a47
commit 235ae14693
4 changed files with 126 additions and 1 deletions
@@ -37,7 +37,7 @@ data class FileInfo(
override val duration: Duration? = null,
@SerialName("start_time")
@Serializable(DurationAsSecondsString::class)
override val startTime: Duration?,
override val startTime: Duration? = null,
override val tags: Map<String, String> = emptyMap(),
) : Stream
@@ -95,6 +95,18 @@ data class FileInfo(
get() = tags["language"] ?: tags["lang"]
}
@Serializable
@SerialName("data")
data class Data(
override val index: Int,
@Serializable(DurationAsSecondsString::class)
override val duration: Duration? = null,
@SerialName("start_time")
@Serializable(DurationAsSecondsString::class)
override val startTime: Duration? = null,
override val tags: Map<String, String> = emptyMap(),
) : Stream
@Serializable
data class Format(
@SerialName("format_name")
@@ -33,6 +33,23 @@ object TestMedia {
return out
}
fun generateSourceWithDataStream(dir: Path, durationSeconds: Int = 2): Path {
val base = generateSource(dir, durationSeconds)
val out = dir.resolve("source-with-data.mov")
val command = listOf(
FFMPEG,
"-y",
"-i", base.toString(),
"-map", "0:v",
"-map", "0:a",
"-c", "copy",
"-timecode", "00:00:00:00",
out.toString(),
)
run(command)
return out
}
fun generateBrokenFile(dir: Path): Path {
Files.createDirectories(dir)
val out = dir.resolve("broken.bin")
@@ -43,6 +43,16 @@ class FfprobeServiceTest {
assertTrue(audio.channels >= 1)
}
@Test
fun `getInfo ignores data streams and keeps audio and video`() {
val source = TestMedia.generateSourceWithDataStream(tempDir)
val info = service().getInfo(source)
assertTrue(info.streams.any { it is FileInfo.Data })
assertEquals(1, info.videoStreams.size)
assertEquals(2, info.audioStreams.size)
}
@Test
fun `getInfo fails on broken file`() {
val broken = TestMedia.generateBrokenFile(tempDir)
@@ -4,6 +4,7 @@ import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -92,6 +93,91 @@ class FileInfoTest {
assertEquals(3, info.videoStreams[0].index)
}
@Test
fun `parses ffprobe json with data stream`() {
val fixture = """
{
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_type": "video",
"width": 1920,
"height": 1080,
"duration": "10.000000",
"start_time": "0.000000",
"tags": {"title": "Main"}
},
{
"index": 1,
"codec_name": "aac",
"codec_type": "audio",
"channels": 2,
"channel_layout": "stereo",
"duration": "10.000000",
"start_time": "0.000000",
"tags": {"language": "eng", "title": "English"}
},
{
"index": 2,
"codec_type": "data",
"codec_tag_string": "tmcd",
"tags": {"language": "eng", "handler_name": "SubtitleHandler"}
},
{
"index": 3,
"codec_name": "subrip",
"codec_type": "subtitle",
"start_time": "0.000000",
"tags": {"language": "rus"}
}
],
"format": {
"format_name": "matroska,webm",
"format_long_name": "Matroska / WebM",
"duration": "10.000000",
"size": "1000",
"bit_rate": "2000",
"tags": {"encoder": "x"}
}
}
""".trimIndent()
val info = json.decodeFromString(FileInfo.serializer(), fixture)
assertEquals(4, info.streams.size)
assertEquals(1, info.videoStreams.size)
assertEquals(1, info.audioStreams.size)
assertIs<FileInfo.VideoStream>(info.streams[0])
assertIs<FileInfo.AudioStream>(info.streams[1])
assertIs<FileInfo.Data>(info.streams[2])
assertIs<FileInfo.Subtitle>(info.streams[3])
assertEquals(1920, info.videoStreams.single().width)
assertEquals(2, info.audioStreams.single().channels)
assertEquals(10.seconds, info.format.duration)
}
@Test
fun `data stream round trip`() {
val info = FileInfo(
streams = listOf(
FileInfo.VideoStream(index = 0, codecName = "vp9", width = 1920, height = 1080, startTime = 0.seconds),
FileInfo.Data(index = 1, tags = mapOf("language" to "eng")),
),
format = FileInfo.Format(
formatName = "matroska",
formatLongName = "M",
duration = 5.seconds,
size = 1,
bitRate = 1,
),
)
val decoded = json.decodeFromString(FileInfo.serializer(), json.encodeToString(FileInfo.serializer(), info))
assertEquals(1, decoded.videoStreams.size)
assertEquals(0, decoded.audioStreams.size)
assertIs<FileInfo.Data>(decoded.streams[1])
}
@Test
fun `audio stream title and language fallbacks`() {
val a1 = FileInfo.AudioStream(