Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 235ae14693 | |||
| 72aa501a47 |
+3
-8
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user