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: library
|
||||||
- name: catalog
|
- name: catalog
|
||||||
- name: search
|
- name: search
|
||||||
|
- name: playback
|
||||||
|
- name: collection
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
@@ -769,6 +771,209 @@ paths:
|
|||||||
format: binary
|
format: binary
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$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:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
SourceId:
|
SourceId:
|
||||||
@@ -1466,7 +1671,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
Track:
|
Track:
|
||||||
type: object
|
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:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
@@ -1494,6 +1699,14 @@ components:
|
|||||||
available:
|
available:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Есть доступный файл (хранилище подключено, файл на месте)
|
description: Есть доступный файл (хранилище подключено, файл на месте)
|
||||||
|
stream_url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Подписанная ссылка на аудио (12 ч); null — трек недоступен
|
||||||
|
reaction:
|
||||||
|
nullable: true
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/Reaction"
|
||||||
Artist:
|
Artist:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, album_count, track_count]
|
required: [id, name, album_count, track_count]
|
||||||
@@ -1599,6 +1812,216 @@ components:
|
|||||||
next_cursor:
|
next_cursor:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
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:
|
Session:
|
||||||
type: object
|
type: object
|
||||||
required: [id, device_name, client_type, created_at, last_used_at, current]
|
required: [id, device_name, client_type, created_at, last_used_at, current]
|
||||||
|
|||||||
@@ -661,6 +661,154 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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 type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1009,6 +1157,9 @@ export interface components {
|
|||||||
track_no: number | null;
|
track_no: number | null;
|
||||||
/** @description Есть доступный файл (хранилище подключено, файл на месте) */
|
/** @description Есть доступный файл (хранилище подключено, файл на месте) */
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
/** @description Подписанная ссылка на аудио (12 ч); null — трек недоступен */
|
||||||
|
stream_url: string | null;
|
||||||
|
reaction: components["schemas"]["Reaction"] | null;
|
||||||
};
|
};
|
||||||
Artist: {
|
Artist: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
@@ -1055,6 +1206,106 @@ export interface components {
|
|||||||
corrected_query: string | null;
|
corrected_query: string | null;
|
||||||
next_cursor: 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: {
|
Session: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -2065,4 +2316,268 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,11 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
@@ -22,6 +24,7 @@ import (
|
|||||||
"pipu/api/internal/platform/db"
|
"pipu/api/internal/platform/db"
|
||||||
"pipu/api/internal/platform/httpx"
|
"pipu/api/internal/platform/httpx"
|
||||||
"pipu/api/internal/platform/sign"
|
"pipu/api/internal/platform/sign"
|
||||||
|
"pipu/api/internal/playback"
|
||||||
"pipu/api/internal/search"
|
"pipu/api/internal/search"
|
||||||
"pipu/api/internal/server"
|
"pipu/api/internal/server"
|
||||||
"pipu/api/internal/system"
|
"pipu/api/internal/system"
|
||||||
@@ -105,6 +108,25 @@ func run() error {
|
|||||||
indexer.Trigger()
|
indexer.Trigger()
|
||||||
}
|
}
|
||||||
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
|
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
|
||||||
|
|
||||||
|
concurrency := cfg.TranscodeConcurrency
|
||||||
|
if concurrency <= 0 {
|
||||||
|
concurrency = max(1, runtime.NumCPU()/2)
|
||||||
|
}
|
||||||
|
transcoder := playback.NewTranscoder(cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "transcode"), concurrency,
|
||||||
|
cfg.TranscodeCacheLimitGB, log)
|
||||||
|
go transcoder.RunCleanup(ctx)
|
||||||
|
rootFor := func(ctx context.Context, sourceID uuid.UUID) (string, error) {
|
||||||
|
src, err := libStore.GetSource(ctx, sourceID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return libSvc.Root(src)
|
||||||
|
}
|
||||||
|
var dto library.DTO
|
||||||
|
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
|
||||||
|
rootFor, cfg.XAccel, log)
|
||||||
|
dto = library.DTO{Signer: signer, Personal: pb}
|
||||||
libSvc.OnCatalogChanged = indexer.Trigger
|
libSvc.OnCatalogChanged = indexer.Trigger
|
||||||
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
|
||||||
go libSvc.Restore(ctx)
|
go libSvc.Restore(ctx)
|
||||||
@@ -115,8 +137,9 @@ func run() error {
|
|||||||
System: sys,
|
System: sys,
|
||||||
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
||||||
AuthService: authSvc,
|
AuthService: authSvc,
|
||||||
Library: library.NewHandler(libSvc, libStore, signer, covers, cfg.XAccel),
|
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
||||||
Search: search.NewHandler(meili, libStore, indexer, library.DTO{Signer: signer}, log),
|
Search: search.NewHandler(meili, libStore, indexer, dto, log),
|
||||||
|
Playback: pb,
|
||||||
}))
|
}))
|
||||||
log.Info("starting", "version", buildinfo.Version)
|
log.Info("starting", "version", buildinfo.Version)
|
||||||
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -88,7 +88,9 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
|||||||
r.ContentLength = 2
|
r.ContentLength = 2
|
||||||
}
|
}
|
||||||
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
|
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
|
||||||
cover := r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/covers/")
|
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
||||||
|
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
|
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/"))
|
||||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -28,12 +28,35 @@ type Handler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler создаёт обработчик.
|
// NewHandler создаёт обработчик.
|
||||||
func NewHandler(svc *Service, store *Store, signer *sign.Signer, covers *Covers, xAccel bool) *Handler {
|
func NewHandler(svc *Service, store *Store, dto DTO, covers *Covers, xAccel bool) *Handler {
|
||||||
return &Handler{DTO: DTO{Signer: signer}, svc: svc, store: store, covers: covers, xAccel: xAccel}
|
return &Handler{DTO: dto, svc: svc, store: store, covers: covers, xAccel: xAccel}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTO переводит строки каталога в схемы API; подписывает ссылки на обложки.
|
// DTO переводит строки каталога в схемы API; подписывает ссылки на обложки.
|
||||||
type DTO struct{ Signer *sign.Signer }
|
type DTO struct {
|
||||||
|
Signer *sign.Signer
|
||||||
|
// Personal дополняет треки данными текущего пользователя (ссылка на стрим, реакция).
|
||||||
|
Personal Personalizer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personalizer заполняет персональные поля треков для пользователя из контекста запроса.
|
||||||
|
type Personalizer interface {
|
||||||
|
Personalize(ctx context.Context, rows []TrackRow, out []gen.Track) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracks переводит треки в схему и добавляет персональные поля.
|
||||||
|
func (d DTO) Tracks(ctx context.Context, rows []TrackRow) ([]gen.Track, error) {
|
||||||
|
out := make([]gen.Track, 0, len(rows))
|
||||||
|
for _, t := range rows {
|
||||||
|
out = append(out, d.Track(t))
|
||||||
|
}
|
||||||
|
if d.Personal != nil && len(out) > 0 {
|
||||||
|
if err := d.Personal.Personalize(ctx, rows, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) sourceDTO(s Source) gen.Source {
|
func (h *Handler) sourceDTO(s Source) gen.Source {
|
||||||
out := gen.Source{
|
out := gen.Source{
|
||||||
@@ -382,11 +405,13 @@ func (h *Handler) GetAlbum(ctx context.Context, req gen.GetAlbumRequestObject) (
|
|||||||
}
|
}
|
||||||
out := gen.GetAlbum200JSONResponse{
|
out := gen.GetAlbum200JSONResponse{
|
||||||
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: h.coverURL(a), CoverColors: colors(a.CoverColors),
|
Id: a.ID, Title: a.Title, Year: a.Year, CoverUrl: h.coverURL(a), CoverColors: colors(a.CoverColors),
|
||||||
Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name}, Tracks: make([]gen.Track, 0, len(tracks)),
|
Artist: gen.ArtistRef{Id: a.Artist.ID, Name: a.Artist.Name},
|
||||||
}
|
}
|
||||||
for _, t := range tracks {
|
for _, t := range tracks {
|
||||||
out.DurationMs += t.DurationMs
|
out.DurationMs += t.DurationMs
|
||||||
out.Tracks = append(out.Tracks, h.Track(t))
|
}
|
||||||
|
if out.Tracks, err = h.Tracks(ctx, tracks); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -397,7 +422,11 @@ func (h *Handler) GetTrack(ctx context.Context, req gen.GetTrackRequestObject) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, catalogErr(err)
|
return nil, catalogErr(err)
|
||||||
}
|
}
|
||||||
return gen.GetTrack200JSONResponse(h.Track(t)), nil
|
dto, err := h.Tracks(ctx, []TrackRow{t})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetTrack200JSONResponse(dto[0]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCover реализует gen.StrictServerInterface: проверяет подпись и отдаёт WebP нужного размера.
|
// GetCover реализует gen.StrictServerInterface: проверяет подпись и отдаёт WebP нужного размера.
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ type API struct {
|
|||||||
FFmpegPath string `env:"FFMPEG_PATH" envDefault:"ffmpeg"`
|
FFmpegPath string `env:"FFMPEG_PATH" envDefault:"ffmpeg"`
|
||||||
// XAccel — файлы отдаёт nginx по X-Accel-Redirect; false — для запуска api без nginx.
|
// XAccel — файлы отдаёт nginx по X-Accel-Redirect; false — для запуска api без nginx.
|
||||||
XAccel bool `env:"X_ACCEL" envDefault:"true"`
|
XAccel bool `env:"X_ACCEL" envDefault:"true"`
|
||||||
|
// TranscodeConcurrency — параллельных ffmpeg; 0 — половина ядер.
|
||||||
|
TranscodeConcurrency int `env:"TRANSCODE_CONCURRENCY" envDefault:"0"`
|
||||||
|
TranscodeCacheLimitGB int `env:"TRANSCODE_CACHE_LIMIT_GB" envDefault:"50"`
|
||||||
|
|
||||||
MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"`
|
MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"`
|
||||||
MeiliMasterKey string `env:"MEILI_MASTER_KEY"`
|
MeiliMasterKey string `env:"MEILI_MASTER_KEY"`
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package httpx
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type requestKey struct{}
|
||||||
|
|
||||||
|
// StrictRequest — middleware strict-сервера: кладёт *http.Request в контекст для обработчиков,
|
||||||
|
// которым нужен сам запрос (Range при отдаче файлов без nginx).
|
||||||
|
func StrictRequest(next strictnethttp.StrictHTTPHandlerFunc, _ string) strictnethttp.StrictHTTPHandlerFunc {
|
||||||
|
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, req any) (any, error) {
|
||||||
|
return next(context.WithValue(ctx, requestKey{}, r), w, r, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestFrom — исходный HTTP-запрос (nil вне strict-обработчика).
|
||||||
|
func RequestFrom(ctx context.Context) *http.Request {
|
||||||
|
r, _ := ctx.Value(requestKey{}).(*http.Request)
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,553 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
"pipu/api/internal/platform/httpx"
|
||||||
|
"pipu/api/internal/platform/sign"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Коды ошибок.
|
||||||
|
const (
|
||||||
|
CodeLinkInvalid = "playback.link_invalid"
|
||||||
|
CodeUnavailable = "playback.track_unavailable"
|
||||||
|
CodeTranscodeFail = "playback.transcode_failed"
|
||||||
|
CodeTrackNotFound = "catalog.not_found"
|
||||||
|
maxEventAge = 30 * 24 * time.Hour
|
||||||
|
maxEventClockSkew = 5 * time.Minute
|
||||||
|
defaultCollectPage = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler реализует playback- и collection-части gen.StrictServerInterface,
|
||||||
|
// а также library.Personalizer (ссылки на стрим и реакции в треках).
|
||||||
|
type Handler struct {
|
||||||
|
store *Store
|
||||||
|
catalog *library.Store
|
||||||
|
dto func() library.DTO
|
||||||
|
signer *sign.Signer
|
||||||
|
transcoder *Transcoder
|
||||||
|
// RootFor — каталог хранилища в файловой системе api (для отдачи файлов без nginx и транскода).
|
||||||
|
rootFor func(ctx context.Context, sourceID uuid.UUID) (string, error)
|
||||||
|
xAccel bool
|
||||||
|
log *slog.Logger
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler создаёт обработчик. dto — DTO каталога с персонализацией (замыкание: DTO ссылается на сам Handler).
|
||||||
|
func NewHandler(store *Store, catalog *library.Store, dto func() library.DTO, signer *sign.Signer, tr *Transcoder,
|
||||||
|
rootFor func(context.Context, uuid.UUID) (string, error), xAccel bool, log *slog.Logger,
|
||||||
|
) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
store: store, catalog: catalog, dto: dto, signer: signer, transcoder: tr, rootFor: rootFor, xAccel: xAccel,
|
||||||
|
log: log, now: func() time.Time { return time.Now().UTC() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func principal(ctx context.Context) (auth.Principal, error) {
|
||||||
|
p, ok := auth.PrincipalFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
return auth.Principal{}, apperr.Unauthorized(auth.CodeUnauthorized, "not authenticated")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personalize реализует library.Personalizer.
|
||||||
|
func (h *Handler) Personalize(ctx context.Context, rows []library.TrackRow, out []gen.Track) error {
|
||||||
|
p, ok := auth.PrincipalFrom(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
ids[i] = r.ID
|
||||||
|
}
|
||||||
|
reactions, err := h.store.Reactions(ctx, p.UserID, ids)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := h.now()
|
||||||
|
for i := range out {
|
||||||
|
if out[i].Available {
|
||||||
|
u := StreamURL(h.signer, p.UserID, rows[i].ID, now)
|
||||||
|
out[i].StreamUrl = &u
|
||||||
|
}
|
||||||
|
if r, ok := reactions[rows[i].ID]; ok {
|
||||||
|
rr := gen.Reaction(r)
|
||||||
|
out[i].Reaction = &rr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- стрим ---
|
||||||
|
|
||||||
|
// StreamTrack реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) StreamTrack(ctx context.Context, req gen.StreamTrackRequestObject) (gen.StreamTrackResponseObject, error) {
|
||||||
|
prm := req.Params
|
||||||
|
if !VerifyStream(h.signer, prm.Sig, prm.U, req.Id, prm.Exp, h.now()) {
|
||||||
|
return nil, apperr.Forbidden(CodeLinkInvalid, "link is invalid or expired")
|
||||||
|
}
|
||||||
|
blocked, err := h.store.UserBlocked(ctx, prm.U)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if blocked {
|
||||||
|
return nil, apperr.Forbidden(auth.CodeUserBlocked, "user is blocked")
|
||||||
|
}
|
||||||
|
f, err := h.store.StreamFile(ctx, req.Id)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, apperr.NotFound(CodeUnavailable, "track has no available file")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if f.SourceStatus != "mounted" {
|
||||||
|
return nil, apperr.NotFound(CodeUnavailable, "storage is offline")
|
||||||
|
}
|
||||||
|
q := QualityAuto
|
||||||
|
if prm.Q != nil {
|
||||||
|
q = string(*prm.Q)
|
||||||
|
}
|
||||||
|
codec, bitrate := "", 0
|
||||||
|
if f.Codec != nil {
|
||||||
|
codec = *f.Codec
|
||||||
|
}
|
||||||
|
if f.Bitrate != nil {
|
||||||
|
bitrate = *f.Bitrate
|
||||||
|
}
|
||||||
|
|
||||||
|
target := Target(q, f.Format, codec, bitrate)
|
||||||
|
res := streamResponse{req: httpx.RequestFrom(ctx), xAccel: h.xAccel}
|
||||||
|
if target == "" {
|
||||||
|
res.contentType = ContentType(f.Format)
|
||||||
|
res.accel = accelPath(f)
|
||||||
|
if !h.xAccel {
|
||||||
|
root, err := h.rootFor(ctx, f.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.file = filepath.Join(root, filepath.FromSlash(f.RelPath))
|
||||||
|
}
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := h.rootFor(ctx, f.SourceID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
name := CacheName(f.FileID, f.Size, target)
|
||||||
|
path, err := h.transcoder.Ensure(ctx, filepath.Join(root, filepath.FromSlash(f.RelPath)), name, target)
|
||||||
|
if err != nil {
|
||||||
|
h.log.ErrorContext(ctx, "transcode failed", "track_id", req.Id, "err", err)
|
||||||
|
return nil, apperr.New(http.StatusBadGateway, CodeTranscodeFail, "transcoding failed")
|
||||||
|
}
|
||||||
|
res.contentType = "audio/ogg; codecs=opus"
|
||||||
|
res.accel = "/_internal/cache/" + name
|
||||||
|
res.file = path
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// streamResponse — X-Accel-Redirect для nginx или сам файл с поддержкой Range.
|
||||||
|
type streamResponse struct {
|
||||||
|
req *http.Request
|
||||||
|
contentType string
|
||||||
|
accel string
|
||||||
|
file string
|
||||||
|
xAccel bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// VisitStreamTrackResponse реализует gen.StreamTrackResponseObject.
|
||||||
|
func (s streamResponse) VisitStreamTrackResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", s.contentType)
|
||||||
|
w.Header().Set("Cache-Control", "private, max-age=43200")
|
||||||
|
if s.xAccel {
|
||||||
|
w.Header().Set("X-Accel-Redirect", s.accel)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
f, err := os.Open(s.file)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.req == nil {
|
||||||
|
return fmt.Errorf("playback: request missing in context")
|
||||||
|
}
|
||||||
|
http.ServeContent(w, s.req, "", info.ModTime(), f)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- треки пачкой ---
|
||||||
|
|
||||||
|
// GetTracksBatch реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) GetTracksBatch(ctx context.Context, req gen.GetTracksBatchRequestObject) (gen.GetTracksBatchResponseObject, error) {
|
||||||
|
rows, err := h.catalog.TracksByIDs(ctx, req.Body.Ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, err := h.dto().Tracks(ctx, rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetTracksBatch200JSONResponse{Items: items}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- события ---
|
||||||
|
|
||||||
|
// PostEvents реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) PostEvents(ctx context.Context, req gen.PostEventsRequestObject) (gen.PostEventsResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
now := h.now()
|
||||||
|
ids := make([]uuid.UUID, 0, len(req.Body.Events))
|
||||||
|
for _, e := range req.Body.Events {
|
||||||
|
ids = append(ids, e.TrackId)
|
||||||
|
}
|
||||||
|
durations, err := h.store.TrackDurations(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
clientType := h.store.SessionClientType(ctx, p.SessionID)
|
||||||
|
|
||||||
|
out := gen.PostEvents200JSONResponse{}
|
||||||
|
tx, err := h.store.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
for _, e := range req.Body.Events {
|
||||||
|
dur, known := durations[e.TrackId]
|
||||||
|
at := e.OccurredAt.UTC()
|
||||||
|
if !known || at.Before(now.Add(-maxEventAge)) {
|
||||||
|
out.Rejected++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if at.After(now.Add(maxEventClockSkew)) {
|
||||||
|
at = now // часы клиента спешат
|
||||||
|
}
|
||||||
|
ev := Event{
|
||||||
|
ID: e.Id, Type: string(e.Type), TrackID: e.TrackId, OccurredAt: at, PositionMs: e.PositionMs,
|
||||||
|
PlayedMs: e.PlayedMs, ContextID: e.ContextId, WaveSessionID: e.WaveSessionId,
|
||||||
|
Offline: e.Offline != nil && *e.Offline,
|
||||||
|
}
|
||||||
|
if e.ContextType != nil {
|
||||||
|
ct := string(*e.ContextType)
|
||||||
|
ev.ContextType = &ct
|
||||||
|
}
|
||||||
|
inserted, err := h.store.InsertEvent(ctx, tx, p.UserID, clientType, ev)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !inserted {
|
||||||
|
out.Duplicates++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Accepted++
|
||||||
|
played := 0
|
||||||
|
if e.PlayedMs != nil {
|
||||||
|
played = *e.PlayedMs
|
||||||
|
}
|
||||||
|
if o := Judge(ev.Type, played, dur); o != (Outcome{}) {
|
||||||
|
if err := h.store.ApplyOutcome(ctx, tx, p.UserID, ev, o); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- состояние плеера ---
|
||||||
|
|
||||||
|
func stateDTO(s State) gen.PlaybackState {
|
||||||
|
out := gen.PlaybackState{
|
||||||
|
TrackId: s.TrackID, PositionMs: s.PositionMs, Queue: s.Queue, QueueIndex: s.QueueIndex, Shuffle: s.Shuffle,
|
||||||
|
RepeatMode: gen.PlaybackStateRepeatMode(s.RepeatMode), Volume: s.Volume, DeviceId: s.DeviceID, UpdatedAt: s.UpdatedAt,
|
||||||
|
}
|
||||||
|
if out.Queue == nil {
|
||||||
|
out.Queue = []uuid.UUID{}
|
||||||
|
}
|
||||||
|
if s.Context != nil {
|
||||||
|
c := gen.PlaybackContext{}
|
||||||
|
str := func(k string) *string {
|
||||||
|
if v, ok := s.Context[k].(string); ok && v != "" {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.Type, c.Id, c.Label = str("type"), str("id"), str("label")
|
||||||
|
if w := str("wave_session_id"); w != nil {
|
||||||
|
if id, err := uuid.Parse(*w); err == nil {
|
||||||
|
c.WaveSessionId = &id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Context = &c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlaybackState реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) GetPlaybackState(ctx context.Context, _ gen.GetPlaybackStateRequestObject) (gen.GetPlaybackStateResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s, err := h.store.GetState(ctx, p.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetPlaybackState200JSONResponse(stateDTO(s)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutPlaybackState реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) PutPlaybackState(ctx context.Context, req gen.PutPlaybackStateRequestObject) (gen.PutPlaybackStateResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
b := req.Body
|
||||||
|
s := State{
|
||||||
|
TrackID: b.TrackId, PositionMs: max(b.PositionMs, 0), Queue: b.Queue, QueueIndex: b.QueueIndex,
|
||||||
|
RepeatMode: "off", Volume: 1, DeviceID: b.DeviceId,
|
||||||
|
}
|
||||||
|
if s.QueueIndex >= len(s.Queue) {
|
||||||
|
s.QueueIndex = max(len(s.Queue)-1, 0)
|
||||||
|
}
|
||||||
|
if b.Shuffle != nil {
|
||||||
|
s.Shuffle = *b.Shuffle
|
||||||
|
}
|
||||||
|
if b.RepeatMode != nil {
|
||||||
|
s.RepeatMode = string(*b.RepeatMode)
|
||||||
|
}
|
||||||
|
if b.Volume != nil {
|
||||||
|
s.Volume = min(max(*b.Volume, 0), 1)
|
||||||
|
}
|
||||||
|
if c := b.Context; c != nil {
|
||||||
|
s.Context = map[string]any{}
|
||||||
|
for k, v := range map[string]*string{"type": c.Type, "id": c.Id, "label": c.Label} {
|
||||||
|
if v != nil {
|
||||||
|
s.Context[k] = *v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.WaveSessionId != nil {
|
||||||
|
s.Context["wave_session_id"] = c.WaveSessionId.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saved, err := h.store.PutState(ctx, p.UserID, s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.PutPlaybackState200JSONResponse(stateDTO(saved)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- коллекция ---
|
||||||
|
|
||||||
|
func encodeCursor(t time.Time, id uuid.UUID) string {
|
||||||
|
return base64.RawURLEncoding.EncodeToString([]byte(t.Format(time.RFC3339Nano) + "|" + id.String()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCursor(c *string) (time.Time, uuid.UUID, bool, error) {
|
||||||
|
if c == nil || *c == "" {
|
||||||
|
return time.Time{}, uuid.Nil, false, nil
|
||||||
|
}
|
||||||
|
bad := apperr.Validation("cursor", "validation.cursor_invalid", "malformed cursor", nil)
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(*c)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, uuid.Nil, false, bad
|
||||||
|
}
|
||||||
|
ts, idStr, ok := strings.Cut(string(raw), "|")
|
||||||
|
t, err1 := time.Parse(time.RFC3339Nano, ts)
|
||||||
|
id, err2 := uuid.Parse(idStr)
|
||||||
|
if !ok || err1 != nil || err2 != nil {
|
||||||
|
return time.Time{}, uuid.Nil, false, bad
|
||||||
|
}
|
||||||
|
return t, id, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageLimit(l *int) int {
|
||||||
|
if l == nil {
|
||||||
|
return defaultCollectPage
|
||||||
|
}
|
||||||
|
return min(max(*l, 1), 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hydrate — треки каталога по id; удалённые пропускаются.
|
||||||
|
func (h *Handler) hydrate(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]gen.Track, error) {
|
||||||
|
rows, err := h.catalog.TracksByIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dtos, err := h.dto().Tracks(ctx, rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make(map[uuid.UUID]gen.Track, len(dtos))
|
||||||
|
for _, t := range dtos {
|
||||||
|
out[t.Id] = t
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistory реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) GetHistory(ctx context.Context, req gen.GetHistoryRequestObject) (gen.GetHistoryResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t, id, ok, err := decodeCursor(req.Params.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var before *HistoryRow
|
||||||
|
if ok {
|
||||||
|
before = &HistoryRow{ID: id, PlayedAt: t}
|
||||||
|
}
|
||||||
|
limit := pageLimit(req.Params.Limit)
|
||||||
|
rows, err := h.store.History(ctx, p.UserID, before, limit+1)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := gen.GetHistory200JSONResponse{Items: []gen.HistoryItem{}}
|
||||||
|
if len(rows) > limit {
|
||||||
|
rows = rows[:limit]
|
||||||
|
c := encodeCursor(rows[limit-1].PlayedAt, rows[limit-1].ID)
|
||||||
|
out.NextCursor = &c
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
ids[i] = r.TrackID
|
||||||
|
}
|
||||||
|
tracks, err := h.hydrate(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if tr, ok := tracks[r.TrackID]; ok {
|
||||||
|
out.Items = append(out.Items, gen.HistoryItem{Id: r.ID, PlayedAt: r.PlayedAt, Track: tr})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearHistory реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ClearHistory(ctx context.Context, _ gen.ClearHistoryRequestObject) (gen.ClearHistoryResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.store.ClearHistory(ctx, p.UserID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.ClearHistory204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) reacted(ctx context.Context, reaction string, limitP *int, cursor *string) (gen.ReactedTrackPage, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return gen.ReactedTrackPage{}, err
|
||||||
|
}
|
||||||
|
t, id, ok, err := decodeCursor(cursor)
|
||||||
|
if err != nil {
|
||||||
|
return gen.ReactedTrackPage{}, err
|
||||||
|
}
|
||||||
|
var before *Reacted
|
||||||
|
if ok {
|
||||||
|
before = &Reacted{TrackID: id, CreatedAt: t}
|
||||||
|
}
|
||||||
|
limit := pageLimit(limitP)
|
||||||
|
rows, total, err := h.store.ReactedPage(ctx, p.UserID, reaction, before, limit+1)
|
||||||
|
if err != nil {
|
||||||
|
return gen.ReactedTrackPage{}, err
|
||||||
|
}
|
||||||
|
out := gen.ReactedTrackPage{Items: []gen.ReactedTrack{}, Total: total}
|
||||||
|
if len(rows) > limit {
|
||||||
|
rows = rows[:limit]
|
||||||
|
c := encodeCursor(rows[limit-1].CreatedAt, rows[limit-1].TrackID)
|
||||||
|
out.NextCursor = &c
|
||||||
|
}
|
||||||
|
ids := make([]uuid.UUID, len(rows))
|
||||||
|
for i, r := range rows {
|
||||||
|
ids[i] = r.TrackID
|
||||||
|
}
|
||||||
|
tracks, err := h.hydrate(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return gen.ReactedTrackPage{}, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if tr, ok := tracks[r.TrackID]; ok {
|
||||||
|
out.Items = append(out.Items, gen.ReactedTrack{CreatedAt: r.CreatedAt, Track: tr})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLikes реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) GetLikes(ctx context.Context, req gen.GetLikesRequestObject) (gen.GetLikesResponseObject, error) {
|
||||||
|
page, err := h.reacted(ctx, "like", req.Params.Limit, req.Params.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetLikes200JSONResponse(page), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDislikes реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) GetDislikes(ctx context.Context, req gen.GetDislikesRequestObject) (gen.GetDislikesResponseObject, error) {
|
||||||
|
page, err := h.reacted(ctx, "dislike", req.Params.Limit, req.Params.Cursor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetDislikes200JSONResponse(page), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTrackReaction реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) SetTrackReaction(ctx context.Context, req gen.SetTrackReactionRequestObject) (gen.SetTrackReactionResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = h.store.SetReaction(ctx, p.UserID, req.Id, string(req.Body.Reaction))
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return nil, apperr.NotFound(CodeTrackNotFound, "track not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.SetTrackReaction204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearTrackReaction реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) ClearTrackReaction(ctx context.Context, req gen.ClearTrackReactionRequestObject) (gen.ClearTrackReactionResponseObject, error) {
|
||||||
|
p, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := h.store.ClearReaction(ctx, p.UserID, req.Id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.ClearTrackReaction204Response{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ library.Personalizer = (*Handler)(nil)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Package playback — стриминг, транскодинг, события прослушивания, история, реакции и
|
||||||
|
// состояние плеера (docs/playback.md).
|
||||||
|
package playback
|
||||||
|
|
||||||
|
// Качество стрима.
|
||||||
|
const (
|
||||||
|
QualityAuto = "auto"
|
||||||
|
QualityOriginal = "original"
|
||||||
|
QualityHigh = "high"
|
||||||
|
QualityMedium = "medium"
|
||||||
|
QualityLow = "low"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Целевой битрейт Opus, кбит/с.
|
||||||
|
var opusKbps = map[string]int{QualityHigh: 256, QualityMedium: 128, QualityLow: 64}
|
||||||
|
|
||||||
|
// Форматы, которые браузеры не играют: их всегда отдаём транскодом.
|
||||||
|
var browserUnplayable = map[string]bool{"ape": true, "wv": true, "aiff": true, "aif": true}
|
||||||
|
|
||||||
|
var losslessCodecs = map[string]bool{"flac": true, "alac": true, "wavpack": true, "ape": true, "tta": true}
|
||||||
|
|
||||||
|
func lossless(codec string) bool {
|
||||||
|
return losslessCodecs[codec] || len(codec) > 4 && codec[:4] == "pcm_"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target решает, что отдать: "" — оригинал, иначе качество транскода.
|
||||||
|
// Перекодировать вверх бессмысленно: lossy-исходник не выше целевого битрейта отдаётся как есть.
|
||||||
|
func Target(q, format, codec string, bitrate int) string {
|
||||||
|
switch q {
|
||||||
|
case QualityHigh, QualityMedium, QualityLow:
|
||||||
|
default: // auto, original, пусто
|
||||||
|
if browserUnplayable[format] {
|
||||||
|
return QualityHigh
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if lossless(codec) || browserUnplayable[format] {
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
if bitrate > 0 && bitrate <= opusKbps[q]*1000*11/10 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пороги засчитывания (docs/playback.md#события-прослушивания).
|
||||||
|
const (
|
||||||
|
countCapMs = 4 * 60 * 1000 // засчитано после min(50% длительности, 4 мин)
|
||||||
|
historyMinMs = 30 * 1000 // в историю — от 30 с
|
||||||
|
earlySkipMs = 30 * 1000 // ранний скип — до 30 с
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outcome — что значит событие окончания попытки (play_end / skip) для статистики.
|
||||||
|
type Outcome struct {
|
||||||
|
Counted bool // засчитанное прослушивание
|
||||||
|
Complete bool // доиграно до конца
|
||||||
|
Skip bool
|
||||||
|
EarlySkip bool
|
||||||
|
History bool // попадает в историю
|
||||||
|
PlayedMs int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Judge оценивает событие окончания попытки. durationMs — длительность трека.
|
||||||
|
func Judge(eventType string, playedMs, durationMs int) Outcome {
|
||||||
|
o := Outcome{PlayedMs: max(playedMs, 0)}
|
||||||
|
switch eventType {
|
||||||
|
case "play_end":
|
||||||
|
o.Complete, o.Counted = true, true
|
||||||
|
case "skip":
|
||||||
|
o.Skip = true
|
||||||
|
o.EarlySkip = o.PlayedMs < earlySkipMs
|
||||||
|
threshold := min(durationMs/2, countCapMs)
|
||||||
|
o.Counted = durationMs > 0 && o.PlayedMs >= threshold
|
||||||
|
default:
|
||||||
|
return Outcome{}
|
||||||
|
}
|
||||||
|
o.History = o.PlayedMs >= historyMinMs || (durationMs > 0 && durationMs < historyMinMs && o.Complete)
|
||||||
|
return o
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTarget(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
q, format, codec string
|
||||||
|
bitrate int
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"auto", "flac", "flac", 900000, ""},
|
||||||
|
{"original", "mp3", "mp3", 320000, ""},
|
||||||
|
{"auto", "ape", "ape", 800000, "high"},
|
||||||
|
{"original", "aiff", "pcm_s16be", 1411000, "high"},
|
||||||
|
{"high", "flac", "flac", 900000, "high"},
|
||||||
|
{"high", "mp3", "mp3", 192000, ""}, // ниже целевых 256 — оригинал
|
||||||
|
{"medium", "mp3", "mp3", 320000, "medium"},
|
||||||
|
{"low", "opus", "opus", 64000, ""},
|
||||||
|
{"medium", "m4a", "aac", 0, "medium"}, // битрейт неизвестен — транскодируем
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
assert.Equal(t, tc.want, Target(tc.q, tc.format, tc.codec, tc.bitrate), "%+v", tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJudge(t *testing.T) {
|
||||||
|
const min4 = 4 * 60 * 1000
|
||||||
|
o := Judge("play_end", 200000, 210000)
|
||||||
|
assert.Equal(t, Outcome{Counted: true, Complete: true, History: true, PlayedMs: 200000}, o)
|
||||||
|
|
||||||
|
o = Judge("skip", 10000, 210000)
|
||||||
|
assert.True(t, o.Skip)
|
||||||
|
assert.True(t, o.EarlySkip)
|
||||||
|
assert.False(t, o.Counted)
|
||||||
|
assert.False(t, o.History)
|
||||||
|
|
||||||
|
assert.True(t, Judge("skip", 105000, 210000).Counted, "half of the track")
|
||||||
|
assert.False(t, Judge("skip", 104000, 210000).Counted)
|
||||||
|
assert.True(t, Judge("skip", min4, 20*60*1000).Counted, "4 minutes of a long track")
|
||||||
|
assert.True(t, Judge("skip", 31000, 210000).History)
|
||||||
|
assert.True(t, Judge("play_end", 20000, 20000).History, "short track played to the end")
|
||||||
|
assert.False(t, Judge("skip", 15000, 20000).History)
|
||||||
|
assert.Equal(t, Outcome{}, Judge("pause", 1000, 1000))
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound — трек не найден.
|
||||||
|
var ErrNotFound = errors.New("playback: not found")
|
||||||
|
|
||||||
|
// Store — доступ к таблицам воспроизведения.
|
||||||
|
type Store struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
// NewStore создаёт Store.
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }
|
||||||
|
|
||||||
|
// StreamFile — основной файл трека и его хранилище.
|
||||||
|
type StreamFile struct {
|
||||||
|
FileID uuid.UUID
|
||||||
|
SourceID uuid.UUID
|
||||||
|
SourceType string
|
||||||
|
SourceStatus string
|
||||||
|
LocalPath *string
|
||||||
|
RelPath string
|
||||||
|
Format string
|
||||||
|
Codec *string
|
||||||
|
Bitrate *int
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamFile — что отдавать по треку.
|
||||||
|
func (st *Store) StreamFile(ctx context.Context, trackID uuid.UUID) (StreamFile, error) {
|
||||||
|
var f StreamFile
|
||||||
|
err := st.pool.QueryRow(ctx, `
|
||||||
|
SELECT f.id, s.id, s.type, s.status, s.local_path, f.rel_path, f.format, f.codec, f.bitrate, f.size_bytes
|
||||||
|
FROM tracks t
|
||||||
|
JOIN media_files f ON f.id = t.primary_file_id
|
||||||
|
JOIN library_sources s ON s.id = f.source_id
|
||||||
|
WHERE t.id = $1`, trackID).
|
||||||
|
Scan(&f.FileID, &f.SourceID, &f.SourceType, &f.SourceStatus, &f.LocalPath, &f.RelPath, &f.Format, &f.Codec,
|
||||||
|
&f.Bitrate, &f.Size)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return StreamFile{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return StreamFile{}, fmt.Errorf("playback: stream file: %w", err)
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBlocked — заблокирован ли пользователь (или удалён).
|
||||||
|
func (st *Store) UserBlocked(ctx context.Context, userID uuid.UUID) (bool, error) {
|
||||||
|
var blocked bool
|
||||||
|
err := st.pool.QueryRow(ctx, `SELECT is_blocked FROM users WHERE id = $1`, userID).Scan(&blocked)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("playback: user blocked: %w", err)
|
||||||
|
}
|
||||||
|
return blocked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reactions — реакции пользователя на треки.
|
||||||
|
func (st *Store) Reactions(ctx context.Context, userID uuid.UUID, trackIDs []uuid.UUID) (map[uuid.UUID]string, error) {
|
||||||
|
rows, err := st.pool.Query(ctx, `
|
||||||
|
SELECT track_id, reaction::text FROM track_reactions WHERE user_id = $1 AND track_id = ANY($2)`, userID, trackIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("playback: reactions: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[uuid.UUID]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
r string
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[id] = r
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetReaction ставит лайк / дизлайк (заменяет прежнюю реакцию).
|
||||||
|
func (st *Store) SetReaction(ctx context.Context, userID, trackID uuid.UUID, reaction string) error {
|
||||||
|
_, err := st.pool.Exec(ctx, `
|
||||||
|
INSERT INTO track_reactions (user_id, track_id, reaction) VALUES ($1, $2, $3::reaction)
|
||||||
|
ON CONFLICT (user_id, track_id) DO UPDATE SET reaction = EXCLUDED.reaction, created_at = now()
|
||||||
|
WHERE track_reactions.reaction <> EXCLUDED.reaction`, userID, trackID, reaction)
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23503" {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("playback: set reaction: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearReaction снимает реакцию.
|
||||||
|
func (st *Store) ClearReaction(ctx context.Context, userID, trackID uuid.UUID) error {
|
||||||
|
if _, err := st.pool.Exec(ctx, `DELETE FROM track_reactions WHERE user_id = $1 AND track_id = $2`, userID, trackID); err != nil {
|
||||||
|
return fmt.Errorf("playback: clear reaction: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reacted — трек с реакцией и временем.
|
||||||
|
type Reacted struct {
|
||||||
|
TrackID uuid.UUID
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactedPage — треки с реакцией, новые сверху, после курсора (created_at, track_id).
|
||||||
|
func (st *Store) ReactedPage(ctx context.Context, userID uuid.UUID, reaction string, before *Reacted, limit int) ([]Reacted, int, error) {
|
||||||
|
var total int
|
||||||
|
if err := st.pool.QueryRow(ctx, `
|
||||||
|
SELECT count(*) FROM track_reactions WHERE user_id = $1 AND reaction = $2::reaction`, userID, reaction).
|
||||||
|
Scan(&total); err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("playback: reacted count: %w", err)
|
||||||
|
}
|
||||||
|
args := []any{userID, reaction, limit}
|
||||||
|
cond := ""
|
||||||
|
if before != nil {
|
||||||
|
cond = "AND (created_at, track_id) < ($4, $5)"
|
||||||
|
args = append(args, before.CreatedAt, before.TrackID)
|
||||||
|
}
|
||||||
|
rows, err := st.pool.Query(ctx, `
|
||||||
|
SELECT track_id, created_at FROM track_reactions
|
||||||
|
WHERE user_id = $1 AND reaction = $2::reaction `+cond+`
|
||||||
|
ORDER BY created_at DESC, track_id DESC LIMIT $3`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("playback: reacted: %w", err)
|
||||||
|
}
|
||||||
|
out, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (Reacted, error) {
|
||||||
|
var x Reacted
|
||||||
|
return x, r.Scan(&x.TrackID, &x.CreatedAt)
|
||||||
|
})
|
||||||
|
return out, total, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionClientType — тип клиента сессии (web / android) по семье refresh-токенов.
|
||||||
|
func (st *Store) SessionClientType(ctx context.Context, sessionID uuid.UUID) string {
|
||||||
|
var ct string
|
||||||
|
if err := st.pool.QueryRow(ctx, `SELECT client_type FROM refresh_tokens WHERE family_id = $1 LIMIT 1`, sessionID).
|
||||||
|
Scan(&ct); err != nil {
|
||||||
|
return "web"
|
||||||
|
}
|
||||||
|
return ct
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrackDurations — длительности известных треков.
|
||||||
|
func (st *Store) TrackDurations(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID]int, error) {
|
||||||
|
rows, err := st.pool.Query(ctx, `SELECT id, duration_ms FROM tracks WHERE id = ANY($1)`, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("playback: durations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[uuid.UUID]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
id uuid.UUID
|
||||||
|
d int
|
||||||
|
)
|
||||||
|
if err := rows.Scan(&id, &d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[id] = d
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event — событие прослушивания к записи.
|
||||||
|
type Event struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
Type string
|
||||||
|
TrackID uuid.UUID
|
||||||
|
OccurredAt time.Time
|
||||||
|
PositionMs *int
|
||||||
|
PlayedMs *int
|
||||||
|
ContextType *string
|
||||||
|
ContextID *string
|
||||||
|
WaveSessionID *uuid.UUID
|
||||||
|
Offline bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertEvent записывает событие; false — такое событие уже было (повторная отправка).
|
||||||
|
func (st *Store) InsertEvent(ctx context.Context, tx pgx.Tx, userID uuid.UUID, clientType string, e Event) (bool, error) {
|
||||||
|
tag, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO play_events (id, user_id, track_id, type, occurred_at, position_ms, played_ms, context_type,
|
||||||
|
context_id, wave_session_id, client_type, offline)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
ON CONFLICT DO NOTHING`,
|
||||||
|
e.ID, userID, e.TrackID, e.Type, e.OccurredAt, e.PositionMs, e.PlayedMs, e.ContextType, e.ContextID,
|
||||||
|
e.WaveSessionID, clientType, e.Offline)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("playback: insert event: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyOutcome обновляет статистику пользователя по треку и историю.
|
||||||
|
func (st *Store) ApplyOutcome(ctx context.Context, tx pgx.Tx, userID uuid.UUID, e Event, o Outcome) error {
|
||||||
|
b2i := func(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO user_track_stats (user_id, track_id, play_count, complete_count, skip_count, early_skip_count,
|
||||||
|
total_played_ms, last_played_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (user_id, track_id) DO UPDATE SET
|
||||||
|
play_count = user_track_stats.play_count + EXCLUDED.play_count,
|
||||||
|
complete_count = user_track_stats.complete_count + EXCLUDED.complete_count,
|
||||||
|
skip_count = user_track_stats.skip_count + EXCLUDED.skip_count,
|
||||||
|
early_skip_count = user_track_stats.early_skip_count + EXCLUDED.early_skip_count,
|
||||||
|
total_played_ms = user_track_stats.total_played_ms + EXCLUDED.total_played_ms,
|
||||||
|
last_played_at = GREATEST(user_track_stats.last_played_at, EXCLUDED.last_played_at)`,
|
||||||
|
userID, e.TrackID, b2i(o.Counted), b2i(o.Complete), b2i(o.Skip), b2i(o.EarlySkip), o.PlayedMs, e.OccurredAt); err != nil {
|
||||||
|
return fmt.Errorf("playback: stats: %w", err)
|
||||||
|
}
|
||||||
|
if o.History {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO listen_history (id, user_id, track_id, played_at, played_ms) VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT DO NOTHING`, e.ID, userID, e.TrackID, e.OccurredAt, o.PlayedMs); err != nil {
|
||||||
|
return fmt.Errorf("playback: history: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HistoryRow — запись истории.
|
||||||
|
type HistoryRow struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
TrackID uuid.UUID
|
||||||
|
PlayedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// History — страница истории, новые сверху, после курсора (played_at, id).
|
||||||
|
func (st *Store) History(ctx context.Context, userID uuid.UUID, before *HistoryRow, limit int) ([]HistoryRow, error) {
|
||||||
|
args := []any{userID, limit}
|
||||||
|
cond := ""
|
||||||
|
if before != nil {
|
||||||
|
cond = "AND (played_at, id) < ($3, $4)"
|
||||||
|
args = append(args, before.PlayedAt, before.ID)
|
||||||
|
}
|
||||||
|
rows, err := st.pool.Query(ctx, `
|
||||||
|
SELECT id, track_id, played_at FROM listen_history
|
||||||
|
WHERE user_id = $1 AND NOT hidden `+cond+`
|
||||||
|
ORDER BY played_at DESC, id DESC LIMIT $2`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("playback: history: %w", err)
|
||||||
|
}
|
||||||
|
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (HistoryRow, error) {
|
||||||
|
var h HistoryRow
|
||||||
|
return h, r.Scan(&h.ID, &h.TrackID, &h.PlayedAt)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearHistory скрывает историю (события и статистика остаются).
|
||||||
|
func (st *Store) ClearHistory(ctx context.Context, userID uuid.UUID) error {
|
||||||
|
if _, err := st.pool.Exec(ctx, `UPDATE listen_history SET hidden = true WHERE user_id = $1 AND NOT hidden`, userID); err != nil {
|
||||||
|
return fmt.Errorf("playback: clear history: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// State — состояние плеера пользователя.
|
||||||
|
type State struct {
|
||||||
|
TrackID *uuid.UUID
|
||||||
|
PositionMs int
|
||||||
|
Queue []uuid.UUID
|
||||||
|
QueueIndex int
|
||||||
|
Context map[string]any
|
||||||
|
Shuffle bool
|
||||||
|
RepeatMode string
|
||||||
|
Volume float32
|
||||||
|
DeviceID *string
|
||||||
|
UpdatedAt *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState — сохранённое состояние; пустое, если плеер ещё не запускался.
|
||||||
|
func (st *Store) GetState(ctx context.Context, userID uuid.UUID) (State, error) {
|
||||||
|
var (
|
||||||
|
s State
|
||||||
|
queue, ctx2 []byte
|
||||||
|
updated time.Time
|
||||||
|
)
|
||||||
|
err := st.pool.QueryRow(ctx, `
|
||||||
|
SELECT track_id, position_ms, queue, queue_index, context, shuffle, repeat_mode, volume, device_id, updated_at
|
||||||
|
FROM playback_state WHERE user_id = $1`, userID).
|
||||||
|
Scan(&s.TrackID, &s.PositionMs, &queue, &s.QueueIndex, &ctx2, &s.Shuffle, &s.RepeatMode, &s.Volume,
|
||||||
|
&s.DeviceID, &updated)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return State{Queue: []uuid.UUID{}, RepeatMode: "off", Volume: 1}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return State{}, fmt.Errorf("playback: get state: %w", err)
|
||||||
|
}
|
||||||
|
s.UpdatedAt = &updated
|
||||||
|
if err := json.Unmarshal(queue, &s.Queue); err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
if len(ctx2) > 0 {
|
||||||
|
_ = json.Unmarshal(ctx2, &s.Context)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutState сохраняет состояние (last-write-wins). Трек, удалённый из каталога, сохраняется как пустой.
|
||||||
|
func (st *Store) PutState(ctx context.Context, userID uuid.UUID, s State) (State, error) {
|
||||||
|
queue, err := json.Marshal(s.Queue)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
var ctxJSON []byte
|
||||||
|
if s.Context != nil {
|
||||||
|
if ctxJSON, err = json.Marshal(s.Context); err != nil {
|
||||||
|
return State{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = st.pool.Exec(ctx, `
|
||||||
|
INSERT INTO playback_state (user_id, track_id, position_ms, queue, queue_index, context, shuffle, repeat_mode,
|
||||||
|
volume, device_id, updated_at)
|
||||||
|
VALUES ($1, (SELECT id FROM tracks WHERE id = $2), $3, $4, $5, $6, $7, $8, $9, $10, now())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET track_id = EXCLUDED.track_id, position_ms = EXCLUDED.position_ms,
|
||||||
|
queue = EXCLUDED.queue, queue_index = EXCLUDED.queue_index, context = EXCLUDED.context,
|
||||||
|
shuffle = EXCLUDED.shuffle, repeat_mode = EXCLUDED.repeat_mode, volume = EXCLUDED.volume,
|
||||||
|
device_id = EXCLUDED.device_id, updated_at = now()`,
|
||||||
|
userID, s.TrackID, s.PositionMs, queue, s.QueueIndex, ctxJSON, s.Shuffle, s.RepeatMode, s.Volume, s.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
return State{}, fmt.Errorf("playback: put state: %w", err)
|
||||||
|
}
|
||||||
|
return st.GetState(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin открывает транзакцию.
|
||||||
|
func (st *Store) Begin(ctx context.Context) (pgx.Tx, error) { return st.pool.Begin(ctx) }
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"pipu/api/internal/platform/sign"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamTTL — срок жизни ссылки на стрим (ADR-006).
|
||||||
|
const StreamTTL = 12 * time.Hour
|
||||||
|
|
||||||
|
// StreamURL подписывает ссылку на стрим трека для пользователя. Качество клиент добавляет сам (&q=).
|
||||||
|
func StreamURL(s *sign.Signer, userID, trackID uuid.UUID, now time.Time) string {
|
||||||
|
exp := now.Add(StreamTTL).Unix()
|
||||||
|
e := strconv.FormatInt(exp, 10)
|
||||||
|
return "/api/v1/stream/" + trackID.String() + "?u=" + userID.String() + "&exp=" + e +
|
||||||
|
"&sig=" + s.Sign("stream", userID.String(), trackID.String(), e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyStream проверяет подпись и срок.
|
||||||
|
func VerifyStream(s *sign.Signer, sig string, userID, trackID uuid.UUID, exp int64, now time.Time) bool {
|
||||||
|
if now.Unix() > exp {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return s.Verify(sig, "stream", userID.String(), trackID.String(), strconv.FormatInt(exp, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIME-типы оригиналов.
|
||||||
|
var mimeTypes = map[string]string{
|
||||||
|
"flac": "audio/flac", "mp3": "audio/mpeg", "m4a": "audio/mp4", "aac": "audio/aac", "ogg": "audio/ogg",
|
||||||
|
"opus": "audio/ogg; codecs=opus", "wav": "audio/wav", "aiff": "audio/aiff", "aif": "audio/aiff",
|
||||||
|
"wv": "audio/x-wavpack", "ape": "audio/x-ape",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentType — MIME оригинала по формату.
|
||||||
|
func ContentType(format string) string {
|
||||||
|
if m, ok := mimeTypes[format]; ok {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
return "application/octet-stream"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcoder перекодирует в Opus и хранит результат в кэше. Один файл в одном качестве
|
||||||
|
// кодируется не больше одного раза одновременно; общее число ffmpeg ограничено.
|
||||||
|
type Transcoder struct {
|
||||||
|
FFmpeg string
|
||||||
|
Dir string // /cache/transcode
|
||||||
|
LimitGB int
|
||||||
|
log *slog.Logger
|
||||||
|
sem chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
inflight map[string]*flight
|
||||||
|
}
|
||||||
|
|
||||||
|
type flight struct {
|
||||||
|
done chan struct{}
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTranscoder создаёт транскодер с concurrency параллельными ffmpeg.
|
||||||
|
func NewTranscoder(ffmpeg, dir string, concurrency, limitGB int, log *slog.Logger) *Transcoder {
|
||||||
|
return &Transcoder{
|
||||||
|
FFmpeg: ffmpeg, Dir: dir, LimitGB: limitGB, log: log,
|
||||||
|
sem: make(chan struct{}, max(1, concurrency)), inflight: map[string]*flight{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheName — имя файла транскода: зависит от файла и его размера, чтобы изменённый файл
|
||||||
|
// не отдавался из устаревшего кэша.
|
||||||
|
func CacheName(fileID uuid.UUID, size int64, quality string) string {
|
||||||
|
return fmt.Sprintf("%s-%d-%s.opus", fileID, size, quality)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure возвращает путь к готовому транскоду, кодируя его при необходимости.
|
||||||
|
func (t *Transcoder) Ensure(ctx context.Context, src string, name, quality string) (string, error) {
|
||||||
|
dst := filepath.Join(t.Dir, name)
|
||||||
|
if _, err := os.Stat(dst); err == nil {
|
||||||
|
now := time.Now()
|
||||||
|
_ = os.Chtimes(dst, now, now) // LRU: отмечаем использование
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
t.mu.Lock()
|
||||||
|
if f, ok := t.inflight[name]; ok {
|
||||||
|
t.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-f.done:
|
||||||
|
return dst, f.err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f := &flight{done: make(chan struct{})}
|
||||||
|
t.inflight[name] = f
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
f.err = t.encode(src, dst, quality)
|
||||||
|
close(f.done)
|
||||||
|
t.mu.Lock()
|
||||||
|
delete(t.inflight, name)
|
||||||
|
t.mu.Unlock()
|
||||||
|
return dst, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// encode не зависит от контекста запроса: если слушатель ушёл, транскод всё равно пригодится.
|
||||||
|
func (t *Transcoder) encode(src, dst, quality string) error {
|
||||||
|
t.sem <- struct{}{}
|
||||||
|
defer func() { <-t.sem }()
|
||||||
|
if err := os.MkdirAll(t.Dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmp := dst + ".tmp"
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, t.FFmpeg, "-v", "error", "-y", "-i", src, "-map", "0:a:0",
|
||||||
|
"-c:a", "libopus", "-b:a", fmt.Sprintf("%dk", opusKbps[quality]), "-vbr", "on", "-f", "ogg", tmp).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return fmt.Errorf("ffmpeg opus: %s", strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCleanup раз в час удаляет давно не использованные транскоды сверх лимита.
|
||||||
|
func (t *Transcoder) RunCleanup(ctx context.Context) {
|
||||||
|
tick := time.NewTicker(time.Hour)
|
||||||
|
defer tick.Stop()
|
||||||
|
for {
|
||||||
|
t.cleanup()
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Transcoder) cleanup() {
|
||||||
|
type entry struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
files []entry
|
||||||
|
total int64
|
||||||
|
)
|
||||||
|
_ = filepath.WalkDir(t.Dir, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() || !strings.HasSuffix(p, ".opus") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info, err := d.Info(); err == nil {
|
||||||
|
files = append(files, entry{p, info.Size(), info.ModTime()})
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
limit := int64(t.LimitGB) << 30
|
||||||
|
if total <= limit {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sort.Slice(files, func(i, j int) bool { return files[i].at.Before(files[j].at) })
|
||||||
|
removed := 0
|
||||||
|
for _, f := range files {
|
||||||
|
if total <= limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if os.Remove(f.path) == nil {
|
||||||
|
total -= f.size
|
||||||
|
removed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.log.Info("transcode cache trimmed", "removed", removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// accelPath — внутренний путь nginx к оригиналу (сегменты экранированы).
|
||||||
|
func accelPath(f StreamFile) string {
|
||||||
|
segs := strings.Split(f.RelPath, "/")
|
||||||
|
if f.SourceType == "local" && f.LocalPath != nil && *f.LocalPath != "" {
|
||||||
|
segs = append(strings.Split(*f.LocalPath, "/"), segs...)
|
||||||
|
}
|
||||||
|
for i, s := range segs {
|
||||||
|
segs[i] = url.PathEscape(s)
|
||||||
|
}
|
||||||
|
if f.SourceType == "local" {
|
||||||
|
return "/_internal/library-host/" + strings.Join(segs, "/")
|
||||||
|
}
|
||||||
|
return "/_internal/library/" + f.SourceID.String() + "/" + strings.Join(segs, "/")
|
||||||
|
}
|
||||||
@@ -141,8 +141,8 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
for _, t := range rows {
|
if out.Tracks, err = h.dto.Tracks(ctx, rows); err != nil {
|
||||||
out.Tracks = append(out.Tracks, h.dto.Track(t))
|
return out, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"pipu/api/internal/auth"
|
"pipu/api/internal/auth"
|
||||||
"pipu/api/internal/library"
|
"pipu/api/internal/library"
|
||||||
"pipu/api/internal/platform/httpx"
|
"pipu/api/internal/platform/httpx"
|
||||||
|
"pipu/api/internal/playback"
|
||||||
"pipu/api/internal/search"
|
"pipu/api/internal/search"
|
||||||
"pipu/api/internal/system"
|
"pipu/api/internal/system"
|
||||||
)
|
)
|
||||||
@@ -24,6 +25,8 @@ type Deps struct {
|
|||||||
// Library — хранилища, сканы и каталог.
|
// Library — хранилища, сканы и каталог.
|
||||||
Library *library.Handler
|
Library *library.Handler
|
||||||
Search *search.Handler
|
Search *search.Handler
|
||||||
|
// Playback — стрим, события, история, реакции, состояние плеера.
|
||||||
|
Playback *playback.Handler
|
||||||
// AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов.
|
// AuthService проверяет access-токены в middleware. nil — только для тестов служебных эндпоинтов.
|
||||||
AuthService *auth.Service
|
AuthService *auth.Service
|
||||||
}
|
}
|
||||||
@@ -32,10 +35,11 @@ type Deps struct {
|
|||||||
// По мере появления модулей (catalog, ...) они встраиваются сюда.
|
// По мере появления модулей (catalog, ...) они встраиваются сюда.
|
||||||
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
|
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
|
||||||
type (
|
type (
|
||||||
systemHandler = system.Handler
|
systemHandler = system.Handler
|
||||||
authHandler = auth.Handler
|
authHandler = auth.Handler
|
||||||
libraryHandler = library.Handler
|
libraryHandler = library.Handler
|
||||||
searchHandler = search.Handler
|
searchHandler = search.Handler
|
||||||
|
playbackHandler = playback.Handler
|
||||||
)
|
)
|
||||||
|
|
||||||
type handlers struct {
|
type handlers struct {
|
||||||
@@ -43,6 +47,7 @@ type handlers struct {
|
|||||||
*authHandler
|
*authHandler
|
||||||
*libraryHandler
|
*libraryHandler
|
||||||
*searchHandler
|
*searchHandler
|
||||||
|
*playbackHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ gen.StrictServerInterface = handlers{}
|
var _ gen.StrictServerInterface = handlers{}
|
||||||
@@ -62,10 +67,11 @@ func NewRouter(d Deps) http.Handler {
|
|||||||
r.NotFound(httpx.NotFound)
|
r.NotFound(httpx.NotFound)
|
||||||
r.MethodNotAllowed(httpx.MethodNotAllowed)
|
r.MethodNotAllowed(httpx.MethodNotAllowed)
|
||||||
|
|
||||||
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search}, nil, gen.StrictHTTPServerOptions{
|
strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback},
|
||||||
RequestErrorHandlerFunc: httpx.RequestError,
|
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
|
||||||
ResponseErrorHandlerFunc: httpx.ResponseError(log),
|
RequestErrorHandlerFunc: httpx.RequestError,
|
||||||
})
|
ResponseErrorHandlerFunc: httpx.ResponseError(log),
|
||||||
|
})
|
||||||
return gen.HandlerWithOptions(strict, gen.ChiServerOptions{
|
return gen.HandlerWithOptions(strict, gen.ChiServerOptions{
|
||||||
BaseRouter: r,
|
BaseRouter: r,
|
||||||
// Ошибки разбора path- и query-параметров — тоже problem+json, а не текст по умолчанию.
|
// Ошибки разбора path- и query-параметров — тоже problem+json, а не текст по умолчанию.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TYPE reaction AS ENUM ('like', 'dislike');
|
||||||
|
|
||||||
|
CREATE TABLE track_reactions (
|
||||||
|
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
reaction reaction NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, track_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX track_reactions_list_idx ON track_reactions (user_id, reaction, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE artist_reactions (
|
||||||
|
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
|
reaction reaction NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, artist_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Сырые события. Помесячные партиции добавим, когда объём того потребует; пока всё в DEFAULT.
|
||||||
|
CREATE TABLE play_events (
|
||||||
|
id uuid NOT NULL,
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
track_id uuid NOT NULL,
|
||||||
|
type text NOT NULL,
|
||||||
|
occurred_at timestamptz NOT NULL,
|
||||||
|
received_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
position_ms int,
|
||||||
|
played_ms int,
|
||||||
|
context_type text,
|
||||||
|
context_id text,
|
||||||
|
wave_session_id uuid,
|
||||||
|
client_type text NOT NULL,
|
||||||
|
offline boolean NOT NULL DEFAULT false,
|
||||||
|
PRIMARY KEY (id, occurred_at)
|
||||||
|
) PARTITION BY RANGE (occurred_at);
|
||||||
|
CREATE TABLE play_events_default PARTITION OF play_events DEFAULT;
|
||||||
|
CREATE INDEX play_events_user_idx ON play_events (user_id, occurred_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE user_track_stats (
|
||||||
|
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
play_count int NOT NULL DEFAULT 0,
|
||||||
|
complete_count int NOT NULL DEFAULT 0,
|
||||||
|
skip_count int NOT NULL DEFAULT 0,
|
||||||
|
early_skip_count int NOT NULL DEFAULT 0,
|
||||||
|
total_played_ms bigint NOT NULL DEFAULT 0,
|
||||||
|
last_played_at timestamptz,
|
||||||
|
implicit_score real NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, track_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- История: по строке на прослушивание ≥ 30 с (или коротких треков целиком). id — id события.
|
||||||
|
CREATE TABLE listen_history (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
played_at timestamptz NOT NULL,
|
||||||
|
played_ms int NOT NULL,
|
||||||
|
hidden boolean NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
CREATE INDEX listen_history_user_idx ON listen_history (user_id, played_at DESC, id DESC) WHERE NOT hidden;
|
||||||
|
|
||||||
|
CREATE TABLE playback_state (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
|
||||||
|
position_ms int NOT NULL DEFAULT 0,
|
||||||
|
queue jsonb NOT NULL DEFAULT '[]',
|
||||||
|
queue_index int NOT NULL DEFAULT 0,
|
||||||
|
context jsonb,
|
||||||
|
shuffle boolean NOT NULL DEFAULT false,
|
||||||
|
repeat_mode text NOT NULL DEFAULT 'off',
|
||||||
|
volume real NOT NULL DEFAULT 1,
|
||||||
|
device_id text,
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE playback_state;
|
||||||
|
DROP TABLE listen_history;
|
||||||
|
DROP TABLE user_track_stats;
|
||||||
|
DROP TABLE play_events;
|
||||||
|
DROP TABLE artist_reactions;
|
||||||
|
DROP TABLE track_reactions;
|
||||||
|
DROP TYPE reaction;
|
||||||
Reference in new issue
Block a user