2 Commits
4 ... 6

Author SHA1 Message Date
Hermes Agent 05a4aa775a feat: видео-кодек h264_nvenc (GPU) вместо libvpx-vp9 (CPU), кодек в конфиг
Build Media Mirror Worker / Build and publish (release) Successful in 1m35s
- AppProperties.videoCodec (дефолт h264_nvenc), application.yaml
- тесты: libx264 (без GPU), JobExecutorTest/JobPollerTest
- аудио libvorbis/ogg и mkv-контейнер не тронуты
2026-08-11 00:34:01 +03:00
Hermes Agent 235ae14693 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-треком
2026-08-11 00:00:39 +03:00
9 changed files with 131 additions and 3 deletions
@@ -14,6 +14,7 @@ data class AppProperties(
val ffprobePath: String = "ffprobe", val ffprobePath: String = "ffprobe",
val localFileStorage: Path = Path.of("/tmp/media-mirror-worker"), val localFileStorage: Path = Path.of("/tmp/media-mirror-worker"),
val ffmpegThreadCount: Int? = null, val ffmpegThreadCount: Int? = null,
val videoCodec: String = "h264_nvenc",
) { ) {
data class Jellyfin( data class Jellyfin(
val url: String = "", val url: String = "",
@@ -37,7 +37,7 @@ data class FileInfo(
override val duration: Duration? = null, override val duration: Duration? = null,
@SerialName("start_time") @SerialName("start_time")
@Serializable(DurationAsSecondsString::class) @Serializable(DurationAsSecondsString::class)
override val startTime: Duration?, override val startTime: Duration? = null,
override val tags: Map<String, String> = emptyMap(), override val tags: Map<String, String> = emptyMap(),
) : Stream ) : Stream
@@ -95,6 +95,18 @@ data class FileInfo(
get() = tags["language"] ?: tags["lang"] 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 @Serializable
data class Format( data class Format(
@SerialName("format_name") @SerialName("format_name")
@@ -100,7 +100,7 @@ class JobExecutor(
output = videoFile, output = videoFile,
width = newWidth, width = newWidth,
height = newHeight, height = newHeight,
videoCodec = VIDEO_CODEC, videoCodec = props.videoCodec,
removeAudio = true, removeAudio = true,
removeSubtitles = true, removeSubtitles = true,
removeMetadata = true, removeMetadata = true,
@@ -154,7 +154,6 @@ class JobExecutor(
} }
companion object { companion object {
private const val VIDEO_CODEC = "libvpx-vp9"
private const val MAX_HEIGHT = 480 private const val MAX_HEIGHT = 480
private const val VIDEO_CONTAINER = "mkv" private const val VIDEO_CONTAINER = "mkv"
private const val AUDIO_CODEC = "libvorbis" private const val AUDIO_CODEC = "libvorbis"
+1
View File
@@ -24,6 +24,7 @@ app:
ffmpegPath: ffmpeg ffmpegPath: ffmpeg
ffprobePath: ffprobe ffprobePath: ffprobe
localFileStorage: /tmp/media-mirror-worker localFileStorage: /tmp/media-mirror-worker
videoCodec: h264_nvenc
server: server:
port: 8081 port: 8081
@@ -54,6 +54,7 @@ class JobPollerTest : AbstractMinioTest() {
registry.add("app.s3.prefix") { "mirror" } registry.add("app.s3.prefix") { "mirror" }
registry.add("app.ffmpegPath") { "/usr/bin/ffmpeg" } registry.add("app.ffmpegPath") { "/usr/bin/ffmpeg" }
registry.add("app.ffprobePath") { "/usr/bin/ffprobe" } registry.add("app.ffprobePath") { "/usr/bin/ffprobe" }
registry.add("app.videoCodec") { "libx264" }
registry.add("app.localFileStorage") { registry.add("app.localFileStorage") {
Files.createTempDirectory("mmw-worker-test").toString() Files.createTempDirectory("mmw-worker-test").toString()
} }
@@ -33,6 +33,23 @@ object TestMedia {
return out 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 { fun generateBrokenFile(dir: Path): Path {
Files.createDirectories(dir) Files.createDirectories(dir)
val out = dir.resolve("broken.bin") val out = dir.resolve("broken.bin")
@@ -43,6 +43,16 @@ class FfprobeServiceTest {
assertTrue(audio.channels >= 1) 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 @Test
fun `getInfo fails on broken file`() { fun `getInfo fails on broken file`() {
val broken = TestMedia.generateBrokenFile(tempDir) val broken = TestMedia.generateBrokenFile(tempDir)
@@ -4,6 +4,7 @@ import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull import kotlin.test.assertNotNull
import kotlin.test.assertTrue import kotlin.test.assertTrue
@@ -92,6 +93,91 @@ class FileInfoTest {
assertEquals(3, info.videoStreams[0].index) 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 @Test
fun `audio stream title and language fallbacks`() { fun `audio stream title and language fallbacks`() {
val a1 = FileInfo.AudioStream( val a1 = FileInfo.AudioStream(
@@ -23,6 +23,7 @@ class JobExecutorTest : AbstractMinioTest() {
ffmpegPath = "/usr/bin/ffmpeg", ffmpegPath = "/usr/bin/ffmpeg",
ffprobePath = "/usr/bin/ffprobe", ffprobePath = "/usr/bin/ffprobe",
localFileStorage = tempDir, localFileStorage = tempDir,
videoCodec = "libx264",
) )
private fun executor(): JobExecutor { private fun executor(): JobExecutor {