- /wave/options, POST /wave/sessions (activity, mood, seed), /next with reactions, PATCH to switch the context on the fly, DELETE - the recommender is called with an 800 ms budget; on failure the api serves a fallback wave of likes, favourite artists and random available tracks with the same dislike / recent / served filters - every served track is an impression with its source and model; wave events (context_type wave) set its outcome, reactions win - library status shows the analysis queue and 7-day wave metrics: listen-through, early skip and like rate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
365 lines
13 KiB
Go
365 lines
13 KiB
Go
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) }
|
|
|
|
// MarkImpression записывает исход показа трека в сессии волны (для метрик и обучения).
|
|
// Реакция (like / dislike) важнее исхода прослушивания и перезаписывает его.
|
|
func (st *Store) MarkImpression(ctx context.Context, tx pgx.Tx, sessionID, trackID uuid.UUID, outcome string) error {
|
|
_, err := tx.Exec(ctx, `
|
|
UPDATE wave_impressions SET outcome = $3
|
|
WHERE wave_session_id = $1 AND track_id = $2
|
|
AND (outcome IS NULL OR $3 IN ('like', 'dislike') OR outcome NOT IN ('like', 'dislike'))`,
|
|
sessionID, trackID, outcome)
|
|
if err != nil {
|
|
return fmt.Errorf("playback: impression: %w", err)
|
|
}
|
|
return nil
|
|
}
|