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:
@@ -18,12 +18,22 @@ kotlin {
|
||||
commonMain.dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.client.logging)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.ktor.client.mock)
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user