generated from VLADIMIR/template
up
This commit is contained in:
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
if result.RowsAffected() == 0 {
|
|
||||||
return ErrScreenNotFound
|
// 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
|
||||||
}
|
}
|
||||||
return nil
|
defer tx.Rollback(ctx)
|
||||||
|
if _, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`SELECT id
|
||||||
|
FROM games
|
||||||
|
WHERE id = $1
|
||||||
|
FOR UPDATE`,
|
||||||
|
gameID,
|
||||||
|
); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
var previousGameID *int
|
||||||
|
if err := tx.QueryRow(
|
||||||
|
ctx,
|
||||||
|
`SELECT game_id
|
||||||
|
FROM screen_sessions
|
||||||
|
WHERE id = $1 AND access_token_hash = $2
|
||||||
|
FOR UPDATE`,
|
||||||
|
id,
|
||||||
|
tokenHash,
|
||||||
|
).Scan(&previousGameID); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, ErrScreenNotFound
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
result, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`UPDATE screen_sessions
|
||||||
|
SET game_id = $1,
|
||||||
|
sound_enabled = CASE WHEN $4 THEN TRUE ELSE sound_enabled END,
|
||||||
|
volume = CASE WHEN $4 THEN 100 ELSE volume END,
|
||||||
|
state_version = state_version + 1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2 AND access_token_hash = $3`,
|
||||||
|
gameID,
|
||||||
|
id,
|
||||||
|
tokenHash,
|
||||||
|
initializeAudio,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return false, ErrScreenNotFound
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user