From dbabc58615928633044abbf870c29f0adadaefa2 Mon Sep 17 00:00:00 2001 From: schromp Date: Fri, 16 Jan 2026 20:59:35 +0100 Subject: [PATCH] added song handlers --- backend/gen/radio/v1/radio.pb.go | 415 ++++++++++++++++-- .../radio/v1/radiov1connect/radio.connect.go | 233 ++++++++++ backend/internal/song/handler.go | 149 ++++++- backend/internal/song/model.go | 3 +- backend/internal/song/store.go | 57 ++- ...20260116192633_add_songs_channel_table.sql | 17 + proto/radio/v1/radio.proto | 39 ++ 7 files changed, 872 insertions(+), 41 deletions(-) create mode 100644 backend/migrations/20260116192633_add_songs_channel_table.sql diff --git a/backend/gen/radio/v1/radio.pb.go b/backend/gen/radio/v1/radio.pb.go index 12fd789..66e4828 100644 --- a/backend/gen/radio/v1/radio.pb.go +++ b/backend/gen/radio/v1/radio.pb.go @@ -22,6 +22,42 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type Empty struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Empty) Reset() { + *x = Empty{} + mi := &file_radio_v1_radio_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Empty) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Empty) ProtoMessage() {} + +func (x *Empty) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_radio_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Empty.ProtoReflect.Descriptor instead. +func (*Empty) Descriptor() ([]byte, []int) { + return file_radio_v1_radio_proto_rawDescGZIP(), []int{0} +} + type Channel struct { state protoimpl.MessageState `protogen:"open.v1"` Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` @@ -33,7 +69,7 @@ type Channel struct { func (x *Channel) Reset() { *x = Channel{} - mi := &file_radio_v1_radio_proto_msgTypes[0] + mi := &file_radio_v1_radio_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -45,7 +81,7 @@ func (x *Channel) String() string { func (*Channel) ProtoMessage() {} func (x *Channel) ProtoReflect() protoreflect.Message { - mi := &file_radio_v1_radio_proto_msgTypes[0] + mi := &file_radio_v1_radio_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -58,7 +94,7 @@ func (x *Channel) ProtoReflect() protoreflect.Message { // Deprecated: Use Channel.ProtoReflect.Descriptor instead. func (*Channel) Descriptor() ([]byte, []int) { - return file_radio_v1_radio_proto_rawDescGZIP(), []int{0} + return file_radio_v1_radio_proto_rawDescGZIP(), []int{1} } func (x *Channel) GetId() string { @@ -92,7 +128,7 @@ type CreateChannelRequest struct { func (x *CreateChannelRequest) Reset() { *x = CreateChannelRequest{} - mi := &file_radio_v1_radio_proto_msgTypes[1] + mi := &file_radio_v1_radio_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -104,7 +140,7 @@ func (x *CreateChannelRequest) String() string { func (*CreateChannelRequest) ProtoMessage() {} func (x *CreateChannelRequest) ProtoReflect() protoreflect.Message { - mi := &file_radio_v1_radio_proto_msgTypes[1] + mi := &file_radio_v1_radio_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -117,7 +153,7 @@ func (x *CreateChannelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateChannelRequest.ProtoReflect.Descriptor instead. func (*CreateChannelRequest) Descriptor() ([]byte, []int) { - return file_radio_v1_radio_proto_rawDescGZIP(), []int{1} + return file_radio_v1_radio_proto_rawDescGZIP(), []int{2} } func (x *CreateChannelRequest) GetFrequency() string { @@ -143,7 +179,7 @@ type CreateChannelResponse struct { func (x *CreateChannelResponse) Reset() { *x = CreateChannelResponse{} - mi := &file_radio_v1_radio_proto_msgTypes[2] + mi := &file_radio_v1_radio_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -155,7 +191,7 @@ func (x *CreateChannelResponse) String() string { func (*CreateChannelResponse) ProtoMessage() {} func (x *CreateChannelResponse) ProtoReflect() protoreflect.Message { - mi := &file_radio_v1_radio_proto_msgTypes[2] + mi := &file_radio_v1_radio_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -168,7 +204,7 @@ func (x *CreateChannelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateChannelResponse.ProtoReflect.Descriptor instead. func (*CreateChannelResponse) Descriptor() ([]byte, []int) { - return file_radio_v1_radio_proto_rawDescGZIP(), []int{2} + return file_radio_v1_radio_proto_rawDescGZIP(), []int{3} } func (x *CreateChannelResponse) GetChannel() *Channel { @@ -187,7 +223,7 @@ type DeleteChannelRequest struct { func (x *DeleteChannelRequest) Reset() { *x = DeleteChannelRequest{} - mi := &file_radio_v1_radio_proto_msgTypes[3] + mi := &file_radio_v1_radio_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -199,7 +235,7 @@ func (x *DeleteChannelRequest) String() string { func (*DeleteChannelRequest) ProtoMessage() {} func (x *DeleteChannelRequest) ProtoReflect() protoreflect.Message { - mi := &file_radio_v1_radio_proto_msgTypes[3] + mi := &file_radio_v1_radio_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -212,7 +248,7 @@ func (x *DeleteChannelRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteChannelRequest.ProtoReflect.Descriptor instead. func (*DeleteChannelRequest) Descriptor() ([]byte, []int) { - return file_radio_v1_radio_proto_rawDescGZIP(), []int{3} + return file_radio_v1_radio_proto_rawDescGZIP(), []int{4} } func (x *DeleteChannelRequest) GetId() string { @@ -230,7 +266,7 @@ type DeleteChannelResponse struct { func (x *DeleteChannelResponse) Reset() { *x = DeleteChannelResponse{} - mi := &file_radio_v1_radio_proto_msgTypes[4] + mi := &file_radio_v1_radio_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -242,7 +278,7 @@ func (x *DeleteChannelResponse) String() string { func (*DeleteChannelResponse) ProtoMessage() {} func (x *DeleteChannelResponse) ProtoReflect() protoreflect.Message { - mi := &file_radio_v1_radio_proto_msgTypes[4] + mi := &file_radio_v1_radio_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -255,14 +291,289 @@ func (x *DeleteChannelResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use DeleteChannelResponse.ProtoReflect.Descriptor instead. func (*DeleteChannelResponse) Descriptor() ([]byte, []int) { - return file_radio_v1_radio_proto_rawDescGZIP(), []int{4} + return file_radio_v1_radio_proto_rawDescGZIP(), []int{5} +} + +type UploadRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Data: + // + // *UploadRequest_Filename + // *UploadRequest_Chunk + Data isUploadRequest_Data `protobuf_oneof:"data"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadRequest) Reset() { + *x = UploadRequest{} + mi := &file_radio_v1_radio_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadRequest) ProtoMessage() {} + +func (x *UploadRequest) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_radio_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadRequest.ProtoReflect.Descriptor instead. +func (*UploadRequest) Descriptor() ([]byte, []int) { + return file_radio_v1_radio_proto_rawDescGZIP(), []int{6} +} + +func (x *UploadRequest) GetData() isUploadRequest_Data { + if x != nil { + return x.Data + } + return nil +} + +func (x *UploadRequest) GetFilename() string { + if x != nil { + if x, ok := x.Data.(*UploadRequest_Filename); ok { + return x.Filename + } + } + return "" +} + +func (x *UploadRequest) GetChunk() []byte { + if x != nil { + if x, ok := x.Data.(*UploadRequest_Chunk); ok { + return x.Chunk + } + } + return nil +} + +type isUploadRequest_Data interface { + isUploadRequest_Data() +} + +type UploadRequest_Filename struct { + Filename string `protobuf:"bytes,1,opt,name=filename,proto3,oneof"` +} + +type UploadRequest_Chunk struct { + Chunk []byte `protobuf:"bytes,2,opt,name=chunk,proto3,oneof"` +} + +func (*UploadRequest_Filename) isUploadRequest_Data() {} + +func (*UploadRequest_Chunk) isUploadRequest_Data() {} + +type UploadResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + SongId string `protobuf:"bytes,1,opt,name=song_id,json=songId,proto3" json:"song_id,omitempty"` + Size int64 `protobuf:"varint,2,opt,name=size,proto3" json:"size,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UploadResponse) Reset() { + *x = UploadResponse{} + mi := &file_radio_v1_radio_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UploadResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UploadResponse) ProtoMessage() {} + +func (x *UploadResponse) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_radio_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UploadResponse.ProtoReflect.Descriptor instead. +func (*UploadResponse) Descriptor() ([]byte, []int) { + return file_radio_v1_radio_proto_rawDescGZIP(), []int{7} +} + +func (x *UploadResponse) GetSongId() string { + if x != nil { + return x.SongId + } + return "" +} + +func (x *UploadResponse) GetSize() int64 { + if x != nil { + return x.Size + } + return 0 +} + +type AddSongToChannelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SongId string `protobuf:"bytes,1,opt,name=song_id,json=songId,proto3" json:"song_id,omitempty"` + ChannelId string `protobuf:"bytes,2,opt,name=channel_id,json=channelId,proto3" json:"channel_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AddSongToChannelRequest) Reset() { + *x = AddSongToChannelRequest{} + mi := &file_radio_v1_radio_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AddSongToChannelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AddSongToChannelRequest) ProtoMessage() {} + +func (x *AddSongToChannelRequest) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_radio_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AddSongToChannelRequest.ProtoReflect.Descriptor instead. +func (*AddSongToChannelRequest) Descriptor() ([]byte, []int) { + return file_radio_v1_radio_proto_rawDescGZIP(), []int{8} +} + +func (x *AddSongToChannelRequest) GetSongId() string { + if x != nil { + return x.SongId + } + return "" +} + +func (x *AddSongToChannelRequest) GetChannelId() string { + if x != nil { + return x.ChannelId + } + return "" +} + +type DeleteSongRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + SongId string `protobuf:"bytes,1,opt,name=song_id,json=songId,proto3" json:"song_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSongRequest) Reset() { + *x = DeleteSongRequest{} + mi := &file_radio_v1_radio_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSongRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSongRequest) ProtoMessage() {} + +func (x *DeleteSongRequest) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_radio_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSongRequest.ProtoReflect.Descriptor instead. +func (*DeleteSongRequest) Descriptor() ([]byte, []int) { + return file_radio_v1_radio_proto_rawDescGZIP(), []int{9} +} + +func (x *DeleteSongRequest) GetSongId() string { + if x != nil { + return x.SongId + } + return "" +} + +type DeleteSongResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteSongResponse) Reset() { + *x = DeleteSongResponse{} + mi := &file_radio_v1_radio_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteSongResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteSongResponse) ProtoMessage() {} + +func (x *DeleteSongResponse) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_radio_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteSongResponse.ProtoReflect.Descriptor instead. +func (*DeleteSongResponse) Descriptor() ([]byte, []int) { + return file_radio_v1_radio_proto_rawDescGZIP(), []int{10} +} + +func (x *DeleteSongResponse) GetId() string { + if x != nil { + return x.Id + } + return "" } var File_radio_v1_radio_proto protoreflect.FileDescriptor const file_radio_v1_radio_proto_rawDesc = "" + "\n" + - "\x14radio/v1/radio.proto\x12\bradio.v1\x1a\x1bbuf/validate/validate.proto\"Y\n" + + "\x14radio/v1/radio.proto\x12\bradio.v1\x1a\x1bbuf/validate/validate.proto\"\a\n" + + "\x05Empty\"Y\n" + "\aChannel\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + "\tfrequency\x18\x02 \x01(\tR\tfrequency\x12 \n" + @@ -274,10 +585,32 @@ const file_radio_v1_radio_proto_rawDesc = "" + "\achannel\x18\x01 \x01(\v2\x11.radio.v1.ChannelR\achannel\"&\n" + "\x14DeleteChannelRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\"\x17\n" + - "\x15DeleteChannelResponse2\xb4\x01\n" + + "\x15DeleteChannelResponse\"M\n" + + "\rUploadRequest\x12\x1c\n" + + "\bfilename\x18\x01 \x01(\tH\x00R\bfilename\x12\x16\n" + + "\x05chunk\x18\x02 \x01(\fH\x00R\x05chunkB\x06\n" + + "\x04data\"=\n" + + "\x0eUploadResponse\x12\x17\n" + + "\asong_id\x18\x01 \x01(\tR\x06songId\x12\x12\n" + + "\x04size\x18\x02 \x01(\x03R\x04size\"Q\n" + + "\x17AddSongToChannelRequest\x12\x17\n" + + "\asong_id\x18\x01 \x01(\tR\x06songId\x12\x1d\n" + + "\n" + + "channel_id\x18\x02 \x01(\tR\tchannelId\",\n" + + "\x11DeleteSongRequest\x12\x17\n" + + "\asong_id\x18\x01 \x01(\tR\x06songId\"$\n" + + "\x12DeleteSongResponse\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id2\xb4\x01\n" + "\x0eChannelService\x12P\n" + "\rCreateChannel\x12\x1e.radio.v1.CreateChannelRequest\x1a\x1f.radio.v1.CreateChannelResponse\x12P\n" + - "\rDeleteChannel\x12\x1e.radio.v1.DeleteChannelRequest\x1a\x1f.radio.v1.DeleteChannelResponseB\x83\x01\n" + + "\rDeleteChannel\x12\x1e.radio.v1.DeleteChannelRequest\x1a\x1f.radio.v1.DeleteChannelResponse2N\n" + + "\rUploadService\x12=\n" + + "\x06Upload\x12\x17.radio.v1.UploadRequest\x1a\x18.radio.v1.UploadResponse(\x012a\n" + + "\x17AddSongToChannelService\x12F\n" + + "\x10AddSongToChannel\x12!.radio.v1.AddSongToChannelRequest\x1a\x0f.radio.v1.Empty2b\n" + + "\x11DeleteSongService\x12M\n" + + "\n" + + "DeleteSong\x12\x1e.radio.v1.DeleteChannelRequest\x1a\x1f.radio.v1.DeleteChannelResponseB\x83\x01\n" + "\fcom.radio.v1B\n" + "RadioProtoP\x01Z&echsen.club/radio/gen/radio/v1;radiov1\xa2\x02\x03RXX\xaa\x02\bRadio.V1\xca\x02\bRadio\\V1\xe2\x02\x14Radio\\V1\\GPBMetadata\xea\x02\tRadio::V1b\x06proto3" @@ -293,22 +626,34 @@ func file_radio_v1_radio_proto_rawDescGZIP() []byte { return file_radio_v1_radio_proto_rawDescData } -var file_radio_v1_radio_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_radio_v1_radio_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_radio_v1_radio_proto_goTypes = []any{ - (*Channel)(nil), // 0: radio.v1.Channel - (*CreateChannelRequest)(nil), // 1: radio.v1.CreateChannelRequest - (*CreateChannelResponse)(nil), // 2: radio.v1.CreateChannelResponse - (*DeleteChannelRequest)(nil), // 3: radio.v1.DeleteChannelRequest - (*DeleteChannelResponse)(nil), // 4: radio.v1.DeleteChannelResponse + (*Empty)(nil), // 0: radio.v1.Empty + (*Channel)(nil), // 1: radio.v1.Channel + (*CreateChannelRequest)(nil), // 2: radio.v1.CreateChannelRequest + (*CreateChannelResponse)(nil), // 3: radio.v1.CreateChannelResponse + (*DeleteChannelRequest)(nil), // 4: radio.v1.DeleteChannelRequest + (*DeleteChannelResponse)(nil), // 5: radio.v1.DeleteChannelResponse + (*UploadRequest)(nil), // 6: radio.v1.UploadRequest + (*UploadResponse)(nil), // 7: radio.v1.UploadResponse + (*AddSongToChannelRequest)(nil), // 8: radio.v1.AddSongToChannelRequest + (*DeleteSongRequest)(nil), // 9: radio.v1.DeleteSongRequest + (*DeleteSongResponse)(nil), // 10: radio.v1.DeleteSongResponse } var file_radio_v1_radio_proto_depIdxs = []int32{ - 0, // 0: radio.v1.CreateChannelResponse.channel:type_name -> radio.v1.Channel - 1, // 1: radio.v1.ChannelService.CreateChannel:input_type -> radio.v1.CreateChannelRequest - 3, // 2: radio.v1.ChannelService.DeleteChannel:input_type -> radio.v1.DeleteChannelRequest - 2, // 3: radio.v1.ChannelService.CreateChannel:output_type -> radio.v1.CreateChannelResponse - 4, // 4: radio.v1.ChannelService.DeleteChannel:output_type -> radio.v1.DeleteChannelResponse - 3, // [3:5] is the sub-list for method output_type - 1, // [1:3] is the sub-list for method input_type + 1, // 0: radio.v1.CreateChannelResponse.channel:type_name -> radio.v1.Channel + 2, // 1: radio.v1.ChannelService.CreateChannel:input_type -> radio.v1.CreateChannelRequest + 4, // 2: radio.v1.ChannelService.DeleteChannel:input_type -> radio.v1.DeleteChannelRequest + 6, // 3: radio.v1.UploadService.Upload:input_type -> radio.v1.UploadRequest + 8, // 4: radio.v1.AddSongToChannelService.AddSongToChannel:input_type -> radio.v1.AddSongToChannelRequest + 4, // 5: radio.v1.DeleteSongService.DeleteSong:input_type -> radio.v1.DeleteChannelRequest + 3, // 6: radio.v1.ChannelService.CreateChannel:output_type -> radio.v1.CreateChannelResponse + 5, // 7: radio.v1.ChannelService.DeleteChannel:output_type -> radio.v1.DeleteChannelResponse + 7, // 8: radio.v1.UploadService.Upload:output_type -> radio.v1.UploadResponse + 0, // 9: radio.v1.AddSongToChannelService.AddSongToChannel:output_type -> radio.v1.Empty + 5, // 10: radio.v1.DeleteSongService.DeleteSong:output_type -> radio.v1.DeleteChannelResponse + 6, // [6:11] is the sub-list for method output_type + 1, // [1:6] is the sub-list for method input_type 1, // [1:1] is the sub-list for extension type_name 1, // [1:1] is the sub-list for extension extendee 0, // [0:1] is the sub-list for field type_name @@ -319,15 +664,19 @@ func file_radio_v1_radio_proto_init() { if File_radio_v1_radio_proto != nil { return } + file_radio_v1_radio_proto_msgTypes[6].OneofWrappers = []any{ + (*UploadRequest_Filename)(nil), + (*UploadRequest_Chunk)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_radio_v1_radio_proto_rawDesc), len(file_radio_v1_radio_proto_rawDesc)), NumEnums: 0, - NumMessages: 5, + NumMessages: 11, NumExtensions: 0, - NumServices: 1, + NumServices: 4, }, GoTypes: file_radio_v1_radio_proto_goTypes, DependencyIndexes: file_radio_v1_radio_proto_depIdxs, diff --git a/backend/gen/radio/v1/radiov1connect/radio.connect.go b/backend/gen/radio/v1/radiov1connect/radio.connect.go index 6a552e2..ffa2d90 100644 --- a/backend/gen/radio/v1/radiov1connect/radio.connect.go +++ b/backend/gen/radio/v1/radiov1connect/radio.connect.go @@ -23,6 +23,12 @@ const _ = connect.IsAtLeastVersion1_13_0 const ( // ChannelServiceName is the fully-qualified name of the ChannelService service. ChannelServiceName = "radio.v1.ChannelService" + // UploadServiceName is the fully-qualified name of the UploadService service. + UploadServiceName = "radio.v1.UploadService" + // AddSongToChannelServiceName is the fully-qualified name of the AddSongToChannelService service. + AddSongToChannelServiceName = "radio.v1.AddSongToChannelService" + // DeleteSongServiceName is the fully-qualified name of the DeleteSongService service. + DeleteSongServiceName = "radio.v1.DeleteSongService" ) // These constants are the fully-qualified names of the RPCs defined in this package. They're @@ -39,6 +45,14 @@ const ( // ChannelServiceDeleteChannelProcedure is the fully-qualified name of the ChannelService's // DeleteChannel RPC. ChannelServiceDeleteChannelProcedure = "/radio.v1.ChannelService/DeleteChannel" + // UploadServiceUploadProcedure is the fully-qualified name of the UploadService's Upload RPC. + UploadServiceUploadProcedure = "/radio.v1.UploadService/Upload" + // AddSongToChannelServiceAddSongToChannelProcedure is the fully-qualified name of the + // AddSongToChannelService's AddSongToChannel RPC. + AddSongToChannelServiceAddSongToChannelProcedure = "/radio.v1.AddSongToChannelService/AddSongToChannel" + // DeleteSongServiceDeleteSongProcedure is the fully-qualified name of the DeleteSongService's + // DeleteSong RPC. + DeleteSongServiceDeleteSongProcedure = "/radio.v1.DeleteSongService/DeleteSong" ) // ChannelServiceClient is a client for the radio.v1.ChannelService service. @@ -144,3 +158,222 @@ func (UnimplementedChannelServiceHandler) CreateChannel(context.Context, *v1.Cre func (UnimplementedChannelServiceHandler) DeleteChannel(context.Context, *v1.DeleteChannelRequest) (*v1.DeleteChannelResponse, error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.ChannelService.DeleteChannel is not implemented")) } + +// UploadServiceClient is a client for the radio.v1.UploadService service. +type UploadServiceClient interface { + Upload(context.Context) (*connect.ClientStreamForClientSimple[v1.UploadRequest, v1.UploadResponse], error) +} + +// NewUploadServiceClient constructs a client for the radio.v1.UploadService service. By default, it +// uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, and sends +// uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or +// connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewUploadServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) UploadServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + uploadServiceMethods := v1.File_radio_v1_radio_proto.Services().ByName("UploadService").Methods() + return &uploadServiceClient{ + upload: connect.NewClient[v1.UploadRequest, v1.UploadResponse]( + httpClient, + baseURL+UploadServiceUploadProcedure, + connect.WithSchema(uploadServiceMethods.ByName("Upload")), + connect.WithClientOptions(opts...), + ), + } +} + +// uploadServiceClient implements UploadServiceClient. +type uploadServiceClient struct { + upload *connect.Client[v1.UploadRequest, v1.UploadResponse] +} + +// Upload calls radio.v1.UploadService.Upload. +func (c *uploadServiceClient) Upload(ctx context.Context) (*connect.ClientStreamForClientSimple[v1.UploadRequest, v1.UploadResponse], error) { + return c.upload.CallClientStreamSimple(ctx) +} + +// UploadServiceHandler is an implementation of the radio.v1.UploadService service. +type UploadServiceHandler interface { + Upload(context.Context, *connect.ClientStream[v1.UploadRequest]) (*v1.UploadResponse, error) +} + +// NewUploadServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewUploadServiceHandler(svc UploadServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + uploadServiceMethods := v1.File_radio_v1_radio_proto.Services().ByName("UploadService").Methods() + uploadServiceUploadHandler := connect.NewClientStreamHandlerSimple( + UploadServiceUploadProcedure, + svc.Upload, + connect.WithSchema(uploadServiceMethods.ByName("Upload")), + connect.WithHandlerOptions(opts...), + ) + return "/radio.v1.UploadService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case UploadServiceUploadProcedure: + uploadServiceUploadHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedUploadServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedUploadServiceHandler struct{} + +func (UnimplementedUploadServiceHandler) Upload(context.Context, *connect.ClientStream[v1.UploadRequest]) (*v1.UploadResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.UploadService.Upload is not implemented")) +} + +// AddSongToChannelServiceClient is a client for the radio.v1.AddSongToChannelService service. +type AddSongToChannelServiceClient interface { + AddSongToChannel(context.Context, *v1.AddSongToChannelRequest) (*v1.Empty, error) +} + +// NewAddSongToChannelServiceClient constructs a client for the radio.v1.AddSongToChannelService +// service. By default, it uses the Connect protocol with the binary Protobuf Codec, asks for +// gzipped responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply +// the connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAddSongToChannelServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AddSongToChannelServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + addSongToChannelServiceMethods := v1.File_radio_v1_radio_proto.Services().ByName("AddSongToChannelService").Methods() + return &addSongToChannelServiceClient{ + addSongToChannel: connect.NewClient[v1.AddSongToChannelRequest, v1.Empty]( + httpClient, + baseURL+AddSongToChannelServiceAddSongToChannelProcedure, + connect.WithSchema(addSongToChannelServiceMethods.ByName("AddSongToChannel")), + connect.WithClientOptions(opts...), + ), + } +} + +// addSongToChannelServiceClient implements AddSongToChannelServiceClient. +type addSongToChannelServiceClient struct { + addSongToChannel *connect.Client[v1.AddSongToChannelRequest, v1.Empty] +} + +// AddSongToChannel calls radio.v1.AddSongToChannelService.AddSongToChannel. +func (c *addSongToChannelServiceClient) AddSongToChannel(ctx context.Context, req *v1.AddSongToChannelRequest) (*v1.Empty, error) { + response, err := c.addSongToChannel.CallUnary(ctx, connect.NewRequest(req)) + if response != nil { + return response.Msg, err + } + return nil, err +} + +// AddSongToChannelServiceHandler is an implementation of the radio.v1.AddSongToChannelService +// service. +type AddSongToChannelServiceHandler interface { + AddSongToChannel(context.Context, *v1.AddSongToChannelRequest) (*v1.Empty, error) +} + +// NewAddSongToChannelServiceHandler builds an HTTP handler from the service implementation. It +// returns the path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAddSongToChannelServiceHandler(svc AddSongToChannelServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + addSongToChannelServiceMethods := v1.File_radio_v1_radio_proto.Services().ByName("AddSongToChannelService").Methods() + addSongToChannelServiceAddSongToChannelHandler := connect.NewUnaryHandlerSimple( + AddSongToChannelServiceAddSongToChannelProcedure, + svc.AddSongToChannel, + connect.WithSchema(addSongToChannelServiceMethods.ByName("AddSongToChannel")), + connect.WithHandlerOptions(opts...), + ) + return "/radio.v1.AddSongToChannelService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AddSongToChannelServiceAddSongToChannelProcedure: + addSongToChannelServiceAddSongToChannelHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAddSongToChannelServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAddSongToChannelServiceHandler struct{} + +func (UnimplementedAddSongToChannelServiceHandler) AddSongToChannel(context.Context, *v1.AddSongToChannelRequest) (*v1.Empty, error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.AddSongToChannelService.AddSongToChannel is not implemented")) +} + +// DeleteSongServiceClient is a client for the radio.v1.DeleteSongService service. +type DeleteSongServiceClient interface { + DeleteSong(context.Context, *v1.DeleteChannelRequest) (*v1.DeleteChannelResponse, error) +} + +// NewDeleteSongServiceClient constructs a client for the radio.v1.DeleteSongService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewDeleteSongServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) DeleteSongServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + deleteSongServiceMethods := v1.File_radio_v1_radio_proto.Services().ByName("DeleteSongService").Methods() + return &deleteSongServiceClient{ + deleteSong: connect.NewClient[v1.DeleteChannelRequest, v1.DeleteChannelResponse]( + httpClient, + baseURL+DeleteSongServiceDeleteSongProcedure, + connect.WithSchema(deleteSongServiceMethods.ByName("DeleteSong")), + connect.WithClientOptions(opts...), + ), + } +} + +// deleteSongServiceClient implements DeleteSongServiceClient. +type deleteSongServiceClient struct { + deleteSong *connect.Client[v1.DeleteChannelRequest, v1.DeleteChannelResponse] +} + +// DeleteSong calls radio.v1.DeleteSongService.DeleteSong. +func (c *deleteSongServiceClient) DeleteSong(ctx context.Context, req *v1.DeleteChannelRequest) (*v1.DeleteChannelResponse, error) { + response, err := c.deleteSong.CallUnary(ctx, connect.NewRequest(req)) + if response != nil { + return response.Msg, err + } + return nil, err +} + +// DeleteSongServiceHandler is an implementation of the radio.v1.DeleteSongService service. +type DeleteSongServiceHandler interface { + DeleteSong(context.Context, *v1.DeleteChannelRequest) (*v1.DeleteChannelResponse, error) +} + +// NewDeleteSongServiceHandler builds an HTTP handler from the service implementation. It returns +// the path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewDeleteSongServiceHandler(svc DeleteSongServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + deleteSongServiceMethods := v1.File_radio_v1_radio_proto.Services().ByName("DeleteSongService").Methods() + deleteSongServiceDeleteSongHandler := connect.NewUnaryHandlerSimple( + DeleteSongServiceDeleteSongProcedure, + svc.DeleteSong, + connect.WithSchema(deleteSongServiceMethods.ByName("DeleteSong")), + connect.WithHandlerOptions(opts...), + ) + return "/radio.v1.DeleteSongService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case DeleteSongServiceDeleteSongProcedure: + deleteSongServiceDeleteSongHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedDeleteSongServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedDeleteSongServiceHandler struct{} + +func (UnimplementedDeleteSongServiceHandler) DeleteSong(context.Context, *v1.DeleteChannelRequest) (*v1.DeleteChannelResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.DeleteSongService.DeleteSong is not implemented")) +} diff --git a/backend/internal/song/handler.go b/backend/internal/song/handler.go index 3cc2562..3ca1e21 100644 --- a/backend/internal/song/handler.go +++ b/backend/internal/song/handler.go @@ -1,6 +1,19 @@ package song -import "context" +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + + "connectrpc.com/connect" + radiov1 "echsen.club/radio/gen/radio/v1" + "echsen.club/radio/internal/channel" + "echsen.club/radio/internal/user" + "github.com/google/uuid" +) type SongHandler struct { store *Store @@ -10,6 +23,136 @@ func NewHandler(store *Store) *SongHandler { return &SongHandler{store} } -func (handler *SongHandler) UploadSong(ctx context.Context) { - // upload song, then insert into database, then return song data +func (handler *SongHandler) UploadSong(ctx context.Context, stream *connect.ClientStream[radiov1.UploadRequest]) (*connect.Response[radiov1.UploadResponse], error) { + user, ok := user.FromContext(ctx) + if !ok { + slog.Error("User missing from context in protected route") + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) + } + + var destFile *os.File + var totalBytes int64 + var safeName string + var success bool + + defer func() { + if !success && safeName != "" { + os.Remove(filepath.Join(handler.store.config.SongDirectory, safeName)) + } + }() + + for stream.Receive() { + msg := stream.Msg() + + switch x := msg.Data.(type) { + case *radiov1.UploadRequest_Filename: + safeName = filepath.Base(x.Filename) + f, err := os.Create(filepath.Join(handler.store.config.SongDirectory, safeName)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + destFile = f + defer destFile.Close() + case *radiov1.UploadRequest_Chunk: + if destFile == nil { + return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("filename must be sent first")) + } + n, err := destFile.Write(x.Chunk) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + totalBytes += int64(n) + } + } + + if err := stream.Err(); err != nil { + return nil, connect.NewError(connect.CodeUnknown, err) + } + + song, err := handler.store.CreateSong(ctx, user.ID, safeName) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt add song to database")) + } + + res := connect.NewResponse(&radiov1.UploadResponse{ + SongId: song.id.String(), + Size: totalBytes, + }) + + return res, nil +} + +func (handler *SongHandler) AddSongToChannel(ctx context.Context, req *radiov1.AddSongToChannelRequest) (*radiov1.Empty, error) { + user, ok := user.FromContext(ctx) + if !ok { + slog.Error("User missing from context in protected route") + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) + } + + channelId, err := uuid.Parse(req.ChannelId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid channel id")) + } + + songId, err := uuid.Parse(req.SongId) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid song id")) + } + + c, err := handler.store.channelStore.GetChannel(ctx, channelId) + if err != nil { + if errors.Is(err, channel.ErrChannelDoesntExist) { + return nil, connect.NewError(connect.CodeNotFound, errors.New("channel not found")) + } + slog.Error("Failed to get channel", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("failed to get channel")) + } + + if c.OwnerID != user.ID { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("user is not owner of channel")) + } + + if errPtr := handler.store.AddSongToChannel(ctx, songId, channelId); errPtr != nil { + slog.Error("Failed to add song to channel", "error", *errPtr) + return nil, connect.NewError(connect.CodeInternal, *errPtr) + } + + return &radiov1.Empty{}, nil +} + +func (handler *SongHandler) DeleteSong(ctx context.Context, req *radiov1.DeleteSongRequest) (*radiov1.DeleteSongResponse, error) { + user, ok := user.FromContext(ctx) + if !ok { + slog.Info("User missing from context in protected route") + return nil, connect.NewError(connect.CodeUnauthenticated, fmt.Errorf("User not authenticated")) + } + + song_id, err := uuid.Parse(req.SongId) + if err != nil { + slog.Info("song id not correct") + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("invalid channel id")) + } + + song, err := handler.store.GetSong(ctx, song_id) + if err != nil { + slog.Info("couldnt find song") + return nil, connect.NewError(connect.CodeNotFound, errors.New("couldnt find song")) + } + + if song.owner_id != user.ID { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("not authorized")) + } + + err = handler.store.DeleteSong(ctx, song_id) + if err != nil { + slog.Error("couldnt delete song from database") + return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt delete song")) + } + + err = os.Remove(filepath.Join(handler.store.config.SongDirectory, song.filename)) + if err != nil { + slog.Error("couldnt delete song from filesystem") + } + + return &radiov1.DeleteSongResponse{}, nil } diff --git a/backend/internal/song/model.go b/backend/internal/song/model.go index 8a4025a..a23b8e6 100644 --- a/backend/internal/song/model.go +++ b/backend/internal/song/model.go @@ -9,9 +9,8 @@ import ( type Song struct { id uuid.UUID owner_id uuid.UUID - filename string // or path? + filename string name string - hash string // does this make sense? createdAt time.Time modifiedAt time.Time } diff --git a/backend/internal/song/store.go b/backend/internal/song/store.go index 73aa971..c9ee1b1 100644 --- a/backend/internal/song/store.go +++ b/backend/internal/song/store.go @@ -7,16 +7,20 @@ import ( "fmt" "log/slog" + "echsen.club/radio/internal/channel" + "echsen.club/radio/internal/config" "github.com/google/uuid" "github.com/lib/pq" ) type Store struct { - db *sql.DB + db *sql.DB + config *config.Config + channelStore *channel.Store } -func NewStore(db *sql.DB) *Store { - return &Store{db} +func NewStore(db *sql.DB, config *config.Config, channelStore *channel.Store) *Store { + return &Store{db, config, channelStore} } var ErrSongExists = errors.New("song already exists") @@ -47,3 +51,50 @@ func (store *Store) CreateSong(ctx context.Context, owner_id uuid.UUID, filename return &song, nil } + +func (store *Store) AddSongToChannel(ctx context.Context, song_id uuid.UUID, channel_id uuid.UUID) *error { + return nil +} + +func (store *Store) DeleteSong(ctx context.Context, song_id uuid.UUID) error { + query := ` + DELETE FROM songs WHERE id=$1; + ` + + _, err := store.db.ExecContext(ctx, query, song_id) + if err != nil { + slog.Error("couldnt delete song from database") + return fmt.Errorf("couldnt delete song") + } + + return nil +} + +var ErrSongDoesntExist = errors.New("song doesnt exist") + +func (store *Store) GetSong(ctx context.Context, song_id uuid.UUID) (*Song, error) { + var s Song + query := ` + SELECT id, owner_id, filename, name, created_at, modified_at + FROM songs + WHERE id = $1 + ` + + err := store.db.QueryRowContext(ctx, query, song_id).Scan( + &s.id, + &s.owner_id, + &s.filename, + &s.name, + &s.createdAt, + &s.modifiedAt, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrSongDoesntExist + } + return nil, fmt.Errorf("failed to get song: %w", err) + } + + return &s, nil +} diff --git a/backend/migrations/20260116192633_add_songs_channel_table.sql b/backend/migrations/20260116192633_add_songs_channel_table.sql new file mode 100644 index 0000000..fdadcb0 --- /dev/null +++ b/backend/migrations/20260116192633_add_songs_channel_table.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin +CREATE TABLE channel_songs ( + channel_id UUID REFERENCES channels(id) ON DELETE CASCADE, + song_id UUID REFERENCES songs(id) ON DELETE CASCADE, + PRIMARY KEY (channel_id, song_id), + added_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX idx_channel_songs_song_id ON channel_songs(song_id) +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE channel_songs; +DROP INDEX idx_channel_songs_song_id; +-- +goose StatementEnd diff --git a/proto/radio/v1/radio.proto b/proto/radio/v1/radio.proto index fe890d0..b8a4567 100644 --- a/proto/radio/v1/radio.proto +++ b/proto/radio/v1/radio.proto @@ -4,6 +4,8 @@ package radio.v1; import "buf/validate/validate.proto"; +message Empty {} + message Channel { string id = 1; string frequency = 2; @@ -29,3 +31,40 @@ service ChannelService { rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse); rpc DeleteChannel(DeleteChannelRequest) returns (DeleteChannelResponse); } + +message UploadRequest { + oneof data { + string filename = 1; + bytes chunk = 2; + } +} + +message UploadResponse { + string song_id = 1; + int64 size = 2; +} + +service UploadService { + rpc Upload(stream UploadRequest) returns (UploadResponse); +} + +message AddSongToChannelRequest { + string song_id = 1; + string channel_id = 2; +} + +service AddSongToChannelService { + rpc AddSongToChannel(AddSongToChannelRequest) returns (Empty); +} + +message DeleteSongRequest { + string song_id = 1; +} + +message DeleteSongResponse { + string id = 1; +} + +service DeleteSongService { + rpc DeleteSong(DeleteChannelRequest) returns (DeleteChannelResponse); +} -- 2.51.2