Files
pipu-music/services/api/internal/playback/store.go
T
mrleo1nidandClaude Opus 5.5 3908814cda
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 9s
ci / api / api (push) Successful in 35s
ci / ts / ts (packages + web) (push) Successful in 51s
feat(api): My Wave sessions with a Go fallback and wave metrics (M4)
- /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>
2026-10-20 12:23:17 +03:00

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
}