feat(api): streaming, transcoding, events, history and likes (M3)
- 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:
1 parent
8b76aa3964
commit
c05e523cbe
16 files changed
+3913
-155
No files matched your search
@@ -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"];
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user