From e68cf4322c9d12163fe0e69500eb3c96f00ab6cb Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Tue, 27 Oct 2026 15:16:37 +0700 Subject: [PATCH] up --- api/main.proto | 1 + .../main.swagger.json | 4 + internal/app/playlist_order_test.go | 26 ++++++ internal/app/playlists.go | 1 + internal/repos/game_exports_repo/repo_test.go | 2 +- internal/repos/playlists_repo/groups_test.go | 6 +- internal/repos/playlists_repo/order_test.go | 6 +- internal/repos/playlists_repo/repo.go | 15 ++- internal/repos/playlists_repo/upload_test.go | 93 +++++++++++++++++++ .../game_import_service/import_test.go | 2 +- .../services/playlist_service/groups_test.go | 3 + internal/services/playlist_service/service.go | 6 +- proto/main.pb.go | 13 ++- 13 files changed, 162 insertions(+), 16 deletions(-) create mode 100644 internal/repos/playlists_repo/upload_test.go diff --git a/api/main.proto b/api/main.proto index d5befc4..b48a540 100644 --- a/api/main.proto +++ b/api/main.proto @@ -1544,6 +1544,7 @@ message UploadPlaylistTrackReq { string name = 2; string filename = 3; bytes data = 4; + int32 groupID = 5; } message UploadPlaylistTrackRsp { diff --git a/cmd/evening_detective_server/main.swagger.json b/cmd/evening_detective_server/main.swagger.json index 668af12..f51fe5e 100644 --- a/cmd/evening_detective_server/main.swagger.json +++ b/cmd/evening_detective_server/main.swagger.json @@ -4866,6 +4866,10 @@ "data": { "type": "string", "format": "byte" + }, + "groupID": { + "type": "integer", + "format": "int32" } } }, diff --git a/internal/app/playlist_order_test.go b/internal/app/playlist_order_test.go index 570afd0..8c4ec3e 100644 --- a/internal/app/playlist_order_test.go +++ b/internal/app/playlist_order_test.go @@ -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) + } + }) + } +} diff --git a/internal/app/playlists.go b/internal/app/playlists.go index 2a0a635..dcfdb91 100644 --- a/internal/app/playlists.go +++ b/internal/app/playlists.go @@ -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(), diff --git a/internal/repos/game_exports_repo/repo_test.go b/internal/repos/game_exports_repo/repo_test.go index d9d70fc..4b8b31b 100644 --- a/internal/repos/game_exports_repo/repo_test.go +++ b/internal/repos/game_exports_repo/repo_test.go @@ -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) diff --git a/internal/repos/playlists_repo/groups_test.go b/internal/repos/playlists_repo/groups_test.go index b27bb47..3858b7c 100644 --- a/internal/repos/playlists_repo/groups_test.go +++ b/internal/repos/playlists_repo/groups_test.go @@ -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) diff --git a/internal/repos/playlists_repo/order_test.go b/internal/repos/playlists_repo/order_test.go index b308a29..4a2a214 100644 --- a/internal/repos/playlists_repo/order_test.go +++ b/internal/repos/playlists_repo/order_test.go @@ -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) diff --git a/internal/repos/playlists_repo/repo.go b/internal/repos/playlists_repo/repo.go index c767fea..4fc50dc 100644 --- a/internal/repos/playlists_repo/repo.go +++ b/internal/repos/playlists_repo/repo.go @@ -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 diff --git a/internal/repos/playlists_repo/upload_test.go b/internal/repos/playlists_repo/upload_test.go new file mode 100644 index 0000000..7a707c7 --- /dev/null +++ b/internal/repos/playlists_repo/upload_test.go @@ -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) + } + }) + } +} diff --git a/internal/services/game_import_service/import_test.go b/internal/services/game_import_service/import_test.go index dd60dd8..01ecc64 100644 --- a/internal/services/game_import_service/import_test.go +++ b/internal/services/game_import_service/import_test.go @@ -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 diff --git a/internal/services/playlist_service/groups_test.go b/internal/services/playlist_service/groups_test.go index a267fea..befd13a 100644 --- a/internal/services/playlist_service/groups_test.go +++ b/internal/services/playlist_service/groups_test.go @@ -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 { diff --git a/internal/services/playlist_service/service.go b/internal/services/playlist_service/service.go index 39743b0..f80ef25 100644 --- a/internal/services/playlist_service/service.go +++ b/internal/services/playlist_service/service.go @@ -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) } diff --git a/proto/main.pb.go b/proto/main.pb.go index b115625..ea7e473 100644 --- a/proto/main.pb.go +++ b/proto/main.pb.go @@ -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" +