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

+515
View File
@@ -661,6 +661,154 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/stream/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Аудио трека (поддерживает Range)
* @description Ссылку целиком даёт `stream_url` трека (подпись пользователя и срок 12 ч); клиент может добавить `q`.
* Файл отдаёт nginx через X-Accel-Redirect: оригинал из хранилища или транскод в Opus из кэша.
*/
get: operations["streamTrack"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tracks/batch": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Треки по списку id (восстановление очереди) */
post: operations["getTracksBatch"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/events": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Пачка событий прослушивания (до 500)
* @description Повторно присланные события (тот же id) не учитываются дважды.
*/
post: operations["postEvents"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/playback-state": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Последнее состояние плеера (трек, позиция, очередь) */
get: operations["getPlaybackState"];
/** Сохранить состояние плеера (last-write-wins) */
put: operations["putPlaybackState"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/history": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** История прослушиваний, новые сверху */
get: operations["getHistory"];
put?: never;
post?: never;
/** Очистить историю (события остаются для статистики) */
delete: operations["clearHistory"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/likes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Лайки, новые сверху */
get: operations["getLikes"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/dislikes": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Дизлайки, новые сверху */
get: operations["getDislikes"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/reactions/tracks/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
get?: never;
/** Лайк или дизлайк трека */
put: operations["setTrackReaction"];
post?: never;
/** Снять реакцию */
delete: operations["clearTrackReaction"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -1009,6 +1157,9 @@ export interface components {
track_no: number | null;
/** @description Есть доступный файл (хранилище подключено, файл на месте) */
available: boolean;
/** @description Подписанная ссылка на аудио (12 ч); null — трек недоступен */
stream_url: string | null;
reaction: components["schemas"]["Reaction"] | null;
};
Artist: {
/** Format: uuid */
@@ -1055,6 +1206,106 @@ export interface components {
corrected_query: string | null;
next_cursor: string | null;
};
/** @enum {string} */
StreamQuality: "auto" | "original" | "high" | "medium" | "low";
/** @enum {string} */
Reaction: "like" | "dislike";
TrackIds: {
ids: string[];
};
TrackList: {
items: components["schemas"]["Track"][];
};
PlayEvent: {
/**
* Format: uuid
* @description UUIDv7 от клиента — ключ идемпотентности
*/
id: string;
/** @enum {string} */
type: "play_start" | "play_end" | "skip" | "seek" | "pause" | "resume" | "like" | "unlike" | "dislike" | "undislike";
/** Format: uuid */
track_id: string;
/** Format: date-time */
occurred_at: string;
position_ms?: number;
played_ms?: number;
from_ms?: number;
/** @enum {string} */
context_type?: "wave" | "album" | "artist" | "search" | "collection" | "history" | "queue";
context_id?: string;
/** Format: uuid */
wave_session_id?: string;
offline?: boolean;
};
EventBatch: {
events: components["schemas"]["PlayEvent"][];
};
EventBatchResult: {
accepted: number;
duplicates: number;
/** @description Слишком старые (> 30 дней) или с неизвестным треком */
rejected: number;
};
PlaybackContext: {
type?: string;
id?: string;
label?: string;
/** Format: uuid */
wave_session_id?: string;
};
PlaybackStateInput: {
/** Format: uuid */
track_id: string | null;
position_ms: number;
queue: string[];
queue_index: number;
context?: components["schemas"]["PlaybackContext"];
shuffle?: boolean;
/** @enum {string} */
repeat_mode?: "off" | "one" | "all";
volume?: number;
device_id?: string;
};
PlaybackState: {
/** Format: uuid */
track_id: string | null;
position_ms: number;
queue: string[];
queue_index: number;
context: components["schemas"]["PlaybackContext"] | null;
shuffle: boolean;
/** @enum {string} */
repeat_mode: "off" | "one" | "all";
volume: number;
device_id: string | null;
/** Format: date-time */
updated_at: string | null;
};
HistoryItem: {
/** Format: uuid */
id: string;
/** Format: date-time */
played_at: string;
track: components["schemas"]["Track"];
};
HistoryPage: {
items: components["schemas"]["HistoryItem"][];
next_cursor: string | null;
};
ReactedTrack: {
/** Format: date-time */
created_at: string;
track: components["schemas"]["Track"];
};
ReactedTrackPage: {
items: components["schemas"]["ReactedTrack"][];
total: number;
next_cursor: string | null;
};
ReactionInput: {
reaction: components["schemas"]["Reaction"];
};
Session: {
/** Format: uuid */
id: string;
@@ -2065,4 +2316,268 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
streamTrack: {
parameters: {
query: {
u: string;
exp: number;
sig: string;
q?: components["schemas"]["StreamQuality"];
};
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Аудио */
200: {
headers: {
[name: string]: unknown;
};
content: {
"audio/*": string;
};
};
default: components["responses"]["Problem"];
};
};
getTracksBatch: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["TrackIds"];
};
};
responses: {
/** @description Треки в порядке запроса; удалённые пропущены */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["TrackList"];
};
};
default: components["responses"]["Problem"];
};
};
postEvents: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EventBatch"];
};
};
responses: {
/** @description Принято */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["EventBatchResult"];
};
};
default: components["responses"]["Problem"];
};
};
getPlaybackState: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Состояние; пусто — плеер ещё не запускался */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaybackState"];
};
};
default: components["responses"]["Problem"];
};
};
putPlaybackState: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlaybackStateInput"];
};
};
responses: {
/** @description Сохранённое состояние */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaybackState"];
};
};
default: components["responses"]["Problem"];
};
};
getHistory: {
parameters: {
query?: {
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Страница истории */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HistoryPage"];
};
};
default: components["responses"]["Problem"];
};
};
clearHistory: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Очищено */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getLikes: {
parameters: {
query?: {
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Страница */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReactedTrackPage"];
};
};
default: components["responses"]["Problem"];
};
};
getDislikes: {
parameters: {
query?: {
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Страница */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ReactedTrackPage"];
};
};
default: components["responses"]["Problem"];
};
};
setTrackReaction: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ReactionInput"];
};
};
responses: {
/** @description Сохранено */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
clearTrackReaction: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Снято */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
}