3 Commits
2 .. 5

Author SHA1 Message Date
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
6 changed files with 139 additions and 22 deletions
+3 -12
View File
@@ -24,13 +24,6 @@ kotlin {
configurations { configurations {
named("runtimeClasspath") { 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 { dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.0")) 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-jdbc")
implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.flywaydb:flyway-database-postgresql") implementation("org.flywaydb:flyway-database-postgresql")
@@ -65,10 +60,6 @@ dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
} }
springBoot {
mainClass = "pw.binom.mirror.worker.WorkerApplication"
}
tasks.withType<Test> { tasks.withType<Test> {
useJUnitPlatform() useJUnitPlatform()
maxParallelForks = 1 maxParallelForks = 1
@@ -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")
+10 -9
View File
@@ -1,18 +1,19 @@
CREATE SCHEMA IF NOT EXISTS media_mirror; CREATE SCHEMA IF NOT EXISTS media_mirror;
CREATE TABLE media_mirror.jobs (
CREATE TABLE IF NOT EXISTS media_mirror.jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(), id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
item_id text NOT NULL, item_id text NOT NULL, -- itemId из Jellyfin
source_url text NOT NULL, source_url text NOT NULL, -- URL исходника (jellyfin stream)
source_type text NOT NULL DEFAULT 'jellyfin', source_type text NOT NULL DEFAULT 'jellyfin',
status text NOT NULL DEFAULT 'new', status text NOT NULL DEFAULT 'new', -- new | processing | done | failed | cancelled
progress int NOT NULL DEFAULT 0, progress int NOT NULL DEFAULT 0, -- 0..100
video_key text, video_key text, -- S3-ключ видео (после done)
audio_keys jsonb, audio_keys jsonb, -- [{key, title, language, index}]
error text, error text,
created_at timestamptz NOT NULL DEFAULT now(), created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
taken_at timestamptz taken_at timestamptz
); );
CREATE INDEX IF NOT EXISTS idx_jobs_status ON media_mirror.jobs (status, created_at); 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');
@@ -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(