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
+18
View File
@@ -382,6 +382,14 @@ service EveningDetectiveServer {
}; };
} }
rpc ReorderPlaylistTracks(ReorderPlaylistTracksReq) returns (ReorderPlaylistTracksRsp) {
option (google.api.http) = { put: "/api/playlists/{playlistID}/tracks/order" body: "*" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
tags : "Музыка";
summary: "Изменить порядок треков";
};
}
rpc DeletePlaylistTrack(DeletePlaylistTrackReq) returns (DeletePlaylistTrackRsp) { rpc DeletePlaylistTrack(DeletePlaylistTrackReq) returns (DeletePlaylistTrackRsp) {
option (google.api.http) = { delete: "/api/playlists/{playlistID}/tracks/{trackID}" }; option (google.api.http) = { delete: "/api/playlists/{playlistID}/tracks/{trackID}" };
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = {
@@ -1441,6 +1449,15 @@ message UploadPlaylistTrackRsp {
Playlist playlist = 1; Playlist playlist = 1;
} }
message ReorderPlaylistTracksReq {
int32 playlistID = 1;
repeated int32 trackIDs = 2;
}
message ReorderPlaylistTracksRsp {
Playlist playlist = 1;
}
message DeletePlaylistTrackReq { message DeletePlaylistTrackReq {
int32 playlistID = 1; int32 playlistID = 1;
int32 trackID = 2; int32 trackID = 2;
@@ -1892,6 +1909,7 @@ message BindScreenReq {
string screenID = 1; string screenID = 1;
string accessToken = 2; string accessToken = 2;
int32 gameID = 3; int32 gameID = 3;
bool initializeAudio = 4;
} }
message BindScreenRsp { message BindScreenRsp {
@@ -2054,6 +2054,46 @@
] ]
} }
}, },
"/api/playlists/{playlistID}/tracks/order": {
"put": {
"summary": "Изменить порядок треков",
"operationId": "EveningDetectiveServer_ReorderPlaylistTracks",
"responses": {
"200": {
"description": "A successful response.",
"schema": {
"$ref": "#/definitions/evening_detective_serverReorderPlaylistTracksRsp"
}
},
"default": {
"description": "An unexpected error response.",
"schema": {
"$ref": "#/definitions/rpcStatus"
}
}
},
"parameters": [
{
"name": "playlistID",
"in": "path",
"required": true,
"type": "integer",
"format": "int32"
},
{
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/EveningDetectiveServerReorderPlaylistTracksBody"
}
}
],
"tags": [
"Музыка"
]
}
},
"/api/playlists/{playlistID}/tracks/{trackID}": { "/api/playlists/{playlistID}/tracks/{trackID}": {
"delete": { "delete": {
"summary": "Удалить MP3 из плейлиста", "summary": "Удалить MP3 из плейлиста",
@@ -4154,6 +4194,9 @@
"gameID": { "gameID": {
"type": "integer", "type": "integer",
"format": "int32" "format": "int32"
},
"initializeAudio": {
"type": "boolean"
} }
} }
}, },
@@ -4247,6 +4290,18 @@
"EveningDetectiveServerPublicScenarioBody": { "EveningDetectiveServerPublicScenarioBody": {
"type": "object" "type": "object"
}, },
"EveningDetectiveServerReorderPlaylistTracksBody": {
"type": "object",
"properties": {
"trackIDs": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
}
}
}
},
"EveningDetectiveServerResetGameBody": { "EveningDetectiveServerResetGameBody": {
"type": "object" "type": "object"
}, },
@@ -5881,6 +5936,14 @@
} }
} }
}, },
"evening_detective_serverReorderPlaylistTracksRsp": {
"type": "object",
"properties": {
"playlist": {
"$ref": "#/definitions/evening_detective_serverPlaylist"
}
}
},
"evening_detective_serverResetGameLocalModeRsp": { "evening_detective_serverResetGameLocalModeRsp": {
"type": "object" "type": "object"
}, },
+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.ErrGameNotFound),
errors.Is(err, playlist_service.ErrScenarioNotFound): errors.Is(err, playlist_service.ErrScenarioNotFound):
return status.Error(codes.NotFound, err.Error()) 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): case errors.Is(err, playlist_service.ErrPlaylistInUse):
return status.Error(codes.FailedPrecondition, err.Error()) return status.Error(codes.FailedPrecondition, err.Error())
default: 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 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
}
+5 -2
View File
@@ -123,15 +123,18 @@ func (s *server) BindScreen(ctx context.Context, req *proto.BindScreenReq) (*pro
return nil, status.Error(codes.NotFound, "игра не найдена") return nil, status.Error(codes.NotFound, "игра не найдена")
} }
hash := hashScreenToken(req.AccessToken) 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) { if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана") return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
} }
return nil, status.Error(codes.Internal, "не удалось привязать экран") return nil, status.Error(codes.Internal, "не удалось привязать экран")
} }
if err := s.screenPlanService.ApplyCurrent(ctx, int(req.GameID)); err != nil { if isNewBinding {
if err := s.screenPlanService.ApplyCurrent(ctx, int(req.GameID), id); err != nil {
return nil, status.Error(codes.Internal, "не удалось применить план экранов") return nil, status.Error(codes.Internal, "не удалось применить план экранов")
} }
}
session, err := s.screenService.Get(ctx, id, hash) session, err := s.screenService.Get(ctx, id, hash)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить состояние экрана") return nil, status.Error(codes.Internal, "не удалось получить состояние экрана")
@@ -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)
}
})
}
}
+147 -26
View File
@@ -11,6 +11,7 @@ import (
var ( var (
ErrPlaylistNotFound = errors.New("плейлист не найден") ErrPlaylistNotFound = errors.New("плейлист не найден")
ErrTrackOrderChanged = errors.New("состав треков изменился; обновите список")
ErrPlaylistInUse = errors.New("плейлист используется") ErrPlaylistInUse = errors.New("плейлист используется")
ErrTrackNotFound = errors.New("трек не найден") ErrTrackNotFound = errors.New("трек не найден")
ErrGameNotFound = errors.New("игра не найдена") ErrGameNotFound = errors.New("игра не найдена")
@@ -54,11 +55,28 @@ func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} }
// List returns all playlists with their tracks. // List returns all playlists with their tracks.
func (r *Repo) List(ctx context.Context) ([]*Playlist, error) { func (r *Repo) List(ctx context.Context) ([]*Playlist, error) {
rows, err := r.pool.Query(ctx, ` return list(ctx, r.pool)
SELECT p.id, p.name, t.id, t.name, t.filename, t.position }
// 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 FROM playlists p
LEFT JOIN playlist_tracks t ON t.playlist_id = p.id 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 { if err != nil {
return nil, err 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. // Delete removes a playlist that is not assigned to a scenario or game.
func (r *Repo) Delete(ctx context.Context, id int) error { 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 var used bool
if err := r.pool.QueryRow(ctx, ` if err := tx.QueryRow(
ctx,
`
SELECT EXISTS (SELECT 1 FROM scenarios WHERE playlist_id = $1) 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 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 return err
} }
if used { if used {
return ErrPlaylistInUse return ErrPlaylistInUse
} }
result, err := r.pool.Exec(ctx, ` result, err := tx.Exec(
ctx,
`
DELETE FROM playlists DELETE FROM playlists
WHERE id = $1 WHERE id = $1
`, id) `,
id,
)
if err != nil { if err != nil {
return err return err
} }
if result.RowsAffected() == 0 { if result.RowsAffected() == 0 {
return ErrPlaylistNotFound return ErrPlaylistNotFound
} }
return nil return tx.Commit(ctx)
} }
// AddTrack adds a track to a playlist. // AddTrack adds a track to a playlist.
func (r *Repo) AddTrack(ctx context.Context, playlistID int, name, filename string) error { 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 var position int
if err := r.pool.QueryRow(ctx, ` if err := tx.QueryRow(
ctx,
`
SELECT COALESCE(MAX(position), -1) + 1 SELECT COALESCE(MAX(position), -1) + 1
FROM playlist_tracks FROM playlist_tracks
WHERE playlist_id = $1 WHERE playlist_id = $1
`, playlistID).Scan(&position); err != nil { `,
playlistID,
).Scan(&position); err != nil {
return err return err
} }
_, err := r.pool.Exec(ctx, ` _, err = tx.Exec(
ctx,
`
INSERT INTO playlist_tracks (playlist_id, name, filename, position) INSERT INTO playlist_tracks (playlist_id, name, filename, position)
VALUES ($1, $2, $3, $4) VALUES ($1, $2, $3, $4)
`, playlistID, name, filename, position) `,
playlistID,
name,
filename,
position,
)
if err != nil {
return err return err
}
return tx.Commit(ctx)
} }
// DeleteTrack removes a track and returns its stored filename. // DeleteTrack removes a track and returns its stored filename.
func (r *Repo) DeleteTrack(ctx context.Context, playlistID, trackID int) (string, error) { 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 filename string
var used bool var used bool
if err := r.pool.QueryRow(ctx, ` if err = tx.QueryRow(
ctx,
`
SELECT EXISTS (SELECT 1 FROM scenarios WHERE playlist_id = $1) 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 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 return "", err
} }
if used { if used {
return "", ErrPlaylistInUse return "", ErrPlaylistInUse
} }
err := r.pool.QueryRow(ctx, ` err = tx.QueryRow(
ctx,
`
DELETE FROM playlist_tracks DELETE FROM playlist_tracks
WHERE id = $1 AND playlist_id = $2 WHERE id = $1 AND playlist_id = $2
RETURNING filename RETURNING filename
`, trackID, playlistID).Scan(&filename) `,
trackID,
playlistID,
).Scan(&filename)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
return "", ErrTrackNotFound return "", ErrTrackNotFound
} }
return filename, err if err != nil {
return "", err
}
return filename, tx.Commit(ctx)
} }
// SetScenario assigns a playlist to a scenario. // 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. // GetMusic returns the effective and current music state for a game.
func (r *Repo) GetMusic(ctx context.Context, gameID int) (*MusicState, error) { 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 var gamePlaylistID, scenarioPlaylistID *int
err := r.pool.QueryRow(ctx, ` err = tx.QueryRow(
SELECT g.playlist_id, s.playlist_id ctx,
`
SELECT
g.playlist_id,
s.playlist_id
FROM games g FROM games g
JOIN scenarios s ON s.id = g.scenario_id JOIN scenarios s ON s.id = g.scenario_id
WHERE g.id = $1 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} state := &MusicState{ScenarioID: scenarioPlaylistID, GameID: gamePlaylistID, Volume: 100, Playback: 1}
if playlistID != nil { if playlistID != nil {
state.Playlist, err = r.Get(ctx, *playlistID) state.Playlist, err = get(*playlistID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
} }
var trackIndex int var trackIndex int
var statePlaylistID *int var statePlaylistID *int
err = r.pool.QueryRow(ctx, ` err = tx.QueryRow(
SELECT playlist_id, track_index, position_ms, volume, loop, playback, state_version, started_at ctx,
`
SELECT
playlist_id,
track_index,
position_ms,
volume,
loop,
playback,
state_version,
started_at
FROM game_music_states FROM game_music_states
WHERE game_id = $1 WHERE game_id = $1
`, gameID). `,
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)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
state.Version = 1 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() state.PositionMs += time.Since(*state.StartedAt).Milliseconds()
} }
if statePlaylistID != nil && (state.Playlist == nil || state.Playlist.ID != *statePlaylistID) { 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 { if err != nil {
return nil, err 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. // 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) (*MusicState, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
if playlistID > 0 { if playlistID > 0 {
if _, err := r.Get(ctx, playlistID); err != nil { if err := lockPlaylist(ctx, tx, playlistID); err != nil {
return nil, err return nil, err
} }
} }
_, err := r.pool.Exec(ctx, ` _, err = tx.Exec(
ctx,
`
INSERT INTO game_music_states ( INSERT INTO game_music_states (
game_id, playlist_id, track_index, position_ms, volume, loop, game_id, playlist_id, track_index, position_ms, volume, loop,
playback, state_version, started_at playback, state_version, started_at
@@ -336,5 +454,8 @@ func (r *Repo) SetMusic(ctx context.Context, gameID, playlistID, trackIndex int,
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.GetMusic(ctx, gameID) 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. // Bind attaches a screen session to a game.
func (r *ScreenSessionsRepo) Bind(ctx context.Context, id, gameID int, tokenHash string) error { func (r *ScreenSessionsRepo) Bind(ctx context.Context, id, gameID int, tokenHash string) error {
result, err := r.pool.Exec(ctx, ` _, err := r.BindWithAudio(ctx, id, gameID, tokenHash, false)
UPDATE screen_sessions
SET game_id = $1, updated_at = NOW()
WHERE id = $2 AND access_token_hash = $3`, gameID, id, tokenHash)
if err != nil {
return err 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 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 { 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. // 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. // SetSound enables or disables sound for a screen session.
func (r *ScreenSessionsRepo) SetSound(ctx context.Context, id int, enabled bool) error { 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` query := `UPDATE screen_sessions SET sound_enabled = FALSE, updated_at = NOW() WHERE id = $1`
args := []any{id} args := []any{id}
if enabled { 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() SET sound_enabled = (id = $1), updated_at = NOW()
WHERE game_id = (SELECT game_id FROM screen_sessions WHERE id = $1)` 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 { if err != nil {
return err return err
} }
if result.RowsAffected() == 0 { if result.RowsAffected() == 0 {
return ErrScreenNotFound 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")
}
}
}
@@ -11,6 +11,7 @@ import (
) )
var ( var (
ErrTrackOrderChanged = errors.New("состав треков изменился; обновите список")
ErrInvalidName = errors.New("некорректное имя плейлиста") ErrInvalidName = errors.New("некорректное имя плейлиста")
ErrInvalidTrack = errors.New("некорректный трек") ErrInvalidTrack = errors.New("некорректный трек")
ErrPlaylistNotFound = errors.New("плейлист не найден") ErrPlaylistNotFound = errors.New("плейлист не найден")
@@ -150,6 +151,8 @@ func (s *Service) GetGame(ctx context.Context, gameID int) (*MusicState, error)
func mapError(err error) error { func mapError(err error) error {
switch { switch {
case errors.Is(err, playlists_repo.ErrTrackOrderChanged):
return ErrTrackOrderChanged
case errors.Is(err, playlists_repo.ErrPlaylistNotFound): case errors.Is(err, playlists_repo.ErrPlaylistNotFound):
return ErrPlaylistNotFound return ErrPlaylistNotFound
case errors.Is(err, playlists_repo.ErrPlaylistInUse): case errors.Is(err, playlists_repo.ErrPlaylistInUse):
@@ -164,3 +167,22 @@ func mapError(err error) error {
return err 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. // 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) plan, err := s.Get(ctx, gameID)
if errors.Is(err, ErrPlanNotFound) { if errors.Is(err, ErrPlanNotFound) {
return nil return s.screenService.InitializeScreen(ctx, screenID, gameID, 1, "", "Перерыв", 600)
} }
if err != nil { if err != nil {
return err 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) { 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. // Bind attaches a screen session to a game.
func (s *ScreenService) Bind(ctx context.Context, id, gameID int, tokenHash string) error { func (s *ScreenService) Bind(ctx context.Context, id, gameID int, tokenHash string, initializeAudio bool) (bool, error) {
return mapError(s.repo.Bind(ctx, id, gameID, tokenHash)) isNewBinding, err := s.repo.BindWithAudio(ctx, id, gameID, tokenHash, initializeAudio)
return isNewBinding, mapError(err)
} }
// Unbind detaches a screen session from its game. // Unbind detaches a screen session from its game.
@@ -93,3 +94,8 @@ func mapError(err error) error {
} }
return err 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)
}
+1250 -1131
View File
File diff suppressed because it is too large Load Diff
+84
View File
@@ -1084,6 +1084,51 @@ func local_request_EveningDetectiveServer_UploadPlaylistTrack_0(ctx context.Cont
return msg, metadata, err return msg, metadata, err
} }
func request_EveningDetectiveServer_ReorderPlaylistTracks_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ReorderPlaylistTracksReq
metadata runtime.ServerMetadata
err error
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
if req.Body != nil {
_, _ = io.Copy(io.Discard, req.Body)
}
val, ok := pathParams["playlistID"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "playlistID")
}
protoReq.PlaylistID, err = runtime.Int32(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "playlistID", err)
}
msg, err := client.ReorderPlaylistTracks(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD))
return msg, metadata, err
}
func local_request_EveningDetectiveServer_ReorderPlaylistTracks_0(ctx context.Context, marshaler runtime.Marshaler, server EveningDetectiveServerServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var (
protoReq ReorderPlaylistTracksReq
metadata runtime.ServerMetadata
err error
)
if err := marshaler.NewDecoder(req.Body).Decode(&protoReq); err != nil && !errors.Is(err, io.EOF) {
return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err)
}
val, ok := pathParams["playlistID"]
if !ok {
return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "playlistID")
}
protoReq.PlaylistID, err = runtime.Int32(val)
if err != nil {
return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "playlistID", err)
}
msg, err := server.ReorderPlaylistTracks(ctx, &protoReq)
return msg, metadata, err
}
func request_EveningDetectiveServer_DeletePlaylistTrack_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { func request_EveningDetectiveServer_DeletePlaylistTrack_0(ctx context.Context, marshaler runtime.Marshaler, client EveningDetectiveServerClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) {
var ( var (
protoReq DeletePlaylistTrackReq protoReq DeletePlaylistTrackReq
@@ -5177,6 +5222,26 @@ func RegisterEveningDetectiveServerHandlerServer(ctx context.Context, mux *runti
} }
forward_EveningDetectiveServer_UploadPlaylistTrack_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) forward_EveningDetectiveServer_UploadPlaylistTrack_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
}) })
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_ReorderPlaylistTracks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
var stream runtime.ServerTransportStream
ctx = grpc.NewContextWithServerTransportStream(ctx, &stream)
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateIncomingContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/ReorderPlaylistTracks", runtime.WithHTTPPathPattern("/api/playlists/{playlistID}/tracks/order"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := local_request_EveningDetectiveServer_ReorderPlaylistTracks_0(annotatedContext, inboundMarshaler, server, req, pathParams)
md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer())
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_ReorderPlaylistTracks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodDelete, pattern_EveningDetectiveServer_DeletePlaylistTrack_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { mux.Handle(http.MethodDelete, pattern_EveningDetectiveServer_DeletePlaylistTrack_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context()) ctx, cancel := context.WithCancel(req.Context())
defer cancel() defer cancel()
@@ -7415,6 +7480,23 @@ func RegisterEveningDetectiveServerHandlerClient(ctx context.Context, mux *runti
} }
forward_EveningDetectiveServer_UploadPlaylistTrack_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) forward_EveningDetectiveServer_UploadPlaylistTrack_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
}) })
mux.Handle(http.MethodPut, pattern_EveningDetectiveServer_ReorderPlaylistTracks_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context())
defer cancel()
inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req)
annotatedContext, err := runtime.AnnotateContext(ctx, mux, req, "/crabs.evening_detective_server.EveningDetectiveServer/ReorderPlaylistTracks", runtime.WithHTTPPathPattern("/api/playlists/{playlistID}/tracks/order"))
if err != nil {
runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err)
return
}
resp, md, err := request_EveningDetectiveServer_ReorderPlaylistTracks_0(annotatedContext, inboundMarshaler, client, req, pathParams)
annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md)
if err != nil {
runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err)
return
}
forward_EveningDetectiveServer_ReorderPlaylistTracks_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...)
})
mux.Handle(http.MethodDelete, pattern_EveningDetectiveServer_DeletePlaylistTrack_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { mux.Handle(http.MethodDelete, pattern_EveningDetectiveServer_DeletePlaylistTrack_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) {
ctx, cancel := context.WithCancel(req.Context()) ctx, cancel := context.WithCancel(req.Context())
defer cancel() defer cancel()
@@ -8830,6 +8912,7 @@ var (
pattern_EveningDetectiveServer_UpdatePlaylist_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "playlists", "ID"}, "")) pattern_EveningDetectiveServer_UpdatePlaylist_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "playlists", "ID"}, ""))
pattern_EveningDetectiveServer_DeletePlaylist_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "playlists", "ID"}, "")) pattern_EveningDetectiveServer_DeletePlaylist_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2}, []string{"api", "playlists", "ID"}, ""))
pattern_EveningDetectiveServer_UploadPlaylistTrack_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "playlists", "playlistID", "tracks"}, "")) pattern_EveningDetectiveServer_UploadPlaylistTrack_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "playlists", "playlistID", "tracks"}, ""))
pattern_EveningDetectiveServer_ReorderPlaylistTracks_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 2, 4}, []string{"api", "playlists", "playlistID", "tracks", "order"}, ""))
pattern_EveningDetectiveServer_DeletePlaylistTrack_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "playlists", "playlistID", "tracks", "trackID"}, "")) pattern_EveningDetectiveServer_DeletePlaylistTrack_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3, 1, 0, 4, 1, 5, 4}, []string{"api", "playlists", "playlistID", "tracks", "trackID"}, ""))
pattern_EveningDetectiveServer_SetScenarioPlaylist_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "ID", "playlist"}, "")) pattern_EveningDetectiveServer_SetScenarioPlaylist_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 1, 0, 4, 1, 5, 2, 2, 3}, []string{"api", "scenarios", "ID", "playlist"}, ""))
pattern_EveningDetectiveServer_AddScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "scenario"}, "")) pattern_EveningDetectiveServer_AddScenario_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1}, []string{"api", "scenario"}, ""))
@@ -8948,6 +9031,7 @@ var (
forward_EveningDetectiveServer_UpdatePlaylist_0 = runtime.ForwardResponseMessage forward_EveningDetectiveServer_UpdatePlaylist_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeletePlaylist_0 = runtime.ForwardResponseMessage forward_EveningDetectiveServer_DeletePlaylist_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_UploadPlaylistTrack_0 = runtime.ForwardResponseMessage forward_EveningDetectiveServer_UploadPlaylistTrack_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_ReorderPlaylistTracks_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_DeletePlaylistTrack_0 = runtime.ForwardResponseMessage forward_EveningDetectiveServer_DeletePlaylistTrack_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_SetScenarioPlaylist_0 = runtime.ForwardResponseMessage forward_EveningDetectiveServer_SetScenarioPlaylist_0 = runtime.ForwardResponseMessage
forward_EveningDetectiveServer_AddScenario_0 = runtime.ForwardResponseMessage forward_EveningDetectiveServer_AddScenario_0 = runtime.ForwardResponseMessage
+38
View File
@@ -60,6 +60,7 @@ const (
EveningDetectiveServer_UpdatePlaylist_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdatePlaylist" EveningDetectiveServer_UpdatePlaylist_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UpdatePlaylist"
EveningDetectiveServer_DeletePlaylist_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeletePlaylist" EveningDetectiveServer_DeletePlaylist_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeletePlaylist"
EveningDetectiveServer_UploadPlaylistTrack_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UploadPlaylistTrack" EveningDetectiveServer_UploadPlaylistTrack_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/UploadPlaylistTrack"
EveningDetectiveServer_ReorderPlaylistTracks_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/ReorderPlaylistTracks"
EveningDetectiveServer_DeletePlaylistTrack_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeletePlaylistTrack" EveningDetectiveServer_DeletePlaylistTrack_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/DeletePlaylistTrack"
EveningDetectiveServer_SetScenarioPlaylist_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/SetScenarioPlaylist" EveningDetectiveServer_SetScenarioPlaylist_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/SetScenarioPlaylist"
EveningDetectiveServer_AddScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenario" EveningDetectiveServer_AddScenario_FullMethodName = "/crabs.evening_detective_server.EveningDetectiveServer/AddScenario"
@@ -183,6 +184,7 @@ type EveningDetectiveServerClient interface {
UpdatePlaylist(ctx context.Context, in *UpdatePlaylistReq, opts ...grpc.CallOption) (*UpdatePlaylistRsp, error) UpdatePlaylist(ctx context.Context, in *UpdatePlaylistReq, opts ...grpc.CallOption) (*UpdatePlaylistRsp, error)
DeletePlaylist(ctx context.Context, in *DeletePlaylistReq, opts ...grpc.CallOption) (*DeletePlaylistRsp, error) DeletePlaylist(ctx context.Context, in *DeletePlaylistReq, opts ...grpc.CallOption) (*DeletePlaylistRsp, error)
UploadPlaylistTrack(ctx context.Context, in *UploadPlaylistTrackReq, opts ...grpc.CallOption) (*UploadPlaylistTrackRsp, error) UploadPlaylistTrack(ctx context.Context, in *UploadPlaylistTrackReq, opts ...grpc.CallOption) (*UploadPlaylistTrackRsp, error)
ReorderPlaylistTracks(ctx context.Context, in *ReorderPlaylistTracksReq, opts ...grpc.CallOption) (*ReorderPlaylistTracksRsp, error)
DeletePlaylistTrack(ctx context.Context, in *DeletePlaylistTrackReq, opts ...grpc.CallOption) (*DeletePlaylistTrackRsp, error) DeletePlaylistTrack(ctx context.Context, in *DeletePlaylistTrackReq, opts ...grpc.CallOption) (*DeletePlaylistTrackRsp, error)
SetScenarioPlaylist(ctx context.Context, in *SetScenarioPlaylistReq, opts ...grpc.CallOption) (*SetScenarioPlaylistRsp, error) SetScenarioPlaylist(ctx context.Context, in *SetScenarioPlaylistReq, opts ...grpc.CallOption) (*SetScenarioPlaylistRsp, error)
AddScenario(ctx context.Context, in *AddScenarioReq, opts ...grpc.CallOption) (*AddScenarioRsp, error) AddScenario(ctx context.Context, in *AddScenarioReq, opts ...grpc.CallOption) (*AddScenarioRsp, error)
@@ -626,6 +628,16 @@ func (c *eveningDetectiveServerClient) UploadPlaylistTrack(ctx context.Context,
return out, nil return out, nil
} }
func (c *eveningDetectiveServerClient) ReorderPlaylistTracks(ctx context.Context, in *ReorderPlaylistTracksReq, opts ...grpc.CallOption) (*ReorderPlaylistTracksRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ReorderPlaylistTracksRsp)
err := c.cc.Invoke(ctx, EveningDetectiveServer_ReorderPlaylistTracks_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *eveningDetectiveServerClient) DeletePlaylistTrack(ctx context.Context, in *DeletePlaylistTrackReq, opts ...grpc.CallOption) (*DeletePlaylistTrackRsp, error) { func (c *eveningDetectiveServerClient) DeletePlaylistTrack(ctx context.Context, in *DeletePlaylistTrackReq, opts ...grpc.CallOption) (*DeletePlaylistTrackRsp, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DeletePlaylistTrackRsp) out := new(DeletePlaylistTrackRsp)
@@ -1476,6 +1488,7 @@ type EveningDetectiveServerServer interface {
UpdatePlaylist(context.Context, *UpdatePlaylistReq) (*UpdatePlaylistRsp, error) UpdatePlaylist(context.Context, *UpdatePlaylistReq) (*UpdatePlaylistRsp, error)
DeletePlaylist(context.Context, *DeletePlaylistReq) (*DeletePlaylistRsp, error) DeletePlaylist(context.Context, *DeletePlaylistReq) (*DeletePlaylistRsp, error)
UploadPlaylistTrack(context.Context, *UploadPlaylistTrackReq) (*UploadPlaylistTrackRsp, error) UploadPlaylistTrack(context.Context, *UploadPlaylistTrackReq) (*UploadPlaylistTrackRsp, error)
ReorderPlaylistTracks(context.Context, *ReorderPlaylistTracksReq) (*ReorderPlaylistTracksRsp, error)
DeletePlaylistTrack(context.Context, *DeletePlaylistTrackReq) (*DeletePlaylistTrackRsp, error) DeletePlaylistTrack(context.Context, *DeletePlaylistTrackReq) (*DeletePlaylistTrackRsp, error)
SetScenarioPlaylist(context.Context, *SetScenarioPlaylistReq) (*SetScenarioPlaylistRsp, error) SetScenarioPlaylist(context.Context, *SetScenarioPlaylistReq) (*SetScenarioPlaylistRsp, error)
AddScenario(context.Context, *AddScenarioReq) (*AddScenarioRsp, error) AddScenario(context.Context, *AddScenarioReq) (*AddScenarioRsp, error)
@@ -1681,6 +1694,9 @@ func (UnimplementedEveningDetectiveServerServer) DeletePlaylist(context.Context,
func (UnimplementedEveningDetectiveServerServer) UploadPlaylistTrack(context.Context, *UploadPlaylistTrackReq) (*UploadPlaylistTrackRsp, error) { func (UnimplementedEveningDetectiveServerServer) UploadPlaylistTrack(context.Context, *UploadPlaylistTrackReq) (*UploadPlaylistTrackRsp, error) {
return nil, status.Error(codes.Unimplemented, "method UploadPlaylistTrack not implemented") return nil, status.Error(codes.Unimplemented, "method UploadPlaylistTrack not implemented")
} }
func (UnimplementedEveningDetectiveServerServer) ReorderPlaylistTracks(context.Context, *ReorderPlaylistTracksReq) (*ReorderPlaylistTracksRsp, error) {
return nil, status.Error(codes.Unimplemented, "method ReorderPlaylistTracks not implemented")
}
func (UnimplementedEveningDetectiveServerServer) DeletePlaylistTrack(context.Context, *DeletePlaylistTrackReq) (*DeletePlaylistTrackRsp, error) { func (UnimplementedEveningDetectiveServerServer) DeletePlaylistTrack(context.Context, *DeletePlaylistTrackReq) (*DeletePlaylistTrackRsp, error) {
return nil, status.Error(codes.Unimplemented, "method DeletePlaylistTrack not implemented") return nil, status.Error(codes.Unimplemented, "method DeletePlaylistTrack not implemented")
} }
@@ -2558,6 +2574,24 @@ func _EveningDetectiveServer_UploadPlaylistTrack_Handler(srv interface{}, ctx co
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _EveningDetectiveServer_ReorderPlaylistTracks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ReorderPlaylistTracksReq)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(EveningDetectiveServerServer).ReorderPlaylistTracks(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: EveningDetectiveServer_ReorderPlaylistTracks_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(EveningDetectiveServerServer).ReorderPlaylistTracks(ctx, req.(*ReorderPlaylistTracksReq))
}
return interceptor(ctx, in, info, handler)
}
func _EveningDetectiveServer_DeletePlaylistTrack_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { func _EveningDetectiveServer_DeletePlaylistTrack_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DeletePlaylistTrackReq) in := new(DeletePlaylistTrackReq)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@@ -4159,6 +4193,10 @@ var EveningDetectiveServer_ServiceDesc = grpc.ServiceDesc{
MethodName: "UploadPlaylistTrack", MethodName: "UploadPlaylistTrack",
Handler: _EveningDetectiveServer_UploadPlaylistTrack_Handler, Handler: _EveningDetectiveServer_UploadPlaylistTrack_Handler,
}, },
{
MethodName: "ReorderPlaylistTracks",
Handler: _EveningDetectiveServer_ReorderPlaylistTracks_Handler,
},
{ {
MethodName: "DeletePlaylistTrack", MethodName: "DeletePlaylistTrack",
Handler: _EveningDetectiveServer_DeletePlaylistTrack_Handler, Handler: _EveningDetectiveServer_DeletePlaylistTrack_Handler,