5 Commits
1 ... 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
Hermes Agent 72aa501a47 fix: V1__init.sql приведён к api-версии (uq_jobs_active_item), jackson-exclude из webmvc
Build Media Mirror Worker / Build and publish (release) Successful in 1m36s
2026-08-10 23:43:11 +03:00
Hermes Agent 91642e4933 fix: убран кривой mainClass (WorkerApplication → WorkerApplicationKt автоматически)
Build Media Mirror Worker / Build and publish (release) Successful in 1m34s
2026-08-10 23:37:36 +03:00
Hermes Agent 5dd324e79d db: отдельная база media_mirror вместо glasses
Build Media Mirror Worker / Build and publish (release) Successful in 1m28s
2026-08-10 18:06:44 +03:00
12 changed files with 146 additions and 26 deletions
+3 -12
View File
@@ -24,13 +24,6 @@ kotlin {
configurations {
named("runtimeClasspath") {
exclude(group = "tools.jackson.core")
exclude(group = "tools.jackson.databind")
exclude(group = "com.fasterxml.jackson.core")
exclude(group = "com.fasterxml.jackson.databind")
exclude(group = "com.fasterxml.jackson.datatype")
exclude(group = "com.fasterxml.jackson.dataformat")
exclude(group = "com.fasterxml.jackson.module")
}
}
@@ -45,7 +38,9 @@ configurations.all {
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.0"))
implementation("org.springframework.boot:spring-boot-starter-webmvc")
implementation("org.springframework.boot:spring-boot-starter-webmvc") {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-jackson")
}
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql")
@@ -65,10 +60,6 @@ dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
}
springBoot {
mainClass = "pw.binom.mirror.worker.WorkerApplication"
}
tasks.withType<Test> {
useJUnitPlatform()
maxParallelForks = 1
+1 -1
View File
@@ -17,7 +17,7 @@ image:
db:
host: null
port: 5432
name: glasses
name: media_mirror
user: null
password: null
maxConnections: 10
@@ -14,6 +14,7 @@ data class AppProperties(
val ffprobePath: String = "ffprobe",
val localFileStorage: Path = Path.of("/tmp/media-mirror-worker"),
val ffmpegThreadCount: Int? = null,
val videoCodec: String = "h264_nvenc",
) {
data class Jellyfin(
val url: String = "",
@@ -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")
@@ -100,7 +100,7 @@ class JobExecutor(
output = videoFile,
width = newWidth,
height = newHeight,
videoCodec = VIDEO_CODEC,
videoCodec = props.videoCodec,
removeAudio = true,
removeSubtitles = true,
removeMetadata = true,
@@ -154,7 +154,6 @@ class JobExecutor(
}
companion object {
private const val VIDEO_CODEC = "libvpx-vp9"
private const val MAX_HEIGHT = 480
private const val VIDEO_CONTAINER = "mkv"
private const val AUDIO_CODEC = "libvorbis"
+2 -1
View File
@@ -2,7 +2,7 @@ spring:
application:
name: media-mirror-worker
datasource:
url: jdbc:postgresql://192.168.76.106:5432/glasses
url: jdbc:postgresql://192.168.76.106:5432/media_mirror
username: postgres
password: postgres
flyway:
@@ -24,6 +24,7 @@ app:
ffmpegPath: ffmpeg
ffprobePath: ffprobe
localFileStorage: /tmp/media-mirror-worker
videoCodec: h264_nvenc
server:
port: 8081
+10 -9
View File
@@ -1,18 +1,19 @@
CREATE SCHEMA IF NOT EXISTS media_mirror;
CREATE TABLE IF NOT EXISTS media_mirror.jobs (
CREATE TABLE media_mirror.jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
item_id text NOT NULL,
source_url text NOT NULL,
item_id text NOT NULL, -- itemId из Jellyfin
source_url text NOT NULL, -- URL исходника (jellyfin stream)
source_type text NOT NULL DEFAULT 'jellyfin',
status text NOT NULL DEFAULT 'new',
progress int NOT NULL DEFAULT 0,
video_key text,
audio_keys jsonb,
status text NOT NULL DEFAULT 'new', -- new | processing | done | failed | cancelled
progress int NOT NULL DEFAULT 0, -- 0..100
video_key text, -- S3-ключ видео (после done)
audio_keys jsonb, -- [{key, title, language, index}]
error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
taken_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON media_mirror.jobs (status, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_active_item
ON media_mirror.jobs (item_id)
WHERE status NOT IN ('failed', 'cancelled');
@@ -54,6 +54,7 @@ class JobPollerTest : AbstractMinioTest() {
registry.add("app.s3.prefix") { "mirror" }
registry.add("app.ffmpegPath") { "/usr/bin/ffmpeg" }
registry.add("app.ffprobePath") { "/usr/bin/ffprobe" }
registry.add("app.videoCodec") { "libx264" }
registry.add("app.localFileStorage") {
Files.createTempDirectory("mmw-worker-test").toString()
}
@@ -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(
@@ -23,6 +23,7 @@ class JobExecutorTest : AbstractMinioTest() {
ffmpegPath = "/usr/bin/ffmpeg",
ffprobePath = "/usr/bin/ffprobe",
localFileStorage = tempDir,
videoCodec = "libx264",
)
private fun executor(): JobExecutor {