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
+424
-1
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user