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 }