feat: media-mirror-worker — воркер конвертации зеркал (очередь Postgres, ядро из media-transformer)
- очередь: jobs в схеме media_mirror, SELECT ... FOR UPDATE SKIP LOCKED, stale-возврат - ядро перенесено из media-transformer (FfmpegService, FfprobeService, JellyfinInput, S3Input, OutputService...) - Spring Boot 4.1.0, Kotlin 2.4.10, kotlinx-serialization 1.11.0, NATS выпилен - тесты: 45 (SKIP LOCKED параллельный, конец-в-конец до done, конвертация 480p+2 аудио), LINE 93%
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.gradle/
|
||||
build/
|
||||
.kotlin/
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM eclipse-temurin:21-jre
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
COPY build/libs/*.jar app.jar
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@@ -0,0 +1,110 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
|
||||
plugins {
|
||||
kotlin("jvm") version "2.4.10"
|
||||
kotlin("plugin.serialization") version "2.4.10"
|
||||
id("org.springframework.boot") version "4.1.0"
|
||||
jacoco
|
||||
}
|
||||
|
||||
group = "pw.binom.mirror.worker"
|
||||
version = "1.0.0-SNAPSHOT"
|
||||
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = JvmTarget.JVM_21
|
||||
freeCompilerArgs.add("-Xjsr305=strict")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
configurations.all {
|
||||
resolutionStrategy {
|
||||
force("org.testcontainers:testcontainers:1.21.4")
|
||||
force("org.testcontainers:junit-jupiter:1.21.4")
|
||||
force("org.testcontainers:postgresql:1.21.4")
|
||||
force("org.testcontainers:minio:1.21.4")
|
||||
}
|
||||
}
|
||||
|
||||
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-jdbc")
|
||||
implementation("org.springframework.boot:spring-boot-starter-flyway")
|
||||
implementation("org.flywaydb:flyway-database-postgresql")
|
||||
implementation("org.postgresql:postgresql")
|
||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.10.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
|
||||
implementation(platform("software.amazon.awssdk:bom:2.46.21"))
|
||||
implementation("software.amazon.awssdk:s3")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||
testImplementation(kotlin("test-junit5"))
|
||||
testImplementation("org.testcontainers:testcontainers:1.21.4")
|
||||
testImplementation("org.testcontainers:junit-jupiter:1.21.4")
|
||||
testImplementation("org.testcontainers:postgresql:1.21.4")
|
||||
testImplementation("org.testcontainers:minio:1.21.4")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
|
||||
}
|
||||
|
||||
springBoot {
|
||||
mainClass = "pw.binom.mirror.worker.WorkerApplication"
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
maxParallelForks = 1
|
||||
testLogging {
|
||||
showStandardStreams = true
|
||||
events("passed", "failed", "skipped")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Jar> {
|
||||
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
|
||||
}
|
||||
|
||||
tasks.jacocoTestReport {
|
||||
dependsOn(tasks.test)
|
||||
reports {
|
||||
xml.required.set(true)
|
||||
html.required.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
tasks.jacocoTestCoverageVerification {
|
||||
dependsOn(tasks.jacocoTestReport)
|
||||
violationRules {
|
||||
rule {
|
||||
element = "PACKAGE"
|
||||
includes = listOf("pw.binom.mirror.worker.convert")
|
||||
limit {
|
||||
counter = "LINE"
|
||||
value = "COVEREDRATIO"
|
||||
minimum = "0.80".toBigDecimal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.check {
|
||||
dependsOn(tasks.jacocoTestReport, tasks.jacocoTestCoverageVerification)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
kotlin.code.style=official
|
||||
Vendored
BIN
Binary file not shown.
+6
@@ -0,0 +1,6 @@
|
||||
#Sat Apr 15 17:57:35 CST 2023
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+89
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,15 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "media-mirror-worker"
|
||||
@@ -0,0 +1,11 @@
|
||||
package pw.binom.mirror.worker
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.runApplication
|
||||
|
||||
@SpringBootApplication
|
||||
class WorkerApplication
|
||||
|
||||
fun main(args: Array<String>) {
|
||||
runApplication<WorkerApplication>(*args)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package pw.binom.mirror.worker.config
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(AppProperties::class)
|
||||
open class AppConfig {
|
||||
|
||||
@Bean
|
||||
open fun json(): Json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
|
||||
@Bean
|
||||
open fun jobScope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.mirror.worker.config
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
import java.nio.file.Path
|
||||
import java.time.Duration
|
||||
|
||||
@ConfigurationProperties(prefix = "app")
|
||||
data class AppProperties(
|
||||
val pollInterval: Duration = Duration.ofSeconds(2),
|
||||
val staleTimeout: Duration = Duration.ofMinutes(30),
|
||||
val jellyfin: Jellyfin = Jellyfin(),
|
||||
val s3: S3 = S3(),
|
||||
val ffmpegPath: String = "ffmpeg",
|
||||
val ffprobePath: String = "ffprobe",
|
||||
val localFileStorage: Path = Path.of("/tmp/media-mirror-worker"),
|
||||
val ffmpegThreadCount: Int? = null,
|
||||
) {
|
||||
data class Jellyfin(
|
||||
val url: String = "",
|
||||
val apiKey: String = "",
|
||||
)
|
||||
|
||||
data class S3(
|
||||
val url: String = "",
|
||||
val accessKey: String = "",
|
||||
val secretKey: String = "",
|
||||
val bucket: String = "media",
|
||||
val region: String = "us-east-1",
|
||||
val prefix: String = "mirror",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.descriptors.PrimitiveKind
|
||||
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
object DurationAsSecondsString : KSerializer<Duration> {
|
||||
override val descriptor: SerialDescriptor =
|
||||
PrimitiveSerialDescriptor("DurationAsSecondsString", PrimitiveKind.STRING)
|
||||
|
||||
override fun serialize(encoder: Encoder, value: Duration) {
|
||||
encoder.encodeString((value.inWholeMicroseconds / 1_000_000.0).toString())
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): Duration {
|
||||
val raw = decoder.decodeString().trim()
|
||||
return raw.toDoubleOrNull()?.seconds ?: Duration.ZERO
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import org.springframework.stereotype.Service
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import java.io.IOException
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.microseconds
|
||||
|
||||
@Service
|
||||
class FfmpegService(
|
||||
private val props: AppProperties,
|
||||
private val jobScope: CoroutineScope,
|
||||
) {
|
||||
data class Progress(
|
||||
val speed: Float,
|
||||
val outTime: Duration,
|
||||
val totalSize: Long,
|
||||
val fps: Float?,
|
||||
val frame: Long?,
|
||||
)
|
||||
|
||||
suspend fun extractAudio(
|
||||
input: Path,
|
||||
output: Path,
|
||||
audioIndex: Int,
|
||||
channelCount: Int? = null,
|
||||
codec: String? = null,
|
||||
progress: (suspend (Progress) -> Unit)? = null,
|
||||
) {
|
||||
require(audioIndex >= 0) { "Invalid audioIndex: index should be greater or equals zero" }
|
||||
require(channelCount == null || channelCount > 0) { "Invalid channelCount value: $channelCount" }
|
||||
val args = ArrayList<String>()
|
||||
args += "-map"
|
||||
args += "0:a:$audioIndex"
|
||||
if (channelCount != null) {
|
||||
args += "-ac"
|
||||
args += channelCount.toString()
|
||||
}
|
||||
if (codec != null) {
|
||||
args += "-c:a"
|
||||
args += codec
|
||||
if (codec == "vorbis") {
|
||||
args += "-strict"
|
||||
args += "-2"
|
||||
}
|
||||
}
|
||||
try {
|
||||
run(input = input, output = output, args = args, progress = progress)
|
||||
} catch (e: Exception) {
|
||||
runCatching { Files.deleteIfExists(output) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun transform(
|
||||
input: Path,
|
||||
output: Path,
|
||||
width: Int? = null,
|
||||
height: Int? = null,
|
||||
videoCodec: String? = null,
|
||||
audioCodec: String? = null,
|
||||
channels: Int? = null,
|
||||
removeAudio: Boolean = false,
|
||||
removeSubtitles: Boolean = false,
|
||||
removeMetadata: Boolean = false,
|
||||
removeChapters: Boolean = false,
|
||||
progress: (suspend (Progress) -> Unit)? = null,
|
||||
) {
|
||||
require(videoCodec != "copy") { "videoCodec can't be equals \"copy\"" }
|
||||
val args = ArrayList<String>()
|
||||
if (videoCodec != null) {
|
||||
args += "-c:v"
|
||||
args += videoCodec
|
||||
}
|
||||
if (width != null || height != null) {
|
||||
args += "-vf"
|
||||
args += "scale=${width ?: "-1"}:${height ?: "-1"}"
|
||||
}
|
||||
if (!removeAudio) {
|
||||
if (audioCodec != null) {
|
||||
args += "-c:a"
|
||||
args += audioCodec
|
||||
if (audioCodec == "vorbis") {
|
||||
args += "-strict"
|
||||
args += "-2"
|
||||
}
|
||||
}
|
||||
if (channels != null) {
|
||||
args += "-ac"
|
||||
args += channels.toString()
|
||||
}
|
||||
} else {
|
||||
args += "-an"
|
||||
}
|
||||
if (removeSubtitles) {
|
||||
args += "-sn"
|
||||
}
|
||||
if (removeMetadata) {
|
||||
args += "-map_metadata"
|
||||
args += "-1"
|
||||
}
|
||||
if (removeChapters) {
|
||||
args += "-map_chapters"
|
||||
args += "-1"
|
||||
}
|
||||
try {
|
||||
run(input = input, output = output, args = args, progress = progress)
|
||||
} catch (e: Exception) {
|
||||
runCatching { Files.deleteIfExists(output) }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun run(
|
||||
args: List<String>,
|
||||
input: Path,
|
||||
output: Path,
|
||||
progress: (suspend (Progress) -> Unit)?,
|
||||
) {
|
||||
output.parent?.let { Files.createDirectories(it) }
|
||||
val command = ArrayList<String>()
|
||||
command += props.ffmpegPath
|
||||
command += "-y"
|
||||
command += "-i"
|
||||
command += input.toString()
|
||||
command += "-progress"
|
||||
command += "pipe:1"
|
||||
command += "-stats_period"
|
||||
command += "1"
|
||||
props.ffmpegThreadCount?.let {
|
||||
command += "-threads"
|
||||
command += it.toString()
|
||||
}
|
||||
command += args
|
||||
command += output.toString()
|
||||
|
||||
val process = try {
|
||||
ProcessBuilder(command).start()
|
||||
} catch (e: IOException) {
|
||||
throw IllegalStateException("Can't start ffmpeg ${props.ffmpegPath}", e)
|
||||
}
|
||||
|
||||
val stdout = process.inputStream.bufferedReader()
|
||||
val stderr = process.errorStream.bufferedReader()
|
||||
val stderrText = StringBuilder()
|
||||
|
||||
suspendCancellableCoroutine<Unit> { cont ->
|
||||
cont.invokeOnCancellation {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
|
||||
val progressChannel = Channel<Progress>(Channel.UNLIMITED)
|
||||
val progressJob = if (progress != null) {
|
||||
jobScope.launch {
|
||||
for (item in progressChannel) {
|
||||
progress(item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val readerThread = Thread {
|
||||
runCatching {
|
||||
val kv = HashMap<String, String>()
|
||||
stdout.forEachLine { line ->
|
||||
val idx = line.indexOf('=')
|
||||
if (idx > 0) {
|
||||
val key = line.substring(0, idx)
|
||||
val value = line.substring(idx + 1)
|
||||
kv[key] = value
|
||||
if (key == "progress") {
|
||||
val outTime = kv["out_time_us"]
|
||||
?.takeIf { it != "N/A" }
|
||||
?.toLongOrNull()
|
||||
?.microseconds ?: Duration.ZERO
|
||||
val speed = kv["speed"]
|
||||
?.takeIf { it != "N/A" }
|
||||
?.removeSuffix("x")
|
||||
?.toFloatOrNull() ?: 0f
|
||||
val fps = kv["fps"]
|
||||
?.takeIf { it != "N/A" }
|
||||
?.toFloatOrNull()
|
||||
val frame = kv["frame"]
|
||||
?.takeIf { it != "N/A" }
|
||||
?.toLongOrNull()
|
||||
val totalSize = kv["total_size"]
|
||||
?.takeIf { it != "N/A" }
|
||||
?.toLongOrNull() ?: 0L
|
||||
progressChannel.trySend(
|
||||
Progress(
|
||||
speed = speed,
|
||||
outTime = outTime,
|
||||
totalSize = totalSize,
|
||||
fps = fps,
|
||||
frame = frame,
|
||||
),
|
||||
)
|
||||
kv.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
progressChannel.close()
|
||||
}
|
||||
|
||||
val errorThread = Thread {
|
||||
runCatching {
|
||||
stderr.forEachLine { line ->
|
||||
stderrText.append(line).append('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val waiterThread = Thread {
|
||||
val exitCode = runCatching { process.waitFor() }.getOrDefault(-1)
|
||||
runCatching { readerThread.join(1000) }
|
||||
runCatching { errorThread.join(1000) }
|
||||
progressJob?.cancel()
|
||||
if (!cont.isCancelled) {
|
||||
if (exitCode != 0) {
|
||||
cont.resumeWithException(
|
||||
IllegalStateException(
|
||||
"ffmpeg process exited with $exitCode\nerror:\n$stderrText",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
cont.resume(Unit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readerThread.start()
|
||||
errorThread.start()
|
||||
waiterThread.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.springframework.stereotype.Service
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import java.io.IOException
|
||||
import java.nio.file.Path
|
||||
|
||||
@Service
|
||||
class FfprobeService(
|
||||
private val props: AppProperties,
|
||||
) {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
fun getInfo(file: Path): FileInfo {
|
||||
val command = listOf(
|
||||
props.ffprobePath,
|
||||
"-v",
|
||||
"quiet",
|
||||
"-print_format",
|
||||
"json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
file.toString(),
|
||||
)
|
||||
val process = try {
|
||||
ProcessBuilder(command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
} catch (e: IOException) {
|
||||
throw IllegalStateException("Can't execute ffprobe ${props.ffprobePath}", e)
|
||||
}
|
||||
val output = process.inputStream.readBytes().decodeToString()
|
||||
val exitCode = process.waitFor()
|
||||
if (exitCode != 0) {
|
||||
throw IllegalStateException("Can't execute ffprobe: $output")
|
||||
}
|
||||
return json.decodeFromString(FileInfo.serializer(), output)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.builtins.LongAsStringSerializer
|
||||
import kotlinx.serialization.json.JsonClassDiscriminator
|
||||
import kotlin.time.Duration
|
||||
|
||||
@Serializable
|
||||
data class FileInfo(
|
||||
val streams: List<Stream>,
|
||||
val format: Format,
|
||||
) {
|
||||
val videoStreams: List<VideoStream>
|
||||
get() = streams.filterIsInstance<VideoStream>()
|
||||
.filter { it.codecName != "png" }
|
||||
.filter { it.codecName != "mjpeg" }
|
||||
.filter { it.tags["filename"] != "cover.jpg" }
|
||||
|
||||
val audioStreams: List<AudioStream>
|
||||
get() = streams.filterIsInstance<AudioStream>()
|
||||
|
||||
@Serializable
|
||||
@JsonClassDiscriminator("codec_type")
|
||||
sealed interface Stream {
|
||||
val index: Int
|
||||
val duration: Duration?
|
||||
val startTime: Duration?
|
||||
val tags: Map<String, String>
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("subtitle")
|
||||
data class Subtitle(
|
||||
override val index: Int,
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
override val duration: Duration? = null,
|
||||
@SerialName("start_time")
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
override val startTime: Duration?,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
) : Stream
|
||||
|
||||
@Serializable
|
||||
@SerialName("video")
|
||||
data class VideoStream(
|
||||
override val index: Int,
|
||||
@SerialName("codec_name")
|
||||
val codecName: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
@SerialName("bit_rate")
|
||||
@Serializable(LongAsStringSerializer::class)
|
||||
val bitRate: Long? = null,
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
override val duration: Duration? = null,
|
||||
@SerialName("start_time")
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
override val startTime: Duration,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
) : Stream
|
||||
|
||||
@Serializable
|
||||
@SerialName("attachment")
|
||||
data class Attachment(
|
||||
override val index: Int,
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
override val duration: Duration?,
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
@SerialName("start_time")
|
||||
override val startTime: Duration? = null,
|
||||
override val tags: Map<String, String>,
|
||||
) : Stream
|
||||
|
||||
@Serializable
|
||||
@SerialName("audio")
|
||||
data class AudioStream(
|
||||
override val index: Int,
|
||||
val channels: Int,
|
||||
@SerialName("channel_layout")
|
||||
val channelLayout: String? = null,
|
||||
@SerialName("bit_rate")
|
||||
@Serializable(LongAsStringSerializer::class)
|
||||
val bitRate: Long? = null,
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
override val duration: Duration? = null,
|
||||
@SerialName("start_time")
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
override val startTime: Duration,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
) : Stream {
|
||||
val title: String
|
||||
get() = tags["title"] ?: "#$index"
|
||||
val language: String?
|
||||
get() = tags["language"] ?: tags["lang"]
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Format(
|
||||
@SerialName("format_name")
|
||||
val formatName: String,
|
||||
@SerialName("format_long_name")
|
||||
val formatLongName: String,
|
||||
@Serializable(DurationAsSecondsString::class)
|
||||
val duration: Duration,
|
||||
@Serializable(LongAsStringSerializer::class)
|
||||
val size: Long,
|
||||
@SerialName("bit_rate")
|
||||
@Serializable(LongAsStringSerializer::class)
|
||||
val bitRate: Long,
|
||||
val tags: Map<String, String> = emptyMap(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Path
|
||||
|
||||
@Service
|
||||
class InputService(
|
||||
private val inputs: List<InputImplementation>,
|
||||
) {
|
||||
private val logger: Logger = LoggerFactory.getLogger(InputService::class.java)
|
||||
|
||||
interface InputImplementation {
|
||||
fun isSupport(source: InputSource): Boolean
|
||||
suspend fun download(source: InputSource): Path
|
||||
}
|
||||
|
||||
suspend fun downloadIncome(source: InputSource): Path {
|
||||
val implementation = inputs.find { it.isSupport(source) }
|
||||
?: throw IllegalStateException("Can't find valid implementation for ${source::class.simpleName}")
|
||||
logger.info("Downloading {}", source)
|
||||
val startTime = System.nanoTime()
|
||||
val resultFile = try {
|
||||
implementation.download(source)
|
||||
} catch (e: CancellationException) {
|
||||
logger.info("Download cancelled: {}", source)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
throw IllegalStateException("Can't download video from $source", e)
|
||||
}
|
||||
logger.info("Downloaded success in {} ms", (System.nanoTime() - startTime) / 1_000_000)
|
||||
return resultFile
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
sealed interface InputSource {
|
||||
data class Jellyfin(val streamUrl: String) : InputSource
|
||||
|
||||
data class S3(val destination: S3Destination, val key: String) : InputSource
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Component
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
@Component
|
||||
class JellyfinInput(
|
||||
private val localStorageService: LocalStorageService,
|
||||
) : InputService.InputImplementation {
|
||||
private val logger: Logger = LoggerFactory.getLogger(JellyfinInput::class.java)
|
||||
|
||||
override fun isSupport(source: InputSource): Boolean = source is InputSource.Jellyfin
|
||||
|
||||
override suspend fun download(source: InputSource): Path = withContext(Dispatchers.IO) {
|
||||
source as InputSource.Jellyfin
|
||||
val url = URL(source.streamUrl)
|
||||
val connection = url.openConnection() as HttpURLConnection
|
||||
connection.requestMethod = "GET"
|
||||
connection.instanceFollowRedirects = true
|
||||
connection.connectTimeout = 30_000
|
||||
connection.readTimeout = 120_000
|
||||
try {
|
||||
if (connection.responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw IllegalStateException("Invalid response code ${connection.responseCode}")
|
||||
}
|
||||
val resultFile = localStorageService.genFile()
|
||||
logger.info("Downloading file in to ${resultFile}...")
|
||||
try {
|
||||
connection.inputStream.use { input ->
|
||||
Files.copy(input, resultFile, StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
logger.info("File success downloaded!")
|
||||
resultFile
|
||||
} catch (e: Throwable) {
|
||||
runCatching { Files.deleteIfExists(resultFile) }
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import pw.binom.mirror.worker.db.AudioKey
|
||||
import pw.binom.mirror.worker.db.Job
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Service
|
||||
class JobExecutor(
|
||||
private val props: AppProperties,
|
||||
private val inputService: InputService,
|
||||
private val outputService: OutputService,
|
||||
private val localStorageService: LocalStorageService,
|
||||
private val ffmpegService: FfmpegService,
|
||||
private val ffprobeService: FfprobeService,
|
||||
) {
|
||||
private val logger: Logger = LoggerFactory.getLogger(JobExecutor::class.java)
|
||||
|
||||
data class ConversionResult(
|
||||
val videoKey: String,
|
||||
val audio: List<AudioKey>,
|
||||
val duration: Duration,
|
||||
)
|
||||
|
||||
data class Progress(
|
||||
val audioProgress: List<Duration>,
|
||||
val videoProgress: Duration,
|
||||
val totalDuration: Duration,
|
||||
)
|
||||
|
||||
suspend fun execute(
|
||||
job: Job,
|
||||
destination: S3Destination,
|
||||
progress: (suspend (Progress) -> Unit)? = null,
|
||||
): ConversionResult {
|
||||
logger.info("Start processing job {}", job.id)
|
||||
val sourceFile = inputService.downloadIncome(InputSource.Jellyfin(streamUrl(job)))
|
||||
val tmpFiles = ArrayList<Path>()
|
||||
try {
|
||||
val info = ffprobeService.getInfo(sourceFile)
|
||||
if (info.videoStreams.size > 1) {
|
||||
throw IllegalStateException("Video has more than one video streams")
|
||||
}
|
||||
if (info.videoStreams.isEmpty()) {
|
||||
throw IllegalStateException("Video has no any video streams")
|
||||
}
|
||||
val videoStream = info.videoStreams.single()
|
||||
|
||||
val audioProgress = info.audioStreams.map { 0.seconds }.toMutableList()
|
||||
val audioKeys = ArrayList<AudioKey>()
|
||||
val audioFiles = ArrayList<Path>()
|
||||
info.audioStreams.forEachIndexed { index, stream ->
|
||||
val file = localStorageService.genFile(AUDIO_CONTAINER)
|
||||
tmpFiles.add(file)
|
||||
ffmpegService.extractAudio(
|
||||
input = sourceFile,
|
||||
output = file,
|
||||
audioIndex = index,
|
||||
codec = AUDIO_CODEC,
|
||||
progress = { p ->
|
||||
audioProgress[index] = p.outTime
|
||||
progress?.invoke(
|
||||
Progress(
|
||||
audioProgress = audioProgress.toList(),
|
||||
videoProgress = 0.seconds,
|
||||
totalDuration = info.format.duration,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val audioName = "${job.itemId}/audio-$index.$AUDIO_CONTAINER"
|
||||
audioKeys.add(
|
||||
AudioKey(
|
||||
key = destination.fullKey(audioName),
|
||||
title = stream.title,
|
||||
language = stream.language,
|
||||
index = index,
|
||||
),
|
||||
)
|
||||
audioFiles.add(file)
|
||||
}
|
||||
|
||||
val videoFile = localStorageService.genFile(VIDEO_CONTAINER)
|
||||
tmpFiles.add(videoFile)
|
||||
val (newWidth, newHeight) = ViewSizeCalculate.calc(
|
||||
width = videoStream.width,
|
||||
height = videoStream.height,
|
||||
maxWidth = null,
|
||||
maxHeight = MAX_HEIGHT,
|
||||
)
|
||||
ffmpegService.transform(
|
||||
input = sourceFile,
|
||||
output = videoFile,
|
||||
width = newWidth,
|
||||
height = newHeight,
|
||||
videoCodec = VIDEO_CODEC,
|
||||
removeAudio = true,
|
||||
removeSubtitles = true,
|
||||
removeMetadata = true,
|
||||
removeChapters = true,
|
||||
progress = { p ->
|
||||
progress?.invoke(
|
||||
Progress(
|
||||
audioProgress = audioProgress.toList(),
|
||||
videoProgress = p.outTime,
|
||||
totalDuration = info.format.duration,
|
||||
),
|
||||
)
|
||||
},
|
||||
)
|
||||
val videoName = "${job.itemId}/video.$VIDEO_CONTAINER"
|
||||
val videoKey = destination.fullKey(videoName)
|
||||
|
||||
logger.info("Converting finished. Start upload data")
|
||||
logger.info("Audio Files: {}", audioKeys)
|
||||
logger.info("Video File: {}", videoKey)
|
||||
audioFiles.zip(audioKeys).forEach { (file, audioKey) ->
|
||||
val audioName = "${job.itemId}/audio-${audioKey.index}.$AUDIO_CONTAINER"
|
||||
logger.info("Upload as name {}", audioName)
|
||||
outputService.pushResult(destination, file, audioName)
|
||||
}
|
||||
logger.info("Upload as name {}", videoName)
|
||||
outputService.pushResult(destination, videoFile, videoName)
|
||||
logger.info("Upload finished!")
|
||||
|
||||
return ConversionResult(
|
||||
videoKey = videoKey,
|
||||
audio = audioKeys,
|
||||
duration = info.format.duration,
|
||||
)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
logger.warn("Job {} finished with error: {}", job.id, e.message)
|
||||
throw e
|
||||
} finally {
|
||||
tmpFiles.forEach { runCatching { Files.deleteIfExists(it) } }
|
||||
runCatching { Files.deleteIfExists(sourceFile) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun streamUrl(job: Job): String {
|
||||
return job.sourceUrl.ifBlank {
|
||||
props.jellyfin.url.trimEnd('/') +
|
||||
"/Videos/${job.itemId}/stream?api_key=${props.jellyfin.apiKey}&static=true"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
private const val AUDIO_CONTAINER = "ogg"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import org.springframework.stereotype.Service
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.Paths
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class LocalStorageService(
|
||||
private val props: AppProperties,
|
||||
) {
|
||||
private val storageDirectory: Path = Paths.get(props.localFileStorage.toString()).toAbsolutePath()
|
||||
|
||||
fun genFile(ext: String? = null): Path {
|
||||
Files.createDirectories(storageDirectory)
|
||||
var name = UUID.randomUUID().toString()
|
||||
if (ext != null) {
|
||||
name = "$name.$ext"
|
||||
}
|
||||
return storageDirectory.resolve(name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.springframework.stereotype.Service
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
@Service
|
||||
class OutputService {
|
||||
|
||||
suspend fun pushResult(destination: S3Destination, file: Path, name: String = file.fileName.toString()) {
|
||||
check(Files.isRegularFile(file)) { "File $file is not found" }
|
||||
withContext(Dispatchers.IO) {
|
||||
val key = destination.fullKey(name)
|
||||
val client = S3Clients.client(destination)
|
||||
try {
|
||||
client.putObject(
|
||||
PutObjectRequest.builder()
|
||||
.bucket(destination.bucket)
|
||||
.key(key)
|
||||
.build(),
|
||||
file,
|
||||
)
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
|
||||
import software.amazon.awssdk.regions.Region
|
||||
import software.amazon.awssdk.services.s3.S3Client
|
||||
import software.amazon.awssdk.services.s3.S3Configuration
|
||||
import java.net.URI
|
||||
|
||||
object S3Clients {
|
||||
fun client(config: S3Destination): S3Client =
|
||||
S3Client.builder()
|
||||
.endpointOverride(URI.create(config.url))
|
||||
.region(Region.of(config.region))
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(config.accessKey, config.secretKey),
|
||||
),
|
||||
)
|
||||
.serviceConfiguration(
|
||||
S3Configuration.builder()
|
||||
.pathStyleAccessEnabled(true)
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
data class S3Destination(
|
||||
val url: String,
|
||||
val accessKey: String,
|
||||
val secretKey: String,
|
||||
val bucket: String,
|
||||
val region: String,
|
||||
val prefix: String = "",
|
||||
) {
|
||||
fun fullKey(name: String): String =
|
||||
if (prefix.isEmpty()) name else "${prefix.removeSuffix("/")}/$name"
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.springframework.stereotype.Component
|
||||
import software.amazon.awssdk.core.exception.SdkServiceException
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
@Component
|
||||
class S3Input(
|
||||
private val localStorageService: LocalStorageService,
|
||||
) : InputService.InputImplementation {
|
||||
override fun isSupport(source: InputSource): Boolean = source is InputSource.S3
|
||||
|
||||
override suspend fun download(source: InputSource): Path = withContext(Dispatchers.IO) {
|
||||
source as InputSource.S3
|
||||
val client = S3Clients.client(source.destination)
|
||||
try {
|
||||
val resultFile = localStorageService.genFile()
|
||||
try {
|
||||
client.getObject(
|
||||
GetObjectRequest.builder()
|
||||
.bucket(source.destination.bucket)
|
||||
.key(source.key)
|
||||
.build(),
|
||||
).use { obj ->
|
||||
Files.copy(obj, resultFile, StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
resultFile
|
||||
} catch (e: Throwable) {
|
||||
runCatching { Files.deleteIfExists(resultFile) }
|
||||
if (e is SdkServiceException && e.statusCode() == 404) {
|
||||
throw IllegalStateException(
|
||||
"File ${source.key} not found in ${source.destination.url}/${source.destination.bucket}",
|
||||
e,
|
||||
)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
} finally {
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
object ViewSizeCalculate {
|
||||
fun calc(width: Int, height: Int, maxWidth: Int?, maxHeight: Int?): Pair<Int, Int> {
|
||||
val maxWidth = maxWidth ?: width
|
||||
val maxHeight = maxHeight ?: height
|
||||
if (width <= maxWidth && height <= maxHeight) {
|
||||
return width to height
|
||||
}
|
||||
|
||||
val widthScale = maxWidth.toDouble() / width
|
||||
val heightScale = maxHeight.toDouble() / height
|
||||
val scale = minOf(widthScale, heightScale)
|
||||
|
||||
val newWidth = (width * scale).toInt()
|
||||
val newHeight = (height * scale).toInt()
|
||||
|
||||
return newWidth to newHeight
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package pw.binom.mirror.worker.db
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
data class AudioKey(
|
||||
val key: String,
|
||||
val title: String,
|
||||
val language: String? = null,
|
||||
val index: Int,
|
||||
)
|
||||
|
||||
data class Job(
|
||||
val id: UUID,
|
||||
val itemId: String,
|
||||
val sourceUrl: String,
|
||||
val sourceType: String,
|
||||
val status: String,
|
||||
val progress: Int,
|
||||
val videoKey: String? = null,
|
||||
val audioKeys: List<AudioKey> = emptyList(),
|
||||
val error: String? = null,
|
||||
val createdAt: Instant,
|
||||
val updatedAt: Instant,
|
||||
val takenAt: Instant? = null,
|
||||
) {
|
||||
companion object {
|
||||
const val STATUS_NEW = "new"
|
||||
const val STATUS_PROCESSING = "processing"
|
||||
const val STATUS_DONE = "done"
|
||||
const val STATUS_FAILED = "failed"
|
||||
const val STATUS_CANCELLED = "cancelled"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package pw.binom.mirror.worker.db
|
||||
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import java.sql.ResultSet
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
open class JobRepository(
|
||||
private val jdbcTemplate: JdbcTemplate,
|
||||
private val transactionTemplate: TransactionTemplate,
|
||||
private val json: Json,
|
||||
) {
|
||||
|
||||
private val audioKeysSerializer = ListSerializer(AudioKey.serializer())
|
||||
|
||||
open fun takeNextJob(): Job? {
|
||||
return transactionTemplate.execute {
|
||||
val job = queryNewJob() ?: return@execute null
|
||||
updateStatus(job.id, Job.STATUS_PROCESSING)
|
||||
job.copy(
|
||||
status = Job.STATUS_PROCESSING,
|
||||
takenAt = Instant.now(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
open fun updateProgress(id: UUID, progress: Int) {
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
UPDATE media_mirror.jobs
|
||||
SET progress = ?, updated_at = now()
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
progress,
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
open fun markDone(id: UUID, videoKey: String, audioKeys: List<AudioKey>) {
|
||||
val audioJson = json.encodeToString(audioKeysSerializer, audioKeys)
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
UPDATE media_mirror.jobs
|
||||
SET status = 'done', progress = 100, video_key = ?, audio_keys = CAST(? AS jsonb), error = NULL, updated_at = now()
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
videoKey,
|
||||
audioJson,
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
open fun markFailed(id: UUID, error: String) {
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
UPDATE media_mirror.jobs
|
||||
SET status = 'failed', error = ?, updated_at = now()
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
error.take(4000),
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
open fun returnStaleToNew(staleTimeout: Duration): Int {
|
||||
return jdbcTemplate.update(
|
||||
"""
|
||||
UPDATE media_mirror.jobs
|
||||
SET status = 'new', taken_at = NULL, updated_at = now()
|
||||
WHERE status = 'processing' AND taken_at < now() - (? || ' seconds')::interval
|
||||
""".trimIndent(),
|
||||
staleTimeout.seconds,
|
||||
)
|
||||
}
|
||||
|
||||
open fun findByStatus(status: String): List<Job> {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT * FROM media_mirror.jobs WHERE status = ? ORDER BY created_at",
|
||||
{ rs, _ -> rowToJob(rs) },
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
open fun findById(id: UUID): Job? {
|
||||
return jdbcTemplate.query(
|
||||
"SELECT * FROM media_mirror.jobs WHERE id = ?",
|
||||
{ rs, _ -> rowToJob(rs) },
|
||||
id,
|
||||
).firstOrNull()
|
||||
}
|
||||
|
||||
private fun queryNewJob(): Job? {
|
||||
val jobs = jdbcTemplate.query(
|
||||
"""
|
||||
SELECT * FROM media_mirror.jobs
|
||||
WHERE status = 'new'
|
||||
ORDER BY created_at
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""".trimIndent(),
|
||||
{ rs, _ -> rowToJob(rs) },
|
||||
)
|
||||
return jobs.firstOrNull()
|
||||
}
|
||||
|
||||
private fun updateStatus(id: UUID, status: String) {
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
UPDATE media_mirror.jobs
|
||||
SET status = ?, taken_at = now(), updated_at = now()
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
status,
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
private fun rowToJob(rs: ResultSet): Job {
|
||||
val audioKeysRaw = rs.getString("audio_keys")
|
||||
val audioKeys = if (audioKeysRaw == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
runCatching { json.decodeFromString(audioKeysSerializer, audioKeysRaw) }.getOrElse { emptyList() }
|
||||
}
|
||||
return Job(
|
||||
id = rs.getObject("id", UUID::class.java),
|
||||
itemId = rs.getString("item_id"),
|
||||
sourceUrl = rs.getString("source_url"),
|
||||
sourceType = rs.getString("source_type"),
|
||||
status = rs.getString("status"),
|
||||
progress = rs.getInt("progress"),
|
||||
videoKey = rs.getString("video_key"),
|
||||
audioKeys = audioKeys,
|
||||
error = rs.getString("error"),
|
||||
createdAt = rs.getTimestamp("created_at").toInstant(),
|
||||
updatedAt = rs.getTimestamp("updated_at").toInstant(),
|
||||
takenAt = rs.getTimestamp("taken_at")?.toInstant(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package pw.binom.mirror.worker.worker
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.boot.ApplicationArguments
|
||||
import org.springframework.boot.ApplicationRunner
|
||||
import org.springframework.stereotype.Service
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import pw.binom.mirror.worker.db.JobRepository
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
@Service
|
||||
class JobPoller(
|
||||
private val jobRepository: JobRepository,
|
||||
private val jobProcessor: JobProcessor,
|
||||
private val props: AppProperties,
|
||||
private val jobScope: CoroutineScope,
|
||||
) : ApplicationRunner {
|
||||
private val logger: Logger = LoggerFactory.getLogger(JobPoller::class.java)
|
||||
|
||||
override fun run(args: ApplicationArguments) {
|
||||
jobScope.launch { pollLoop() }
|
||||
jobScope.launch { staleLoop() }
|
||||
logger.info("Job poller started with interval {}", props.pollInterval)
|
||||
}
|
||||
|
||||
private suspend fun pollLoop() {
|
||||
while (coroutineContext.isActive) {
|
||||
try {
|
||||
val job = withContext(Dispatchers.IO) { jobRepository.takeNextJob() }
|
||||
if (job != null) {
|
||||
logger.info("Taken job {}", job.id)
|
||||
jobProcessor.process(job)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Poll loop error", e)
|
||||
} finally {
|
||||
delay(props.pollInterval.toMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun staleLoop() {
|
||||
while (coroutineContext.isActive) {
|
||||
delay(props.pollInterval.toMillis())
|
||||
try {
|
||||
val count = jobRepository.returnStaleToNew(props.staleTimeout)
|
||||
if (count > 0) {
|
||||
logger.info("Returned {} stale jobs to new", count)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.warn("Stale check error", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package pw.binom.mirror.worker.worker
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import org.slf4j.Logger
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import pw.binom.mirror.worker.convert.JobExecutor
|
||||
import pw.binom.mirror.worker.convert.S3Destination
|
||||
import pw.binom.mirror.worker.db.Job
|
||||
import pw.binom.mirror.worker.db.JobRepository
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Service
|
||||
class JobProcessor(
|
||||
private val jobExecutor: JobExecutor,
|
||||
private val jobRepository: JobRepository,
|
||||
private val props: AppProperties,
|
||||
) {
|
||||
private val logger: Logger = LoggerFactory.getLogger(JobProcessor::class.java)
|
||||
|
||||
suspend fun process(job: Job) {
|
||||
logger.info("Processing job {}", job.id)
|
||||
val destination = S3Destination(
|
||||
url = props.s3.url,
|
||||
accessKey = props.s3.accessKey,
|
||||
secretKey = props.s3.secretKey,
|
||||
bucket = props.s3.bucket,
|
||||
region = props.s3.region,
|
||||
prefix = props.s3.prefix,
|
||||
)
|
||||
try {
|
||||
val result = jobExecutor.execute(
|
||||
job = job,
|
||||
destination = destination,
|
||||
progress = { p ->
|
||||
jobRepository.updateProgress(job.id, calcPercent(p))
|
||||
},
|
||||
)
|
||||
jobRepository.markDone(job.id, result.videoKey, result.audio)
|
||||
logger.info("Job {} done, videoKey={}", job.id, result.videoKey)
|
||||
} catch (e: CancellationException) {
|
||||
logger.info("Job {} cancelled", job.id)
|
||||
throw e
|
||||
} catch (e: Throwable) {
|
||||
logger.warn("Job {} finished with error: {}", job.id, e.message)
|
||||
jobRepository.markFailed(job.id, e.message ?: e.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcPercent(progress: JobExecutor.Progress): Int {
|
||||
if (!progress.totalDuration.isPositive()) {
|
||||
return 0
|
||||
}
|
||||
val maxProgress = maxOf(
|
||||
progress.videoProgress.inWholeMilliseconds,
|
||||
(progress.audioProgress.maxOrNull() ?: 0.seconds).inWholeMilliseconds,
|
||||
)
|
||||
val percent = maxProgress * 100 / progress.totalDuration.inWholeMilliseconds
|
||||
return percent.coerceIn(0, 99).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
spring:
|
||||
application:
|
||||
name: media-mirror-worker
|
||||
datasource:
|
||||
url: jdbc:postgresql://192.168.76.106:5432/glasses
|
||||
username: postgres
|
||||
password: postgres
|
||||
flyway:
|
||||
schemas: media_mirror
|
||||
|
||||
app:
|
||||
poll-interval: 2s
|
||||
stale-timeout: 30m
|
||||
jellyfin:
|
||||
url: https://jellyfin.binom.pw/
|
||||
apiKey: ${JELLYFIN_API_KEY:}
|
||||
s3:
|
||||
url: https://s3.binom.pw
|
||||
accessKey: ${S3_ACCESS_KEY:}
|
||||
secretKey: ${S3_SECRET_KEY:}
|
||||
bucket: media
|
||||
region: us-east-1
|
||||
prefix: mirror
|
||||
ffmpegPath: ffmpeg
|
||||
ffprobePath: ffprobe
|
||||
localFileStorage: /tmp/media-mirror-worker
|
||||
|
||||
server:
|
||||
port: 8081
|
||||
|
||||
logging:
|
||||
level:
|
||||
root: INFO
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE SCHEMA IF NOT EXISTS media_mirror;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS media_mirror.jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
item_id text NOT NULL,
|
||||
source_url text NOT NULL,
|
||||
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,
|
||||
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);
|
||||
@@ -0,0 +1,60 @@
|
||||
package pw.binom.mirror.worker
|
||||
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import org.testcontainers.containers.MinIOContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import pw.binom.mirror.worker.convert.S3Destination
|
||||
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials
|
||||
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider
|
||||
import software.amazon.awssdk.regions.Region
|
||||
import software.amazon.awssdk.services.s3.S3Client
|
||||
import software.amazon.awssdk.services.s3.S3Configuration
|
||||
import software.amazon.awssdk.services.s3.model.CreateBucketRequest
|
||||
import java.net.URI
|
||||
import java.nio.file.Path
|
||||
|
||||
@Testcontainers
|
||||
abstract class AbstractMinioTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Container
|
||||
val minio: MinIOContainer = MinIOContainer("minio/minio:latest")
|
||||
.withUserName("minioadmin")
|
||||
.withPassword("minioadmin")
|
||||
}
|
||||
|
||||
protected fun s3Destination(bucket: String = "media", prefix: String = "mirror"): S3Destination =
|
||||
S3Destination(
|
||||
url = minio.s3URL,
|
||||
accessKey = minio.userName,
|
||||
secretKey = minio.password,
|
||||
bucket = bucket,
|
||||
region = "us-east-1",
|
||||
prefix = prefix,
|
||||
)
|
||||
|
||||
protected fun s3Client(): S3Client =
|
||||
S3Client.builder()
|
||||
.endpointOverride(URI.create(minio.s3URL))
|
||||
.region(Region.of("us-east-1"))
|
||||
.credentialsProvider(
|
||||
StaticCredentialsProvider.create(
|
||||
AwsBasicCredentials.create(minio.userName, minio.password),
|
||||
),
|
||||
)
|
||||
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
|
||||
.build()
|
||||
|
||||
protected fun createBucket(bucket: String) {
|
||||
s3Client().use { client ->
|
||||
runCatching {
|
||||
client.createBucket(CreateBucketRequest.builder().bucket(bucket).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package pw.binom.mirror.worker
|
||||
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
|
||||
@Testcontainers
|
||||
abstract class AbstractPostgresTest {
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Container
|
||||
val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine")
|
||||
.withDatabaseName("glasses")
|
||||
.withUsername("postgres")
|
||||
.withPassword("postgres")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package pw.binom.mirror.worker
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.boot.test.context.SpringBootTest
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.test.context.DynamicPropertyRegistry
|
||||
import org.springframework.test.context.DynamicPropertySource
|
||||
import org.testcontainers.containers.PostgreSQLContainer
|
||||
import org.testcontainers.junit.jupiter.Container
|
||||
import org.testcontainers.junit.jupiter.Testcontainers
|
||||
import pw.binom.mirror.worker.db.Job
|
||||
import pw.binom.mirror.worker.db.JobRepository
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.UUID
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@Testcontainers
|
||||
@SpringBootTest
|
||||
class JobPollerTest : AbstractMinioTest() {
|
||||
|
||||
@Autowired
|
||||
lateinit var jobRepository: JobRepository
|
||||
|
||||
@Autowired
|
||||
lateinit var jdbcTemplate: JdbcTemplate
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Container
|
||||
val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine")
|
||||
.withDatabaseName("glasses")
|
||||
.withUsername("postgres")
|
||||
.withPassword("postgres")
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun properties(registry: DynamicPropertyRegistry) {
|
||||
registry.add("spring.datasource.url") { postgres.jdbcUrl }
|
||||
registry.add("spring.datasource.username") { postgres.username }
|
||||
registry.add("spring.datasource.password") { postgres.password }
|
||||
registry.add("app.poll-interval") { "200ms" }
|
||||
registry.add("app.jellyfin.url") { "http://jellyfin.test" }
|
||||
registry.add("app.jellyfin.apiKey") { "secret" }
|
||||
registry.add("app.s3.url") { AbstractMinioTest.minio.s3URL }
|
||||
registry.add("app.s3.accessKey") { AbstractMinioTest.minio.userName }
|
||||
registry.add("app.s3.secretKey") { AbstractMinioTest.minio.password }
|
||||
registry.add("app.s3.bucket") { "media" }
|
||||
registry.add("app.s3.region") { "us-east-1" }
|
||||
registry.add("app.s3.prefix") { "mirror" }
|
||||
registry.add("app.ffmpegPath") { "/usr/bin/ffmpeg" }
|
||||
registry.add("app.ffprobePath") { "/usr/bin/ffprobe" }
|
||||
registry.add("app.localFileStorage") {
|
||||
Files.createTempDirectory("mmw-worker-test").toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `poller picks up job and processes it to done`() {
|
||||
createBucket("media")
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val server = HttpServer.create(InetSocketAddress(0), 0)
|
||||
server.createContext("/video.mp4") { exchange ->
|
||||
exchange.sendResponseHeaders(200, Files.size(source))
|
||||
exchange.responseBody.use { out -> Files.newInputStream(source).use { it.copyTo(out) } }
|
||||
}
|
||||
server.start()
|
||||
try {
|
||||
val id = insertJob(itemId = "item-1", sourceUrl = "http://127.0.0.1:${server.address.port}/video.mp4")
|
||||
val done = await(120_000) {
|
||||
jobRepository.findById(id)?.takeIf { it.status == Job.STATUS_DONE }
|
||||
} ?: error("Timed out waiting for job to become done")
|
||||
|
||||
assertEquals("mirror/item-1/video.mkv", done.videoKey)
|
||||
assertEquals(100, done.progress)
|
||||
assertEquals(2, done.audioKeys.size)
|
||||
assertEquals("mirror/item-1/audio-0.ogg", done.audioKeys[0].key)
|
||||
assertEquals("mirror/item-1/audio-1.ogg", done.audioKeys[1].key)
|
||||
} finally {
|
||||
server.stop(0)
|
||||
}
|
||||
|
||||
s3Client().use { client ->
|
||||
val keys = client.listObjectsV2 { it.bucket("media") }.contents().map { it.key() }
|
||||
assertTrue(keys.contains("mirror/item-1/video.mkv"))
|
||||
assertTrue(keys.contains("mirror/item-1/audio-0.ogg"))
|
||||
assertTrue(keys.contains("mirror/item-1/audio-1.ogg"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `poller marks job as failed on broken source`() {
|
||||
val id = insertJob(itemId = "broken", sourceUrl = "http://127.0.0.1:1/video.mp4")
|
||||
val failed = await(120_000) {
|
||||
jobRepository.findById(id)?.takeIf { it.status == Job.STATUS_FAILED }
|
||||
} ?: error("Timed out waiting for job to become failed")
|
||||
assertTrue(failed.error?.isNotBlank() == true)
|
||||
}
|
||||
|
||||
private fun insertJob(itemId: String, sourceUrl: String): UUID {
|
||||
val id = UUID.randomUUID()
|
||||
jdbcTemplate.update(
|
||||
"""
|
||||
INSERT INTO media_mirror.jobs (id, item_id, source_url, status)
|
||||
VALUES (?, ?, ?, 'new')
|
||||
""".trimIndent(),
|
||||
id,
|
||||
itemId,
|
||||
sourceUrl,
|
||||
)
|
||||
return id
|
||||
}
|
||||
|
||||
private fun <T> await(timeoutMs: Long, condition: () -> T?): T? {
|
||||
val deadline = System.currentTimeMillis() + timeoutMs
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
condition()?.let { return it }
|
||||
Thread.sleep(100)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pw.binom.mirror.worker
|
||||
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
object TestMedia {
|
||||
|
||||
private const val FFMPEG = "/usr/bin/ffmpeg"
|
||||
|
||||
fun generateSource(dir: Path, durationSeconds: Int = 2): Path {
|
||||
Files.createDirectories(dir)
|
||||
val out = dir.resolve("source.mp4")
|
||||
val command = listOf(
|
||||
FFMPEG,
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc=duration=$durationSeconds:size=160x120:rate=10",
|
||||
"-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=$durationSeconds",
|
||||
"-f", "lavfi",
|
||||
"-i", "sine=frequency=880:duration=$durationSeconds",
|
||||
"-map", "0:v",
|
||||
"-map", "1:a",
|
||||
"-map", "2:a",
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-t", "$durationSeconds",
|
||||
"-movflags", "+faststart",
|
||||
out.toString(),
|
||||
)
|
||||
run(command)
|
||||
return out
|
||||
}
|
||||
|
||||
fun generateBrokenFile(dir: Path): Path {
|
||||
Files.createDirectories(dir)
|
||||
val out = dir.resolve("broken.bin")
|
||||
Files.write(out, byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04))
|
||||
return out
|
||||
}
|
||||
|
||||
private fun run(command: List<String>) {
|
||||
val process = ProcessBuilder(command)
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
val output = process.inputStream.readBytes().decodeToString()
|
||||
val exitCode = process.waitFor()
|
||||
check(exitCode == 0) {
|
||||
"Command failed with exit $exitCode:\n$command\n$output"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class DurationAsSecondsStringTest {
|
||||
|
||||
private val json = Json
|
||||
|
||||
@Test
|
||||
fun `serializes duration to seconds string`() {
|
||||
val encoded = json.encodeToString(DurationAsSecondsString, 12.5.seconds)
|
||||
assertEquals("\"12.5\"", encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserializes seconds string to duration`() {
|
||||
assertEquals(12.5.seconds, json.decodeFromString(DurationAsSecondsString, "\"12.5\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deserializes invalid value to zero`() {
|
||||
assertEquals(Duration.ZERO, json.decodeFromString(DurationAsSecondsString, "\"N/A\""))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import pw.binom.mirror.worker.TestMedia
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FfmpegServiceTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
private fun service() = FfmpegService(
|
||||
props = AppProperties(ffmpegPath = "/usr/bin/ffmpeg", localFileStorage = tempDir),
|
||||
jobScope = CoroutineScope(Dispatchers.Default),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `extractAudio extracts single audio track`() = runBlocking {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val output = tempDir.resolve("audio-0.m4a")
|
||||
service().extractAudio(
|
||||
input = source,
|
||||
output = output,
|
||||
audioIndex = 0,
|
||||
codec = "aac",
|
||||
channelCount = 2,
|
||||
)
|
||||
assertTrue(Files.size(output) > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractAudio supports vorbis codec`() = runBlocking {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val output = tempDir.resolve("audio-0.ogg")
|
||||
service().extractAudio(
|
||||
input = source,
|
||||
output = output,
|
||||
audioIndex = 0,
|
||||
codec = "vorbis",
|
||||
channelCount = 2,
|
||||
)
|
||||
assertTrue(Files.size(output) > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractAudio deletes output on failure`() = runBlocking {
|
||||
val source = TestMedia.generateBrokenFile(tempDir)
|
||||
val output = tempDir.resolve("bad.ogg")
|
||||
runCatching {
|
||||
service().extractAudio(
|
||||
input = source,
|
||||
output = output,
|
||||
audioIndex = 0,
|
||||
)
|
||||
}
|
||||
assertFalse(Files.exists(output))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transform removes audio for video-only output`() = runBlocking {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val output = tempDir.resolve("video.mp4")
|
||||
service().transform(
|
||||
input = source,
|
||||
output = output,
|
||||
width = 160,
|
||||
height = 120,
|
||||
videoCodec = "libx264",
|
||||
removeAudio = true,
|
||||
removeSubtitles = true,
|
||||
removeMetadata = true,
|
||||
removeChapters = true,
|
||||
)
|
||||
assertTrue(Files.size(output) > 0)
|
||||
val info = FfprobeService(AppProperties(ffprobePath = "/usr/bin/ffprobe")).getInfo(output)
|
||||
assertEquals(1, info.videoStreams.size)
|
||||
assertEquals(0, info.audioStreams.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transform keeps audio for single channel output`() = runBlocking {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val output = tempDir.resolve("single.mp4")
|
||||
service().transform(
|
||||
input = source,
|
||||
output = output,
|
||||
width = 160,
|
||||
height = 120,
|
||||
videoCodec = "libx264",
|
||||
audioCodec = "aac",
|
||||
channels = 2,
|
||||
)
|
||||
assertTrue(Files.size(output) > 0)
|
||||
val info = FfprobeService(AppProperties(ffprobePath = "/usr/bin/ffprobe")).getInfo(output)
|
||||
assertEquals(1, info.videoStreams.size)
|
||||
assertEquals(1, info.audioStreams.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transform reports progress`() = runBlocking {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val output = tempDir.resolve("progress.mp4")
|
||||
val progresses = mutableListOf<FfmpegService.Progress>()
|
||||
service().transform(
|
||||
input = source,
|
||||
output = output,
|
||||
videoCodec = "libx264",
|
||||
removeAudio = true,
|
||||
progress = { progresses += it },
|
||||
)
|
||||
assertTrue(progresses.isNotEmpty(), "Expected progress callbacks to be reported")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `transform rejects copy codec`() = runBlocking {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val output = tempDir.resolve("copy.mp4")
|
||||
val result = runCatching {
|
||||
service().transform(
|
||||
input = source,
|
||||
output = output,
|
||||
videoCodec = "copy",
|
||||
)
|
||||
}
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import pw.binom.mirror.worker.TestMedia
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FfprobeServiceTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
private fun service() = FfprobeService(AppProperties(ffprobePath = "/usr/bin/ffprobe"))
|
||||
|
||||
@Test
|
||||
fun `getInfo returns video and audio streams`() {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val info = service().getInfo(source)
|
||||
|
||||
assertEquals(1, info.videoStreams.size)
|
||||
assertEquals(2, info.audioStreams.size)
|
||||
|
||||
val video = info.videoStreams.single()
|
||||
assertEquals(160, video.width)
|
||||
assertEquals(120, video.height)
|
||||
assertTrue(video.duration!!.isPositive())
|
||||
|
||||
assertTrue(info.format.duration.isPositive())
|
||||
assertNotNull(info.format.formatName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getInfo parses audio stream metadata`() {
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val info = service().getInfo(source)
|
||||
|
||||
val audio = info.audioStreams[0]
|
||||
assertTrue(audio.index >= 0)
|
||||
assertTrue(audio.channels >= 1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getInfo fails on broken file`() {
|
||||
val broken = TestMedia.generateBrokenFile(tempDir)
|
||||
val result = runCatching { service().getInfo(broken) }
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FileInfoTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun `round trip with subtitle and attachment streams`() {
|
||||
val info = FileInfo(
|
||||
streams = listOf(
|
||||
FileInfo.VideoStream(
|
||||
index = 0,
|
||||
codecName = "vp9",
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
bitRate = 1000,
|
||||
duration = 10.seconds,
|
||||
startTime = 0.seconds,
|
||||
tags = mapOf("title" to "Main"),
|
||||
),
|
||||
FileInfo.AudioStream(
|
||||
index = 1,
|
||||
channels = 2,
|
||||
channelLayout = "stereo",
|
||||
bitRate = 500,
|
||||
duration = 10.seconds,
|
||||
startTime = 0.seconds,
|
||||
tags = mapOf("title" to "English", "language" to "eng"),
|
||||
),
|
||||
FileInfo.Subtitle(
|
||||
index = 2,
|
||||
duration = 10.seconds,
|
||||
startTime = 0.seconds,
|
||||
tags = mapOf("language" to "rus"),
|
||||
),
|
||||
FileInfo.Attachment(
|
||||
index = 3,
|
||||
duration = null,
|
||||
startTime = null,
|
||||
tags = mapOf("filename" to "font.ttf"),
|
||||
),
|
||||
),
|
||||
format = FileInfo.Format(
|
||||
formatName = "matroska",
|
||||
formatLongName = "Matroska",
|
||||
duration = 10.seconds,
|
||||
size = 1000,
|
||||
bitRate = 2000,
|
||||
tags = mapOf("encoder" to "x"),
|
||||
),
|
||||
)
|
||||
|
||||
val decoded = json.decodeFromString(FileInfo.serializer(), json.encodeToString(FileInfo.serializer(), info))
|
||||
assertEquals(1, decoded.videoStreams.size)
|
||||
assertEquals(1, decoded.audioStreams.size)
|
||||
assertNotNull(decoded.format.formatName)
|
||||
assertTrue(decoded.format.duration.isPositive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `videoStreams filters png mjpeg and cover jpg`() {
|
||||
val info = FileInfo(
|
||||
streams = listOf(
|
||||
FileInfo.VideoStream(index = 0, codecName = "png", width = 100, height = 100, startTime = 0.seconds),
|
||||
FileInfo.VideoStream(index = 1, codecName = "mjpeg", width = 100, height = 100, startTime = 0.seconds),
|
||||
FileInfo.VideoStream(
|
||||
index = 2,
|
||||
codecName = "vp9",
|
||||
width = 1920,
|
||||
height = 1080,
|
||||
tags = mapOf("filename" to "cover.jpg"),
|
||||
startTime = 0.seconds,
|
||||
),
|
||||
FileInfo.VideoStream(index = 3, codecName = "vp9", width = 1920, height = 1080, startTime = 0.seconds),
|
||||
),
|
||||
format = FileInfo.Format(
|
||||
formatName = "matroska",
|
||||
formatLongName = "M",
|
||||
duration = 5.seconds,
|
||||
size = 1,
|
||||
bitRate = 1,
|
||||
),
|
||||
)
|
||||
assertEquals(1, info.videoStreams.size)
|
||||
assertEquals(3, info.videoStreams[0].index)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio stream title and language fallbacks`() {
|
||||
val a1 = FileInfo.AudioStream(
|
||||
index = 0,
|
||||
channels = 2,
|
||||
startTime = 0.seconds,
|
||||
tags = mapOf("language" to "eng"),
|
||||
)
|
||||
assertEquals("#0", a1.title)
|
||||
assertEquals("eng", a1.language)
|
||||
|
||||
val a2 = FileInfo.AudioStream(
|
||||
index = 1,
|
||||
channels = 2,
|
||||
startTime = 0.seconds,
|
||||
tags = emptyMap(),
|
||||
)
|
||||
assertEquals("#1", a2.title)
|
||||
assertEquals(null, a2.language)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import pw.binom.mirror.worker.AbstractMinioTest
|
||||
import pw.binom.mirror.worker.TestMedia
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import software.amazon.awssdk.services.s3.model.PutObjectRequest
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class InputServiceTest : AbstractMinioTest() {
|
||||
|
||||
private val localStorage: LocalStorageService by lazy {
|
||||
LocalStorageService(AppProperties(localFileStorage = tempDir))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadIncome downloads s3 source`() = runBlocking {
|
||||
createBucket("media")
|
||||
val sourceFile = TestMedia.generateSource(tempDir)
|
||||
val destination = s3Destination("media")
|
||||
s3Client().use { client ->
|
||||
client.putObject(
|
||||
PutObjectRequest.builder().bucket("media").key("in/video.mp4").build(),
|
||||
sourceFile,
|
||||
)
|
||||
}
|
||||
val inputService = InputService(listOf(S3Input(localStorage)))
|
||||
val result = inputService.downloadIncome(
|
||||
InputSource.S3(destination = destination, key = "in/video.mp4"),
|
||||
)
|
||||
assertTrue(Files.exists(result))
|
||||
assertEquals(Files.size(sourceFile), Files.size(result))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadIncome fails for missing s3 key`() = runBlocking {
|
||||
createBucket("media")
|
||||
val inputService = InputService(listOf(S3Input(localStorage)))
|
||||
val result = runCatching {
|
||||
inputService.downloadIncome(
|
||||
InputSource.S3(destination = s3Destination("media"), key = "missing.mp4"),
|
||||
)
|
||||
}
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jellyfin input downloads stream url`() = runBlocking {
|
||||
val server = HttpServer.create(InetSocketAddress(0), 0)
|
||||
val content = "jellyfin-video-content".toByteArray()
|
||||
server.createContext("/video.mp4") { exchange ->
|
||||
assertEquals("/video.mp4", exchange.requestURI.path)
|
||||
exchange.sendResponseHeaders(200, content.size.toLong())
|
||||
exchange.responseBody.use { it.write(content) }
|
||||
}
|
||||
server.start()
|
||||
try {
|
||||
val inputService = InputService(listOf(JellyfinInput(localStorage)))
|
||||
val result = inputService.downloadIncome(
|
||||
InputSource.Jellyfin(streamUrl = "http://127.0.0.1:${server.address.port}/video.mp4"),
|
||||
)
|
||||
assertContentEquals(content, Files.readAllBytes(result))
|
||||
} finally {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloadIncome fails when no implementation supports source`() = runBlocking {
|
||||
val inputService = InputService(emptyList())
|
||||
val result = runCatching {
|
||||
inputService.downloadIncome(
|
||||
InputSource.Jellyfin(streamUrl = "http://localhost/x.mp4"),
|
||||
)
|
||||
}
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import pw.binom.mirror.worker.AbstractMinioTest
|
||||
import pw.binom.mirror.worker.TestMedia
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import pw.binom.mirror.worker.db.Job
|
||||
import java.net.InetSocketAddress
|
||||
import java.nio.file.Files
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JobExecutorTest : AbstractMinioTest() {
|
||||
|
||||
private fun props() = AppProperties(
|
||||
jellyfin = AppProperties.Jellyfin(url = "http://jellyfin.test", apiKey = "secret"),
|
||||
ffmpegPath = "/usr/bin/ffmpeg",
|
||||
ffprobePath = "/usr/bin/ffprobe",
|
||||
localFileStorage = tempDir,
|
||||
)
|
||||
|
||||
private fun executor(): JobExecutor {
|
||||
val p = props()
|
||||
return JobExecutor(
|
||||
props = p,
|
||||
inputService = InputService(listOf(JellyfinInput(LocalStorageService(p)))),
|
||||
outputService = OutputService(),
|
||||
localStorageService = LocalStorageService(p),
|
||||
ffmpegService = FfmpegService(p, CoroutineScope(Dispatchers.Default)),
|
||||
ffprobeService = FfprobeService(p),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `separate channels produces video and audio files in s3`() {
|
||||
createBucket("media")
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val server = HttpServer.create(InetSocketAddress(0), 0)
|
||||
server.createContext("/video.mp4") { exchange ->
|
||||
exchange.sendResponseHeaders(200, Files.size(source))
|
||||
exchange.responseBody.use { out -> Files.newInputStream(source).use { it.copyTo(out) } }
|
||||
}
|
||||
server.start()
|
||||
try {
|
||||
val job = job(itemId = "item-1", sourceUrl = "http://127.0.0.1:${server.address.port}/video.mp4")
|
||||
val result = runBlocking {
|
||||
executor().execute(job = job, destination = s3Destination("media"))
|
||||
}
|
||||
|
||||
assertEquals("mirror/item-1/video.mkv", result.videoKey)
|
||||
assertEquals(2, result.audio.size)
|
||||
assertEquals("mirror/item-1/audio-0.ogg", result.audio[0].key)
|
||||
assertEquals("mirror/item-1/audio-1.ogg", result.audio[1].key)
|
||||
assertTrue(result.duration.isPositive())
|
||||
|
||||
s3Client().use { client ->
|
||||
val keys = client.listObjectsV2 { it.bucket("media") }.contents().map { it.key() }
|
||||
assertTrue(keys.contains("mirror/item-1/video.mkv"))
|
||||
assertTrue(keys.contains("mirror/item-1/audio-0.ogg"))
|
||||
assertTrue(keys.contains("mirror/item-1/audio-1.ogg"))
|
||||
}
|
||||
} finally {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `downloaded video has no audio and fits 480p height`() {
|
||||
createBucket("media")
|
||||
val source = TestMedia.generateSource(tempDir)
|
||||
val server = HttpServer.create(InetSocketAddress(0), 0)
|
||||
server.createContext("/video.mp4") { exchange ->
|
||||
exchange.sendResponseHeaders(200, Files.size(source))
|
||||
exchange.responseBody.use { out -> Files.newInputStream(source).use { it.copyTo(out) } }
|
||||
}
|
||||
server.start()
|
||||
try {
|
||||
val job = job(itemId = "item-2", sourceUrl = "http://127.0.0.1:${server.address.port}/video.mp4")
|
||||
runBlocking {
|
||||
executor().execute(job = job, destination = s3Destination("media"))
|
||||
}
|
||||
|
||||
val probe = FfprobeService(props())
|
||||
s3Client().use { client ->
|
||||
client.getObject { it.bucket("media").key("mirror/item-2/video.mkv") }.use { obj ->
|
||||
val videoFile = tempDir.resolve("video-download.mkv")
|
||||
Files.copy(obj, videoFile)
|
||||
val info = probe.getInfo(videoFile)
|
||||
assertEquals(1, info.videoStreams.size)
|
||||
assertTrue(info.audioStreams.isEmpty(), "video should have no audio streams")
|
||||
assertTrue(info.videoStreams.single().height <= 480)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
server.stop(0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `broken source fails with exception`() {
|
||||
val job = job(itemId = "broken", sourceUrl = "http://127.0.0.1:1/video.mp4")
|
||||
val result = runCatching {
|
||||
runBlocking { executor().execute(job = job, destination = s3Destination("media")) }
|
||||
}
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
|
||||
private fun job(itemId: String, sourceUrl: String): Job = Job(
|
||||
id = UUID.randomUUID(),
|
||||
itemId = itemId,
|
||||
sourceUrl = sourceUrl,
|
||||
sourceType = "jellyfin",
|
||||
status = "new",
|
||||
progress = 0,
|
||||
createdAt = Instant.now(),
|
||||
updatedAt = Instant.now(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import pw.binom.mirror.worker.config.AppProperties
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class LocalStorageServiceTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var tempDir: Path
|
||||
|
||||
@Test
|
||||
fun `genFile without extension`() {
|
||||
val service = LocalStorageService(AppProperties(localFileStorage = tempDir))
|
||||
val file = service.genFile()
|
||||
assertTrue(file.fileName.toString().isNotBlank())
|
||||
assertFalse(Files.exists(file))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `genFile with extension`() {
|
||||
val service = LocalStorageService(AppProperties(localFileStorage = tempDir))
|
||||
val file = service.genFile("mp4")
|
||||
assertTrue(file.fileName.toString().endsWith(".mp4"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `genFile creates storage directory`() {
|
||||
val nested = tempDir.resolve("nested").resolve("dir")
|
||||
val service = LocalStorageService(AppProperties(localFileStorage = nested))
|
||||
service.genFile()
|
||||
assertTrue(nested.toFile().isDirectory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Test
|
||||
import pw.binom.mirror.worker.AbstractMinioTest
|
||||
import software.amazon.awssdk.services.s3.model.GetObjectRequest
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class OutputServiceTest : AbstractMinioTest() {
|
||||
|
||||
@Test
|
||||
fun `pushResult uploads file under prefix`() = runBlocking {
|
||||
createBucket("media")
|
||||
val destination = s3Destination("media", prefix = "out")
|
||||
val file = tempDir.resolve("result.mp4")
|
||||
Files.write(file, ByteArray(1024) { 1 })
|
||||
|
||||
OutputService().pushResult(
|
||||
destination = destination,
|
||||
file = file,
|
||||
name = "video.mp4",
|
||||
)
|
||||
|
||||
s3Client().use { client ->
|
||||
val obj = client.getObject(
|
||||
GetObjectRequest.builder().bucket("media").key("out/video.mp4").build(),
|
||||
)
|
||||
val bytes = obj.readAllBytes()
|
||||
assertEquals(Files.size(file), bytes.size.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pushResult uploads without prefix when empty`() = runBlocking {
|
||||
createBucket("media")
|
||||
val destination = s3Destination("media", prefix = "")
|
||||
val file = tempDir.resolve("plain.mp4")
|
||||
Files.write(file, byteArrayOf(1, 2, 3))
|
||||
|
||||
OutputService().pushResult(
|
||||
destination = destination,
|
||||
file = file,
|
||||
name = "plain.mp4",
|
||||
)
|
||||
|
||||
s3Client().use { client ->
|
||||
val obj = client.getObject(
|
||||
GetObjectRequest.builder().bucket("media").key("plain.mp4").build(),
|
||||
)
|
||||
assertEquals(3L, obj.readAllBytes().size.toLong())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pushResult trims trailing slash from prefix`() = runBlocking {
|
||||
createBucket("media")
|
||||
val destination = s3Destination("media", prefix = "out/")
|
||||
val file = tempDir.resolve("x.mp4")
|
||||
Files.write(file, byteArrayOf(9))
|
||||
|
||||
OutputService().pushResult(
|
||||
destination = destination,
|
||||
file = file,
|
||||
name = "x.mp4",
|
||||
)
|
||||
|
||||
s3Client().use { client ->
|
||||
val keys = client.listObjectsV2 { it.bucket("media") }.contents().map { it.key() }
|
||||
assertTrue(keys.contains("out/x.mp4"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pushResult defaults name to file name`() = runBlocking {
|
||||
createBucket("media")
|
||||
val destination = s3Destination("media", prefix = "out")
|
||||
val file = tempDir.resolve("default.mp4")
|
||||
Files.write(file, byteArrayOf(5))
|
||||
|
||||
OutputService().pushResult(
|
||||
destination = destination,
|
||||
file = file,
|
||||
)
|
||||
|
||||
s3Client().use { client ->
|
||||
val keys = client.listObjectsV2 { it.bucket("media") }.contents().map { it.key() }
|
||||
assertTrue(keys.contains("out/default.mp4"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pushResult fails for missing file`() = runBlocking {
|
||||
createBucket("media")
|
||||
val destination = s3Destination("media", prefix = "")
|
||||
val missing = tempDir.resolve("missing.mp4")
|
||||
val result = runCatching {
|
||||
OutputService().pushResult(
|
||||
destination = destination,
|
||||
file = missing,
|
||||
name = "missing.mp4",
|
||||
)
|
||||
}
|
||||
assertTrue(result.isFailure)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package pw.binom.mirror.worker.convert
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ViewSizeCalculateTest {
|
||||
|
||||
@Test
|
||||
fun `no limits keeps original size`() {
|
||||
assertEquals(1920 to 1080, ViewSizeCalculate.calc(1920, 1080, null, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scales down preserving aspect ratio`() {
|
||||
val (w, h) = ViewSizeCalculate.calc(1920, 1080, 1280, 720)
|
||||
assertTrue(w <= 1280)
|
||||
assertTrue(h <= 720)
|
||||
assertEquals(1280, w)
|
||||
assertEquals(720, h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `width limit only`() {
|
||||
val (w, h) = ViewSizeCalculate.calc(1920, 1080, 640, null)
|
||||
assertEquals(640, w)
|
||||
assertEquals(360, h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `height limit only`() {
|
||||
val (w, h) = ViewSizeCalculate.calc(1920, 1080, null, 540)
|
||||
assertEquals(960, w)
|
||||
assertEquals(540, h)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `smaller than limits stays the same`() {
|
||||
assertEquals(640 to 360, ViewSizeCalculate.calc(640, 360, 1920, 1080))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `portrait scaling`() {
|
||||
val (w, h) = ViewSizeCalculate.calc(1080, 1920, 720, 1280)
|
||||
assertEquals(720, w)
|
||||
assertEquals(1280, h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package pw.binom.mirror.worker.db
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig
|
||||
import com.zaxxer.hikari.HikariDataSource
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.flywaydb.core.Flyway
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.jdbc.datasource.DataSourceTransactionManager
|
||||
import org.springframework.transaction.support.TransactionTemplate
|
||||
import pw.binom.mirror.worker.AbstractPostgresTest
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JobRepositoryTest : AbstractPostgresTest() {
|
||||
|
||||
private lateinit var dataSource: HikariDataSource
|
||||
private lateinit var repository: JobRepository
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
dataSource = HikariDataSource(
|
||||
HikariConfig().apply {
|
||||
jdbcUrl = postgres.jdbcUrl
|
||||
username = postgres.username
|
||||
password = postgres.password
|
||||
maximumPoolSize = 5
|
||||
},
|
||||
)
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.schemas("media_mirror")
|
||||
.load()
|
||||
.migrate()
|
||||
JdbcTemplate(dataSource).execute("TRUNCATE TABLE media_mirror.jobs")
|
||||
repository = JobRepository(
|
||||
jdbcTemplate = JdbcTemplate(dataSource),
|
||||
transactionTemplate = TransactionTemplate(DataSourceTransactionManager(dataSource)),
|
||||
json = Json { ignoreUnknownKeys = true },
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() {
|
||||
dataSource.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `takeNextJob marks job as processing with takenAt`() {
|
||||
val id = insertJob("item-1")
|
||||
|
||||
val taken = repository.takeNextJob()
|
||||
|
||||
assertNotNull(taken)
|
||||
assertEquals(id, taken.id)
|
||||
assertEquals("processing", taken.status)
|
||||
assertNotNull(taken.takenAt)
|
||||
assertEquals(1, repository.findByStatus("processing").size)
|
||||
assertTrue(repository.findByStatus("new").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `skip locked gives a job to only one of two concurrent workers`() {
|
||||
insertJob("item-1")
|
||||
|
||||
val startGate = CountDownLatch(2)
|
||||
val taken = ConcurrentLinkedQueue<UUID>()
|
||||
val attempts = java.util.concurrent.atomic.AtomicInteger()
|
||||
val threads = (1..2).map {
|
||||
Thread {
|
||||
startGate.countDown()
|
||||
startGate.await()
|
||||
attempts.incrementAndGet()
|
||||
repository.takeNextJob()?.id?.let { taken.add(it) }
|
||||
}
|
||||
}
|
||||
threads.forEach { it.start() }
|
||||
threads.forEach { it.join(30_000) }
|
||||
|
||||
assertEquals(2, attempts.get())
|
||||
assertEquals(1, taken.size)
|
||||
assertEquals(1, repository.findByStatus("processing").size)
|
||||
assertTrue(repository.findByStatus("new").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markDone stores video key and audio keys`() {
|
||||
val id = insertJob("item-1")
|
||||
repository.takeNextJob()
|
||||
val audioKeys = listOf(
|
||||
AudioKey(key = "mirror/item-1/audio-0.ogg", title = "Track 1", language = "eng", index = 0),
|
||||
AudioKey(key = "mirror/item-1/audio-1.ogg", title = "#1", language = null, index = 1),
|
||||
)
|
||||
|
||||
repository.markDone(id, "mirror/item-1/video.mkv", audioKeys)
|
||||
|
||||
val job = repository.findByStatus("done").single()
|
||||
assertEquals(id, job.id)
|
||||
assertEquals("mirror/item-1/video.mkv", job.videoKey)
|
||||
assertEquals(audioKeys, job.audioKeys)
|
||||
assertEquals(100, job.progress)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `markFailed stores error message`() {
|
||||
val id = insertJob("item-1")
|
||||
repository.takeNextJob()
|
||||
|
||||
repository.markFailed(id, "boom")
|
||||
|
||||
val job = repository.findByStatus("failed").single()
|
||||
assertEquals("boom", job.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stale processing job returns to new`() {
|
||||
val id = insertJob(
|
||||
itemId = "item-1",
|
||||
status = "processing",
|
||||
takenAt = Instant.now().minus(31, ChronoUnit.MINUTES),
|
||||
)
|
||||
|
||||
val count = repository.returnStaleToNew(Duration.ofMinutes(30))
|
||||
|
||||
assertEquals(1, count)
|
||||
val job = repository.findByStatus("new").single()
|
||||
assertEquals(id, job.id)
|
||||
assertNull(job.takenAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fresh processing job is not stale`() {
|
||||
insertJob(itemId = "item-1", status = "processing", takenAt = Instant.now())
|
||||
|
||||
assertEquals(0, repository.returnStaleToNew(Duration.ofMinutes(30)))
|
||||
assertEquals(1, repository.findByStatus("processing").size)
|
||||
}
|
||||
|
||||
private fun insertJob(
|
||||
itemId: String,
|
||||
status: String = "new",
|
||||
sourceUrl: String = "http://localhost/video.mp4",
|
||||
takenAt: Instant? = null,
|
||||
): UUID {
|
||||
val id = UUID.randomUUID()
|
||||
JdbcTemplate(dataSource).update(
|
||||
"""
|
||||
INSERT INTO media_mirror.jobs (id, item_id, source_url, status, taken_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
id,
|
||||
itemId,
|
||||
sourceUrl,
|
||||
status,
|
||||
takenAt?.let { java.sql.Timestamp.from(it) },
|
||||
)
|
||||
return id
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user