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:
@@ -2,6 +2,7 @@
|
|||||||
kotlin = "2.2.0"
|
kotlin = "2.2.0"
|
||||||
kotlinx-serialization = "1.8.1"
|
kotlinx-serialization = "1.8.1"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
|
ktor = "3.3.0"
|
||||||
android = "8.9.2"
|
android = "8.9.2"
|
||||||
compose-plugin = "1.8.0"
|
compose-plugin = "1.8.0"
|
||||||
androidx-compose-bom = "2025.05.00"
|
androidx-compose-bom = "2025.05.00"
|
||||||
@@ -13,11 +14,21 @@ kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serializa
|
|||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||||
|
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
|
||||||
android-material3 = { module = "androidx.compose.material3:material3" }
|
android-material3 = { module = "androidx.compose.material3:material3" }
|
||||||
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" }
|
||||||
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
exoplayer = { module = "com.google.android.exoplayer:exoplayer", version.ref = "exoplayer" }
|
||||||
|
|
||||||
|
# Ktor client (HTTP)
|
||||||
|
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
|
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||||
|
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||||
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "android" }
|
android-application = { id = "com.android.application", version.ref = "android" }
|
||||||
android-library = { id = "com.android.library", version.ref = "android" }
|
android-library = { id = "com.android.library", version.ref = "android" }
|
||||||
|
|||||||
@@ -18,12 +18,22 @@ kotlin {
|
|||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
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 {
|
commonTest.dependencies {
|
||||||
implementation(kotlin("test"))
|
implementation(kotlin("test"))
|
||||||
|
implementation(libs.ktor.client.mock)
|
||||||
|
implementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.kotlinx.coroutines.android)
|
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