This commit is contained in:
2026-10-18 05:28:57 +07:00
parent bfd6841d7d
commit e96199afba
18 changed files with 2331 additions and 1193 deletions
+2
View File
@@ -18,6 +18,8 @@ func playlistErrorStatus(err error, fallback string) error {
errors.Is(err, playlist_service.ErrGameNotFound),
errors.Is(err, playlist_service.ErrScenarioNotFound):
return status.Error(codes.NotFound, err.Error())
case errors.Is(err, playlist_service.ErrTrackOrderChanged):
return status.Error(codes.Aborted, err.Error())
case errors.Is(err, playlist_service.ErrPlaylistInUse):
return status.Error(codes.FailedPrecondition, err.Error())
default:
+47
View File
@@ -0,0 +1,47 @@
package app
import (
"context"
"errors"
"testing"
"evening_detective_server/internal/services/playlist_service"
proto "evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestPlaylistOrderErrorCodes(t *testing.T) {
cases := []struct {
name string
err error
code codes.Code
}{
{"invalid", playlist_service.ErrInvalidTrack, codes.InvalidArgument},
{"conflict", playlist_service.ErrTrackOrderChanged, codes.Aborted},
{"missing", playlist_service.ErrPlaylistNotFound, codes.NotFound},
{"internal", errors.New("secret SQL"), codes.Internal},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := playlistErrorStatus(tc.err, "Не удалось сохранить порядок")
if status.Code(err) != tc.code {
t.Fatal(err)
}
if tc.code == codes.Internal && status.Convert(err).Message() != "Не удалось сохранить порядок" {
t.Fatal("internal details exposed")
}
})
}
}
func TestPlaylistOrderRequiresAuthentication(t *testing.T) {
for _, ctx := range []context.Context{context.Background()} {
t.Run("anonymous", func(t *testing.T) {
_, err := (&server{}).ReorderPlaylistTracks(ctx, &proto.ReorderPlaylistTracksReq{PlaylistID: 1, TrackIDs: []int32{1}})
if status.Code(err) != codes.PermissionDenied {
t.Fatal(err)
}
})
}
}
+15
View File
@@ -159,3 +159,18 @@ func (s *server) SetGameMusic(ctx context.Context, req *proto.SetGameMusicReq) (
}
return &proto.SetGameMusicRsp{State: mapMusicState(state)}, nil
}
func (s *server) ReorderPlaylistTracks(ctx context.Context, req *proto.ReorderPlaylistTracksReq) (*proto.ReorderPlaylistTracksRsp, error) {
if !canManagePlaylists(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
ids := make([]int, len(req.GetTrackIDs()))
for i, id := range req.GetTrackIDs() {
ids[i] = int(id)
}
item, err := s.playlistService.Reorder(ctx, int(req.GetPlaylistID()), ids)
if err != nil {
return nil, playlistErrorStatus(err, "не удалось сохранить порядок треков")
}
return &proto.ReorderPlaylistTracksRsp{Playlist: mapPlaylist(item)}, nil
}
+6 -3
View File
@@ -123,14 +123,17 @@ func (s *server) BindScreen(ctx context.Context, req *proto.BindScreenReq) (*pro
return nil, status.Error(codes.NotFound, "игра не найдена")
}
hash := hashScreenToken(req.AccessToken)
if err = s.screenService.Bind(ctx, id, int(req.GameID), hash); err != nil {
isNewBinding, err := s.screenService.Bind(ctx, id, int(req.GameID), hash, req.GetInitializeAudio())
if err != nil {
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
}
return nil, status.Error(codes.Internal, "не удалось привязать экран")
}
if err := s.screenPlanService.ApplyCurrent(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.Internal, "не удалось применить план экранов")
if isNewBinding {
if err := s.screenPlanService.ApplyCurrent(ctx, int(req.GameID), id); err != nil {
return nil, status.Error(codes.Internal, "не удалось применить план экранов")
}
}
session, err := s.screenService.Get(ctx, id, hash)
if err != nil {
@@ -0,0 +1,88 @@
package playlists_repo
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"evening_detective_server/internal/repos/games_repo"
"evening_detective_server/internal/repos/scenarios_repo"
"evening_detective_server/internal/repos/screen_sessions_repo"
"evening_detective_server/internal/repos/user_agreements_repo"
"evening_detective_server/internal/repos/users_repo"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type playlistFixture struct {
pool *pgxpool.Pool
repo *Repo
screens *screen_sessions_repo.ScreenSessionsRepo
gameID int
}
// newPlaylistFixture uses real migrations in an isolated test schema; all game data uses repositories.
func newPlaylistFixture(t *testing.T) playlistFixture {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set TEST_DATABASE_URL for PostgreSQL playlist checks")
}
ctx := context.Background()
admin, err := pgxpool.New(ctx, dsn)
requireNoError(t, err)
schema := fmt.Sprintf("playlist_test_%d", time.Now().UnixNano())
quoted := pgx.Identifier{schema}.Sanitize()
_, err = admin.Exec(ctx, "CREATE SCHEMA "+quoted)
requireNoError(t, err)
t.Cleanup(func() {
_, err := admin.Exec(ctx, "DROP SCHEMA "+quoted+" CASCADE")
if err != nil {
t.Error(err)
}
admin.Close()
})
config, err := pgxpool.ParseConfig(dsn)
requireNoError(t, err)
config.ConnConfig.RuntimeParams["search_path"] = schema
config.ConnConfig.RuntimeParams["timezone"] = "UTC"
pool, err := pgxpool.NewWithConfig(ctx, config)
requireNoError(t, err)
t.Cleanup(pool.Close)
files, err := filepath.Glob("../../../migrations/*.sql")
requireNoError(t, err)
for _, name := range files {
raw, err := os.ReadFile(name)
requireNoError(t, err)
_, err = pool.Exec(ctx, strings.Split(string(raw), "-- +goose Down")[0])
if err != nil {
t.Fatalf("migration %s: %v", name, err)
}
}
f := playlistFixture{pool: pool, repo: New(pool), screens: screen_sessions_repo.NewScreenSessionsRepo(pool)}
users := users_repo.NewUserRepo(pool)
scenarios := scenarios_repo.NewScenariosRepo(pool)
games := games_repo.NewGamesRepo(pool)
var userID, scenarioID int
userID, err = users.AddUserWithAgreements(ctx, "organizer", "export@test.invalid", "hash", []string{"organizer"},
[]user_agreements_repo.Agreement{{AgreementType: "terms", Version: "test"}})
requireNoError(t, err)
scenarioID, err = scenarios.AddScenario(ctx, "Original story", userID)
requireNoError(t, err)
requireNoError(t, scenarios.UpdateStoryByScenarioID(ctx, scenarioID,
`{"introduction":{"text":"Intro"},"questions":[{"code":"q","text":"Who?","answer":"Butler"}]}`))
f.gameID, err = games.AddGame(ctx, "Test export", "Original description", nil, time.Now().Add(12*time.Hour), int32(scenarioID), "offline")
requireNoError(t, err)
return f
}
func requireNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
+103
View File
@@ -0,0 +1,103 @@
package playlists_repo
import (
"context"
"errors"
"slices"
"github.com/jackc/pgx/v5"
)
func lockPlaylist(ctx context.Context, tx pgx.Tx, id int) error {
var found int
err := tx.QueryRow(
ctx,
`SELECT id
FROM playlists
WHERE id = $1
FOR UPDATE`,
id,
).Scan(&found)
if errors.Is(err, pgx.ErrNoRows) {
return ErrPlaylistNotFound
}
return err
}
// Reorder atomically changes order and preserves each game's current track.
func (r *Repo) Reorder(ctx context.Context, playlistID int, trackIDs []int) (*Playlist, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := lockPlaylist(ctx, tx, playlistID); err != nil {
return nil, err
}
rows, err := tx.Query(
ctx,
`SELECT id
FROM playlist_tracks
WHERE playlist_id = $1
ORDER BY position, id`,
playlistID,
)
if err != nil {
return nil, err
}
old, err := pgx.CollectRows(rows, pgx.RowTo[int])
if err != nil {
return nil, err
}
sortedOld, sortedNew := slices.Clone(old), slices.Clone(trackIDs)
slices.Sort(sortedOld)
slices.Sort(sortedNew)
if !slices.Equal(sortedOld, sortedNew) {
return nil, ErrTrackOrderChanged
}
if !slices.Equal(old, trackIDs) {
// Lock state before changing indices; timing and playback remain untouched.
_, err = tx.Exec(
ctx,
`UPDATE game_music_states ms
SET track_index = COALESCE(array_position($3::int[], ($2::int[])[ms.track_index + 1]) - 1, 0),
state_version = ms.state_version + 1,
updated_at = NOW()
FROM games g
JOIN scenarios s ON s.id = g.scenario_id
WHERE ms.game_id = g.id
AND COALESCE(ms.playlist_id, g.playlist_id, s.playlist_id) = $1`,
playlistID,
old,
trackIDs,
)
if err != nil {
return nil, err
}
_, err = tx.Exec(
ctx,
`UPDATE playlist_tracks t
SET position = ordered.position - 1
FROM unnest($2::int[]) WITH ORDINALITY AS ordered(id, position)
WHERE t.playlist_id = $1 AND t.id = ordered.id`,
playlistID,
trackIDs,
)
if err != nil {
return nil, err
}
}
items, err := list(ctx, tx)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
for _, item := range items {
if item.ID == playlistID {
return item, nil
}
}
return nil, ErrPlaylistNotFound
}
+247
View File
@@ -0,0 +1,247 @@
package playlists_repo
import (
"context"
"errors"
"fmt"
"slices"
"sync"
"testing"
"time"
)
func TestReorderPreservesPlaybackAndRejectsChangedMembership(t *testing.T) {
for _, playback := range []int32{1, 2, 3} {
t.Run(map[int32]string{1: "stopped", 2: "playing", 3: "paused"}[playback], func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
p, err := f.repo.Create(ctx, "Music")
requireNoError(t, err)
for _, name := range []string{"One", "Two", "Three"} {
requireNoError(t, f.repo.AddTrack(ctx, p.ID, name, name+".mp3"))
}
p, err = f.repo.Get(ctx, p.ID)
requireNoError(t, err)
before, err := f.repo.SetMusic(ctx, f.gameID, p.ID, 1, 1234, 37, true, playback)
requireNoError(t, err)
if err := f.repo.Delete(ctx, p.ID); !errors.Is(err, ErrPlaylistInUse) {
t.Fatalf("active playlist deletion: %v", err)
}
if _, err := f.repo.DeleteTrack(ctx, p.ID, p.Tracks[0].ID); !errors.Is(err, ErrPlaylistInUse) {
t.Fatalf("active track deletion: %v", err)
}
order := []int{p.Tracks[1].ID, p.Tracks[2].ID, p.Tracks[0].ID}
reordered, err := f.repo.Reorder(ctx, p.ID, order)
requireNoError(t, err)
if reordered.Tracks[0].ID != p.Tracks[1].ID {
t.Fatal("order not saved")
}
after, err := f.repo.GetMusic(ctx, f.gameID)
requireNoError(t, err)
if after.TrackIndex != 0 || after.Playlist.Tracks[0].ID != before.Playlist.Tracks[1].ID || after.Playback != playback || after.Volume != 37 || !after.Loop || after.Version != before.Version+1 {
t.Fatalf("transport changed: %+v", after)
}
if after.PositionMs < before.PositionMs || after.PositionMs-before.PositionMs > 1000 || !sameTime(before.StartedAt, after.StartedAt) {
t.Fatal("playback position reset")
}
_, err = f.repo.Reorder(ctx, p.ID, order)
requireNoError(t, err)
unchanged, err := f.repo.GetMusic(ctx, f.gameID)
requireNoError(t, err)
if unchanged.Version != after.Version {
t.Fatal("no-op changed version")
}
for _, invalid := range [][]int{order[:2], {order[0], order[0], order[2]}, {order[0], order[1], 999999}} {
if _, err := f.repo.Reorder(ctx, p.ID, invalid); !errors.Is(err, ErrTrackOrderChanged) {
t.Fatalf("expected membership conflict, got %v", err)
}
}
current, err := f.repo.Get(ctx, p.ID)
requireNoError(t, err)
for i, track := range current.Tracks {
if track.ID != order[i] {
t.Fatal("failed reorder changed data")
}
}
empty, err := f.repo.Create(ctx, "Empty")
requireNoError(t, err)
_, err = f.repo.Reorder(ctx, empty.ID, nil)
requireNoError(t, err)
if _, err := f.repo.Reorder(ctx, 999999, nil); !errors.Is(err, ErrPlaylistNotFound) {
t.Fatal(err)
}
})
}
}
func sameTime(a, b *time.Time) bool {
return a == nil && b == nil || a != nil && b != nil && a.Equal(*b)
}
func TestConcurrentAdditionsKeepDistinctPositions(t *testing.T) {
for _, count := range []int{8} {
t.Run("parallel uploads", func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
p, err := f.repo.Create(ctx, "Concurrent")
requireNoError(t, err)
var wg sync.WaitGroup
failures := make(chan error, count)
for i := 0; i < count; i++ {
wg.Go(func() { failures <- f.repo.AddTrack(ctx, p.ID, "Track", fmt.Sprintf("track-%d.mp3", i)) })
}
wg.Wait()
close(failures)
for err := range failures {
requireNoError(t, err)
}
p, err = f.repo.Get(ctx, p.ID)
requireNoError(t, err)
positions := make([]int, len(p.Tracks))
for i, track := range p.Tracks {
positions[i] = track.Position
}
if !slices.Equal(positions, []int{0, 1, 2, 3, 4, 5, 6, 7}) {
t.Fatal(positions)
}
})
}
}
func TestBindInitializesAudioWithoutChangingTransport(t *testing.T) {
for _, initialize := range []bool{false, true} {
t.Run(map[bool]string{false: "legacy", true: "initialize"}[initialize], func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
first, err := f.screens.Create(ctx, "first")
requireNoError(t, err)
requireNoError(t, f.screens.Bind(ctx, first, f.gameID, "first"))
requireNoError(t, f.screens.SetSound(ctx, first, true))
requireNoError(t, f.screens.SetCommand(ctx, f.gameID, 2, 3456, 39))
before, err := f.repo.SetMusic(ctx, f.gameID, 0, 0, 1234, 42, true, 2)
requireNoError(t, err)
second, err := f.screens.Create(ctx, "second")
requireNoError(t, err)
if _, err := f.screens.BindWithAudio(ctx, second, f.gameID, "wrong", initialize); err == nil {
t.Fatal("bad token accepted")
}
isNewBinding, err := f.screens.BindWithAudio(ctx, second, f.gameID, "second", initialize)
requireNoError(t, err)
if !isNewBinding {
t.Fatal("new binding not detected")
}
isNewBinding, err = f.screens.BindWithAudio(ctx, second, f.gameID, "second", initialize)
requireNoError(t, err)
if isNewBinding {
t.Fatal("repeat binding would reset transport")
}
requireNoError(t, f.screens.InitializeScreen(ctx, second, f.gameID, 1, "", "Pause", 600))
original, err := f.screens.Get(ctx, first, "first")
requireNoError(t, err)
joined, err := f.screens.Get(ctx, second, "second")
requireNoError(t, err)
after, err := f.repo.GetMusic(ctx, f.gameID)
requireNoError(t, err)
if original.Command != 2 || original.Position != 3456 || joined.Command != 2 || joined.Position != 3456 {
t.Fatal("join reset display")
}
if original.Sound == initialize || joined.Sound != initialize {
t.Fatal("incorrect sound assignment")
}
expectedVolume := int32(42)
if initialize {
expectedVolume = 100
if joined.Volume != 100 {
t.Fatal(joined.Volume)
}
}
if after.Volume != expectedVolume || after.Playback != before.Playback || !sameTime(after.StartedAt, before.StartedAt) {
t.Fatal("audio initialization changed playback")
}
})
}
}
func TestReorderReadersSeeConsistentTrackAndIndex(t *testing.T) {
for _, iterations := range []int{20} {
t.Run("concurrent snapshots", func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
p, err := f.repo.Create(ctx, "Snapshot")
requireNoError(t, err)
for _, name := range []string{"a", "b", "c"} {
requireNoError(t, f.repo.AddTrack(ctx, p.ID, name, name+".mp3"))
}
p, err = f.repo.Get(ctx, p.ID)
requireNoError(t, err)
_, err = f.repo.SetMusic(ctx, f.gameID, p.ID, 1, 1000, 50, true, 3)
requireNoError(t, err)
old := []int{p.Tracks[0].ID, p.Tracks[1].ID, p.Tracks[2].ID}
reordered := []int{old[1], old[2], old[0]}
failures := make(chan error, 1)
go func() {
for i := 0; i < iterations; i++ {
order := old
if i%2 == 0 {
order = reordered
}
if _, err := f.repo.Reorder(ctx, p.ID, order); err != nil {
failures <- err
return
}
}
failures <- nil
}()
for i := 0; i < iterations; i++ {
state, err := f.repo.GetMusic(ctx, f.gameID)
requireNoError(t, err)
if state.Playlist.Tracks[state.TrackIndex].ID != old[1] {
t.Fatal("mixed playlist order and current index")
}
}
requireNoError(t, <-failures)
})
}
}
func TestConcurrentBindingsAssignSoundOnce(t *testing.T) {
for _, count := range []int{2} {
t.Run("two screens", func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
ids := make([]int, count)
for i := range ids {
var err error
ids[i], err = f.screens.Create(ctx, fmt.Sprintf("token-%d", i))
requireNoError(t, err)
}
failures := make(chan error, count)
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
_, err := f.screens.BindWithAudio(ctx, id, f.gameID, fmt.Sprintf("token-%d", i), true)
failures <- err
})
}
wg.Wait()
close(failures)
for err := range failures {
requireNoError(t, err)
}
screens, err := f.screens.ListByGame(ctx, f.gameID)
requireNoError(t, err)
sound := 0
for _, s := range screens {
if s.Sound {
sound++
}
if s.Volume != 100 {
t.Fatal(s.Volume)
}
}
if sound != 1 {
t.Fatalf("%d screens have sound", sound)
}
})
}
}
+153 -32
View File
@@ -10,11 +10,12 @@ import (
)
var (
ErrPlaylistNotFound = errors.New("плейлист не найден")
ErrPlaylistInUse = errors.New("плейлист используется")
ErrTrackNotFound = errors.New("трек не найден")
ErrGameNotFound = errors.New("игра не найдена")
ErrScenarioNotFound = errors.New("сценарий не найден")
ErrPlaylistNotFound = errors.New("плейлист не найден")
ErrTrackOrderChanged = errors.New("состав треков изменился; обновите список")
ErrPlaylistInUse = errors.New("плейлист используется")
ErrTrackNotFound = errors.New("трек не найден")
ErrGameNotFound = errors.New("игра не найдена")
ErrScenarioNotFound = errors.New("сценарий не найден")
)
// Track describes a stored playlist track.
@@ -54,11 +55,28 @@ func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
// List returns all playlists with their tracks.
func (r *Repo) List(ctx context.Context) ([]*Playlist, error) {
rows, err := r.pool.Query(ctx, `
SELECT p.id, p.name, t.id, t.name, t.filename, t.position
return list(ctx, r.pool)
}
// IPlaylistReader reads catalogues from a pool or a consistent transaction snapshot.
type IPlaylistReader interface {
Query(context.Context, string, ...any) (pgx.Rows, error)
}
func list(ctx context.Context, reader IPlaylistReader) ([]*Playlist, error) {
rows, err := reader.Query(
ctx,
`SELECT
p.id,
p.name,
t.id,
t.name,
t.filename,
t.position
FROM playlists p
LEFT JOIN playlist_tracks t ON t.playlist_id = p.id
ORDER BY p.id, t.position, t.id`)
ORDER BY p.id, t.position, t.id`,
)
if err != nil {
return nil, err
}
@@ -136,68 +154,128 @@ func (r *Repo) Update(ctx context.Context, id int, name string) (*Playlist, erro
// Delete removes a playlist that is not assigned to a scenario or game.
func (r *Repo) Delete(ctx context.Context, id int) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if err := lockPlaylist(ctx, tx, id); err != nil {
return err
}
var used bool
if err := r.pool.QueryRow(ctx, `
if err := tx.QueryRow(
ctx,
`
SELECT EXISTS (SELECT 1 FROM scenarios WHERE playlist_id = $1)
OR EXISTS (SELECT 1 FROM games WHERE playlist_id = $1)
`, id).Scan(&used); err != nil {
OR EXISTS (SELECT 1 FROM game_music_states WHERE playlist_id = $1)
`,
id,
).Scan(&used); err != nil {
return err
}
if used {
return ErrPlaylistInUse
}
result, err := r.pool.Exec(ctx, `
result, err := tx.Exec(
ctx,
`
DELETE FROM playlists
WHERE id = $1
`, id)
`,
id,
)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrPlaylistNotFound
}
return nil
return tx.Commit(ctx)
}
// AddTrack adds a track to a playlist.
func (r *Repo) AddTrack(ctx context.Context, playlistID int, name, filename string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if err := lockPlaylist(ctx, tx, playlistID); err != nil {
return err
}
var position int
if err := r.pool.QueryRow(ctx, `
if err := tx.QueryRow(
ctx,
`
SELECT COALESCE(MAX(position), -1) + 1
FROM playlist_tracks
WHERE playlist_id = $1
`, playlistID).Scan(&position); err != nil {
`,
playlistID,
).Scan(&position); err != nil {
return err
}
_, err := r.pool.Exec(ctx, `
_, err = tx.Exec(
ctx,
`
INSERT INTO playlist_tracks (playlist_id, name, filename, position)
VALUES ($1, $2, $3, $4)
`, playlistID, name, filename, position)
return err
`,
playlistID,
name,
filename,
position,
)
if err != nil {
return err
}
return tx.Commit(ctx)
}
// DeleteTrack removes a track and returns its stored filename.
func (r *Repo) DeleteTrack(ctx context.Context, playlistID, trackID int) (string, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return "", err
}
defer tx.Rollback(ctx)
if err := lockPlaylist(ctx, tx, playlistID); err != nil {
return "", err
}
var filename string
var used bool
if err := r.pool.QueryRow(ctx, `
if err = tx.QueryRow(
ctx,
`
SELECT EXISTS (SELECT 1 FROM scenarios WHERE playlist_id = $1)
OR EXISTS (SELECT 1 FROM games WHERE playlist_id = $1)
`, playlistID).Scan(&used); err != nil {
OR EXISTS (SELECT 1 FROM game_music_states WHERE playlist_id = $1)
`,
playlistID,
).Scan(&used); err != nil {
return "", err
}
if used {
return "", ErrPlaylistInUse
}
err := r.pool.QueryRow(ctx, `
err = tx.QueryRow(
ctx,
`
DELETE FROM playlist_tracks
WHERE id = $1 AND playlist_id = $2
RETURNING filename
`, trackID, playlistID).Scan(&filename)
`,
trackID,
playlistID,
).Scan(&filename)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrTrackNotFound
}
return filename, err
if err != nil {
return "", err
}
return filename, tx.Commit(ctx)
}
// SetScenario assigns a playlist to a scenario.
@@ -244,9 +322,30 @@ func (r *Repo) SetGame(ctx context.Context, gameID, playlistID int) error {
// GetMusic returns the effective and current music state for a game.
func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) {
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
playlists, err := list(ctx, tx)
if err != nil {
return nil, err
}
get := func(id int) (*Playlist, error) {
for _, playlist := range playlists {
if playlist.ID == id {
return playlist, nil
}
}
return nil, ErrPlaylistNotFound
}
var gamePlaylistID, scenarioPlaylistID *int
err := r.pool.QueryRow(ctx, `
SELECT g.playlist_id, s.playlist_id
err = tx.QueryRow(
ctx,
`
SELECT
g.playlist_id,
s.playlist_id
FROM games g
JOIN scenarios s ON s.id = g.scenario_id
WHERE g.id = $1
@@ -265,18 +364,30 @@ func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) {
}
state := &MusicState{ScenarioID: scenarioPlaylistID, GameID: gamePlaylistID, Volume: 100, Playback: 1}
if playlistID != nil {
state.Playlist, err = r.Get(ctx, *playlistID)
state.Playlist, err = get(*playlistID)
if err != nil {
return nil, err
}
}
var trackIndex int
var statePlaylistID *int
err = r.pool.QueryRow(ctx, `
SELECT playlist_id, track_index, position_ms, volume, loop, playback, state_version, started_at
err = tx.QueryRow(
ctx,
`
SELECT
playlist_id,
track_index,
position_ms,
volume,
loop,
playback,
state_version,
started_at
FROM game_music_states
WHERE game_id = $1
`, gameID).
`,
gameID,
).
Scan(&statePlaylistID, &trackIndex, &state.PositionMs, &state.Volume, &state.Loop, &state.Playback, &state.Version, &state.StartedAt)
if errors.Is(err, pgx.ErrNoRows) {
state.Version = 1
@@ -290,7 +401,7 @@ func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) {
state.PositionMs += time.Since(*state.StartedAt).Milliseconds()
}
if statePlaylistID != nil && (state.Playlist == nil || state.Playlist.ID != *statePlaylistID) {
state.Playlist, err = r.Get(ctx, *statePlaylistID)
state.Playlist, err = get(*statePlaylistID)
if err != nil {
return nil, err
}
@@ -300,12 +411,19 @@ func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) {
// SetMusic stores the current music state for a game.
func (r *Repo) SetMusic(ctx context.Context, gameID, playlistID, trackIndex int, positionMs int64, volume int32, loop bool, playback int32) (*MusicState, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if playlistID > 0 {
if _, err := r.Get(ctx, playlistID); err != nil {
if err := lockPlaylist(ctx, tx, playlistID); err != nil {
return nil, err
}
}
_, err := r.pool.Exec(ctx, `
_, err = tx.Exec(
ctx,
`
INSERT INTO game_music_states (
game_id, playlist_id, track_index, position_ms, volume, loop,
playback, state_version, started_at
@@ -336,5 +454,8 @@ func (r *Repo) SetMusic(ctx context.Context, gameID, playlistID, trackIndex int,
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.GetMusic(ctx, gameID)
}
+135 -9
View File
@@ -198,17 +198,84 @@ func (r *ScreenSessionsRepo) SetTimerCommand(ctx context.Context, gameID int, co
// Bind attaches a screen session to a game.
func (r *ScreenSessionsRepo) Bind(ctx context.Context, id, gameID int, tokenHash string) error {
result, err := r.pool.Exec(ctx, `
UPDATE screen_sessions
SET game_id = $1, updated_at = NOW()
WHERE id = $2 AND access_token_hash = $3`, gameID, id, tokenHash)
_, err := r.BindWithAudio(ctx, id, gameID, tokenHash, false)
return err
}
// BindWithAudio initializes audio without transport commands and reports a new game binding.
func (r *ScreenSessionsRepo) BindWithAudio(ctx context.Context, id, gameID int, tokenHash string, initializeAudio bool) (bool, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
return false, err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(
ctx,
`SELECT id
FROM games
WHERE id = $1
FOR UPDATE`,
gameID,
); err != nil {
return false, err
}
var previousGameID *int
if err := tx.QueryRow(
ctx,
`SELECT game_id
FROM screen_sessions
WHERE id = $1 AND access_token_hash = $2
FOR UPDATE`,
id,
tokenHash,
).Scan(&previousGameID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return false, ErrScreenNotFound
}
return false, err
}
result, err := tx.Exec(
ctx,
`UPDATE screen_sessions
SET game_id = $1,
sound_enabled = CASE WHEN $4 THEN TRUE ELSE sound_enabled END,
volume = CASE WHEN $4 THEN 100 ELSE volume END,
state_version = state_version + 1,
updated_at = NOW()
WHERE id = $2 AND access_token_hash = $3`,
gameID,
id,
tokenHash,
initializeAudio,
)
if err != nil {
return false, err
}
if result.RowsAffected() == 0 {
return ErrScreenNotFound
return false, ErrScreenNotFound
}
return nil
if initializeAudio {
if _, err := tx.Exec(
ctx,
`UPDATE screen_sessions
SET sound_enabled = FALSE, updated_at = NOW()
WHERE game_id = $1 AND id <> $2`,
gameID,
id,
); err != nil {
return false, err
}
if _, err := tx.Exec(
ctx,
`UPDATE game_music_states
SET volume = 100, state_version = state_version + 1, updated_at = NOW()
WHERE game_id = $1`,
gameID,
); err != nil {
return false, err
}
}
return previousGameID == nil || *previousGameID != gameID, tx.Commit(ctx)
}
// Unbind detaches a screen session from its game.
@@ -239,6 +306,21 @@ func (r *ScreenSessionsRepo) Unbind(ctx context.Context, id int) error {
// SetSound enables or disables sound for a screen session.
func (r *ScreenSessionsRepo) SetSound(ctx context.Context, id int, enabled bool) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(
ctx,
`SELECT id
FROM games
WHERE id = (SELECT game_id FROM screen_sessions WHERE id = $1)
FOR UPDATE`,
id,
); err != nil {
return err
}
query := `UPDATE screen_sessions SET sound_enabled = FALSE, updated_at = NOW() WHERE id = $1`
args := []any{id}
if enabled {
@@ -246,12 +328,56 @@ func (r *ScreenSessionsRepo) SetSound(ctx context.Context, id int, enabled bool)
SET sound_enabled = (id = $1), updated_at = NOW()
WHERE game_id = (SELECT game_id FROM screen_sessions WHERE id = $1)`
}
result, err := r.pool.Exec(ctx, query, args...)
result, err := tx.Exec(ctx, query, args...)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrScreenNotFound
}
return nil
return tx.Commit(ctx)
}
// InitializeScreen copies a live display or uses the plan for the first screen.
func (r *ScreenSessionsRepo) InitializeScreen(ctx context.Context, id, gameID int, mode int32, text, label string, duration int64) error {
_, err := r.pool.Exec(
ctx,
`UPDATE screen_sessions target
SET mode = COALESCE(source.mode, $3),
text = COALESCE(source.text, $4),
command = COALESCE(source.command, 0),
position_ms = COALESCE(source.position_ms, 0),
timer_label = COALESCE(source.timer_label, $5),
timer_duration_seconds = COALESCE(source.timer_duration_seconds, $6),
timer_remaining_seconds = COALESCE(source.timer_remaining_seconds, $6),
timer_state = COALESCE(source.timer_state, 1),
timer_ends_at = source.timer_ends_at,
state_version = target.state_version + 1,
updated_at = NOW()
FROM (SELECT 1) seed
LEFT JOIN LATERAL (
SELECT
mode,
text,
command,
position_ms,
timer_label,
timer_duration_seconds,
timer_remaining_seconds,
timer_state,
timer_ends_at
FROM screen_sessions
WHERE game_id = $2 AND id <> $1
ORDER BY updated_at DESC, id DESC
LIMIT 1
) source ON TRUE
WHERE target.id = $1 AND target.game_id = $2`,
id,
gameID,
mode,
text,
label,
duration,
)
return err
}
@@ -0,0 +1,35 @@
package playlist_service
import (
"context"
"errors"
"testing"
"evening_detective_server/internal/repos/playlists_repo"
)
func TestReorderRejectsInvalidIDsBeforeRepository(t *testing.T) {
cases := []struct {
name string
playlistID int
ids []int
}{
{"playlist", 0, []int{1}}, {"negative", 1, []int{-1}}, {"duplicate", 1, []int{1, 1}}, {"zero", 1, []int{0}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := New(nil, nil).Reorder(context.Background(), tc.playlistID, tc.ids)
if !errors.Is(err, ErrInvalidTrack) {
t.Fatal(err)
}
})
}
}
func TestOrderConflictMapping(t *testing.T) {
for _, tc := range []struct{ input, expected error }{{playlists_repo.ErrTrackOrderChanged, ErrTrackOrderChanged}} {
if !errors.Is(mapError(tc.input), tc.expected) {
t.Fatal("lost conflict")
}
}
}
+29 -7
View File
@@ -11,13 +11,14 @@ import (
)
var (
ErrInvalidName = errors.New("некорректное имя плейлиста")
ErrInvalidTrack = errors.New("некорректный трек")
ErrPlaylistNotFound = errors.New("плейлист не найден")
ErrPlaylistInUse = errors.New("плейлист используется")
ErrTrackNotFound = errors.New("трек не найден")
ErrGameNotFound = errors.New("игра не найдена")
ErrScenarioNotFound = errors.New("сценарий не найден")
ErrTrackOrderChanged = errors.New("состав треков изменился; обновите список")
ErrInvalidName = errors.New("некорректное имя плейлиста")
ErrInvalidTrack = errors.New("некорректный трек")
ErrPlaylistNotFound = errors.New("плейлист не найден")
ErrPlaylistInUse = errors.New("плейлист используется")
ErrTrackNotFound = errors.New("трек не найден")
ErrGameNotFound = errors.New("игра не найдена")
ErrScenarioNotFound = errors.New("сценарий не найден")
)
// Service implements playlist and game music use cases.
@@ -150,6 +151,8 @@ func (s *Service) GetGame(ctx context.Context, gameID int) (*MusicState, error)
func mapError(err error) error {
switch {
case errors.Is(err, playlists_repo.ErrTrackOrderChanged):
return ErrTrackOrderChanged
case errors.Is(err, playlists_repo.ErrPlaylistNotFound):
return ErrPlaylistNotFound
case errors.Is(err, playlists_repo.ErrPlaylistInUse):
@@ -164,3 +167,22 @@ func mapError(err error) error {
return err
}
}
// Reorder validates a complete track order and preserves live playback.
func (s *Service) Reorder(ctx context.Context, playlistID int, trackIDs []int) (*Playlist, error) {
if playlistID <= 0 {
return nil, ErrInvalidTrack
}
seen := make(map[int]bool, len(trackIDs))
for _, id := range trackIDs {
if id <= 0 || seen[id] {
return nil, ErrInvalidTrack
}
seen[id] = true
}
item, err := s.repo.Reorder(ctx, playlistID, trackIDs)
if err != nil {
return nil, mapError(err)
}
return mapPlaylist(item), nil
}
@@ -100,15 +100,16 @@ func (s *ScreenPlanService) SetStep(ctx context.Context, gameID, currentStep int
}
// ApplyCurrent applies the current step when a screen joins a game.
func (s *ScreenPlanService) ApplyCurrent(ctx context.Context, gameID int) error {
func (s *ScreenPlanService) ApplyCurrent(ctx context.Context, gameID, screenID int) error {
plan, err := s.Get(ctx, gameID)
if errors.Is(err, ErrPlanNotFound) {
return nil
return s.screenService.InitializeScreen(ctx, screenID, gameID, 1, "", "Перерыв", 600)
}
if err != nil {
return err
}
return s.applyStep(ctx, gameID, plan)
step := plan.Steps[plan.CurrentStep]
return s.screenService.InitializeScreen(ctx, screenID, gameID, step.Mode, step.Text, step.TimerLabel, step.TimerDurationSeconds)
}
func normalizePlan(steps []Step, currentStep int) ([]Step, error) {
+8 -2
View File
@@ -73,8 +73,9 @@ func (s *ScreenService) SetCommand(ctx context.Context, gameID int, command int3
}
// Bind attaches a screen session to a game.
func (s *ScreenService) Bind(ctx context.Context, id, gameID int, tokenHash string) error {
return mapError(s.repo.Bind(ctx, id, gameID, tokenHash))
func (s *ScreenService) Bind(ctx context.Context, id, gameID int, tokenHash string, initializeAudio bool) (bool, error) {
isNewBinding, err := s.repo.BindWithAudio(ctx, id, gameID, tokenHash, initializeAudio)
return isNewBinding, mapError(err)
}
// Unbind detaches a screen session from its game.
@@ -93,3 +94,8 @@ func mapError(err error) error {
}
return err
}
// InitializeScreen joins the current display without resetting other screens.
func (s *ScreenService) InitializeScreen(ctx context.Context, id, gameID int, mode int32, text, label string, duration int64) error {
return s.repo.InitializeScreen(ctx, id, gameID, mode, text, label, duration)
}