Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc33b7ab08 | |||
| 6e5b20b578 | |||
| 72548b798a | |||
| 342ceee28d | |||
| ecf82b0e2e |
@@ -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"}` — сервис жив.
|
||||
|
||||
@@ -83,12 +83,12 @@ spec:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -37,3 +37,5 @@ app:
|
||||
|
||||
securityContext:
|
||||
privileged: false
|
||||
ingress:
|
||||
host: null
|
||||
|
||||
@@ -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 @@
|
||||
ALTER TABLE media_mirror.jobs ADD COLUMN IF NOT EXISTS retry_count int NOT NULL DEFAULT 0;
|
||||
@@ -83,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")
|
||||
|
||||
Reference in New Issue
Block a user