9 Commits
1 ... main

Author SHA1 Message Date
Hermes Agent dc33b7ab08 feat: V2__add_retry_count.sql (retry_count для jobs)
Build Media Mirror API / Build and publish (release) Successful in 31s
⚠️ ПОБАЙТОВО одинаковый с worker (общая БД)
2026-08-21 12:17:53 +03:00
Hermes Agent 6e5b20b578 docs: полное описание API media-mirror (эндпоинты, авторизация, формат зеркала, S3-креды клиента, пример цикла) 2026-08-11 01:02:39 +03:00
Hermes Agent 72548b798a fix: sourceUrl опциональный (воркер сам строит URL из itemId)
Build Media Mirror API / Build and publish (release) Successful in 29s
2026-08-10 23:29:06 +03:00
Hermes Agent 342ceee28d fix: helm-деплой — ключи секрета S3 (accessKey/secretKey)
Build Media Mirror API / Build and publish (release) Successful in 29s
2026-08-10 22:36:02 +03:00
Hermes Agent ecf82b0e2e helm: ingress для внешнего доступа
Build Media Mirror API / Build and publish (release) Successful in 31s
2026-08-10 22:31:06 +03:00
Hermes Agent c100b52720 helm: секреты API_KEY (X-API-Key) и JELLYFIN_API_KEY (сканер)
Build Media Mirror API / Build and publish (release) Successful in 29s
2026-08-10 22:28:20 +03:00
Hermes Agent 8d4a87f077 feat: API-ключ — фильтр X-API-Key на /api/**, /actuator открыт
Build Media Mirror API / Build and publish (release) Successful in 34s
- ключ из конфига (app.api-key), пустой = dev-режим без защиты
- 401 без/с неверным ключом, actuator не закрыт (k8s-пробы)
- добавлен spring-boot-starter-actuator
- тесты: 35 (401/200/actuator/dev-режим)
2026-08-10 22:06:33 +03:00
Hermes Agent ff72389ea6 feat: POST /api/mirror/scan — сканер Jellyfin (догоняет недосконвертированное)
Build Media Mirror API / Build and publish (release) Successful in 29s
- обход /Users + пагинация Items (Movie,Episode, Limit=200)
- done → skip, failed/cancelled → пересоздать, идемпотентен (unique-индекс + DuplicateKey)
- Jellyfin недоступен → 502; apiKey в конфиг
- тесты: пагинация 450, done-skip, failed-recreate, идемпотентность, 500→исключение
2026-08-10 21:42:23 +03:00
Hermes Agent bd85ff74f4 db: отдельная база media_mirror вместо glasses
Build Media Mirror API / Build and publish (release) Successful in 27s
2026-08-10 18:06:44 +03:00
21 changed files with 713 additions and 8 deletions
+209 -2
View File
@@ -1,3 +1,210 @@
# media-mirror-api
# media-mirror — API координатора зеркал
Media mirror: конвертация в зеркала для очков
Сервис конвертации видео в «зеркала» для AR-очков: одно видео (480p, H.264, без звука) + N аудиодорожек (Vorbis/ogg) отдельными файлами. Воркеры на GPU-машинах молотят очередь из Postgres, готовые файлы лежат в S3 (SeaweedFS).
## Базовый URL
| Окружение | URL |
|-----------|-----|
| Прод (из интернета) | `https://mirror.binom.pw` |
| Прод (внутри сети) | `http://mirror-api-media-mirror-api:8080` |
## Авторизация
Все эндпоинты `/api/**` требуют заголовок:
```
X-API-Key: <ключ>
```
Без ключа или с неверным — `401`. `/actuator/**` открыт (health-пробы).
## Модель задачи (Job)
| Поле | Тип | Описание |
|------|-----|----------|
| `id` | uuid | ID задачи |
| `itemId` | string | ID элемента из Jellyfin |
| `status` | string | `new` → `processing` → `done` / `failed` / `cancelled` |
| `progress` | int | 0..100 |
| `videoKey` | string | S3-ключ видео (после done) |
| `audioKeys` | array | список аудиодорожек (после done) |
## Эндпоинты
### 1. Создать задачу на конвертацию
```
POST /api/mirror
```
**Запрос:**
```json
{
"itemId": "d57a20a9909921f0bd133c3bbae6f9db",
"sourceUrl": "", // опционально; пусто = воркер сам построит из itemId
"sourceType": "jellyfin" // по умолчанию jellyfin
}
```
**Ответы:**
- `201 Created` — задача создана
- `200 OK` — задача с таким itemId уже существует (не дублируется), возвращается существующая
```json
{ "id": "4a063de2-f5e9-40b7-b395-e9c17dd23301", "status": "new" }
```
- `400` — itemId пустой / битый JSON
- `401` — нет/неверный X-API-Key
### 2. Статус задачи по ID
```
GET /api/mirror/{id}
```
```json
{
"id": "4a063de2-f5e9-40b7-b395-e9c17dd23301",
"itemId": "d57a20a9909921f0bd133c3bbae6f9db",
"status": "processing",
"progress": 45
}
```
`404` — задачи нет.
### 3. Статус + файлы зеркала по itemId (главный для клиента!)
```
GET /api/mirror/by-item/{itemId}
```
**Ответ, если готово:**
```json
{
"itemId": "d57a20a9909921f0bd133c3bbae6f9db",
"status": "done",
"files": {
"video": {
"key": "mirror/d57a20a9909921f0bd133c3bbae6f9db/video.mkv",
"url": "https://s3.binom.pw/media/mirror/d57a20a9909921f0bd133c3bbae6f9db/video.mkv"
},
"audios": [
{
"index": 0,
"key": "mirror/d57a20a9909921f0bd133c3bbae6f9db/audio-0.ogg",
"title": "Dub [Reanimedia]",
"language": "rus",
"url": "https://s3.binom.pw/media/mirror/d57a20a9909921f0bd133c3bbae6f9db/audio-0.ogg"
},
{
"index": 1,
"key": "mirror/d57a20a9909921f0bd133c3bbae6f9db/audio-1.ogg",
"title": "Original",
"language": "eng",
"url": "https://s3.binom.pw/media/mirror/d57a20a9909921f0bd133c3bbae6f9db/audio-1.ogg"
}
]
}
}
```
**Ответ, если ещё не готово:**
```json
{ "itemId": "...", "status": "processing", "files": null }
```
**Ответ, если задачи нет вообще:**
`404` — клиент понимает: «ещё не конвертировано, можно заказать».
### 4. Список задач (фильтр по статусу)
```
GET /api/mirror?status=done&limit=50
```
`status` — опционально (`new`/`processing`/`done`/`failed`/`cancelled`), `limit` — опционально (дефолт 50).
**Ответ:**
```json
[
{ "id": "...", "itemId": "...", "status": "done", "progress": 100 }
]
```
### 5. Удалить / отменить задачу
```
DELETE /api/mirror/{id}
```
- задача `new`/`processing` → `cancelled` (воркер пропустит)
- задача `done` → файлы удаляются из S3 + запись удаляется
- повторный DELETE → `404`
### 6. Сканировать библиотеку Jellyfin (догонялка)
```
POST /api/mirror/scan
```
Обходит Jellyfin (Movies + Episodes), создаёт задачи на **только недостающее**:
- есть активная задача (`new`/`processing`/`done`) → **skip**
- нет задачи / последняя `failed`/`cancelled` → **create**
Идемпотентен — можно дёргать сколько угодно, дублей не будет. Используется, когда webhook'и Jellyfin потерялись.
**Ответ:**
```json
{ "found": 1387, "created": 12, "skipped": 1375 }
```
`502` — Jellyfin недоступен.
## Формат зеркала (что получит клиент)
| Файл | Кодек | Контейнер | Разрешение | Описание |
|------|-------|-----------|------------|----------|
| `video.mkv` | H.264 (NVENC) | mkv | 480p (высота ≤480) | **без звука**, без субтитров/метаданных |
| `audio-N.ogg` | Vorbis | ogg | 2ch | **каждая** дорожка исходника отдельным файлом |
Сколько аудио — столько дорожек было в исходнике (все без исключения). Мета каждой дорожки (`title`, `language`) — в ответе `by-item`, берётся из ffprobe исходника.
## Скачивание файлов (S3)
Клиент качает файлы **напрямую из S3** (SeaweedFS), не через координатор:
- S3 endpoint: `https://s3.binom.pw`
- bucket: `media`
- путь: `mirror/{itemId}/video.mkv`, `mirror/{itemId}/audio-{index}.ogg`
**Креды клиента (зашиты в приложение):**
```
accessKey: MirrorClient01QwertyKey
secretKey: MirrorClient01SecretKeyValueForGlassesOnly
```
Права клиентского ключа: **только чтение** `media/mirror/*` — писать/удалять/листать нельзя. Подписывать запросы по S3 SigV4 (AWS SDK, region `us-east-1`). Поддержка Range-запросов есть (видео можно качать/играть по кускам).
## Пример полного цикла клиента
```text
1. GET https://mirror.binom.pw/api/mirror/by-item/{itemId} [X-API-Key]
→ 404 → 2а
→ files → 4
2а. POST /api/mirror {"itemId": "..."} [X-API-Key]
2б. GET by-item, пока status != done (поллинг раз в 5-10с)
3. status == done → берём files.video.url + files.audios[].url
4. Качаем по url с mirror-client кредами (SigV4) → играем
(видео — на очки, выбранную аудио-дорожку — на телефон)
```
## Health
```
GET /actuator/health
```
Открыт без ключа. `{"status": "UP"}` — сервис жив.
+1
View File
@@ -33,6 +33,7 @@ dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0")
implementation("org.springframework.boot:spring-boot-starter-jdbc")
implementation("org.springframework.boot:spring-boot-starter-flyway")
implementation("org.springframework.boot:spring-boot-starter-actuator")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.postgresql:postgresql")
implementation("org.flywaydb:flyway-database-postgresql")
+2
View File
@@ -9,8 +9,10 @@ data:
forward-headers-strategy: framework
app:
api-key: ${API_KEY}
jellyfin:
url: {{ $.Values.app.jellyfin.url | quote }}
api-key: ${JELLYFIN_API_KEY}
s3:
url: {{ $.Values.app.s3.url | quote }}
access-key: ${S3_ACCESS_KEY}
+12 -2
View File
@@ -69,16 +69,26 @@ spec:
key: dbPassword
- name: DB_URL
value: "jdbc:postgresql://{{ $.Values.db.host }}:{{ $.Values.db.port }}/{{ $.Values.db.name }}"
- name: API_KEY
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
key: apiKey
- name: JELLYFIN_API_KEY
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
key: jellyfinApiKey
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
key: app.s3.accessKey
key: accessKey
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: {{ $.Release.Name }}-{{ $.Chart.Name }}-secret
key: app.s3.secretKey
key: secretKey
volumeMounts:
- mountPath: '/opt/app/config'
name: application-properties-volume
+17
View File
@@ -0,0 +1,17 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
annotations: {}
spec:
rules:
- host: {{.Values.ingress.host }}
http:
paths:
- backend:
service:
name: "{{ $.Release.Name }}-{{ $.Chart.Name }}"
port:
number: {{ .Values.port }}
path: /
pathType: Prefix
+2
View File
@@ -8,3 +8,5 @@ data:
dbPassword: {{ (required "DB password не установлен" $.Values.db.password) | b64enc | quote }}
accessKey: {{ (required "Accesskey не установлен" $.Values.app.s3.accessKey) | b64enc | quote }}
secretKey: {{ (required "Secretkey не установлен" $.Values.app.s3.secretKey) | b64enc | quote }}
apiKey: {{ (required "ApiKey не установлен" $.Values.app.apiKey) | b64enc | quote }}
jellyfinApiKey: {{ (required "Jellyfin ApiKey не установлен" $.Values.app.jellyfin.apiKey) | b64enc | quote }}
+5 -1
View File
@@ -17,14 +17,16 @@ image:
db:
host: null
port: 5432
name: glasses
name: media_mirror
user: null
password: null
maxConnections: 10
app:
apiKey: null
jellyfin:
url: null
apiKey: null
s3:
url: null
accessKey: null
@@ -35,3 +37,5 @@ app:
securityContext:
privileged: false
ingress:
host: null
@@ -0,0 +1,49 @@
package pw.binom.mirror.api.config
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import org.springframework.core.Ordered
import org.springframework.core.annotation.Order
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import pw.binom.mirror.api.dto.ErrorResponse
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
class ApiKeyFilter(
private val properties: AppProperties,
private val json: Json,
) : OncePerRequestFilter() {
private val log = LoggerFactory.getLogger(ApiKeyFilter::class.java)
init {
if (properties.apiKey.isEmpty()) {
log.warn("app.api-key is empty, API is unprotected (dev mode)")
}
}
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val apiKey = properties.apiKey
if (apiKey.isEmpty()) {
filterChain.doFilter(request, response)
return
}
if (request.requestURI.startsWith("/api/") && request.getHeader("X-API-Key") != apiKey) {
response.status = HttpStatus.UNAUTHORIZED.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.writer.write(json.encodeToString(ErrorResponse("Invalid or missing X-API-Key")))
return
}
filterChain.doFilter(request, response)
}
}
@@ -9,10 +9,14 @@ data class AppProperties(
val jellyfin: Jellyfin,
@param:DefaultValue
val s3: S3,
@param:DefaultValue("")
val apiKey: String,
) {
data class Jellyfin(
@param:DefaultValue("https://jellyfin.binom.pw/")
val url: String,
@param:DefaultValue("")
val apiKey: String,
)
data class S3(
@@ -21,6 +21,9 @@ import pw.binom.mirror.api.dto.CreateMirrorRequest
import pw.binom.mirror.api.dto.CreateMirrorResponse
import pw.binom.mirror.api.dto.ErrorResponse
import pw.binom.mirror.api.dto.MirrorStatus
import pw.binom.mirror.api.dto.ScanResult
import pw.binom.mirror.api.service.JellyfinScanner
import pw.binom.mirror.api.service.JellyfinUnavailableException
import pw.binom.mirror.api.service.MirrorService
import java.util.UUID
@@ -28,6 +31,7 @@ import java.util.UUID
@RequestMapping("/api/mirror")
class MirrorController(
private val service: MirrorService,
private val scanner: JellyfinScanner,
private val json: Json,
private val validator: Validator,
) {
@@ -79,6 +83,20 @@ class MirrorController(
ResponseEntity.notFound().build()
}
@PostMapping("/scan")
fun scan(): ResponseEntity<String> {
val result = try {
scanner.scan()
} catch (e: JellyfinUnavailableException) {
logger.warn("Jellyfin scan failed: {}", e.message)
return jsonBody(
json.encodeToString(ErrorResponse(e.message ?: "Jellyfin unavailable")),
HttpStatus.BAD_GATEWAY,
)
}
return jsonBody(json.encodeToString(ScanResult.serializer(), result), HttpStatus.OK)
}
private fun badRequest(message: String): ResponseEntity<String> {
logger.warn("Bad request: {}", message)
return jsonBody(json.encodeToString(ErrorResponse(message)), HttpStatus.BAD_REQUEST)
@@ -7,7 +7,6 @@ import kotlinx.serialization.Serializable
data class CreateMirrorRequest(
@field:NotBlank(message = "itemId must not be blank")
val itemId: String,
@field:NotBlank(message = "sourceUrl must not be blank")
val sourceUrl: String,
val sourceUrl: String = "",
val sourceType: String = "jellyfin",
)
@@ -0,0 +1,10 @@
package pw.binom.mirror.api.dto
import kotlinx.serialization.Serializable
@Serializable
data class ScanResult(
val found: Int,
val created: Int,
val skipped: Int,
)
@@ -0,0 +1,114 @@
package pw.binom.mirror.api.service
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import org.slf4j.LoggerFactory
import org.springframework.dao.DuplicateKeyException
import org.springframework.stereotype.Service
import pw.binom.mirror.api.config.AppProperties
import pw.binom.mirror.api.db.JobRepository
import pw.binom.mirror.api.dto.ScanResult
import java.net.HttpURLConnection
import java.net.URL
import java.nio.charset.StandardCharsets
@Service
class JellyfinScanner(
private val repository: JobRepository,
private val properties: AppProperties,
private val json: Json,
) {
private val logger = LoggerFactory.getLogger(JellyfinScanner::class.java)
fun scan(): ScanResult {
val baseUrl = properties.jellyfin.url.trimEnd('/')
val apiKey = properties.jellyfin.apiKey
val userId = fetchUsers(baseUrl, apiKey).first().id
var found = 0
var created = 0
var skipped = 0
var startIndex = 0
while (true) {
val page = fetchItems(baseUrl, userId, apiKey, startIndex, PAGE_SIZE)
found += page.items.size
for (item in page.items) {
if (repository.findActiveByItemId(item.id) != null) {
skipped++
} else {
try {
repository.insert(item.id, "", "jellyfin")
created++
} catch (e: DuplicateKeyException) {
logger.warn("Concurrent insert for itemId={}, skipping", item.id)
skipped++
}
}
}
if (page.items.isEmpty()) break
startIndex += page.items.size
if (startIndex >= page.totalRecordCount) break
}
logger.info("Scan finished: found={}, created={}, skipped={}", found, created, skipped)
return ScanResult(found = found, created = created, skipped = skipped)
}
private fun fetchUsers(baseUrl: String, apiKey: String): List<JellyfinUser> {
val body = get(baseUrl, "/Users", apiKey)
return json.decodeFromString<List<JellyfinUser>>(body)
}
private fun fetchItems(
baseUrl: String,
userId: String,
apiKey: String,
startIndex: Int,
limit: Int,
): ItemsPage {
val path = "/Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Episode&StartIndex=$startIndex&Limit=$limit"
val body = get(baseUrl, path, apiKey)
return json.decodeFromString<ItemsPage>(body)
}
private fun get(baseUrl: String, path: String, apiKey: String): String {
val connection = URL("$baseUrl$path").openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.setRequestProperty("X-Emby-Token", apiKey)
connection.connectTimeout = 10_000
connection.readTimeout = 30_000
try {
val code = connection.responseCode
if (code != HttpURLConnection.HTTP_OK) {
val detail = connection.errorStream
?.use { it.bufferedReader(StandardCharsets.UTF_8).readText() }
.orEmpty()
throw JellyfinUnavailableException("Jellyfin returned HTTP $code: $detail")
}
return connection.inputStream.use { it.bufferedReader(StandardCharsets.UTF_8).readText() }
} finally {
connection.disconnect()
}
}
private companion object {
const val PAGE_SIZE = 200
}
}
class JellyfinUnavailableException(message: String) : RuntimeException(message)
@Serializable
private data class JellyfinUser(
@SerialName("Id") val id: String,
)
@Serializable
private data class ItemsPage(
@SerialName("TotalRecordCount") val totalRecordCount: Int,
@SerialName("Items") val items: List<Item>,
) {
@Serializable
data class Item(
@SerialName("Id") val id: String,
)
}
+3 -1
View File
@@ -1,6 +1,8 @@
app:
api-key: ${MIRROR_API_KEY:}
jellyfin:
url: https://jellyfin.binom.pw/
api-key: ${JELLYFIN_API_KEY}
s3:
url: https://s3.binom.pw
accessKey: ${S3_ACCESS_KEY}
@@ -11,7 +13,7 @@ app:
spring:
datasource:
url: jdbc:postgresql://192.168.76.106:5432/glasses
url: jdbc:postgresql://192.168.76.106:5432/media_mirror
username: postgres
password: postgres
flyway:
@@ -0,0 +1 @@
ALTER TABLE media_mirror.jobs ADD COLUMN IF NOT EXISTS retry_count int NOT NULL DEFAULT 0;
@@ -33,6 +33,8 @@ abstract class AbstractIntegrationTest {
fun cleanState() {
ensureBucket()
jdbcTemplate.update("DELETE FROM media_mirror.jobs")
fakeJellyfin.items = emptyList()
fakeJellyfin.fail = false
}
private fun ensureBucket() {
@@ -61,6 +63,8 @@ abstract class AbstractIntegrationTest {
private val postgres: PostgreSQLContainer<*> = PostgreSQLContainer("postgres:16-alpine").apply { start() }
private val minio: MinIOContainer = MinIOContainer("minio/minio:latest").apply { start() }
val fakeJellyfin = FakeJellyfin()
@JvmStatic
@DynamicPropertySource
fun properties(registry: DynamicPropertyRegistry) {
@@ -71,6 +75,8 @@ abstract class AbstractIntegrationTest {
registry.add("app.s3.accessKey") { minio.userName }
registry.add("app.s3.secretKey") { minio.password }
registry.add("app.s3.bucket") { "media" }
registry.add("app.jellyfin.url") { fakeJellyfin.url }
registry.add("app.jellyfin.apiKey") { "test-api-key" }
}
}
}
@@ -0,0 +1,39 @@
package pw.binom.mirror.api
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
@SpringBootTest(properties = ["app.api-key=test-secret"])
class ApiKeyFilterTest : AbstractIntegrationTest() {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `missing X-API-Key returns 401`() {
mockMvc.perform(get("/api/mirror"))
.andExpect(status().isUnauthorized)
}
@Test
fun `invalid X-API-Key returns 401`() {
mockMvc.perform(get("/api/mirror").header("X-API-Key", "wrong-key"))
.andExpect(status().isUnauthorized)
}
@Test
fun `valid X-API-Key is accepted`() {
mockMvc.perform(get("/api/mirror").header("X-API-Key", "test-secret"))
.andExpect(status().isOk)
}
@Test
fun `actuator health is not protected`() {
mockMvc.perform(get("/actuator/health"))
.andExpect(status().isOk)
}
}
@@ -0,0 +1,19 @@
package pw.binom.mirror.api
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
class DevModeFilterTest : AbstractIntegrationTest() {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `empty api key allows requests without X-API-Key`() {
mockMvc.perform(get("/api/mirror"))
.andExpect(status().isOk)
}
}
@@ -0,0 +1,67 @@
package pw.binom.mirror.api
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
class FakeJellyfin {
@Volatile
var items: List<String> = emptyList()
@Volatile
var fail: Boolean = false
private val server: HttpServer = HttpServer.create(InetSocketAddress(0), 0).also { s ->
s.createContext("/") { exchange -> handle(exchange) }
s.start()
}
val url: String
get() = "http://localhost:${server.address.port}"
fun close() = server.stop(0)
private fun handle(exchange: HttpExchange) {
try {
if (fail) {
exchange.sendResponseHeaders(500, -1)
return
}
val path = exchange.requestURI.path
when {
path == "/Users" -> respond(exchange, 200, """[{"Id": "user-1", "Name": "Test User"}]""")
path.startsWith("/Users/") && path.endsWith("/Items") -> respondItems(exchange)
else -> respond(exchange, 404, """{"error": "not found"}""")
}
} catch (e: Exception) {
runCatching { exchange.sendResponseHeaders(500, -1) }
} finally {
exchange.close()
}
}
private fun respondItems(exchange: HttpExchange) {
val query = exchange.requestURI.rawQuery.orEmpty()
val params = query.split("&").mapNotNull { part ->
val eq = part.indexOf('=')
if (eq < 0) null else part.substring(0, eq) to part.substring(eq + 1)
}.toMap()
val startIndex = params["StartIndex"]?.toIntOrNull() ?: 0
val limit = params["Limit"]?.toIntOrNull() ?: 200
val page = items.drop(startIndex).take(limit)
val body = buildString {
append("{\"TotalRecordCount\": ${items.size}, \"Items\": [")
append(page.joinToString(",") { id -> """{"Id": "$id", "Name": "Item $id", "Type": "Movie"}""" })
append("]}")
}
respond(exchange, 200, body)
}
private fun respond(exchange: HttpExchange, code: Int, body: String) {
val bytes = body.toByteArray(StandardCharsets.UTF_8)
exchange.sendResponseHeaders(code, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
}
@@ -21,6 +21,7 @@ import pw.binom.mirror.api.dto.ByItemResponse
import pw.binom.mirror.api.dto.CreateMirrorResponse
import pw.binom.mirror.api.dto.MirrorFiles
import pw.binom.mirror.api.dto.MirrorStatus
import pw.binom.mirror.api.dto.ScanResult
import pw.binom.mirror.api.dto.VideoFile
import pw.binom.mirror.api.db.JobRepository
import pw.binom.mirror.api.s3.S3Storage
@@ -82,6 +83,21 @@ class MirrorControllerTest : AbstractIntegrationTest() {
assertEquals(1, count)
}
@Test
fun `POST mirror without sourceUrl creates a job`() {
mockMvc.perform(
post("/api/mirror")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"itemId":"no-url-id"}"""),
).andExpect(status().isCreated)
val response = json.decodeFromString<ByItemResponse>(
bodyOf(mockMvc.perform(get("/api/mirror/by-item/no-url-id")).andExpect(status().isOk).andReturn()),
)
assertEquals("no-url-id", response.itemId)
assertEquals("new", response.status)
}
@Test
fun `POST mirror returns 400 on blank itemId`() {
postCreate("", "https://source.example/stream")
@@ -223,6 +239,27 @@ class MirrorControllerTest : AbstractIntegrationTest() {
mockMvc.perform(delete("/api/mirror/${UUID.randomUUID()}")).andExpect(status().isNotFound)
}
@Test
fun `POST scan returns counts of found created skipped`() {
fakeJellyfin.items = listOf("scan-1", "scan-2", "scan-3")
repository.insert("scan-2", "", "jellyfin")
val result = json.decodeFromString<ScanResult>(
bodyOf(mockMvc.perform(post("/api/mirror/scan")).andExpect(status().isOk).andReturn()),
)
assertEquals(3, result.found)
assertEquals(2, result.created)
assertEquals(1, result.skipped)
}
@Test
fun `POST scan returns 502 when jellyfin is unavailable`() {
fakeJellyfin.fail = true
mockMvc.perform(post("/api/mirror/scan")).andExpect(status().isBadGateway)
}
private fun markDone(id: UUID, itemId: String) {
jdbcTemplate.update(
"""
@@ -0,0 +1,97 @@
package pw.binom.mirror.api.service
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import pw.binom.mirror.api.AbstractIntegrationTest
import pw.binom.mirror.api.db.JobRepository
import java.util.UUID
class JellyfinScannerTest : AbstractIntegrationTest() {
@Autowired
lateinit var repository: JobRepository
@Autowired
lateinit var scanner: JellyfinScanner
@Test
fun `scan finds all items across multiple pages`() {
fakeJellyfin.items = (0 until 450).map { "item-$it" }
val result = scanner.scan()
assertEquals(450, result.found)
assertEquals(450, result.created)
assertEquals(0, result.skipped)
val count = jdbcTemplate.queryForObject("SELECT count(*) FROM media_mirror.jobs", Int::class.java)
assertEquals(450, count)
}
@Test
fun `scan skips active jobs and recreates failed ones`() {
val done = repository.insert("done-item", "", "jellyfin")
markStatus(done.id, "done")
val failed = repository.insert("failed-item", "", "jellyfin")
markStatus(failed.id, "failed")
val cancelled = repository.insert("cancelled-item", "", "jellyfin")
markStatus(cancelled.id, "cancelled")
fakeJellyfin.items = listOf("done-item", "failed-item", "cancelled-item", "fresh-item")
val result = scanner.scan()
assertEquals(4, result.found)
assertEquals(3, result.created)
assertEquals(1, result.skipped)
}
@Test
fun `scan is idempotent`() {
fakeJellyfin.items = (0 until 10).map { "idem-$it" }
assertEquals(10, scanner.scan().created)
val second = scanner.scan()
assertEquals(10, second.found)
assertEquals(0, second.created)
assertEquals(10, second.skipped)
}
@Test
fun `scan recreates job after done job is deleted`() {
val itemId = "recreate-item"
val job = repository.insert(itemId, "", "jellyfin")
markStatus(job.id, "done")
fakeJellyfin.items = listOf(itemId)
assertEquals(0, scanner.scan().created)
repository.delete(job.id)
val result = scanner.scan()
assertEquals(1, result.found)
assertEquals(1, result.created)
assertEquals(0, result.skipped)
}
@Test
fun `scan throws when jellyfin returns an error`() {
fakeJellyfin.fail = true
assertThrows(JellyfinUnavailableException::class.java) {
scanner.scan()
}
}
@Test
fun `scan with empty library creates nothing`() {
val result = scanner.scan()
assertEquals(0, result.found)
assertEquals(0, result.created)
assertEquals(0, result.skipped)
}
private fun markStatus(id: UUID, status: String) {
jdbcTemplate.update("UPDATE media_mirror.jobs SET status = ? WHERE id = ?", status, id)
}
}