generated from VLADIMIR/template
up
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user