feat(api): streaming, transcoding, events, history and likes (M3)
ci / api / api (push) Successful in 29s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
ci / ts / ts (packages + web) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s

- tracks carry a stream_url signed for the user for 12 h and the
  user's reaction; /stream checks the signature, expiry and that the
  user is not blocked, then hands the file to nginx by X-Accel-Redirect
  (or serves it with Range support when X_ACCEL=false)
- original by default; formats browsers can't play and explicit
  high/medium/low go through Opus transcoding, never upwards: a lossy
  source under the target bitrate stays original; transcodes are
  cached per file and size, deduplicated in flight, limited by
  TRANSCODE_CONCURRENCY and trimmed hourly by LRU to the size limit
- /events takes up to 500 events with client UUIDs, drops repeats and
  events older than 30 days, counts plays (≥ half or 4 min, or
  finished), early skips and history entries (≥ 30 s)
- history with clearing, likes and dislikes with cursors and totals,
  track reactions, playback state with last-write-wins, tracks batch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 07:25:15 +03:00
1 parent 8b76aa3964
commit c05e523cbe
16 files changed
+3913 -155

No files matched your search

+424 -1
View File
@@ -15,6 +15,8 @@ tags:
- name: library
- name: catalog
- name: search
- name: playback
- name: collection
security:
- bearerAuth: []
paths:
@@ -769,6 +771,209 @@ paths:
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/stream/{id}:
get:
operationId: streamTrack
tags: [playback]
security: []
summary: Аудио трека (поддерживает Range)
description: |
Ссылку целиком даёт `stream_url` трека (подпись пользователя и срок 12 ч); клиент может добавить `q`.
Файл отдаёт nginx через X-Accel-Redirect: оригинал из хранилища или транскод в Opus из кэша.
parameters:
- $ref: "#/components/parameters/EntityId"
- name: u
in: query
required: true
schema:
type: string
format: uuid
- name: exp
in: query
required: true
schema:
type: integer
format: int64
- name: sig
in: query
required: true
schema:
type: string
- name: q
in: query
schema:
$ref: "#/components/schemas/StreamQuality"
responses:
"200":
description: Аудио
content:
audio/*:
schema:
type: string
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/tracks/batch:
post:
operationId: getTracksBatch
tags: [catalog]
summary: Треки по списку id (восстановление очереди)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TrackIds"
responses:
"200":
description: Треки в порядке запроса; удалённые пропущены
content:
application/json:
schema:
$ref: "#/components/schemas/TrackList"
default:
$ref: "#/components/responses/Problem"
/api/v1/events:
post:
operationId: postEvents
tags: [playback]
summary: Пачка событий прослушивания (до 500)
description: Повторно присланные события (тот же id) не учитываются дважды.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EventBatch"
responses:
"200":
description: Принято
content:
application/json:
schema:
$ref: "#/components/schemas/EventBatchResult"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/playback-state:
get:
operationId: getPlaybackState
tags: [playback]
summary: Последнее состояние плеера (трек, позиция, очередь)
responses:
"200":
description: Состояние; пусто — плеер ещё не запускался
content:
application/json:
schema:
$ref: "#/components/schemas/PlaybackState"
default:
$ref: "#/components/responses/Problem"
put:
operationId: putPlaybackState
tags: [playback]
summary: Сохранить состояние плеера (last-write-wins)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PlaybackStateInput"
responses:
"200":
description: Сохранённое состояние
content:
application/json:
schema:
$ref: "#/components/schemas/PlaybackState"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/history:
get:
operationId: getHistory
tags: [collection]
summary: История прослушиваний, новые сверху
parameters:
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Страница истории
content:
application/json:
schema:
$ref: "#/components/schemas/HistoryPage"
default:
$ref: "#/components/responses/Problem"
delete:
operationId: clearHistory
tags: [collection]
summary: Очистить историю (события остаются для статистики)
responses:
"204":
description: Очищено
default:
$ref: "#/components/responses/Problem"
/api/v1/me/likes:
get:
operationId: getLikes
tags: [collection]
summary: Лайки, новые сверху
parameters:
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Страница
content:
application/json:
schema:
$ref: "#/components/schemas/ReactedTrackPage"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/dislikes:
get:
operationId: getDislikes
tags: [collection]
summary: Дизлайки, новые сверху
parameters:
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Страница
content:
application/json:
schema:
$ref: "#/components/schemas/ReactedTrackPage"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/reactions/tracks/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
put:
operationId: setTrackReaction
tags: [collection]
summary: Лайк или дизлайк трека
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ReactionInput"
responses:
"204":
description: Сохранено
default:
$ref: "#/components/responses/Problem"
delete:
operationId: clearTrackReaction
tags: [collection]
summary: Снять реакцию
responses:
"204":
description: Снято
default:
$ref: "#/components/responses/Problem"
components:
parameters:
SourceId:
@@ -1466,7 +1671,7 @@ components:
type: string
Track:
type: object
required: [id, title, duration_ms, artists, album, genres, disc_no, track_no, available]
required: [id, title, duration_ms, artists, album, genres, disc_no, track_no, available, stream_url, reaction]
properties:
id:
type: string
@@ -1494,6 +1699,14 @@ components:
available:
type: boolean
description: Есть доступный файл (хранилище подключено, файл на месте)
stream_url:
type: string
nullable: true
description: Подписанная ссылка на аудио (12 ч); null — трек недоступен
reaction:
nullable: true
allOf:
- $ref: "#/components/schemas/Reaction"
Artist:
type: object
required: [id, name, album_count, track_count]
@@ -1599,6 +1812,216 @@ components:
next_cursor:
type: string
nullable: true
StreamQuality:
type: string
enum: [auto, original, high, medium, low]
Reaction:
type: string
enum: [like, dislike]
TrackIds:
type: object
required: [ids]
properties:
ids:
type: array
maxItems: 500
items:
type: string
format: uuid
TrackList:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/Track"
PlayEvent:
type: object
required: [id, type, track_id, occurred_at]
properties:
id:
type: string
format: uuid
description: UUIDv7 от клиента — ключ идемпотентности
type:
type: string
enum: [play_start, play_end, skip, seek, pause, resume, like, unlike, dislike, undislike]
track_id:
type: string
format: uuid
occurred_at:
type: string
format: date-time
position_ms:
type: integer
played_ms:
type: integer
from_ms:
type: integer
context_type:
type: string
enum: [wave, album, artist, search, collection, history, queue]
context_id:
type: string
maxLength: 100
wave_session_id:
type: string
format: uuid
offline:
type: boolean
EventBatch:
type: object
required: [events]
properties:
events:
type: array
maxItems: 500
items:
$ref: "#/components/schemas/PlayEvent"
EventBatchResult:
type: object
required: [accepted, duplicates, rejected]
properties:
accepted:
type: integer
duplicates:
type: integer
rejected:
type: integer
description: Слишком старые (> 30 дней) или с неизвестным треком
PlaybackContext:
type: object
properties:
type:
type: string
id:
type: string
label:
type: string
wave_session_id:
type: string
format: uuid
PlaybackStateInput:
type: object
required: [track_id, position_ms, queue, queue_index]
properties:
track_id:
type: string
format: uuid
nullable: true
position_ms:
type: integer
minimum: 0
queue:
type: array
maxItems: 500
items:
type: string
format: uuid
queue_index:
type: integer
minimum: 0
context:
$ref: "#/components/schemas/PlaybackContext"
shuffle:
type: boolean
repeat_mode:
type: string
enum: ["off", one, all]
volume:
type: number
minimum: 0
maximum: 1
device_id:
type: string
maxLength: 100
PlaybackState:
type: object
required: [track_id, position_ms, queue, queue_index, context, shuffle, repeat_mode, volume, device_id, updated_at]
properties:
track_id:
type: string
format: uuid
nullable: true
position_ms:
type: integer
queue:
type: array
items:
type: string
format: uuid
queue_index:
type: integer
context:
nullable: true
allOf:
- $ref: "#/components/schemas/PlaybackContext"
shuffle:
type: boolean
repeat_mode:
type: string
enum: ["off", one, all]
volume:
type: number
device_id:
type: string
nullable: true
updated_at:
type: string
format: date-time
nullable: true
HistoryItem:
type: object
required: [id, played_at, track]
properties:
id:
type: string
format: uuid
played_at:
type: string
format: date-time
track:
$ref: "#/components/schemas/Track"
HistoryPage:
type: object
required: [items, next_cursor]
properties:
items:
type: array
items:
$ref: "#/components/schemas/HistoryItem"
next_cursor:
type: string
nullable: true
ReactedTrack:
type: object
required: [created_at, track]
properties:
created_at:
type: string
format: date-time
track:
$ref: "#/components/schemas/Track"
ReactedTrackPage:
type: object
required: [items, total, next_cursor]
properties:
items:
type: array
items:
$ref: "#/components/schemas/ReactedTrack"
total:
type: integer
next_cursor:
type: string
nullable: true
ReactionInput:
type: object
required: [reaction]
properties:
reaction:
$ref: "#/components/schemas/Reaction"
Session:
type: object
required: [id, device_name, client_type, created_at, last_used_at, current]