This commit is contained in:
2026-10-27 12:03:22 +07:00
parent 0879a8946a
commit 0341bfac25
32 changed files with 4119 additions and 1350 deletions
+26 -2
View File
@@ -36,9 +36,14 @@ func TestExportSnapshotAndEditableGame(t *testing.T) {
requireNoError(t, err)
selected, err := music.Create(ctx, "Selected by screen")
requireNoError(t, err)
requireNoError(t, music.AddTrack(ctx, selected.ID, "Grouped", "grouped.mp3"))
selected, err = music.SaveGroup(ctx, selected.ID, 0, "Final", "stop")
requireNoError(t, err)
selected, err = music.SetTrackGroup(ctx, selected.ID, selected.Tracks[0].ID, selected.Groups[0].ID)
requireNoError(t, err)
requireNoError(t, music.SetScenario(ctx, f.scenarioID, base.ID))
requireNoError(t, music.SetGame(ctx, f.gameID, assigned.ID))
_, err = music.SetMusic(ctx, f.gameID, selected.ID, 0, 0, 100, false, 1)
_, err = music.SetMusic(ctx, f.gameID, selected.ID, 0, 0, 100, false, 1, 0)
requireNoError(t, err)
ticketRepo := tickets_repo.New(f.pool)
activeHash := sha256.Sum256([]byte("active-ticket"))
@@ -86,6 +91,25 @@ func TestExportSnapshotAndEditableGame(t *testing.T) {
}
}
requireNoError(t, json.Unmarshal(attempt.Snapshot, &snapshot))
var groupedSnapshot struct {
Playlists []struct {
ID int `json:"id"`
Groups []struct {
ID int `json:"id"`
EndMode string `json:"end_mode"`
} `json:"groups"`
Tracks []struct {
GroupID int `json:"group_id"`
} `json:"tracks"`
} `json:"playlists"`
}
requireNoError(t, json.Unmarshal(attempt.Snapshot, &groupedSnapshot))
for _, p := range groupedSnapshot.Playlists {
if p.ID == selected.ID && (len(p.Groups) != 1 || p.Groups[0].EndMode != "stop" || len(p.Tracks) != 1 || p.Tracks[0].GroupID != p.Groups[0].ID) {
t.Fatal("export lost group metadata")
}
}
if snapshot.Game.PlaylistID != selected.ID || len(snapshot.Playlists) != 2 || len(snapshot.Tickets) != 2 ||
!bytes.Equal(snapshot.Tickets[0].TokenHash, activeHash[:]) || snapshot.Teams[0].Password != "original-qr" {
t.Fatalf("snapshot lost effective music, ticket or QR: %+v", snapshot)
@@ -99,7 +123,7 @@ func TestExportSnapshotAndEditableGame(t *testing.T) {
requireNoError(t, err)
_, err = ticketRepo.Accept(ctx, activeHash[:], f.userID)
requireNoError(t, err)
_, err = music.SetMusic(ctx, f.gameID, base.ID, 0, 0, 100, false, 1)
_, err = music.SetMusic(ctx, f.gameID, base.ID, 0, 0, 100, false, 1, 0)
requireNoError(t, err)
_, err = plans.Save(ctx, f.gameID, []game_screen_plans_repo.Step{{Mode: 2}}, 0)
requireNoError(t, err)
+8 -2
View File
@@ -48,9 +48,15 @@ func captureSnapshot(ctx context.Context, tx pgx.Tx, gameID int) ([]byte, error)
), '[]'::jsonb),
'playlists', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', p.id, 'name', p.name, 'tracks', COALESCE((
'id', p.id, 'name', p.name, 'groups', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', pg.id, 'name', pg.name, 'position', pg.position, 'end_mode', pg.end_mode
) ORDER BY pg.position, pg.id)
FROM playlist_groups pg
WHERE pg.playlist_id = p.id
), '[]'::jsonb), 'tracks', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'id', tr.id, 'name', tr.name, 'filename', tr.filename, 'position', tr.position
'id', tr.id, 'name', tr.name, 'filename', tr.filename, 'position', tr.position, 'group_id', COALESCE(tr.group_id, 0)
) ORDER BY tr.position, tr.id)
FROM playlist_tracks tr
WHERE tr.playlist_id = p.id
@@ -0,0 +1,59 @@
package game_imports_repo
import (
"context"
"encoding/json"
"testing"
"time"
"evening_detective_server/internal/modules/storytelling"
"evening_detective_server/internal/repos/playlists_repo"
"evening_detective_server/internal/services/game_package_service"
)
func TestImportPreservesPlaylistGroupsAndLegacyTracks(t *testing.T) {
for _, grouped := range []bool{false, true} {
t.Run(map[bool]string{false: "legacy", true: "groups"}[grouped], func(t *testing.T) {
repo, actor := repoFixture(t)
ctx := context.Background()
now := time.Now().UTC()
playlistID := 900
data := game_package_service.Data{
Game: game_package_service.Game{ID: 100, Name: "Game", StartAt: now, Status: "draft", Format: "offline", ScenarioID: 200, PlaylistID: &playlistID},
Scenario: game_package_service.Scenario{ID: 200, Name: "Story", Story: &storytelling.Story{}, Status: "draft", CreatedAt: now, UpdatedAt: now},
Playlists: []game_package_service.Playlist{{ID: playlistID, Name: "Music", Tracks: []game_package_service.Track{{ID: 901, Name: "One", Filename: "one.mp3", Position: 3}, {ID: 902, Name: "Two", Filename: "two.mp3", Position: 4}}}},
}
if grouped {
data.Playlists[0].Groups = []game_package_service.PlaylistGroup{{ID: 800, Name: "Second", Position: 0, EndMode: "stop"}, {ID: 801, Name: "First", Position: 1, EndMode: "next"}, {ID: 802, Name: "Empty", Position: 2, EndMode: "repeat"}}
data.Playlists[0].Tracks[0].GroupID = 801
data.Playlists[0].Tracks[1].GroupID = 800
}
raw, err := json.Marshal(data)
if err != nil {
t.Fatal(err)
}
attempt, err := repo.Start(ctx, &Attempt{ID: "group-import", PackageID: "group-package", SourceServer: "https://online.example", GameID: 100, SHA256: "digest", InitiatorID: actor, Manifest: []byte(`{}`), OriginalData: raw, ObjectKeys: []string{}})
if err != nil {
t.Fatal(err)
}
if err := repo.Complete(ctx, attempt.ID, raw); err != nil {
t.Fatal(err)
}
items, err := playlists_repo.New(repo.pool).List(ctx)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 || len(items[0].Tracks) != 2 {
t.Fatalf("lost playlist: %+v", items)
}
p := items[0]
if grouped {
if len(p.Groups) != 3 || p.Groups[0].Name != "Second" || p.Groups[1].EndMode != "next" || p.Groups[2].EndMode != "repeat" || p.Tracks[0].GroupID != p.Groups[1].ID || p.Tracks[1].GroupID != p.Groups[0].ID {
t.Fatalf("lost group mappings: %+v", p)
}
} else if len(p.Groups) != 0 || p.Tracks[0].GroupID != 0 {
t.Fatal("legacy tracks gained groups")
}
})
}
}
+45 -7
View File
@@ -178,8 +178,9 @@ func insertPlaylists(ctx context.Context, tx pgx.Tx, data []byte, mappings *Mapp
`SELECT
id,
name,
tracks
FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'playlists', 'null'::jsonb), '[]'::jsonb)) AS p(id integer, name text, tracks jsonb)
tracks,
groups
FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb->'playlists', 'null'::jsonb), '[]'::jsonb)) AS p(id integer, name text, tracks jsonb, groups jsonb)
ORDER BY id`,
data,
)
@@ -191,11 +192,12 @@ func insertPlaylists(ctx context.Context, tx pgx.Tx, data []byte, mappings *Mapp
id int
name string
tracks []byte
groups []byte
}
var playlists []playlist
for rows.Next() {
var p playlist
if err := rows.Scan(&p.id, &p.name, &p.tracks); err != nil {
if err := rows.Scan(&p.id, &p.name, &p.tracks, &p.groups); err != nil {
return err
}
playlists = append(playlists, p)
@@ -218,6 +220,39 @@ func insertPlaylists(ctx context.Context, tx pgx.Tx, data []byte, mappings *Mapp
return err
}
mappings.Playlists[p.id] = localID
var groupMap []byte
err = tx.QueryRow(
ctx,
`WITH source AS MATERIALIZED (
SELECT
id AS source_id,
nextval(pg_get_serial_sequence('playlist_groups', 'id'))::integer AS local_id,
name,
position,
end_mode
FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb, 'null'::jsonb), '[]'::jsonb)) AS g(id integer, name text, position integer, end_mode text)
), inserted AS (
INSERT INTO playlist_groups (id, playlist_id, name, position, end_mode)
SELECT
local_id,
$2,
name,
position,
end_mode
FROM source
RETURNING
id
)
SELECT
COALESCE(jsonb_object_agg(s.source_id, s.local_id), '{}'::jsonb)
FROM source s
JOIN inserted i ON i.id = s.local_id`,
p.groups,
localID,
).Scan(&groupMap)
if err != nil {
return err
}
var trackMap []byte
err = tx.QueryRow(
ctx,
@@ -227,16 +262,18 @@ func insertPlaylists(ctx context.Context, tx pgx.Tx, data []byte, mappings *Mapp
nextval(pg_get_serial_sequence('playlist_tracks', 'id'))::integer AS local_id,
name,
filename,
position
FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb, 'null'::jsonb), '[]'::jsonb)) AS t(id integer, name text, filename text, position integer)
position,
group_id
FROM jsonb_to_recordset(COALESCE(NULLIF($1::jsonb, 'null'::jsonb), '[]'::jsonb)) AS t(id integer, name text, filename text, position integer, group_id integer)
), inserted AS (
INSERT INTO playlist_tracks (id, playlist_id, name, filename, position)
INSERT INTO playlist_tracks (id, playlist_id, name, filename, position, group_id)
SELECT
local_id,
$2,
name,
filename,
position
position,
($3::jsonb->>group_id::text)::integer
FROM source
RETURNING
id
@@ -247,6 +284,7 @@ func insertPlaylists(ctx context.Context, tx pgx.Tx, data []byte, mappings *Mapp
JOIN inserted i ON i.id = s.local_id`,
p.tracks,
localID,
groupMap,
).Scan(&trackMap)
if err != nil {
return err
+288
View File
@@ -0,0 +1,288 @@
package playlists_repo
import (
"context"
"errors"
"slices"
"github.com/jackc/pgx/v5"
)
// groupInUse runs under the playlist lock shared with playback updates.
func groupInUse(ctx context.Context, tx pgx.Tx, playlistID, groupID int) error {
var used bool
err := tx.QueryRow(
ctx,
`SELECT EXISTS (
SELECT 1
FROM game_music_states ms
JOIN games g ON g.id = ms.game_id
JOIN scenarios s ON s.id = g.scenario_id
WHERE COALESCE(ms.playlist_id, g.playlist_id, s.playlist_id) = $1
AND ms.playback IN (2, 3)
AND ($2 = 0 OR ms.group_id IS NULL OR ms.group_id = $2)
)`,
playlistID,
groupID,
).Scan(&used)
if err != nil {
return err
}
if used {
return ErrPlaylistInUse
}
return nil
}
func readGroup(ctx context.Context, tx pgx.Tx, playlistID, groupID int) (*Group, error) {
var group Group
err := tx.QueryRow(
ctx,
`SELECT
id,
name,
position,
end_mode
FROM playlist_groups
WHERE playlist_id = $1 AND id = $2`,
playlistID,
groupID,
).Scan(&group.ID, &group.Name, &group.Position, &group.EndMode)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrGroupNotFound
}
return &group, err
}
// SaveGroup creates a group or changes its name and completion mode.
func (r *Repo) SaveGroup(ctx context.Context, playlistID, groupID int, name, mode string) (*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
}
if groupID == 0 {
_, err = tx.Exec(
ctx,
`INSERT INTO playlist_groups (playlist_id, name, position, end_mode)
SELECT
$1,
$2,
COALESCE(MAX(position), -1) + 1,
$3
FROM playlist_groups
WHERE playlist_id = $1`,
playlistID,
name,
mode,
)
} else {
group, readErr := readGroup(ctx, tx, playlistID, groupID)
if readErr != nil {
return nil, readErr
}
if mode != group.EndMode {
if err := groupInUse(ctx, tx, playlistID, groupID); err != nil {
return nil, err
}
}
_, err = tx.Exec(
ctx,
`UPDATE playlist_groups
SET name = $3, end_mode = $4
WHERE playlist_id = $1 AND id = $2`,
playlistID,
groupID,
name,
mode,
)
}
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.Get(ctx, playlistID)
}
// DeleteGroup keeps its tracks and clears stopped selections before deleting it.
func (r *Repo) DeleteGroup(ctx context.Context, playlistID, groupID 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
}
if _, err := readGroup(ctx, tx, playlistID, groupID); err != nil {
return nil, err
}
if err := groupInUse(ctx, tx, playlistID, groupID); err != nil {
return nil, err
}
_, err = tx.Exec(
ctx,
`UPDATE playlist_tracks
SET group_id = NULL
WHERE playlist_id = $1 AND group_id = $2`,
playlistID,
groupID,
)
if err != nil {
return nil, err
}
_, err = tx.Exec(
ctx,
`UPDATE game_music_states
SET group_id = NULL, state_version = state_version + 1
WHERE playlist_id = $1 AND group_id = $2`,
playlistID,
groupID,
)
if err != nil {
return nil, err
}
_, err = tx.Exec(
ctx,
`DELETE FROM playlist_groups
WHERE playlist_id = $1 AND id = $2`,
playlistID,
groupID,
)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.Get(ctx, playlistID)
}
// ReorderGroups requires a complete group order to detect stale editors.
func (r *Repo) ReorderGroups(ctx context.Context, playlistID int, ids []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_groups
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(ids)
slices.Sort(sortedOld)
slices.Sort(sortedNew)
if !slices.Equal(sortedOld, sortedNew) {
return nil, ErrGroupOrderChanged
}
if !slices.Equal(old, ids) {
if err := groupInUse(ctx, tx, playlistID, 0); err != nil {
return nil, err
}
_, err = tx.Exec(
ctx,
`UPDATE playlist_groups g
SET position = ordered.position - 1
FROM unnest($2::int[]) WITH ORDINALITY AS ordered(id, position)
WHERE g.playlist_id = $1 AND g.id = ordered.id`,
playlistID,
ids,
)
if err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.Get(ctx, playlistID)
}
// SetTrackGroup moves a track without changing its global playback index.
func (r *Repo) SetTrackGroup(ctx context.Context, playlistID, trackID, groupID 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
}
var oldGroup int
err = tx.QueryRow(
ctx,
`SELECT COALESCE(group_id, 0)
FROM playlist_tracks
WHERE playlist_id = $1 AND id = $2`,
playlistID,
trackID,
).Scan(&oldGroup)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrTrackNotFound
}
if err != nil {
return nil, err
}
if groupID != 0 {
if _, err := readGroup(ctx, tx, playlistID, groupID); err != nil {
return nil, err
}
}
if oldGroup != groupID {
// An ungrouped track must not block edits to unrelated active groups.
for _, id := range []int{oldGroup, groupID} {
if id != 0 {
if err := groupInUse(ctx, tx, playlistID, id); err != nil {
return nil, err
}
}
}
_, err = tx.Exec(
ctx,
`UPDATE playlist_tracks
SET group_id = NULLIF($3, 0)
WHERE playlist_id = $1 AND id = $2`,
playlistID,
trackID,
groupID,
)
if err != nil {
return nil, err
}
_, err = tx.Exec(
ctx,
`UPDATE game_music_states ms
SET group_id = NULL, state_version = state_version + 1
WHERE ms.playlist_id = $1 AND ms.playback = 1
AND ms.group_id = $2`,
playlistID,
oldGroup,
)
if err != nil {
return nil, err
}
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.Get(ctx, playlistID)
}
@@ -0,0 +1,167 @@
package playlists_repo
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
)
func TestGroupEditingProtectsLivePlayback(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.SaveGroup(ctx, p.ID, 0, "First", "repeat")
requireNoError(t, err)
first := p.Groups[0].ID
p, err = f.repo.SaveGroup(ctx, p.ID, 0, "Second", "stop")
requireNoError(t, err)
second := p.Groups[1].ID
p, err = f.repo.SetTrackGroup(ctx, p.ID, p.Tracks[0].ID, first)
requireNoError(t, err)
p, err = f.repo.SetTrackGroup(ctx, p.ID, p.Tracks[1].ID, first)
requireNoError(t, err)
p, err = f.repo.SetTrackGroup(ctx, p.ID, p.Tracks[2].ID, second)
requireNoError(t, err)
state, err := f.repo.SetMusic(ctx, f.gameID, p.ID, 0, 123, 50, false, playback, first)
requireNoError(t, err)
_, err = f.repo.SaveGroup(ctx, p.ID, first, "Renamed", "repeat")
requireNoError(t, err)
for _, tc := range []struct {
name string
run func() error
}{
{"mode", func() error { _, err := f.repo.SaveGroup(ctx, p.ID, first, "Renamed", "next"); return err }},
{"group order", func() error { _, err := f.repo.ReorderGroups(ctx, p.ID, []int{second, first}); return err }},
{"track order", func() error {
_, err := f.repo.Reorder(ctx, p.ID, []int{p.Tracks[1].ID, p.Tracks[0].ID, p.Tracks[2].ID})
return err
}},
{"membership", func() error { _, err := f.repo.SetTrackGroup(ctx, p.ID, p.Tracks[0].ID, second); return err }},
{"delete group", func() error { _, err := f.repo.DeleteGroup(ctx, p.ID, first); return err }},
} {
t.Run(tc.name, func(t *testing.T) {
// Each stopped edit gets its own fixture state; live edits must be rejected atomically.
if playback == 1 {
return
}
if err := tc.run(); !errors.Is(err, ErrPlaylistInUse) {
t.Fatalf("got %v", err)
}
})
}
if playback != 1 {
_, err := f.repo.DeleteTrack(ctx, p.ID, p.Tracks[0].ID)
if !errors.Is(err, ErrPlaylistInUse) {
t.Fatal(err)
}
// An unrelated group's name and mode remain editable.
_, err = f.repo.SaveGroup(ctx, p.ID, second, "Other", "next")
requireNoError(t, err)
_, err = f.repo.SetMusic(ctx, f.gameID, p.ID, 0, 0, 50, false, 1, first)
requireNoError(t, err)
}
_, err = f.repo.SaveGroup(ctx, p.ID, first, "Renamed", "next")
requireNoError(t, err)
p, err = f.repo.ReorderGroups(ctx, p.ID, []int{second, first})
requireNoError(t, err)
if p.Groups[0].ID != second {
t.Fatal("group order lost")
}
p, err = f.repo.DeleteGroup(ctx, p.ID, first)
requireNoError(t, err)
if len(p.Tracks) != 3 || p.Tracks[0].GroupID != 0 || p.Tracks[1].GroupID != 0 {
t.Fatal("group deletion lost tracks")
}
after, err := f.repo.GetMusic(ctx, f.gameID)
requireNoError(t, err)
if after.GroupID != 0 {
t.Fatal("deleted group remains selected")
}
_, err = f.repo.DeleteTrack(ctx, p.ID, p.Tracks[0].ID)
requireNoError(t, err)
// A stale completion must never restore a deleted section.
_, err = f.repo.AdvanceMusic(ctx, f.gameID, state.Version, func(*MusicState) (int, int, int32) { t.Fatal("stale completion applied"); return 0, second, 2 })
requireNoError(t, err)
})
}
}
func TestGroupAssignmentsAndPlaybackRejectForeignIDs(t *testing.T) {
for _, name := range []string{"foreign and empty groups"} {
t.Run(name, func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
p, err := f.repo.Create(ctx, "One")
requireNoError(t, err)
requireNoError(t, f.repo.AddTrack(ctx, p.ID, "Track", "track.mp3"))
p, err = f.repo.SaveGroup(ctx, p.ID, 0, "Empty", "repeat")
requireNoError(t, err)
other, err := f.repo.Create(ctx, "Other")
requireNoError(t, err)
other, err = f.repo.SaveGroup(ctx, other.ID, 0, "Foreign", "stop")
requireNoError(t, err)
_, err = f.repo.SetTrackGroup(ctx, p.ID, p.Tracks[0].ID, other.Groups[0].ID)
if !errors.Is(err, ErrGroupNotFound) {
t.Fatal(err)
}
_, err = f.repo.SetMusic(ctx, f.gameID, p.ID, 0, 0, 50, false, 2, p.Groups[0].ID)
if !errors.Is(err, ErrInvalidTrack) {
t.Fatal(err)
}
_, err = f.repo.ReorderGroups(ctx, p.ID, []int{other.Groups[0].ID})
if !errors.Is(err, ErrGroupOrderChanged) {
t.Fatal(err)
}
})
}
}
func TestConcurrentCompletionAdvancesOnce(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, "Concurrent completion")
requireNoError(t, err)
for _, name := range []string{"one", "two"} {
requireNoError(t, f.repo.AddTrack(ctx, p.ID, name, name+".mp3"))
}
before, err := f.repo.SetMusic(ctx, f.gameID, p.ID, 0, 0, 50, false, playback, 0)
requireNoError(t, err)
var calls atomic.Int32
failures := make(chan error, 8)
var wg sync.WaitGroup
for range 8 {
wg.Go(func() {
_, err := f.repo.AdvanceMusic(ctx, f.gameID, before.Version, func(state *MusicState) (int, int, int32) {
calls.Add(1)
return state.TrackIndex + 1, 0, state.Playback
})
failures <- err
})
}
wg.Wait()
close(failures)
for err := range failures {
requireNoError(t, err)
}
after, err := f.repo.GetMusic(ctx, f.gameID)
requireNoError(t, err)
want := int32(0)
if playback == 2 {
want = 1
}
if calls.Load() != want || after.TrackIndex != int(want) || after.Version != before.Version+int64(want) {
t.Fatalf("completion repeated or applied while stopped: calls=%d state=%+v", calls.Load(), after)
}
})
}
}
+29
View File
@@ -56,6 +56,35 @@ func (r *Repo) Reorder(ctx context.Context, playlistID int, trackIDs []int) (*Pl
return nil, ErrTrackOrderChanged
}
if !slices.Equal(old, trackIDs) {
items, err := list(ctx, tx)
if err != nil {
return nil, err
}
for _, item := range items {
if item.ID != playlistID {
continue
}
for _, group := range item.Groups {
var before, after []int
members := make(map[int]bool)
for _, track := range item.Tracks {
if track.GroupID == group.ID {
before = append(before, track.ID)
members[track.ID] = true
}
}
for _, id := range trackIDs {
if members[id] {
after = append(after, id)
}
}
if !slices.Equal(before, after) {
if err := groupInUse(ctx, tx, playlistID, group.ID); err != nil {
return nil, err
}
}
}
}
// Lock state before changing indices; timing and playback remain untouched.
_, err = tx.Exec(
ctx,
+4 -4
View File
@@ -24,12 +24,12 @@ func TestReorderPreservesPlaybackAndRejectsChangedMembership(t *testing.T) {
}
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)
before, err := f.repo.SetMusic(ctx, f.gameID, p.ID, 1, 1234, 37, true, playback, 0)
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) {
if _, err := f.repo.DeleteTrack(ctx, p.ID, 999999); !errors.Is(err, ErrTrackNotFound) {
t.Fatalf("active track deletion: %v", err)
}
order := []int{p.Tracks[1].ID, p.Tracks[2].ID, p.Tracks[0].ID}
@@ -120,7 +120,7 @@ func TestBindInitializesAudioWithoutChangingTransport(t *testing.T) {
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)
before, err := f.repo.SetMusic(ctx, f.gameID, 0, 0, 1234, 42, true, 2, 0)
requireNoError(t, err)
second, err := f.screens.Create(ctx, "second")
requireNoError(t, err)
@@ -176,7 +176,7 @@ func TestReorderReadersSeeConsistentTrackAndIndex(t *testing.T) {
}
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)
_, err = f.repo.SetMusic(ctx, f.gameID, p.ID, 1, 1000, 50, true, 3, 0)
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]}
+189 -21
View File
@@ -10,7 +10,10 @@ import (
)
var (
ErrInvalidTrack = errors.New("некорректный трек или группа")
ErrGroupNotFound = errors.New("группа не найдена")
ErrPlaylistNotFound = errors.New("плейлист не найден")
ErrGroupOrderChanged = errors.New("состав групп изменился; обновите список")
ErrTrackOrderChanged = errors.New("состав треков изменился; обновите список")
ErrPlaylistInUse = errors.New("плейлист используется")
ErrTrackNotFound = errors.New("трек не найден")
@@ -24,6 +27,15 @@ type Track struct {
Name string
Filename string
Position int
GroupID int
}
// Group describes an ordered section of a playlist.
type Group struct {
ID int
Name string
Position int
EndMode string
}
// Playlist describes a stored playlist.
@@ -31,12 +43,14 @@ type Playlist struct {
ID int
Name string
Tracks []*Track
Groups []*Group
}
// MusicState describes stored game music state.
type MusicState struct {
Playlist *Playlist
TrackIndex int
GroupID int
PositionMs int64
Volume int32
Loop bool
@@ -55,7 +69,12 @@ 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) {
return list(ctx, r.pool)
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
return list(ctx, tx)
}
// IPlaylistReader reads catalogues from a pool or a consistent transaction snapshot.
@@ -72,7 +91,8 @@ func list(ctx context.Context, reader IPlaylistReader) ([]*Playlist, error) {
t.id,
t.name,
t.filename,
t.position
t.position,
COALESCE(t.group_id, 0)
FROM playlists p
LEFT JOIN playlist_tracks t ON t.playlist_id = p.id
ORDER BY p.id, t.position, t.id`,
@@ -88,7 +108,8 @@ func list(ctx context.Context, reader IPlaylistReader) ([]*Playlist, error) {
var trackID *int
var trackName, filename *string
var position *int
if err := rows.Scan(&p.ID, &p.Name, &trackID, &trackName, &filename, &position); err != nil {
var groupID int
if err := rows.Scan(&p.ID, &p.Name, &trackID, &trackName, &filename, &position, &groupID); err != nil {
return nil, err
}
item := byID[p.ID]
@@ -98,10 +119,36 @@ func list(ctx context.Context, reader IPlaylistReader) ([]*Playlist, error) {
result = append(result, item)
}
if trackID != nil {
item.Tracks = append(item.Tracks, &Track{ID: *trackID, Name: *trackName, Filename: *filename, Position: *position})
item.Tracks = append(item.Tracks, &Track{ID: *trackID, Name: *trackName, Filename: *filename, Position: *position, GroupID: groupID})
}
}
return result, rows.Err()
if err := rows.Err(); err != nil {
return nil, err
}
rows.Close()
groups, err := reader.Query(ctx, `SELECT
playlist_id,
id,
name,
position,
end_mode
FROM playlist_groups
ORDER BY playlist_id, position, id`)
if err != nil {
return nil, err
}
defer groups.Close()
for groups.Next() {
var playlistID int
var group Group
if err := groups.Scan(&playlistID, &group.ID, &group.Name, &group.Position, &group.EndMode); err != nil {
return nil, err
}
if p := byID[playlistID]; p != nil {
p.Groups = append(p.Groups, &group)
}
}
return result, groups.Err()
}
// Get returns a playlist by ID.
@@ -244,20 +291,59 @@ func (r *Repo) DeleteTrack(ctx context.Context, playlistID, trackID int) (string
return "", err
}
var filename string
var used bool
if err = tx.QueryRow(
var groupID int
err = tx.QueryRow(
ctx,
`
SELECT EXISTS (SELECT 1 FROM scenarios WHERE playlist_id = $1)
OR EXISTS (SELECT 1 FROM games WHERE playlist_id = $1)
OR EXISTS (SELECT 1 FROM game_music_states WHERE playlist_id = $1)
`,
`SELECT COALESCE(group_id, 0)
FROM playlist_tracks
WHERE playlist_id = $1 AND id = $2`,
playlistID,
).Scan(&used); err != nil {
trackID,
).Scan(&groupID)
if errors.Is(err, pgx.ErrNoRows) {
return "", ErrTrackNotFound
}
if err != nil {
return "", err
}
if used {
return "", ErrPlaylistInUse
if groupID == 0 {
groupID = -1
}
if err := groupInUse(ctx, tx, playlistID, groupID); err != nil {
return "", err
}
_, err = tx.Exec(
ctx,
`WITH ordered AS (
SELECT
id,
row_number() OVER (ORDER BY position, id) - 1 AS index,
count(*) OVER () AS total
FROM playlist_tracks
WHERE playlist_id = $1
), removed AS (
SELECT index, total
FROM ordered
WHERE id = $2
)
UPDATE game_music_states ms
SET track_index = CASE
WHEN ms.track_index > removed.index THEN ms.track_index - 1
WHEN ms.track_index = removed.index THEN GREATEST(0, LEAST(ms.track_index, removed.total - 2))
ELSE ms.track_index END,
group_id = CASE WHEN ms.track_index = removed.index THEN NULL ELSE ms.group_id END,
position_ms = CASE WHEN ms.track_index = removed.index THEN 0 ELSE ms.position_ms END,
state_version = ms.state_version + 1
FROM games g
JOIN scenarios s ON s.id = g.scenario_id
CROSS JOIN removed
WHERE ms.game_id = g.id
AND COALESCE(ms.playlist_id, g.playlist_id, s.playlist_id) = $1`,
playlistID,
trackID,
)
if err != nil {
return "", err
}
err = tx.QueryRow(
ctx,
@@ -327,6 +413,10 @@ func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) {
return nil, err
}
defer tx.Rollback(ctx)
return getMusic(ctx, tx, gameID)
}
func getMusic(ctx context.Context, tx pgx.Tx, gameID int) (*MusicState, error) {
playlists, err := list(ctx, tx)
if err != nil {
return nil, err
@@ -382,13 +472,14 @@ func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) {
loop,
playback,
state_version,
started_at
started_at,
COALESCE(group_id, 0)
FROM game_music_states
WHERE game_id = $1
`,
gameID,
).
Scan(&statePlaylistID, &trackIndex, &state.PositionMs, &state.Volume, &state.Loop, &state.Playback, &state.Version, &state.StartedAt)
Scan(&statePlaylistID, &trackIndex, &state.PositionMs, &state.Volume, &state.Loop, &state.Playback, &state.Version, &state.StartedAt, &state.GroupID)
if errors.Is(err, pgx.ErrNoRows) {
state.Version = 1
return state, nil
@@ -410,7 +501,7 @@ 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) {
func (r *Repo) SetMusic(ctx context.Context, gameID, playlistID, trackIndex int, positionMs int64, volume int32, loop bool, playback int32, groupID int) (*MusicState, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
@@ -421,19 +512,55 @@ func (r *Repo) SetMusic(ctx context.Context, gameID, playlistID, trackIndex int,
return nil, err
}
}
_, err = tx.Exec(
if playlistID < 0 || groupID < 0 {
return nil, ErrInvalidTrack
}
if playlistID > 0 {
items, err := list(ctx, tx)
if err != nil {
return nil, err
}
for _, item := range items {
if item.ID != playlistID {
continue
}
if trackIndex < 0 || (len(item.Tracks) == 0 && trackIndex != 0) || (len(item.Tracks) > 0 && trackIndex >= len(item.Tracks)) || (len(item.Tracks) == 0 && playback != 1) {
return nil, ErrInvalidTrack
}
if groupID != 0 {
if trackIndex >= len(item.Tracks) || item.Tracks[trackIndex].GroupID != groupID {
return nil, ErrInvalidTrack
}
}
}
} else if groupID != 0 {
return nil, ErrInvalidTrack
}
err = writeMusic(ctx, tx, gameID, playlistID, trackIndex, positionMs, volume, loop, playback, groupID)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.GetMusic(ctx, gameID)
}
func writeMusic(ctx context.Context, tx pgx.Tx, gameID, playlistID, trackIndex int, positionMs int64, volume int32, loop bool, playback int32, groupID int) error {
_, err := tx.Exec(
ctx,
`
INSERT INTO game_music_states (
game_id, playlist_id, track_index, position_ms, volume, loop,
playback, state_version, started_at
playback, state_version, started_at, group_id
)
VALUES (
$1, NULLIF($2, 0), $3, $4, $5, $6, $7, 1,
CASE WHEN $7 = 2 THEN NOW() ELSE NULL END
CASE WHEN $7 = 2 THEN NOW() ELSE NULL END, NULLIF($8, 0)
)
ON CONFLICT (game_id) DO UPDATE SET
playlist_id = EXCLUDED.playlist_id,
group_id = EXCLUDED.group_id,
track_index = EXCLUDED.track_index,
position_ms = EXCLUDED.position_ms,
volume = EXCLUDED.volume,
@@ -450,10 +577,51 @@ func (r *Repo) SetMusic(ctx context.Context, gameID, playlistID, trackIndex int,
volume,
loop,
playback,
groupID,
)
return err
}
// AdvanceMusic keeps the catalogue and current command locked while the service chooses a transition.
func (r *Repo) AdvanceMusic(ctx context.Context, gameID int, expectedVersion int64, next func(*MusicState) (int, int, int32)) (*MusicState, error) {
before, err := r.GetMusic(ctx, gameID)
if err != nil {
return nil, err
}
if before.Playlist == nil || before.Playback != 2 {
return before, nil
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if err := lockPlaylist(ctx, tx, before.Playlist.ID); err != nil {
return nil, err
}
var id int
err = tx.QueryRow(
ctx,
`SELECT game_id
FROM game_music_states
WHERE game_id = $1
FOR UPDATE`,
gameID,
).Scan(&id)
if err != nil {
return nil, err
}
state, err := getMusic(ctx, tx, gameID)
if err != nil {
return nil, err
}
if state.Playlist == nil || state.Playlist.ID != before.Playlist.ID || state.Playback != 2 || len(state.Playlist.Tracks) == 0 || (expectedVersion != 0 && expectedVersion != state.Version) {
return state, nil
}
index, groupID, playback := next(state)
if err := writeMusic(ctx, tx, gameID, state.Playlist.ID, index, 0, state.Volume, state.Loop, playback, groupID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}