diff --git a/backend/gen/radio/v1/radiov1connect/song.connect.go b/backend/gen/radio/v1/radiov1connect/song.connect.go index 36cc639..764bd87 100644 --- a/backend/gen/radio/v1/radiov1connect/song.connect.go +++ b/backend/gen/radio/v1/radiov1connect/song.connect.go @@ -38,12 +38,15 @@ const ( SongServiceUploadSongProcedure = "/radio.v1.SongService/UploadSong" // SongServiceDeleteSongProcedure is the fully-qualified name of the SongService's DeleteSong RPC. SongServiceDeleteSongProcedure = "/radio.v1.SongService/DeleteSong" + // SongServiceSongListProcedure is the fully-qualified name of the SongService's SongList RPC. + SongServiceSongListProcedure = "/radio.v1.SongService/SongList" ) // SongServiceClient is a client for the radio.v1.SongService service. type SongServiceClient interface { UploadSong(context.Context) (*connect.ClientStreamForClientSimple[v1.UploadSongRequest, v1.UploadSongResponse], error) DeleteSong(context.Context, *v1.DeleteSongRequest) (*emptypb.Empty, error) + SongList(context.Context, *emptypb.Empty) (*v1.SongListResponse, error) } // NewSongServiceClient constructs a client for the radio.v1.SongService service. By default, it @@ -69,6 +72,12 @@ func NewSongServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. connect.WithSchema(songServiceMethods.ByName("DeleteSong")), connect.WithClientOptions(opts...), ), + songList: connect.NewClient[emptypb.Empty, v1.SongListResponse]( + httpClient, + baseURL+SongServiceSongListProcedure, + connect.WithSchema(songServiceMethods.ByName("SongList")), + connect.WithClientOptions(opts...), + ), } } @@ -76,6 +85,7 @@ func NewSongServiceClient(httpClient connect.HTTPClient, baseURL string, opts .. type songServiceClient struct { uploadSong *connect.Client[v1.UploadSongRequest, v1.UploadSongResponse] deleteSong *connect.Client[v1.DeleteSongRequest, emptypb.Empty] + songList *connect.Client[emptypb.Empty, v1.SongListResponse] } // UploadSong calls radio.v1.SongService.UploadSong. @@ -92,10 +102,20 @@ func (c *songServiceClient) DeleteSong(ctx context.Context, req *v1.DeleteSongRe return nil, err } +// SongList calls radio.v1.SongService.SongList. +func (c *songServiceClient) SongList(ctx context.Context, req *emptypb.Empty) (*v1.SongListResponse, error) { + response, err := c.songList.CallUnary(ctx, connect.NewRequest(req)) + if response != nil { + return response.Msg, err + } + return nil, err +} + // SongServiceHandler is an implementation of the radio.v1.SongService service. type SongServiceHandler interface { UploadSong(context.Context, *connect.ClientStream[v1.UploadSongRequest]) (*v1.UploadSongResponse, error) DeleteSong(context.Context, *v1.DeleteSongRequest) (*emptypb.Empty, error) + SongList(context.Context, *emptypb.Empty) (*v1.SongListResponse, error) } // NewSongServiceHandler builds an HTTP handler from the service implementation. It returns the path @@ -117,12 +137,20 @@ func NewSongServiceHandler(svc SongServiceHandler, opts ...connect.HandlerOption connect.WithSchema(songServiceMethods.ByName("DeleteSong")), connect.WithHandlerOptions(opts...), ) + songServiceSongListHandler := connect.NewUnaryHandlerSimple( + SongServiceSongListProcedure, + svc.SongList, + connect.WithSchema(songServiceMethods.ByName("SongList")), + connect.WithHandlerOptions(opts...), + ) return "/radio.v1.SongService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case SongServiceUploadSongProcedure: songServiceUploadSongHandler.ServeHTTP(w, r) case SongServiceDeleteSongProcedure: songServiceDeleteSongHandler.ServeHTTP(w, r) + case SongServiceSongListProcedure: + songServiceSongListHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -139,3 +167,7 @@ func (UnimplementedSongServiceHandler) UploadSong(context.Context, *connect.Clie func (UnimplementedSongServiceHandler) DeleteSong(context.Context, *v1.DeleteSongRequest) (*emptypb.Empty, error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.SongService.DeleteSong is not implemented")) } + +func (UnimplementedSongServiceHandler) SongList(context.Context, *emptypb.Empty) (*v1.SongListResponse, error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("radio.v1.SongService.SongList is not implemented")) +} diff --git a/backend/gen/radio/v1/song.pb.go b/backend/gen/radio/v1/song.pb.go index dcb5d24..630472d 100644 --- a/backend/gen/radio/v1/song.pb.go +++ b/backend/gen/radio/v1/song.pb.go @@ -286,6 +286,50 @@ func (x *DeleteSongRequest) GetSongId() string { return "" } +type SongListResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Songs []*Song `protobuf:"bytes,1,rep,name=songs,proto3" json:"songs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SongListResponse) Reset() { + *x = SongListResponse{} + mi := &file_radio_v1_song_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SongListResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SongListResponse) ProtoMessage() {} + +func (x *SongListResponse) ProtoReflect() protoreflect.Message { + mi := &file_radio_v1_song_proto_msgTypes[4] + 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 SongListResponse.ProtoReflect.Descriptor instead. +func (*SongListResponse) Descriptor() ([]byte, []int) { + return file_radio_v1_song_proto_rawDescGZIP(), []int{4} +} + +func (x *SongListResponse) GetSongs() []*Song { + if x != nil { + return x.Songs + } + return nil +} + var File_radio_v1_song_proto protoreflect.FileDescriptor const file_radio_v1_song_proto_rawDesc = "" + @@ -308,12 +352,15 @@ const file_radio_v1_song_proto_rawDesc = "" + "\asong_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06songId\x12\x12\n" + "\x04size\x18\x02 \x01(\x03R\x04size\"6\n" + "\x11DeleteSongRequest\x12!\n" + - "\asong_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06songId2\x9b\x01\n" + + "\asong_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01R\x06songId\"8\n" + + "\x10SongListResponse\x12$\n" + + "\x05songs\x18\x01 \x03(\v2\x0e.radio.v1.SongR\x05songs2\xdb\x01\n" + "\vSongService\x12I\n" + "\n" + "UploadSong\x12\x1b.radio.v1.UploadSongRequest\x1a\x1c.radio.v1.UploadSongResponse(\x01\x12A\n" + "\n" + - "DeleteSong\x12\x1b.radio.v1.DeleteSongRequest\x1a\x16.google.protobuf.EmptyB\x82\x01\n" + + "DeleteSong\x12\x1b.radio.v1.DeleteSongRequest\x1a\x16.google.protobuf.Empty\x12>\n" + + "\bSongList\x12\x16.google.protobuf.Empty\x1a\x1a.radio.v1.SongListResponseB\x82\x01\n" + "\fcom.radio.v1B\tSongProtoP\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" var ( @@ -328,27 +375,31 @@ func file_radio_v1_song_proto_rawDescGZIP() []byte { return file_radio_v1_song_proto_rawDescData } -var file_radio_v1_song_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_radio_v1_song_proto_msgTypes = make([]protoimpl.MessageInfo, 5) var file_radio_v1_song_proto_goTypes = []any{ (*Song)(nil), // 0: radio.v1.Song (*UploadSongRequest)(nil), // 1: radio.v1.UploadSongRequest (*UploadSongResponse)(nil), // 2: radio.v1.UploadSongResponse (*DeleteSongRequest)(nil), // 3: radio.v1.DeleteSongRequest - (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 5: google.protobuf.Empty + (*SongListResponse)(nil), // 4: radio.v1.SongListResponse + (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 6: google.protobuf.Empty } var file_radio_v1_song_proto_depIdxs = []int32{ - 4, // 0: radio.v1.Song.created_at:type_name -> google.protobuf.Timestamp - 4, // 1: radio.v1.Song.modified_at:type_name -> google.protobuf.Timestamp - 1, // 2: radio.v1.SongService.UploadSong:input_type -> radio.v1.UploadSongRequest - 3, // 3: radio.v1.SongService.DeleteSong:input_type -> radio.v1.DeleteSongRequest - 2, // 4: radio.v1.SongService.UploadSong:output_type -> radio.v1.UploadSongResponse - 5, // 5: radio.v1.SongService.DeleteSong:output_type -> google.protobuf.Empty - 4, // [4:6] is the sub-list for method output_type - 2, // [2:4] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 5, // 0: radio.v1.Song.created_at:type_name -> google.protobuf.Timestamp + 5, // 1: radio.v1.Song.modified_at:type_name -> google.protobuf.Timestamp + 0, // 2: radio.v1.SongListResponse.songs:type_name -> radio.v1.Song + 1, // 3: radio.v1.SongService.UploadSong:input_type -> radio.v1.UploadSongRequest + 3, // 4: radio.v1.SongService.DeleteSong:input_type -> radio.v1.DeleteSongRequest + 6, // 5: radio.v1.SongService.SongList:input_type -> google.protobuf.Empty + 2, // 6: radio.v1.SongService.UploadSong:output_type -> radio.v1.UploadSongResponse + 6, // 7: radio.v1.SongService.DeleteSong:output_type -> google.protobuf.Empty + 4, // 8: radio.v1.SongService.SongList:output_type -> radio.v1.SongListResponse + 6, // [6:9] is the sub-list for method output_type + 3, // [3:6] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_radio_v1_song_proto_init() } @@ -366,7 +417,7 @@ func file_radio_v1_song_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_radio_v1_song_proto_rawDesc), len(file_radio_v1_song_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 5, NumExtensions: 0, NumServices: 1, }, diff --git a/backend/internal/oauth/oauth.go b/backend/internal/oauth/oauth.go index ac6a6f1..2688545 100644 --- a/backend/internal/oauth/oauth.go +++ b/backend/internal/oauth/oauth.go @@ -88,6 +88,8 @@ func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value strin MaxAge: int(time.Hour.Seconds()), Secure: r.TLS != nil, HttpOnly: true, + Path: "/", + SameSite: http.SameSiteLaxMode, } http.SetCookie(w, c) } @@ -107,6 +109,8 @@ func (s *Oauth) LoginHandler(w http.ResponseWriter, r *http.Request) { setCallbackCookie(w, r, "state", state) setCallbackCookie(w, r, "nonce", nonce) + + http.Redirect(w, r, s.Oauth2Config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) } type UserClaims struct { @@ -157,16 +161,14 @@ func (s *Oauth) CallbackHandler(onSuccess func(w http.ResponseWriter, r *http.Re return } - http.SetCookie(w, &http.Cookie{Name: "state", MaxAge: -1, Path: "/"}) - http.SetCookie(w, &http.Cookie{Name: "nonce", MaxAge: -1, Path: "/"}) + http.SetCookie(w, &http.Cookie{Name: "state", MaxAge: -1, Path: "/", SameSite: http.SameSiteLaxMode}) + http.SetCookie(w, &http.Cookie{Name: "nonce", MaxAge: -1, Path: "/", SameSite: http.SameSiteLaxMode}) onSuccess(w, r, UserClaims{ Subject: claims.Subject, Email: claims.Email, Name: claims.Name, }) - - http.Redirect(w, r, "http://localhost:5173", http.StatusTemporaryRedirect) } } diff --git a/backend/internal/song/handler.go b/backend/internal/song/handler.go index 31c4a5e..1fef7ac 100644 --- a/backend/internal/song/handler.go +++ b/backend/internal/song/handler.go @@ -1,8 +1,16 @@ package song import ( + "context" + "errors" + "log/slog" + + "connectrpc.com/connect" + radiov1 "echsen.club/radio/gen/radio/v1" "echsen.club/radio/internal/config" s "echsen.club/radio/internal/store" + "echsen.club/radio/internal/user" + "google.golang.org/protobuf/types/known/emptypb" ) type SongHandler struct { @@ -13,3 +21,23 @@ type SongHandler struct { func NewHandler(store *s.Store, config *config.Config) *SongHandler { return &SongHandler{store, config} } + +func (h *SongHandler) SongList(ctx context.Context, req *emptypb.Empty) (*radiov1.SongListResponse, error) { + _, err := user.RequireUser(ctx) + if err != nil { + return nil, err + } + + songs, err := h.store.SongList(ctx) + if err != nil { + slog.Error("couldnt list songs", "error", err) + return nil, connect.NewError(connect.CodeInternal, errors.New("couldnt get songs")) + } + + pbSongs := make([]*radiov1.Song, len(songs)) + for i, s := range songs { + pbSongs[i] = s.ToProto() + } + + return &radiov1.SongListResponse{Songs: pbSongs}, nil +} diff --git a/backend/internal/store/song.go b/backend/internal/store/song.go index 8a82ea7..d4d1432 100644 --- a/backend/internal/store/song.go +++ b/backend/internal/store/song.go @@ -83,3 +83,10 @@ func (store *Store) GetSong(ctx context.Context, song_id uuid.UUID) (*m.Song, er return &s, nil } + +func (s *Store) SongList(ctx context.Context) ([]m.Song, error) { + var songs []m.Song + err := s.db.SelectContext(ctx, &songs, `SELECT * FROM songs`) + + return songs, err +} diff --git a/frontend/src/gen/radio/v1/song_pb.ts b/frontend/src/gen/radio/v1/song_pb.ts index 89b1171..cd03463 100644 --- a/frontend/src/gen/radio/v1/song_pb.ts +++ b/frontend/src/gen/radio/v1/song_pb.ts @@ -13,7 +13,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file radio/v1/song.proto. */ export const file_radio_v1_song: GenFile = /*@__PURE__*/ - fileDesc("ChNyYWRpby92MS9zb25nLnByb3RvEghyYWRpby52MSK5AQoEU29uZxIUCgJpZBgBIAEoCUIIukgFcgOwAQESGgoIb3duZXJfaWQYAiABKAlCCLpIBXIDsAEBEhAKCGZpbGVuYW1lGAMgASgJEgwKBG5hbWUYBCABKAkSLgoKY3JlYXRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLbW9kaWZpZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIkAKEVVwbG9hZFNvbmdSZXF1ZXN0EhIKCGZpbGVuYW1lGAEgASgJSAASDwoFY2h1bmsYAiABKAxIAEIGCgRkYXRhIj0KElVwbG9hZFNvbmdSZXNwb25zZRIZCgdzb25nX2lkGAEgASgJQgi6SAVyA7ABARIMCgRzaXplGAIgASgDIi4KEURlbGV0ZVNvbmdSZXF1ZXN0EhkKB3NvbmdfaWQYASABKAlCCLpIBXIDsAEBMpsBCgtTb25nU2VydmljZRJJCgpVcGxvYWRTb25nEhsucmFkaW8udjEuVXBsb2FkU29uZ1JlcXVlc3QaHC5yYWRpby52MS5VcGxvYWRTb25nUmVzcG9uc2UoARJBCgpEZWxldGVTb25nEhsucmFkaW8udjEuRGVsZXRlU29uZ1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHlCggEKDGNvbS5yYWRpby52MUIJU29uZ1Byb3RvUAFaJmVjaHNlbi5jbHViL3JhZGlvL2dlbi9yYWRpby92MTtyYWRpb3YxogIDUlhYqgIIUmFkaW8uVjHKAghSYWRpb1xWMeICFFJhZGlvXFYxXEdQQk1ldGFkYXRh6gIJUmFkaW86OlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_timestamp]); + fileDesc("ChNyYWRpby92MS9zb25nLnByb3RvEghyYWRpby52MSK5AQoEU29uZxIUCgJpZBgBIAEoCUIIukgFcgOwAQESGgoIb3duZXJfaWQYAiABKAlCCLpIBXIDsAEBEhAKCGZpbGVuYW1lGAMgASgJEgwKBG5hbWUYBCABKAkSLgoKY3JlYXRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASLwoLbW9kaWZpZWRfYXQYBiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIkAKEVVwbG9hZFNvbmdSZXF1ZXN0EhIKCGZpbGVuYW1lGAEgASgJSAASDwoFY2h1bmsYAiABKAxIAEIGCgRkYXRhIj0KElVwbG9hZFNvbmdSZXNwb25zZRIZCgdzb25nX2lkGAEgASgJQgi6SAVyA7ABARIMCgRzaXplGAIgASgDIi4KEURlbGV0ZVNvbmdSZXF1ZXN0EhkKB3NvbmdfaWQYASABKAlCCLpIBXIDsAEBIjEKEFNvbmdMaXN0UmVzcG9uc2USHQoFc29uZ3MYASADKAsyDi5yYWRpby52MS5Tb25nMtsBCgtTb25nU2VydmljZRJJCgpVcGxvYWRTb25nEhsucmFkaW8udjEuVXBsb2FkU29uZ1JlcXVlc3QaHC5yYWRpby52MS5VcGxvYWRTb25nUmVzcG9uc2UoARJBCgpEZWxldGVTb25nEhsucmFkaW8udjEuRGVsZXRlU29uZ1JlcXVlc3QaFi5nb29nbGUucHJvdG9idWYuRW1wdHkSPgoIU29uZ0xpc3QSFi5nb29nbGUucHJvdG9idWYuRW1wdHkaGi5yYWRpby52MS5Tb25nTGlzdFJlc3BvbnNlQoIBCgxjb20ucmFkaW8udjFCCVNvbmdQcm90b1ABWiZlY2hzZW4uY2x1Yi9yYWRpby9nZW4vcmFkaW8vdjE7cmFkaW92MaICA1JYWKoCCFJhZGlvLlYxygIIUmFkaW9cVjHiAhRSYWRpb1xWMVxHUEJNZXRhZGF0YeoCCVJhZGlvOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_empty, file_google_protobuf_timestamp]); /** * @generated from message radio.v1.Song @@ -125,6 +125,23 @@ export type DeleteSongRequest = Message<"radio.v1.DeleteSongRequest"> & { export const DeleteSongRequestSchema: GenMessage = /*@__PURE__*/ messageDesc(file_radio_v1_song, 3); +/** + * @generated from message radio.v1.SongListResponse + */ +export type SongListResponse = Message<"radio.v1.SongListResponse"> & { + /** + * @generated from field: repeated radio.v1.Song songs = 1; + */ + songs: Song[]; +}; + +/** + * Describes the message radio.v1.SongListResponse. + * Use `create(SongListResponseSchema)` to create a new message. + */ +export const SongListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_radio_v1_song, 4); + /** * @generated from service radio.v1.SongService */ @@ -145,6 +162,14 @@ export const SongService: GenService<{ input: typeof DeleteSongRequestSchema; output: typeof EmptySchema; }, + /** + * @generated from rpc radio.v1.SongService.SongList + */ + songList: { + methodKind: "unary"; + input: typeof EmptySchema; + output: typeof SongListResponseSchema; + }, }> = /*@__PURE__*/ serviceDesc(file_radio_v1_song, 0); diff --git a/frontend/src/lib/components/AddSongSection.svelte b/frontend/src/lib/components/AddSongSection.svelte new file mode 100644 index 0000000..de60884 --- /dev/null +++ b/frontend/src/lib/components/AddSongSection.svelte @@ -0,0 +1,221 @@ + + +
+ {#if !isOpen} + + {:else} +
+
+ CONFIGURATION: PLAYLIST_ADD + +
+ +
+ + +
+ +
+ {#if view === 'upload'} +
+
+ MODE: LOCAL_UPLOAD +
+ +
+ {:else if view === 'select'} +
+
+ MODE: REMOTE_DATABASE +
+ + {#if loading} +
QUERYING DATABASE...
+ {:else if availableSongs.length === 0} +
-- NO SONGS AVAILABLE --
+ {:else} +
+ ({ + id: s.id, + title: s.name || s.filename, + subtitle: s.filename + }))} + onSelect={handleSelectSong} + /> +
+ {/if} +
+ {/if} +
+ + {#if error} +
! ERROR: {error}
+ {/if} +
+ {/if} +
+ + diff --git a/frontend/src/routes/[channelId]/+page.svelte b/frontend/src/routes/[channelId]/+page.svelte index c41402a..574d794 100644 --- a/frontend/src/routes/[channelId]/+page.svelte +++ b/frontend/src/routes/[channelId]/+page.svelte @@ -2,7 +2,7 @@ import { player } from '$lib/state/player.svelte'; import { userState } from '$lib/state/user.svelte'; import TerminalList from '$lib/components/TerminalList.svelte'; - import UploadButton from '$lib/components/UploadButton.svelte'; + import AddSongSection from '$lib/components/AddSongSection.svelte'; import { channelClient, songClient } from '$lib/rpc'; import { goto, invalidateAll } from '$app/navigation'; @@ -74,7 +74,7 @@

PLAYLIST

----------------
{#if isOwner} - + {/if} diff --git a/proto/radio/v1/song.proto b/proto/radio/v1/song.proto index 250f9ad..796f606 100644 --- a/proto/radio/v1/song.proto +++ b/proto/radio/v1/song.proto @@ -32,8 +32,13 @@ message DeleteSongRequest { string song_id = 1 [(buf.validate.field).string.uuid = true]; } +message SongListResponse { + repeated Song songs = 1; +} + service SongService { rpc UploadSong(stream UploadSongRequest) returns (UploadSongResponse); rpc DeleteSong(DeleteSongRequest) returns (google.protobuf.Empty); + rpc SongList(google.protobuf.Empty) returns (SongListResponse); }