diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6e1d655..a4f885b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ kotlin = "2.2.0" kotlinx-serialization = "1.8.1" kotlinx-coroutines = "1.10.2" +ktor = "3.3.0" android = "8.9.2" compose-plugin = "1.8.0" 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-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-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" } android-material3 = { module = "androidx.compose.material3:material3" } androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose-bom" } 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] android-application = { id = "com.android.application", version.ref = "android" } android-library = { id = "com.android.library", version.ref = "android" } diff --git a/lib-core/build.gradle.kts b/lib-core/build.gradle.kts index 580f8c4..b764a4b 100644 --- a/lib-core/build.gradle.kts +++ b/lib-core/build.gradle.kts @@ -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) } } } diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt new file mode 100644 index 0000000..8a30b81 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/HttpClient.kt @@ -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, + ) +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt new file mode 100644 index 0000000..51c1f7c --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinClient.kt @@ -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 { + val response = http.get("$baseUrl/Users") { + header("X-Emby-Token", apiKey) + } + response.ensureSuccess("jellyfin.users") + return response.body() + } + + /** Корень каталога (папки/библиотеки) пользователя. */ + suspend fun rootItems(userId: String): List = requestItems( + userId = userId, + recursive = false, + ) + + /** Содержимое папки. */ + suspend fun items(userId: String, parentId: String): List = requestItems( + userId = userId, + parentId = parentId, + recursive = false, + ) + + /** Поиск по всей библиотеке. */ + suspend fun search(userId: String, query: String): List = requestItems( + userId = userId, + searchTerm = query, + recursive = true, + ) + + /** Вся библиотека (для скана/поиска). */ + suspend fun allItems(userId: String, types: List = listOf("Movie", "Episode")): List = + 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 = listOf("Movie", "Series", "Episode"), + ): List { + 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().Items + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinModels.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinModels.kt new file mode 100644 index 0000000..4bc5ded --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/JellyfinModels.kt @@ -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 = 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? = 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 сек) +) diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/MirrorClient.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/MirrorClient.kt new file mode 100644 index 0000000..cbc9622 --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/MirrorClient.kt @@ -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 { + 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() + } +} diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/MirrorModels.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/MirrorModels.kt new file mode 100644 index 0000000..cf394fc --- /dev/null +++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/media/MirrorModels.kt @@ -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 = 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", +) diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/JellyfinClientTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/JellyfinClientTest.kt new file mode 100644 index 0000000..32aa1f8 --- /dev/null +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/JellyfinClientTest.kt @@ -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) + } +} diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/MirrorClientTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/MirrorClientTest.kt new file mode 100644 index 0000000..5023655 --- /dev/null +++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/media/MirrorClientTest.kt @@ -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(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) + } +}