fix: устойчивое скачивание из Jellyfin — OkHttp + retry вместо HttpURLConnection
Build Media Mirror Worker / Build and publish (release) Successful in 1m25s

Прод-инцидент (3 джоба failed подряд): HttpURLConnection рвал скачивание
7.2 ГБ на ~5-й минуте ('Can't download video from Jellyfin'), при этом curl
с той же машины качал стабильно (HTTP/1.1 и HTTP/2). Сеть и Jellyfin чисты.

- JellyfinInput: OkHttpClient (readTimeout=0, followRedirects), byteStream→Files.copy,
  retry до 3 попыток с паузой 5с на IOException, warn-лог каждой попытки
- InputService: лог с полным stacktrace (реальная причина обрыва теперь видна)
This commit is contained in:
Hermes Agent
2026-08-19 06:00:18 +03:00
parent 68eefff884
commit 96a9a0536d
3 changed files with 42 additions and 16 deletions
+1
View File
@@ -50,6 +50,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation(platform("software.amazon.awssdk:bom:2.46.21")) implementation(platform("software.amazon.awssdk:bom:2.46.21"))
implementation("software.amazon.awssdk:s3") implementation("software.amazon.awssdk:s3")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation(kotlin("test-junit5")) testImplementation(kotlin("test-junit5"))
@@ -28,6 +28,7 @@ class InputService(
logger.info("Download cancelled: {}", source) logger.info("Download cancelled: {}", source)
throw e throw e
} catch (e: Throwable) { } catch (e: Throwable) {
logger.warn("Download failed: {}", e.message, e)
throw IllegalStateException("Can't download video from $source", e) throw IllegalStateException("Can't download video from $source", e)
} }
logger.info("Downloaded success in {} ms", (System.nanoTime() - startTime) / 1_000_000) logger.info("Downloaded success in {} ms", (System.nanoTime() - startTime) / 1_000_000)
@@ -1,15 +1,18 @@
package pw.binom.mirror.worker.convert package pw.binom.mirror.worker.convert
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.net.HttpURLConnection import java.io.IOException
import java.net.URL
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.nio.file.StandardCopyOption import java.nio.file.StandardCopyOption
import java.util.concurrent.TimeUnit
@Component @Component
class JellyfinInput( class JellyfinInput(
@@ -17,24 +20,46 @@ class JellyfinInput(
) : InputService.InputImplementation { ) : InputService.InputImplementation {
private val logger: Logger = LoggerFactory.getLogger(JellyfinInput::class.java) private val logger: Logger = LoggerFactory.getLogger(JellyfinInput::class.java)
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
}
override fun isSupport(source: InputSource): Boolean = source is InputSource.Jellyfin override fun isSupport(source: InputSource): Boolean = source is InputSource.Jellyfin
override suspend fun download(source: InputSource): Path = withContext(Dispatchers.IO) { override suspend fun download(source: InputSource): Path = withContext(Dispatchers.IO) {
source as InputSource.Jellyfin source as InputSource.Jellyfin
val url = URL(source.streamUrl) var lastError: IOException? = null
val connection = url.openConnection() as HttpURLConnection for (attempt in 1..3) {
connection.requestMethod = "GET" try {
connection.instanceFollowRedirects = true return@withContext downloadOnce(source)
connection.connectTimeout = 30_000 } catch (e: IOException) {
connection.readTimeout = 120_000 lastError = e
try { logger.warn("Download attempt {}/3 failed: {}", attempt, e.message)
if (connection.responseCode != HttpURLConnection.HTTP_OK) { if (attempt < 3) {
throw IllegalStateException("Invalid response code ${connection.responseCode}") delay(5_000)
}
}
}
throw lastError!!
}
private fun downloadOnce(source: InputSource.Jellyfin): Path {
val request = Request.Builder().url(source.streamUrl).get().build()
val response = client.newCall(request).execute()
response.use {
if (!response.isSuccessful) {
throw IllegalStateException("Invalid response code ${response.code}")
} }
val resultFile = localStorageService.genFile() val resultFile = localStorageService.genFile()
logger.info("Downloading file in to ${resultFile}...") logger.info("Downloading file in to {}...", resultFile)
try { val path = try {
connection.inputStream.use { input -> val body = response.body ?: throw IllegalStateException("Empty response body")
body.byteStream().use { input ->
Files.copy(input, resultFile, StandardCopyOption.REPLACE_EXISTING) Files.copy(input, resultFile, StandardCopyOption.REPLACE_EXISTING)
} }
logger.info("File success downloaded!") logger.info("File success downloaded!")
@@ -43,8 +68,7 @@ class JellyfinInput(
runCatching { Files.deleteIfExists(resultFile) } runCatching { Files.deleteIfExists(resultFile) }
throw e throw e
} }
} finally { return path
connection.disconnect()
} }
} }
} }