From 235ae14693e7f829624dcd7899fab3681f800940 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Tue, 11 Aug 2026 00:00:39 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20FileInfo.Stream.Data=20=E2=80=94=20data-?= =?UTF-8?q?=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B8=20(tmcd/=D1=81=D1=83=D0=B1?= =?UTF-8?q?=D1=82=D0=B8=D1=82=D1=80=D1=8B)=20=D0=BD=D0=B5=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=BC=D0=B0=D1=8E=D1=82=20ffprobe-=D0=BF=D0=B0=D1=80=D1=81?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - подкласс Stream.Data с @SerialName(data), Subtitle.startTime nullable - JobExecutor не трогался: filterIsInstance игнорирует Data - тесты: JSON-фикстура с data-потоком, реальный .mov с tmcd-треком --- .../binom/mirror/worker/convert/FileInfo.kt | 14 ++- .../pw/binom/mirror/worker/TestMedia.kt | 17 ++++ .../worker/convert/FfprobeServiceTest.kt | 10 +++ .../mirror/worker/convert/FileInfoTest.kt | 86 +++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/pw/binom/mirror/worker/convert/FileInfo.kt b/src/main/kotlin/pw/binom/mirror/worker/convert/FileInfo.kt index 7796897..0e1d6f6 100644 --- a/src/main/kotlin/pw/binom/mirror/worker/convert/FileInfo.kt +++ b/src/main/kotlin/pw/binom/mirror/worker/convert/FileInfo.kt @@ -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 = 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 = emptyMap(), + ) : Stream + @Serializable data class Format( @SerialName("format_name") diff --git a/src/test/kotlin/pw/binom/mirror/worker/TestMedia.kt b/src/test/kotlin/pw/binom/mirror/worker/TestMedia.kt index ef0012a..74917c9 100644 --- a/src/test/kotlin/pw/binom/mirror/worker/TestMedia.kt +++ b/src/test/kotlin/pw/binom/mirror/worker/TestMedia.kt @@ -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") diff --git a/src/test/kotlin/pw/binom/mirror/worker/convert/FfprobeServiceTest.kt b/src/test/kotlin/pw/binom/mirror/worker/convert/FfprobeServiceTest.kt index a328ed9..1e3b62e 100644 --- a/src/test/kotlin/pw/binom/mirror/worker/convert/FfprobeServiceTest.kt +++ b/src/test/kotlin/pw/binom/mirror/worker/convert/FfprobeServiceTest.kt @@ -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) diff --git a/src/test/kotlin/pw/binom/mirror/worker/convert/FileInfoTest.kt b/src/test/kotlin/pw/binom/mirror/worker/convert/FileInfoTest.kt index 4efbc70..af26728 100644 --- a/src/test/kotlin/pw/binom/mirror/worker/convert/FileInfoTest.kt +++ b/src/test/kotlin/pw/binom/mirror/worker/convert/FileInfoTest.kt @@ -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(info.streams[0]) + assertIs(info.streams[1]) + assertIs(info.streams[2]) + assertIs(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(decoded.streams[1]) + } + @Test fun `audio stream title and language fallbacks`() { val a1 = FileInfo.AudioStream(