This commit is contained in:
2026-10-27 15:16:37 +07:00
parent e4c8116e36
commit e68cf4322c
13 changed files with 162 additions and 16 deletions
+1
View File
@@ -1544,6 +1544,7 @@ message UploadPlaylistTrackReq {
string name = 2;
string filename = 3;
bytes data = 4;
int32 groupID = 5;
}
message UploadPlaylistTrackRsp {
@@ -4866,6 +4866,10 @@
"data": {
"type": "string",
"format": "byte"
},
"groupID": {
"type": "integer",
"format": "int32"
}
}
},
+26
View File
@@ -9,6 +9,7 @@ import (
proto "evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/encoding/protojson"
)
func TestPlaylistOrderErrorCodes(t *testing.T) {
@@ -48,3 +49,28 @@ func TestPlaylistOrderRequiresAuthentication(t *testing.T) {
})
}
}
func TestUploadPlaylistTrackGroupCompatibility(t *testing.T) {
for _, tc := range []struct {
name string
body string
want int32
}{
{"old request", `{"playlistID":1,"name":"Track","filename":"track.mp3","data":"AQ=="}`, 0},
{"group request", `{"playlistID":1,"groupID":7,"name":"Track","filename":"track.mp3","data":"AQ=="}`, 7},
} {
t.Run(tc.name, func(t *testing.T) {
var req proto.UploadPlaylistTrackReq
if err := protojson.Unmarshal([]byte(tc.body), &req); err != nil {
t.Fatal(err)
}
if req.GetGroupID() != tc.want {
t.Fatalf("groupID: got %d, want %d", req.GetGroupID(), tc.want)
}
_, err := (&server{}).UploadPlaylistTrack(context.Background(), &req)
if status.Code(err) != codes.PermissionDenied {
t.Fatal(err)
}
})
}
}
+1
View File
@@ -70,6 +70,7 @@ func (s *server) UploadPlaylistTrack(ctx context.Context, req *proto.UploadPlayl
if err := s.playlistService.UploadTrack(
ctx,
int(req.GetPlaylistID()),
int(req.GetGroupID()),
req.GetName(),
req.GetFilename(),
req.GetData(),
@@ -36,7 +36,7 @@ func TestExportSnapshotAndEditableGame(t *testing.T) {
requireNoError(t, err)
selected, err := music.Create(ctx, "Selected by screen")
requireNoError(t, err)
requireNoError(t, music.AddTrack(ctx, selected.ID, "Grouped", "grouped.mp3"))
requireNoError(t, music.AddTrack(ctx, selected.ID, 0, "Grouped", "grouped.mp3"))
selected, err = music.SaveGroup(ctx, selected.ID, 0, "Final", "stop")
requireNoError(t, err)
selected, err = music.SetTrackGroup(ctx, selected.ID, selected.Tracks[0].ID, selected.Groups[0].ID)
+3 -3
View File
@@ -16,7 +16,7 @@ func TestGroupEditingProtectsLivePlayback(t *testing.T) {
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"))
requireNoError(t, f.repo.AddTrack(ctx, p.ID, 0, name, name+".mp3"))
}
p, err = f.repo.SaveGroup(ctx, p.ID, 0, "First", "repeat")
requireNoError(t, err)
@@ -101,7 +101,7 @@ func TestGroupAssignmentsAndPlaybackRejectForeignIDs(t *testing.T) {
ctx := context.Background()
p, err := f.repo.Create(ctx, "One")
requireNoError(t, err)
requireNoError(t, f.repo.AddTrack(ctx, p.ID, "Track", "track.mp3"))
requireNoError(t, f.repo.AddTrack(ctx, p.ID, 0, "Track", "track.mp3"))
p, err = f.repo.SaveGroup(ctx, p.ID, 0, "Empty", "repeat")
requireNoError(t, err)
other, err := f.repo.Create(ctx, "Other")
@@ -132,7 +132,7 @@ func TestConcurrentCompletionAdvancesOnce(t *testing.T) {
p, err := f.repo.Create(ctx, "Concurrent completion")
requireNoError(t, err)
for _, name := range []string{"one", "two"} {
requireNoError(t, f.repo.AddTrack(ctx, p.ID, name, name+".mp3"))
requireNoError(t, f.repo.AddTrack(ctx, p.ID, 0, name, name+".mp3"))
}
before, err := f.repo.SetMusic(ctx, f.gameID, p.ID, 0, 0, 50, false, playback, 0)
requireNoError(t, err)
+3 -3
View File
@@ -20,7 +20,7 @@ func TestReorderPreservesPlaybackAndRejectsChangedMembership(t *testing.T) {
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"))
requireNoError(t, f.repo.AddTrack(ctx, p.ID, 0, name, name+".mp3"))
}
p, err = f.repo.Get(ctx, p.ID)
requireNoError(t, err)
@@ -90,7 +90,7 @@ func TestConcurrentAdditionsKeepDistinctPositions(t *testing.T) {
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.Go(func() { failures <- f.repo.AddTrack(ctx, p.ID, 0, "Track", fmt.Sprintf("track-%d.mp3", i)) })
}
wg.Wait()
close(failures)
@@ -172,7 +172,7 @@ func TestReorderReadersSeeConsistentTrackAndIndex(t *testing.T) {
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"))
requireNoError(t, f.repo.AddTrack(ctx, p.ID, 0, name, name+".mp3"))
}
p, err = f.repo.Get(ctx, p.ID)
requireNoError(t, err)
+12 -3
View File
@@ -242,7 +242,7 @@ func (r *Repo) Delete(ctx context.Context, id int) error {
}
// 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, groupID int, name, filename string) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
@@ -251,6 +251,14 @@ func (r *Repo) AddTrack(ctx context.Context, playlistID int, name, filename stri
if err := lockPlaylist(ctx, tx, playlistID); err != nil {
return err
}
if groupID != 0 {
if _, err := readGroup(ctx, tx, playlistID, groupID); err != nil {
return err
}
if err := groupInUse(ctx, tx, playlistID, groupID); err != nil {
return err
}
}
var position int
if err := tx.QueryRow(
ctx,
@@ -266,13 +274,14 @@ func (r *Repo) AddTrack(ctx context.Context, playlistID int, name, filename stri
_, err = tx.Exec(
ctx,
`
INSERT INTO playlist_tracks (playlist_id, name, filename, position)
VALUES ($1, $2, $3, $4)
INSERT INTO playlist_tracks (playlist_id, name, filename, position, group_id)
VALUES ($1, $2, $3, $4, NULLIF($5, 0))
`,
playlistID,
name,
filename,
position,
groupID,
)
if err != nil {
return err
@@ -0,0 +1,93 @@
package playlists_repo
import (
"context"
"errors"
"reflect"
"testing"
)
func TestAddTrackSavesTargetGroupAtomically(t *testing.T) {
cases := []struct {
name string
target string
playback int32
activeGroup bool
want error
}{
{name: "ungrouped", target: "none"},
{name: "empty group", target: "second"},
{name: "populated group", target: "first"},
{name: "missing group", target: "missing", want: ErrGroupNotFound},
{name: "foreign group", target: "foreign", want: ErrGroupNotFound},
{name: "deleted group", target: "deleted", want: ErrGroupNotFound},
{name: "playing group", target: "first", playback: 2, activeGroup: true, want: ErrPlaylistInUse},
{name: "paused group", target: "first", playback: 3, activeGroup: true, want: ErrPlaylistInUse},
{name: "another group playing", target: "second", playback: 2, activeGroup: true},
{name: "whole playlist playing", target: "second", playback: 2, want: ErrPlaylistInUse},
{name: "legacy ungrouped upload during playback", target: "none", playback: 2},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f := newPlaylistFixture(t)
ctx := context.Background()
p, err := f.repo.Create(ctx, "Upload")
requireNoError(t, err)
p, err = f.repo.SaveGroup(ctx, p.ID, 0, "First", "repeat")
requireNoError(t, err)
first := p.Groups[0].ID
p, err = f.repo.SaveGroup(ctx, p.ID, 0, "Second", "stop")
requireNoError(t, err)
second := p.Groups[1].ID
requireNoError(t, f.repo.AddTrack(ctx, p.ID, first, "Existing", "existing.mp3"))
groupID := first
switch tc.target {
case "none":
groupID = 0
case "second", "deleted":
groupID = second
case "missing":
groupID = second + 1000000
case "foreign":
other, err := f.repo.Create(ctx, "Other")
requireNoError(t, err)
other, err = f.repo.SaveGroup(ctx, other.ID, 0, "Foreign", "repeat")
requireNoError(t, err)
groupID = other.Groups[0].ID
}
if tc.target == "deleted" {
_, err := f.repo.DeleteGroup(ctx, p.ID, groupID)
requireNoError(t, err)
}
if tc.playback != 0 {
activeGroup := 0
if tc.activeGroup {
activeGroup = first
}
_, err := f.repo.SetMusic(ctx, f.gameID, p.ID, 0, 0, 50, false, tc.playback, activeGroup)
requireNoError(t, err)
}
before, err := f.repo.Get(ctx, p.ID)
requireNoError(t, err)
err = f.repo.AddTrack(ctx, p.ID, groupID, "New", "new.mp3")
if !errors.Is(err, tc.want) {
t.Fatalf("expected %v, got %v", tc.want, err)
}
after, err := f.repo.Get(ctx, p.ID)
requireNoError(t, err)
if tc.want != nil {
if !reflect.DeepEqual(before.Tracks, after.Tracks) {
t.Fatal("rejected upload changed playlist tracks")
}
return
}
if len(after.Tracks) != len(before.Tracks)+1 {
t.Fatal("upload did not add exactly one track")
}
track := after.Tracks[len(after.Tracks)-1]
if track.GroupID != groupID || track.Name != "New" || track.Position != before.Tracks[len(before.Tracks)-1].Position+1 {
t.Fatalf("track was not appended to the target group: %+v", track)
}
})
}
}
@@ -226,7 +226,7 @@ func TestImportPreservesGameAndIsIdempotent(t *testing.T) {
for _, name := range []string{"Local first", "Local second"} {
playlist, err := playlistRepo.Create(ctx, name)
check(t, err)
check(t, playlistRepo.AddTrack(ctx, playlist.ID, name, name+".mp3"))
check(t, playlistRepo.AddTrack(ctx, playlist.ID, 0, name, name+".mp3"))
}
source := snapshot()
source.Data.Game.Format = tc.format
@@ -54,6 +54,9 @@ func TestGroupValidationRejectsInvalidRequestsBeforeRepository(t *testing.T) {
}, ErrInvalidTrack},
{"invalid group", func(s *Service) error { _, err := s.DeleteGroup(context.Background(), 1, 0); return err }, ErrInvalidTrack},
{"duplicate order", func(s *Service) error { _, err := s.ReorderGroups(context.Background(), 1, []int{1, 1}); return err }, ErrInvalidTrack},
{"negative upload group", func(s *Service) error {
return s.UploadTrack(context.Background(), 1, -1, "Track", "track.mp3", []byte("data"))
}, ErrInvalidTrack},
{"invalid assignment", func(s *Service) error { _, err := s.SetTrackGroup(context.Background(), 1, 2, -1); return err }, ErrInvalidTrack},
}
for _, tc := range cases {
@@ -88,8 +88,8 @@ func (s *Service) Delete(ctx context.Context, id int) error {
}
// UploadTrack stores an MP3 file and adds it to a playlist.
func (s *Service) UploadTrack(ctx context.Context, playlistID int, name, filename string, data []byte) error {
if playlistID <= 0 || strings.TrimSpace(name) == "" || len(data) == 0 || !strings.HasSuffix(strings.ToLower(filename), ".mp3") {
func (s *Service) UploadTrack(ctx context.Context, playlistID, groupID int, name, filename string, data []byte) error {
if playlistID <= 0 || groupID < 0 || strings.TrimSpace(name) == "" || len(data) == 0 || !strings.HasSuffix(strings.ToLower(filename), ".mp3") {
return ErrInvalidTrack
}
if _, err := s.repo.Get(ctx, playlistID); err != nil {
@@ -99,7 +99,7 @@ func (s *Service) UploadTrack(ctx context.Context, playlistID int, name, filenam
if err != nil || fileType != "audio" || !strings.HasSuffix(strings.ToLower(stored), ".mp3") {
return ErrInvalidTrack
}
if err := s.repo.AddTrack(ctx, playlistID, strings.TrimSpace(name), stored); err != nil {
if err := s.repo.AddTrack(ctx, playlistID, groupID, strings.TrimSpace(name), stored); err != nil {
_ = s.files.DeleteFile(ctx, stored)
return mapError(err)
}
+11 -2
View File
@@ -3046,6 +3046,7 @@ type UploadPlaylistTrackReq struct {
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Filename string `protobuf:"bytes,3,opt,name=filename,proto3" json:"filename,omitempty"`
Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
GroupID int32 `protobuf:"varint,5,opt,name=groupID,proto3" json:"groupID,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -3108,6 +3109,13 @@ func (x *UploadPlaylistTrackReq) GetData() []byte {
return nil
}
func (x *UploadPlaylistTrackReq) GetGroupID() int32 {
if x != nil {
return x.GroupID
}
return 0
}
type UploadPlaylistTrackRsp struct {
state protoimpl.MessageState `protogen:"open.v1"`
Playlist *Playlist `protobuf:"bytes,1,opt,name=playlist,proto3" json:"playlist,omitempty"`
@@ -14077,14 +14085,15 @@ const file_main_proto_rawDesc = "" +
"\bplaylist\x18\x01 \x01(\v2(.crabs.evening_detective_server.PlaylistR\bplaylist\"#\n" +
"\x11DeletePlaylistReq\x12\x0e\n" +
"\x02ID\x18\x01 \x01(\x05R\x02ID\"\x13\n" +
"\x11DeletePlaylistRsp\"|\n" +
"\x11DeletePlaylistRsp\"\x96\x01\n" +
"\x16UploadPlaylistTrackReq\x12\x1e\n" +
"\n" +
"playlistID\x18\x01 \x01(\x05R\n" +
"playlistID\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x1a\n" +
"\bfilename\x18\x03 \x01(\tR\bfilename\x12\x12\n" +
"\x04data\x18\x04 \x01(\fR\x04data\"^\n" +
"\x04data\x18\x04 \x01(\fR\x04data\x12\x18\n" +
"\agroupID\x18\x05 \x01(\x05R\agroupID\"^\n" +
"\x16UploadPlaylistTrackRsp\x12D\n" +
"\bplaylist\x18\x01 \x01(\v2(.crabs.evening_detective_server.PlaylistR\bplaylist\"V\n" +
"\x18ReorderPlaylistTracksReq\x12\x1e\n" +