feat: HTTP-клиенты Jellyfin + media-mirror в lib-core (KMP, Ktor)

media/JellyfinClient: users, rootItems, items, search, allItems, posterUrl (X-Emby-Token)
media/MirrorClient: createMirror, mirrorByItem (404→null), list, delete, scan (X-API-Key)
media/HttpClient: defaultHttpClient (ContentNegotiation+json, таймауты, ApiClientException)
Ktor 3.3.0 (совместим с Kotlin 2.2; 3.5.2 требует Kotlin 2.3 — не берём)

17 тестов (MockEngine): пути, методы, заголовки, JSON, 201/404, round-trip DTO.
Проверено на живых серверах: Jellyfin (6 users, корень, поиск 'дюна'), mirror (by-item done + url, список done)
This commit is contained in:
Hermes Agent
2026-08-11 12:59:44 +03:00
parent 9271517b3c
commit d95d289d29
9 changed files with 750 additions and 0 deletions
@@ -0,0 +1,66 @@
package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
/**
* Общий HTTP-клиент для media-бэкендов (Jellyfin, media-mirror):
* JSON-сериализация kotlinx, таймауты (connect 5s, request 30s), логирование в debug.
*/
fun defaultHttpClient(): HttpClient = HttpClient {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
}
)
}
install(HttpTimeout) {
connectTimeoutMillis = 5_000
requestTimeoutMillis = 30_000
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
if (HttpClientDebugLogging.enabled) println("[view-mate] $message")
}
}
level = LogLevel.INFO
}
}
/** Флаг debug-логирования HTTP (по умолчанию включён). */
object HttpClientDebugLogging {
var enabled: Boolean = true
}
/** Ошибка HTTP-запроса с понятным текстом. */
class ApiClientException(
message: String,
val status: Int? = null,
val body: String? = null,
cause: Throwable? = null,
) : Exception(message, cause)
/** Проверка успешности ответа; 2xx — ок, остальное — исключение с телом ответа. */
internal suspend fun HttpResponse.ensureSuccess(context: String) {
if (status.isSuccess()) return
val bodyText = runCatching { bodyAsText() }.getOrNull()
val detail = if (bodyText.isNullOrBlank()) "" else "\n$bodyText"
throw ApiClientException(
message = "$context failed: HTTP ${status.value} ${status.description}$detail",
status = status.value,
body = bodyText,
)
}
@@ -0,0 +1,73 @@
package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
/**
* HTTP-клиент Jellyfin (каталог).
* Авторизация: X-Emby-Token заголовок; постеры — api_key query-параметр.
*/
class JellyfinClient(
private val baseUrl: String, // https://jellyfin.binom.pw
private val apiKey: String, // api_key / X-Emby-Token
private val http: HttpClient = defaultHttpClient(),
) {
/** Список пользователей; брать первого. */
suspend fun users(): List<JellyfinUser> {
val response = http.get("$baseUrl/Users") {
header("X-Emby-Token", apiKey)
}
response.ensureSuccess("jellyfin.users")
return response.body()
}
/** Корень каталога (папки/библиотеки) пользователя. */
suspend fun rootItems(userId: String): List<JellyfinItem> = requestItems(
userId = userId,
recursive = false,
)
/** Содержимое папки. */
suspend fun items(userId: String, parentId: String): List<JellyfinItem> = requestItems(
userId = userId,
parentId = parentId,
recursive = false,
)
/** Поиск по всей библиотеке. */
suspend fun search(userId: String, query: String): List<JellyfinItem> = requestItems(
userId = userId,
searchTerm = query,
recursive = true,
)
/** Вся библиотека (для скана/поиска). */
suspend fun allItems(userId: String, types: List<String> = listOf("Movie", "Episode")): List<JellyfinItem> =
requestItems(userId = userId, recursive = true, types = types)
/** URL постера (просто строит, не качает). */
fun posterUrl(itemId: String, maxWidth: Int = 512): String =
"$baseUrl/Items/$itemId/Images/Primary?maxWidth=$maxWidth&quality=90&api_key=$apiKey"
private suspend fun requestItems(
userId: String,
parentId: String? = null,
searchTerm: String? = null,
recursive: Boolean = false,
types: List<String> = listOf("Movie", "Series", "Episode"),
): List<JellyfinItem> {
val response = http.get("$baseUrl/Users/$userId/Items") {
header("X-Emby-Token", apiKey)
if (parentId != null) parameter("ParentId", parentId)
if (searchTerm != null) parameter("SearchTerm", searchTerm)
parameter("Recursive", recursive.toString())
parameter("IncludeItemTypes", types.joinToString(","))
parameter("Fields", "PrimaryImageAspectRatio,Overview")
}
response.ensureSuccess("jellyfin.items")
return response.body<JellyfinItemsResponse>().Items
}
}
@@ -0,0 +1,28 @@
package pw.binom.viewmate.core.media
import kotlinx.serialization.Serializable
@Serializable
data class JellyfinUser(
val Id: String,
val Name: String? = null,
)
@Serializable
data class JellyfinItemsResponse(
val Items: List<JellyfinItem> = emptyList(),
)
@Serializable
data class JellyfinItem(
val Id: String,
val Name: String? = null,
val Type: String? = null, // Movie/Series/Episode/Folder/CollectionFolder
val Overview: String? = null,
val ImageTags: Map<String, String>? = null, // Primary есть — есть постер
val SeriesName: String? = null, // для эпизодов
val SeasonId: String? = null,
val IndexNumber: Int? = null, // номер эпизода
val ParentIndexNumber: Int? = null, // номер сезона
val RunTimeTicks: Long? = null, // длительность (10_000_000 тиков = 1 сек)
)
@@ -0,0 +1,72 @@
package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
/**
* HTTP-клиент media-mirror: статусы зеркал, заказ конвертации, файлы с озвучками.
* Авторизация: X-API-Key.
*/
class MirrorClient(
private val baseUrl: String, // https://mirror.binom.pw
private val apiKey: String, // X-API-Key
private val http: HttpClient = defaultHttpClient(),
) {
/** Заказ конвертации элемента. 201 — создан, 200 — уже есть (дубль). */
suspend fun createMirror(itemId: String): MirrorJob {
val response = http.post("$baseUrl/api/mirror") {
header("X-API-Key", apiKey)
contentType(ContentType.Application.Json)
setBody(CreateMirrorRequest(itemId = itemId))
}
response.ensureSuccess("mirror.create")
return response.body()
}
/** Статус по itemId; null если ещё не создано (404). */
suspend fun mirrorByItem(itemId: String): MirrorStatus? {
val response = http.get("$baseUrl/api/mirror/by-item/$itemId") {
header("X-API-Key", apiKey)
}
if (response.status == HttpStatusCode.NotFound) return null
response.ensureSuccess("mirror.byItem")
return response.body()
}
/** Список зеркал: статус (null — все) + лимит. */
suspend fun list(status: String? = null, limit: Int = 50): List<MirrorJob> {
val response = http.get("$baseUrl/api/mirror") {
header("X-API-Key", apiKey)
parameter("limit", limit)
if (status != null) parameter("status", status)
}
response.ensureSuccess("mirror.list")
return response.body()
}
/** Удаление зеркала (204). */
suspend fun delete(id: String) {
val response = http.delete("$baseUrl/api/mirror/$id") {
header("X-API-Key", apiKey)
}
response.ensureSuccess("mirror.delete")
}
/** Догонялка: скан каталога. */
suspend fun scan(): ScanResult {
val response = http.post("$baseUrl/api/mirror/scan") {
header("X-API-Key", apiKey)
}
response.ensureSuccess("mirror.scan")
return response.body()
}
}
@@ -0,0 +1,53 @@
package pw.binom.viewmate.core.media
import kotlinx.serialization.Serializable
@Serializable
data class MirrorJob(
val id: String,
val itemId: String? = null,
val status: String? = null,
val progress: Int? = null,
)
@Serializable
data class MirrorVideo(
val key: String,
val url: String,
)
@Serializable
data class MirrorAudio(
val index: Int,
val key: String,
val title: String? = null,
val language: String? = null,
val url: String,
)
@Serializable
data class MirrorFiles(
val video: MirrorVideo,
val audios: List<MirrorAudio> = emptyList(),
)
@Serializable
data class MirrorStatus(
val itemId: String,
val status: String,
val files: MirrorFiles? = null,
)
@Serializable
data class ScanResult(
val found: Int,
val created: Int,
val skipped: Int,
)
@Serializable
data class CreateMirrorRequest(
val itemId: String,
val sourceUrl: String = "",
val sourceType: String = "jellyfin",
)
@@ -0,0 +1,224 @@
package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class JellyfinClientTest {
private val json = Json { ignoreUnknownKeys = true }
private fun client(
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
): HttpClient = HttpClient(MockEngine { request -> handler(request) }) {
install(ContentNegotiation) { json(json) }
}
private fun MockRequestHandleScope.jsonResponse(body: String) = respond(
content = body,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
private val apiKey = "tok-123"
@Test
fun usersParsesListAndSendsToken() = runTest {
var capturedPath: String? = null
var capturedToken: String? = null
val jf = JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = apiKey,
http = client { request ->
capturedPath = request.url.encodedPath
capturedToken = request.headers["X-Emby-Token"]
jsonResponse("""[{"Id":"u1","Name":"Admin"},{"Id":"u2"}]""")
},
)
val users = jf.users()
assertEquals("/Users", capturedPath)
assertEquals(apiKey, capturedToken)
assertEquals(2, users.size)
assertEquals("u1", users[0].Id)
assertEquals("Admin", users[0].Name)
assertNull(users[1].Name)
}
@Test
fun rootItemsParsesFolders() = runTest {
var capturedPath: String? = null
var capturedRecursive: String? = null
var capturedTypes: String? = null
val jf = JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = apiKey,
http = client { request ->
capturedPath = request.url.encodedPath
capturedRecursive = request.url.parameters["Recursive"]
capturedTypes = request.url.parameters["IncludeItemTypes"]
jsonResponse(
"""{"Items":[{"Id":"f1","Name":"Фильмы","Type":"CollectionFolder"},""" +
"""{"Id":"f2","Name":"Сериалы","Type":"CollectionFolder"}]}"""
)
},
)
val items = jf.rootItems(userId = "u1")
assertEquals("/Users/u1/Items", capturedPath)
assertEquals("false", capturedRecursive)
assertEquals("Movie,Series,Episode", capturedTypes)
assertEquals(2, items.size)
assertEquals("f1", items[0].Id)
assertEquals("Фильмы", items[0].Name)
assertEquals("CollectionFolder", items[0].Type)
}
@Test
fun itemsSendsParentId() = runTest {
var capturedParentId: String? = null
val jf = JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = apiKey,
http = client { request ->
capturedParentId = request.url.parameters["ParentId"]
jsonResponse("""{"Items":[{"Id":"m1","Name":"Interstellar","Type":"Movie"}]}""")
},
)
val items = jf.items(userId = "u1", parentId = "f1")
assertEquals("f1", capturedParentId)
assertEquals(1, items.size)
assertEquals("m1", items[0].Id)
assertEquals("Movie", items[0].Type)
}
@Test
fun searchSendsSearchTerm() = runTest {
var capturedSearchTerm: String? = null
var capturedRecursive: String? = null
val jf = JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = apiKey,
http = client { request ->
capturedSearchTerm = request.url.parameters["SearchTerm"]
capturedRecursive = request.url.parameters["Recursive"]
jsonResponse("""{"Items":[{"Id":"s1","Name":"Солярис","Type":"Movie"}]}""")
},
)
val items = jf.search(userId = "u1", query = "солярис")
assertEquals("солярис", capturedSearchTerm)
assertEquals("true", capturedRecursive)
assertEquals(1, items.size)
assertEquals("Солярис", items[0].Name)
}
@Test
fun allItemsRecursiveAndDefaultTypes() = runTest {
var capturedRecursive: String? = null
var capturedTypes: String? = null
val jf = JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = apiKey,
http = client { request ->
capturedRecursive = request.url.parameters["Recursive"]
capturedTypes = request.url.parameters["IncludeItemTypes"]
jsonResponse(
"""{"Items":[{"Id":"m1","Name":"Interstellar","Type":"Movie","RunTimeTicks":148800000000},""" +
"""{"Id":"e1","Name":"Пилот","Type":"Episode","SeriesName":"Друзья","IndexNumber":1,"ParentIndexNumber":1}]}"""
)
},
)
val items = jf.allItems(userId = "u1")
assertEquals("true", capturedRecursive)
assertEquals("Movie,Episode", capturedTypes)
assertEquals(2, items.size)
assertEquals(148800000000, items[0].RunTimeTicks)
assertEquals("Друзья", items[1].SeriesName)
assertEquals(1, items[1].IndexNumber)
assertEquals(1, items[1].ParentIndexNumber)
}
@Test
fun allItemsCustomTypes() = runTest {
var capturedTypes: String? = null
val jf = JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = apiKey,
http = client { request ->
capturedTypes = request.url.parameters["IncludeItemTypes"]
jsonResponse("""{"Items":[]}""")
},
)
val items = jf.allItems(userId = "u1", types = listOf("Series"))
assertEquals("Series", capturedTypes)
assertEquals(0, items.size)
}
@Test
fun posterUrlBuildsUrlWithApiKey() {
val jf = JellyfinClient(
baseUrl = "https://jellyfin.binom.pw",
apiKey = apiKey,
http = client { jsonResponse("{}") },
)
assertEquals(
"https://jellyfin.binom.pw/Items/m1/Images/Primary?maxWidth=512&quality=90&api_key=tok-123",
jf.posterUrl(itemId = "m1"),
)
assertEquals(
"https://jellyfin.binom.pw/Items/m1/Images/Primary?maxWidth=256&quality=90&api_key=tok-123",
jf.posterUrl(itemId = "m1", maxWidth = 256),
)
}
@Test
fun itemRoundTrip() {
val item = JellyfinItem(
Id = "m1",
Name = "Interstellar",
Type = "Movie",
Overview = "Описание",
ImageTags = mapOf("Primary" to "hash"),
SeriesName = null,
SeasonId = null,
IndexNumber = null,
ParentIndexNumber = null,
RunTimeTicks = 123456L,
)
val text = json.encodeToString(JellyfinItem.serializer(), item)
val decoded = json.decodeFromString(JellyfinItem.serializer(), text)
assertEquals(item, decoded)
}
@Test
fun itemIgnoresUnknownFields() {
val text = """{"Id":"m1","Name":"X","UnknownField":[1,2,3]}"""
val decoded = json.decodeFromString(JellyfinItem.serializer(), text)
assertEquals("m1", decoded.Id)
assertEquals("X", decoded.Name)
}
}
@@ -0,0 +1,213 @@
package pw.binom.viewmate.core.media
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.HttpRequestData
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.TextContent
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class MirrorClientTest {
private val json = Json { ignoreUnknownKeys = true }
private fun client(
handler: MockRequestHandleScope.(HttpRequestData) -> HttpResponseData,
): HttpClient = HttpClient(MockEngine { request -> handler(request) }) {
install(ContentNegotiation) { json(json) }
}
private fun MockRequestHandleScope.jsonResponse(body: String) = respond(
content = body,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
@Test
fun createMirrorSendsCorrectRequestAndParses201() = runTest {
var capturedPath: String? = null
var capturedMethod: HttpMethod? = null
var capturedApiKey: String? = null
var capturedBody: String? = null
val mirror = MirrorClient(
baseUrl = "https://mirror.binom.pw",
apiKey = "secret",
http = client { request ->
capturedPath = request.url.encodedPath
capturedMethod = request.method
capturedApiKey = request.headers["X-API-Key"]
capturedBody = (request.body as TextContent).text
respond(
content = """{"id":"m-1","status":"processing"}""",
status = HttpStatusCode.Created,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
},
)
val job = mirror.createMirror(itemId = "item-42")
assertEquals("/api/mirror", capturedPath)
assertEquals(HttpMethod.Post, capturedMethod)
assertEquals("secret", capturedApiKey)
val sent = Json.decodeFromString<CreateMirrorRequest>(capturedBody!!)
assertEquals("item-42", sent.itemId)
assertEquals("", sent.sourceUrl)
assertEquals("jellyfin", sent.sourceType)
assertEquals("m-1", job.id)
assertEquals("processing", job.status)
}
@Test
fun mirrorByItemReturnsStatusWithFiles() = runTest {
var capturedPath: String? = null
val mirror = MirrorClient(
baseUrl = "https://mirror.binom.pw",
apiKey = "secret",
http = client { request ->
capturedPath = request.url.encodedPath
jsonResponse(
"""{"itemId":"i1","status":"done",""" +
""""files":{"video":{"key":"v1","url":"https://x/v1.mp4"},""" +
""""audios":[{"index":1,"key":"a1","title":"Русский","language":"ru","url":"https://x/a1.mka"},{"index":2,"key":"a2","url":"https://x/a2.mka"}]}}"""
)
},
)
val status = mirror.mirrorByItem(itemId = "i1")
assertEquals("/api/mirror/by-item/i1", capturedPath)
assertNotNull(status)
assertEquals("i1", status.itemId)
assertEquals("done", status.status)
assertNotNull(status.files)
assertEquals("v1", status.files.video.key)
assertEquals(2, status.files.audios.size)
assertEquals("Русский", status.files.audios[0].title)
assertEquals("ru", status.files.audios[0].language)
assertEquals(2, status.files.audios[1].index)
assertNull(status.files.audios[1].title)
}
@Test
fun mirrorByItemReturnsNullOn404() = runTest {
val mirror = MirrorClient(
baseUrl = "https://mirror.binom.pw",
apiKey = "secret",
http = client { respond("", HttpStatusCode.NotFound) },
)
assertNull(mirror.mirrorByItem(itemId = "missing"))
}
@Test
fun listParsesJobsAndSendsQueryParams() = runTest {
var capturedQueryStatus: String? = null
var capturedQueryLimit: String? = null
val mirror = MirrorClient(
baseUrl = "https://mirror.binom.pw",
apiKey = "secret",
http = client { request ->
capturedQueryStatus = request.url.parameters["status"]
capturedQueryLimit = request.url.parameters["limit"]
jsonResponse(
"""[{"id":"m1","itemId":"i1","status":"done","progress":100},""" +
"""{"id":"m2","itemId":"i2","status":"processing","progress":50}]"""
)
},
)
val jobs = mirror.list(status = "done", limit = 20)
assertEquals("done", capturedQueryStatus)
assertEquals("20", capturedQueryLimit)
assertEquals(2, jobs.size)
assertEquals("m1", jobs[0].id)
assertEquals("i1", jobs[0].itemId)
assertEquals("done", jobs[0].status)
assertEquals(100, jobs[0].progress)
assertEquals(50, jobs[1].progress)
}
@Test
fun deleteSendsDeleteRequest() = runTest {
var capturedPath: String? = null
var capturedMethod: HttpMethod? = null
val mirror = MirrorClient(
baseUrl = "https://mirror.binom.pw",
apiKey = "secret",
http = client { request ->
capturedPath = request.url.encodedPath
capturedMethod = request.method
respond("", HttpStatusCode.NoContent)
},
)
mirror.delete(id = "m-9")
assertEquals("/api/mirror/m-9", capturedPath)
assertEquals(HttpMethod.Delete, capturedMethod)
}
@Test
fun scanParsesResult() = runTest {
var capturedPath: String? = null
var capturedMethod: HttpMethod? = null
val mirror = MirrorClient(
baseUrl = "https://mirror.binom.pw",
apiKey = "secret",
http = client { request ->
capturedPath = request.url.encodedPath
capturedMethod = request.method
jsonResponse("""{"found":10,"created":3,"skipped":7}""")
},
)
val result = mirror.scan()
assertEquals("/api/mirror/scan", capturedPath)
assertEquals(HttpMethod.Post, capturedMethod)
assertEquals(10, result.found)
assertEquals(3, result.created)
assertEquals(7, result.skipped)
}
@Test
fun mirrorStatusRoundTrip() {
val status = MirrorStatus(
itemId = "i1",
status = "done",
files = MirrorFiles(
video = MirrorVideo(key = "v1", url = "https://x/v1.mp4"),
audios = listOf(MirrorAudio(index = 1, key = "a1", title = "Русский", language = "ru", url = "https://x/a1.mka")),
),
)
val text = json.encodeToString(MirrorStatus.serializer(), status)
val decoded = json.decodeFromString(MirrorStatus.serializer(), text)
assertEquals(status, decoded)
}
@Test
fun createMirrorRequestRoundTrip() {
val request = CreateMirrorRequest(itemId = "abc")
val text = json.encodeToString(CreateMirrorRequest.serializer(), request)
val decoded = json.decodeFromString(CreateMirrorRequest.serializer(), text)
assertEquals("abc", decoded.itemId)
assertEquals("", decoded.sourceUrl)
assertEquals("jellyfin", decoded.sourceType)
}
}