Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 05a4aa775a | |||
| 235ae14693 |
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user