feat: media-mirror-api — координатор зеркал (REST + очередь в Postgres)

- POST /api/mirror (дубль-контроль по itemId), GET статус/by-item/список, DELETE (cancelled/S3-очистка)
- kotlinx-сериализация, Jackson выпилен из HTTP-стека (транзитив Flyway/AWS остался)
- Spring Boot 4.1.0, Kotlin 2.4.10, Flyway, Testcontainers postgres+minio
- тесты: 22, LINE 92.6%, smoke против живой БД прошёл
This commit is contained in:
Hermes Agent
2026-08-10 17:29:50 +03:00
parent 6250cb0a38
commit c0483ef3d1
25 changed files with 1430 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.gradle/
build/
.kotlin/
+7
View File
@@ -0,0 +1,7 @@
FROM bellsoft/liberica-openjdk-alpine:21
COPY build/libs/media-mirror-api.jar /app.jar
ENV J_XMX "2048m"
ENV J_XMS "512m"
ENV J_PERF_FLAGS "-XX:+PerfDisableSharedMem -Djava.security.egd=file:/dev/./urandom -XX:+UseContainerSupport -XX:+AlwaysActAsServerClassMachine"
CMD java -Xmx$J_XMX -Xms$J_XMS $J_PERF_FLAGS -jar /app.jar
+83
View File
@@ -0,0 +1,83 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("org.springframework.boot") version "4.1.0"
id("org.jetbrains.kotlin.jvm") version "2.4.10"
id("org.jetbrains.kotlin.plugin.spring") version "2.4.10"
id("org.jetbrains.kotlin.plugin.serialization") version "2.4.10"
jacoco
}
group = "pw.binom"
version = "0.0.1-SNAPSHOT"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_21
freeCompilerArgs.add("-Xjsr305=strict")
}
}
dependencies {
implementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.0"))
implementation("org.springframework.boot:spring-boot-starter-webmvc") {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-jackson")
}
implementation(kotlin("reflect"))
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-database-postgresql")
implementation(platform("software.amazon.awssdk:bom:2.46.21"))
implementation("software.amazon.awssdk:s3")
testImplementation(platform("org.springframework.boot:spring-boot-dependencies:4.1.0"))
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.boot:spring-boot-webmvc-test")
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")
}
tasks.withType<Test> {
useJUnitPlatform()
finalizedBy(tasks.jacocoTestReport)
}
tasks.named<org.springframework.boot.gradle.tasks.bundling.BootJar>("bootJar") {
archiveFileName.set("media-mirror-api.jar")
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required = true
html.required = true
}
}
tasks.jacocoTestCoverageVerification {
dependsOn(tasks.test)
violationRules {
rule {
limit {
counter = "LINE"
value = "COVEREDRATIO"
minimum = "0.80".toBigDecimal()
}
}
}
}
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#Sat Apr 15 17:57:35 CST 2023
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
Vendored Executable
+185
View File
@@ -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
View File
@@ -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
+16
View File
@@ -0,0 +1,16 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
google()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenCentral()
}
}
rootProject.name = "media-mirror-api"
@@ -0,0 +1,13 @@
package pw.binom.mirror.api
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
@SpringBootApplication
@ConfigurationPropertiesScan
class MirrorApiApplication
fun main(args: Array<String>) {
runApplication<MirrorApiApplication>(*args)
}
@@ -0,0 +1,32 @@
package pw.binom.mirror.api.config
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.context.properties.bind.DefaultValue
@ConfigurationProperties(prefix = "app")
data class AppProperties(
@param:DefaultValue
val jellyfin: Jellyfin,
@param:DefaultValue
val s3: S3,
) {
data class Jellyfin(
@param:DefaultValue("https://jellyfin.binom.pw/")
val url: String,
)
data class S3(
@param:DefaultValue("https://s3.binom.pw")
val url: String,
@param:DefaultValue("")
val accessKey: String,
@param:DefaultValue("")
val secretKey: String,
@param:DefaultValue("media")
val bucket: String,
@param:DefaultValue("us-east-1")
val region: String,
@param:DefaultValue("mirror")
val prefix: String,
)
}
@@ -0,0 +1,21 @@
package pw.binom.mirror.api.config
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import pw.binom.mirror.api.serialization.UUIDSerializer
import java.util.UUID
@Configuration
class JsonConfig {
@Bean
fun json(): Json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
serializersModule = SerializersModule {
contextual(UUID::class, UUIDSerializer)
}
}
}
@@ -0,0 +1,91 @@
package pw.binom.mirror.api.controller
import jakarta.validation.Validator
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import pw.binom.mirror.api.dto.ByItemResponse
import pw.binom.mirror.api.dto.CreateMirrorRequest
import pw.binom.mirror.api.dto.CreateMirrorResponse
import pw.binom.mirror.api.dto.ErrorResponse
import pw.binom.mirror.api.dto.MirrorStatus
import pw.binom.mirror.api.service.MirrorService
import java.util.UUID
@RestController
@RequestMapping("/api/mirror")
class MirrorController(
private val service: MirrorService,
private val json: Json,
private val validator: Validator,
) {
private val logger = LoggerFactory.getLogger(MirrorController::class.java)
@PostMapping
fun create(@RequestBody(required = false) body: String?): ResponseEntity<String> {
val request = try {
json.decodeFromString<CreateMirrorRequest>(body ?: "")
} catch (e: SerializationException) {
return badRequest("Invalid JSON body: ${e.message}")
}
val violation = validator.validate(request).firstOrNull()
if (violation != null) {
return badRequest(violation.message ?: "Validation failed")
}
val result = service.create(request)
val status = if (result.created) HttpStatus.CREATED else HttpStatus.OK
return jsonBody(json.encodeToString(result.response), status)
}
@GetMapping("/{id}")
fun status(@PathVariable id: UUID): ResponseEntity<String> {
val status = service.status(id) ?: return ResponseEntity.notFound().build()
return jsonBody(json.encodeToString(status), HttpStatus.OK)
}
@GetMapping("/by-item/{itemId}")
fun byItem(@PathVariable itemId: String): ResponseEntity<String> {
val response = service.byItem(itemId) ?: return ResponseEntity.notFound().build()
return jsonBody(json.encodeToString(response), HttpStatus.OK)
}
@GetMapping
fun list(
@RequestParam(required = false) status: String?,
@RequestParam(required = false, defaultValue = "50") limit: Int,
): ResponseEntity<String> {
val jobs = service.list(status, limit)
val body = json.encodeToString(ListSerializer(MirrorStatus.serializer()), jobs)
return jsonBody(body, HttpStatus.OK)
}
@DeleteMapping("/{id}")
fun delete(@PathVariable id: UUID): ResponseEntity<Unit> =
if (service.delete(id)) {
ResponseEntity.noContent().build()
} else {
ResponseEntity.notFound().build()
}
private fun badRequest(message: String): ResponseEntity<String> {
logger.warn("Bad request: {}", message)
return jsonBody(json.encodeToString(ErrorResponse(message)), HttpStatus.BAD_REQUEST)
}
private fun jsonBody(body: String, status: HttpStatus): ResponseEntity<String> =
ResponseEntity.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(body)
}
@@ -0,0 +1,120 @@
package pw.binom.mirror.api.db
import org.springframework.dao.DuplicateKeyException
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.jdbc.core.RowMapper
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.Instant
import java.util.UUID
data class Job(
val id: UUID,
val itemId: String,
val sourceUrl: String,
val sourceType: String,
val status: String,
val progress: Int,
val videoKey: String?,
val audioKeys: String?,
val error: String?,
val createdAt: Instant,
val updatedAt: Instant,
val takenAt: Instant?,
)
@Repository
class JobRepository(private val jdbc: JdbcTemplate) {
fun insert(itemId: String, sourceUrl: String, sourceType: String): Job {
val sql = """
INSERT INTO media_mirror.jobs (item_id, source_url, source_type)
VALUES (?, ?, ?)
RETURNING id, item_id, source_url, source_type, status, progress,
video_key, audio_keys, error, created_at, updated_at, taken_at
""".trimIndent()
return jdbc.queryForObject(sql, JOB_MAPPER, itemId, sourceUrl, sourceType)
}
fun findById(id: UUID): Job? =
findOne(
"""
SELECT id, item_id, source_url, source_type, status, progress,
video_key, audio_keys, error, created_at, updated_at, taken_at
FROM media_mirror.jobs WHERE id = ?
""".trimIndent(),
id,
)
fun findByItemId(itemId: String): Job? =
findOne(
"""
SELECT id, item_id, source_url, source_type, status, progress,
video_key, audio_keys, error, created_at, updated_at, taken_at
FROM media_mirror.jobs WHERE item_id = ?
ORDER BY created_at DESC, id DESC LIMIT 1
""".trimIndent(),
itemId,
)
fun findActiveByItemId(itemId: String): Job? =
findOne(
"""
SELECT id, item_id, source_url, source_type, status, progress,
video_key, audio_keys, error, created_at, updated_at, taken_at
FROM media_mirror.jobs
WHERE item_id = ? AND status NOT IN ('failed', 'cancelled')
""".trimIndent(),
itemId,
)
fun list(status: String?, limit: Int): List<Job> {
val base = """
SELECT id, item_id, source_url, source_type, status, progress,
video_key, audio_keys, error, created_at, updated_at, taken_at
FROM media_mirror.jobs
""".trimIndent()
val sql = if (status == null) {
"$base ORDER BY created_at LIMIT ?"
} else {
"$base WHERE status = ? ORDER BY created_at LIMIT ?"
}
return if (status == null) {
jdbc.query(sql, JOB_MAPPER, limit)
} else {
jdbc.query(sql, JOB_MAPPER, status, limit)
}
}
fun updateStatus(id: UUID, status: String): Int =
jdbc.update(
"UPDATE media_mirror.jobs SET status = ?, updated_at = now() WHERE id = ?",
status,
id,
)
fun delete(id: UUID): Int =
jdbc.update("DELETE FROM media_mirror.jobs WHERE id = ?", id)
private fun findOne(sql: String, vararg args: Any): Job? =
jdbc.query(sql, JOB_MAPPER, *args).firstOrNull()
companion object {
private val JOB_MAPPER = RowMapper { rs: ResultSet, _: Int -> rs.toJob() }
}
}
private fun ResultSet.toJob(): Job = Job(
id = getObject("id", UUID::class.java),
itemId = getString("item_id"),
sourceUrl = getString("source_url"),
sourceType = getString("source_type"),
status = getString("status"),
progress = getInt("progress"),
videoKey = getString("video_key"),
audioKeys = getString("audio_keys"),
error = getString("error"),
createdAt = getTimestamp("created_at").toInstant(),
updatedAt = getTimestamp("updated_at").toInstant(),
takenAt = getTimestamp("taken_at")?.toInstant(),
)
@@ -0,0 +1,13 @@
package pw.binom.mirror.api.dto
import jakarta.validation.constraints.NotBlank
import kotlinx.serialization.Serializable
@Serializable
data class CreateMirrorRequest(
@field:NotBlank(message = "itemId must not be blank")
val itemId: String,
@field:NotBlank(message = "sourceUrl must not be blank")
val sourceUrl: String,
val sourceType: String = "jellyfin",
)
@@ -0,0 +1,8 @@
package pw.binom.mirror.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class ErrorResponse(
val error: String,
)
@@ -0,0 +1,39 @@
package pw.binom.mirror.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class ByItemResponse(
val itemId: String,
val status: String,
val files: MirrorFiles? = null,
)
@Serializable
data class MirrorFiles(
val video: VideoFile,
val audios: List<AudioFile> = emptyList(),
)
@Serializable
data class VideoFile(
val key: String,
val url: String,
)
@Serializable
data class AudioFile(
val index: Int,
val key: String,
val title: String,
val language: String,
val url: String,
)
@Serializable
data class AudioKey(
val key: String,
val title: String,
val language: String,
val index: Int,
)
@@ -0,0 +1,21 @@
package pw.binom.mirror.api.dto
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import java.util.UUID
@Serializable
data class CreateMirrorResponse(
@Contextual
val id: UUID,
val status: String,
)
@Serializable
data class MirrorStatus(
@Contextual
val id: UUID,
val itemId: String,
val status: String,
val progress: Int,
)
@@ -0,0 +1,64 @@
package pw.binom.mirror.api.s3
import org.springframework.stereotype.Component
import pw.binom.mirror.api.config.AppProperties
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.model.DeleteObjectRequest
import software.amazon.awssdk.services.s3.model.HeadObjectRequest
import software.amazon.awssdk.services.s3.model.NoSuchKeyException
import software.amazon.awssdk.services.s3.model.PutObjectRequest
import java.net.URI
@Component
class S3Storage(appProperties: AppProperties) {
private val s3 = appProperties.s3
private val client: S3Client = S3Client.builder()
.endpointOverride(URI.create(s3.url))
.region(Region.of(s3.region))
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(s3.accessKey, s3.secretKey),
),
)
.forcePathStyle(true)
.build()
fun upload(key: String, content: ByteArray): String {
client.putObject(
PutObjectRequest.builder()
.bucket(s3.bucket)
.key(key)
.build(),
software.amazon.awssdk.core.sync.RequestBody.fromBytes(content),
)
return key
}
fun deleteObject(key: String) {
client.deleteObject(
DeleteObjectRequest.builder()
.bucket(s3.bucket)
.key(key)
.build(),
)
}
fun checkExists(key: String): Boolean = try {
client.headObject(
HeadObjectRequest.builder()
.bucket(s3.bucket)
.key(key)
.build(),
)
true
} catch (e: NoSuchKeyException) {
false
}
fun publicUrl(key: String): String = "${s3.url}/${s3.bucket}/$key"
}
@@ -0,0 +1,19 @@
package pw.binom.mirror.api.serialization
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 java.util.UUID
object UUIDSerializer : KSerializer<UUID> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
}
@@ -0,0 +1,120 @@
package pw.binom.mirror.api.service
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
import pw.binom.mirror.api.db.Job
import pw.binom.mirror.api.db.JobRepository
import pw.binom.mirror.api.dto.AudioFile
import pw.binom.mirror.api.dto.AudioKey
import pw.binom.mirror.api.dto.ByItemResponse
import pw.binom.mirror.api.dto.CreateMirrorRequest
import pw.binom.mirror.api.dto.CreateMirrorResponse
import pw.binom.mirror.api.dto.MirrorFiles
import pw.binom.mirror.api.dto.MirrorStatus
import pw.binom.mirror.api.dto.VideoFile
import pw.binom.mirror.api.s3.S3Storage
import java.util.UUID
data class CreateResult(
val response: CreateMirrorResponse,
val created: Boolean,
)
@Service
class MirrorService(
private val repository: JobRepository,
private val s3: S3Storage,
private val json: Json,
) {
private val logger = LoggerFactory.getLogger(MirrorService::class.java)
fun create(request: CreateMirrorRequest): CreateResult {
repository.findActiveByItemId(request.itemId)?.let { existing ->
return CreateResult(
response = CreateMirrorResponse(id = existing.id, status = existing.status),
created = false,
)
}
val job = try {
repository.insert(request.itemId, request.sourceUrl, request.sourceType)
} catch (e: DuplicateKeyException) {
logger.warn("Concurrent create for itemId={}, returning existing job", request.itemId)
repository.findActiveByItemId(request.itemId)
} ?: error("Failed to create job for itemId=${request.itemId}")
return CreateResult(
response = CreateMirrorResponse(id = job.id, status = job.status),
created = true,
)
}
fun status(id: UUID): MirrorStatus? = repository.findById(id)?.toStatus()
fun byItem(itemId: String): ByItemResponse? {
val job = repository.findByItemId(itemId) ?: return null
val files = if (job.status == "done" && job.videoKey != null) {
MirrorFiles(
video = VideoFile(key = job.videoKey, url = s3.publicUrl(job.videoKey)),
audios = parseAudios(job.audioKeys),
)
} else {
null
}
return ByItemResponse(itemId = job.itemId, status = job.status, files = files)
}
fun list(status: String?, limit: Int): List<MirrorStatus> =
repository.list(status, limit.coerceIn(1, 500)).map { it.toStatus() }
fun delete(id: UUID): Boolean {
val job = repository.findById(id) ?: return false
return when (job.status) {
"new", "processing" -> repository.updateStatus(id, "cancelled") > 0
"done" -> {
deleteFilesFromS3(job)
repository.delete(id) > 0
}
else -> false
}
}
private fun deleteFilesFromS3(job: Job) {
val keys = buildList {
job.videoKey?.let { add(it) }
parseAudioKeys(job.audioKeys).forEach { add(it.key) }
}
keys.filter { s3.checkExists(it) }.forEach { key ->
runCatching { s3.deleteObject(key) }
.onFailure { logger.warn("Failed to delete S3 object $key: ${it.message}") }
}
}
private fun parseAudios(raw: String?): List<AudioFile> =
parseAudioKeys(raw).map {
AudioFile(
index = it.index,
key = it.key,
title = it.title,
language = it.language,
url = s3.publicUrl(it.key),
)
}
private fun parseAudioKeys(raw: String?): List<AudioKey> {
if (raw.isNullOrBlank()) return emptyList()
return try {
json.decodeFromString<List<AudioKey>>(raw)
} catch (e: Exception) {
logger.warn("Failed to parse audio_keys for job: {}", e.message)
emptyList()
}
}
}
private fun Job.toStatus(): MirrorStatus = MirrorStatus(
id = id,
itemId = itemId,
status = status,
progress = progress,
)
+21
View File
@@ -0,0 +1,21 @@
app:
jellyfin:
url: https://jellyfin.binom.pw/
s3:
url: https://s3.binom.pw
accessKey: ${S3_ACCESS_KEY}
secretKey: ${S3_SECRET_KEY}
bucket: media
region: us-east-1
prefix: mirror
spring:
datasource:
url: jdbc:postgresql://192.168.76.106:5432/glasses
username: postgres
password: postgres
flyway:
schemas: media_mirror
server:
port: 8080
@@ -0,0 +1,19 @@
CREATE SCHEMA IF NOT EXISTS media_mirror;
CREATE TABLE media_mirror.jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
item_id text NOT NULL, -- itemId из Jellyfin
source_url text NOT NULL, -- URL исходника (jellyfin stream)
source_type text NOT NULL DEFAULT 'jellyfin',
status text NOT NULL DEFAULT 'new', -- new | processing | done | failed | cancelled
progress int NOT NULL DEFAULT 0, -- 0..100
video_key text, -- S3-ключ видео (после done)
audio_keys jsonb, -- [{key, title, language, index}]
error text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
taken_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON media_mirror.jobs (status, created_at);
CREATE UNIQUE INDEX IF NOT EXISTS uq_jobs_active_item
ON media_mirror.jobs (item_id)
WHERE status NOT IN ('failed', 'cancelled');
@@ -0,0 +1,76 @@
package pw.binom.mirror.api
import org.junit.jupiter.api.BeforeEach
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.test.context.DynamicPropertyRegistry
import org.springframework.test.context.DynamicPropertySource
import org.testcontainers.containers.MinIOContainer
import org.testcontainers.containers.PostgreSQLContainer
import pw.binom.mirror.api.s3.S3Storage
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.model.S3Exception
import java.net.URI
@SpringBootTest
@AutoConfigureMockMvc
abstract class AbstractIntegrationTest {
@Autowired
lateinit var s3Storage: S3Storage
@Autowired
lateinit var jdbcTemplate: JdbcTemplate
private var bucketReady = false
@BeforeEach
fun cleanState() {
ensureBucket()
jdbcTemplate.update("DELETE FROM media_mirror.jobs")
}
private fun ensureBucket() {
if (bucketReady) return
S3Client.builder()
.endpointOverride(URI.create(minio.s3URL))
.region(Region.US_EAST_1)
.credentialsProvider(
StaticCredentialsProvider.create(
AwsBasicCredentials.create(minio.userName, minio.password),
),
)
.forcePathStyle(true)
.build()
.use { client ->
try {
client.createBucket { it.bucket("media") }
} catch (e: S3Exception) {
if (e.statusCode() != 409) throw e
}
}
bucketReady = true
}
companion object {
private val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine").apply { start() }
private val minio: MinIOContainer = MinIOContainer("minio/minio:latest").apply { start() }
@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.s3.url") { minio.s3URL }
registry.add("app.s3.accessKey") { minio.userName }
registry.add("app.s3.secretKey") { minio.password }
registry.add("app.s3.bucket") { "media" }
}
}
}
@@ -0,0 +1,257 @@
package pw.binom.mirror.api.controller
import kotlinx.serialization.json.Json
import kotlinx.serialization.modules.SerializersModule
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.MvcResult
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import pw.binom.mirror.api.AbstractIntegrationTest
import pw.binom.mirror.api.dto.AudioFile
import pw.binom.mirror.api.dto.ByItemResponse
import pw.binom.mirror.api.dto.CreateMirrorResponse
import pw.binom.mirror.api.dto.MirrorFiles
import pw.binom.mirror.api.dto.MirrorStatus
import pw.binom.mirror.api.dto.VideoFile
import pw.binom.mirror.api.db.JobRepository
import pw.binom.mirror.api.s3.S3Storage
import pw.binom.mirror.api.serialization.UUIDSerializer
import java.nio.charset.StandardCharsets
import java.util.UUID
class MirrorControllerTest : AbstractIntegrationTest() {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var repository: JobRepository
@Autowired
lateinit var s3: S3Storage
private val json = Json {
ignoreUnknownKeys = true
serializersModule = SerializersModule {
contextual(UUID::class, UUIDSerializer)
}
}
@Test
fun `POST mirror creates a job and returns 201`() {
val result = postCreate("create-id", "https://jellyfin.binom.pw/Videos/1/stream")
.andExpect(status().isCreated)
.andReturn()
val response = json.decodeFromString<CreateMirrorResponse>(bodyOf(result))
assertEquals("new", response.status)
assertTrue(response.id != UUID(0L, 0L))
val status = json.decodeFromString<MirrorStatus>(
bodyOf(mockMvc.perform(get("/api/mirror/${response.id}")).andExpect(status().isOk).andReturn()),
)
assertEquals("create-id", status.itemId)
assertEquals("new", status.status)
assertEquals(0, status.progress)
}
@Test
fun `POST mirror returns 200 with same task on duplicate itemId`() {
val first = postCreate("dup-id").andExpect(status().isCreated).andReturn()
val firstResponse = json.decodeFromString<CreateMirrorResponse>(bodyOf(first))
val second = postCreate("dup-id").andExpect(status().isOk).andReturn()
val secondResponse = json.decodeFromString<CreateMirrorResponse>(bodyOf(second))
assertEquals(firstResponse.id, secondResponse.id)
assertEquals("new", secondResponse.status)
val count = jdbcTemplate.queryForObject(
"SELECT count(*) FROM media_mirror.jobs WHERE item_id = 'dup-id'",
Int::class.java,
)
assertEquals(1, count)
}
@Test
fun `POST mirror returns 400 on blank itemId`() {
postCreate("", "https://source.example/stream")
.andExpect(status().isBadRequest)
}
@Test
fun `POST mirror returns 400 on invalid json body`() {
mockMvc.perform(
post("/api/mirror").contentType(MediaType.APPLICATION_JSON).content("""{"itemId": """),
).andExpect(status().isBadRequest)
}
@Test
fun `POST mirror returns 400 on missing body`() {
mockMvc.perform(
post("/api/mirror").contentType(MediaType.APPLICATION_JSON),
).andExpect(status().isBadRequest)
}
@Test
fun `POST mirror does not duplicate a processing job`() {
val created = json.decodeFromString<CreateMirrorResponse>(
bodyOf(postCreate("busy-id").andExpect(status().isCreated).andReturn()),
)
jdbcTemplate.update(
"UPDATE media_mirror.jobs SET status = 'processing', progress = 42 WHERE id = ?",
created.id,
)
val duplicate = json.decodeFromString<CreateMirrorResponse>(
bodyOf(postCreate("busy-id").andExpect(status().isOk).andReturn()),
)
assertEquals(created.id, duplicate.id)
assertEquals("processing", duplicate.status)
}
@Test
fun `GET by-item returns files when done`() {
val itemId = "done-item"
val job = repository.insert(itemId, "https://source.example/stream", "jellyfin")
markDone(job.id, itemId)
val response = json.decodeFromString<ByItemResponse>(
bodyOf(mockMvc.perform(get("/api/mirror/by-item/$itemId")).andExpect(status().isOk).andReturn()),
)
assertEquals(itemId, response.itemId)
assertEquals("done", response.status)
val files = requireNotNull(response.files)
assertEquals(
VideoFile("mirror/$itemId/video.mkv", s3.publicUrl("mirror/$itemId/video.mkv")),
files.video,
)
assertEquals(
listOf(
AudioFile(0, "mirror/$itemId/audio-0.ogg", "original", "jpn", s3.publicUrl("mirror/$itemId/audio-0.ogg")),
AudioFile(1, "mirror/$itemId/audio-1.ogg", "dub", "rus", s3.publicUrl("mirror/$itemId/audio-1.ogg")),
),
files.audios,
)
}
@Test
fun `GET by-item returns files null when not done`() {
val itemId = "new-item"
repository.insert(itemId, "https://source.example/stream", "jellyfin")
val response = json.decodeFromString<ByItemResponse>(
bodyOf(mockMvc.perform(get("/api/mirror/by-item/$itemId")).andExpect(status().isOk).andReturn()),
)
assertEquals(itemId, response.itemId)
assertEquals("new", response.status)
assertNull(response.files)
}
@Test
fun `GET by-item returns 404 when missing`() {
mockMvc.perform(get("/api/mirror/by-item/unknown-item")).andExpect(status().isNotFound)
}
@Test
fun `GET status returns 404 when missing`() {
mockMvc.perform(get("/api/mirror/${UUID.randomUUID()}")).andExpect(status().isNotFound)
}
@Test
fun `GET list filters by status and applies limit`() {
val newItem = repository.insert("list-new-1", "https://s/1", "jellyfin")
val doneItem = repository.insert("list-done-1", "https://s/2", "jellyfin")
markDone(doneItem.id, "list-done-1")
val doneItem2 = repository.insert("list-done-2", "https://s/3", "jellyfin")
markDone(doneItem2.id, "list-done-2")
repository.insert("list-new-2", "https://s/4", "jellyfin")
assertEquals("new", newItem.status)
val doneList = json.decodeFromString<List<MirrorStatus>>(
bodyOf(mockMvc.perform(get("/api/mirror").param("status", "done")).andExpect(status().isOk).andReturn()),
)
assertEquals(listOf("list-done-1", "list-done-2"), doneList.map { it.itemId })
assertTrue(doneList.all { it.status == "done" })
val limited = json.decodeFromString<List<MirrorStatus>>(
bodyOf(mockMvc.perform(get("/api/mirror").param("limit", "1")).andExpect(status().isOk).andReturn()),
)
assertEquals(1, limited.size)
}
@Test
fun `DELETE cancels a new job and is idempotent`() {
val job = repository.insert("delete-new", "https://s/1", "jellyfin")
mockMvc.perform(delete("/api/mirror/${job.id}")).andExpect(status().isNoContent)
mockMvc.perform(delete("/api/mirror/${job.id}")).andExpect(status().isNotFound)
val status = json.decodeFromString<MirrorStatus>(
bodyOf(mockMvc.perform(get("/api/mirror/${job.id}")).andExpect(status().isOk).andReturn()),
)
assertEquals("cancelled", status.status)
}
@Test
fun `DELETE removes done job and its s3 files`() {
val itemId = "delete-done"
val job = repository.insert(itemId, "https://s/1", "jellyfin")
markDone(job.id, itemId)
assertTrue(s3.checkExists("mirror/$itemId/video.mkv"))
mockMvc.perform(delete("/api/mirror/${job.id}")).andExpect(status().isNoContent)
assertFalse(s3.checkExists("mirror/$itemId/video.mkv"))
assertFalse(s3.checkExists("mirror/$itemId/audio-0.ogg"))
mockMvc.perform(get("/api/mirror/${job.id}")).andExpect(status().isNotFound)
mockMvc.perform(delete("/api/mirror/${job.id}")).andExpect(status().isNotFound)
}
@Test
fun `DELETE returns 404 for missing job`() {
mockMvc.perform(delete("/api/mirror/${UUID.randomUUID()}")).andExpect(status().isNotFound)
}
private fun markDone(id: UUID, itemId: String) {
jdbcTemplate.update(
"""
UPDATE media_mirror.jobs
SET status = 'done', progress = 100, video_key = ?, audio_keys = ?::jsonb
WHERE id = ?
""".trimIndent(),
"mirror/$itemId/video.mkv",
"""[
{"key": "mirror/$itemId/audio-0.ogg", "title": "original", "language": "jpn", "index": 0},
{"key": "mirror/$itemId/audio-1.ogg", "title": "dub", "language": "rus", "index": 1}
]""",
id,
)
s3.upload("mirror/$itemId/video.mkv", byteArrayOf(1, 2, 3))
s3.upload("mirror/$itemId/audio-0.ogg", byteArrayOf(4, 5))
s3.upload("mirror/$itemId/audio-1.ogg", byteArrayOf(6, 7))
}
private fun postCreate(
itemId: String,
sourceUrl: String = "https://jellyfin.binom.pw/Videos/1/stream",
): org.springframework.test.web.servlet.ResultActions =
mockMvc.perform(
post("/api/mirror")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"itemId":"$itemId","sourceUrl":"$sourceUrl","sourceType":"jellyfin"}"""),
)
private fun bodyOf(result: MvcResult): String =
result.response.getContentAsString(StandardCharsets.UTF_8)
}
@@ -0,0 +1,107 @@
package pw.binom.mirror.api.db
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.dao.DuplicateKeyException
import pw.binom.mirror.api.AbstractIntegrationTest
class JobRepositoryTest : AbstractIntegrationTest() {
@Autowired
lateinit var repository: JobRepository
@Test
fun `insert then findById returns the same job`() {
val inserted = repository.insert("crud-id", "https://s/1", "jellyfin")
val found = repository.findById(inserted.id)
assertEquals(inserted, found)
assertEquals("crud-id", found?.itemId)
assertEquals("new", found?.status)
assertEquals(0, found?.progress)
assertNull(found?.videoKey)
assertNull(found?.audioKeys)
}
@Test
fun `updateStatus changes status and updated_at`() {
val inserted = repository.insert("upd-id", "https://s/1", "jellyfin")
assertEquals(1, repository.updateStatus(inserted.id, "processing"))
assertEquals("processing", repository.findById(inserted.id)?.status)
assertEquals(1, repository.updateStatus(inserted.id, "cancelled"))
assertEquals("cancelled", repository.findById(inserted.id)?.status)
}
@Test
fun `delete removes the row`() {
val inserted = repository.insert("del-id", "https://s/1", "jellyfin")
assertEquals(1, repository.delete(inserted.id))
assertNull(repository.findById(inserted.id))
assertEquals(0, repository.delete(inserted.id))
}
@Test
fun `list filters by status and respects limit`() {
repository.insert("l-new-1", "https://s/1", "jellyfin")
repository.insert("l-new-2", "https://s/2", "jellyfin")
val done = repository.insert("l-done-1", "https://s/3", "jellyfin")
jdbcTemplate.update("UPDATE media_mirror.jobs SET status = 'done' WHERE id = ?", done.id)
val all = repository.list(null, 100)
assertEquals(3, all.size)
val doneOnly = repository.list("done", 100)
assertEquals(listOf("l-done-1"), doneOnly.map { it.itemId })
val limited = repository.list(null, 2)
assertEquals(2, limited.size)
}
@Test
fun `duplicate active itemId violates partial unique index`() {
repository.insert("uniq-id", "https://s/1", "jellyfin")
assertThrows(DuplicateKeyException::class.java) {
repository.insert("uniq-id", "https://s/2", "jellyfin")
}
}
@Test
fun `failed job allows creating a new one for the same itemId`() {
val first = repository.insert("reuse-id", "https://s/1", "jellyfin")
repository.updateStatus(first.id, "failed")
val second = repository.insert("reuse-id", "https://s/2", "jellyfin")
assertTrue(second.id != first.id)
assertEquals("new", second.status)
}
@Test
fun `findActiveByItemId only matches non-failed non-cancelled jobs`() {
val active = repository.insert("active-id", "https://s/1", "jellyfin")
assertEquals(active.id, repository.findActiveByItemId("active-id")?.id)
repository.updateStatus(active.id, "cancelled")
assertNull(repository.findActiveByItemId("active-id"))
repository.insert("active2-id", "https://s/2", "jellyfin")
val done = repository.insert("active3-id", "https://s/3", "jellyfin")
jdbcTemplate.update("UPDATE media_mirror.jobs SET status = 'done' WHERE id = ?", done.id)
assertEquals(done.id, repository.findActiveByItemId("active3-id")?.id)
}
@Test
fun `findByItemId returns the latest job for an item`() {
val first = repository.insert("byitem-id", "https://s/1", "jellyfin")
repository.updateStatus(first.id, "failed")
val second = repository.insert("byitem-id", "https://s/2", "jellyfin")
assertEquals(second.id, repository.findByItemId("byitem-id")?.id)
}
}