diff --git a/plugins/README.md b/plugins/README.md index 31f96787..100230cb 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -196,7 +196,7 @@ See the [cache.proto](host/cache/cache.proto) file for the full API definition. #### SchedulerService -The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API. +The SchedulerService provides a unified interface for scheduling both one-time and recurring tasks, as well as accessing current time information. See the [scheduler.proto](host/scheduler/scheduler.proto) file for the full API. ```protobuf service SchedulerService { @@ -208,11 +208,50 @@ service SchedulerService { // Cancel any scheduled job rpc CancelSchedule(CancelRequest) returns (CancelResponse); + + // Get current time in multiple formats + rpc TimeNow(TimeNowRequest) returns (TimeNowResponse); } ``` +**Key Features:** + - **One-time scheduling**: Schedule a callback to be executed once after a specified delay. - **Recurring scheduling**: Schedule a callback to be executed repeatedly according to a cron expression. +- **Current time access**: Get the current time in standardized formats for time-based operations. + +**TimeNow Function:** + +The `TimeNow` function returns the current time in three formats: + +```protobuf +message TimeNowResponse { + string rfc3339_nano = 1; // RFC3339 format with nanosecond precision + int64 unix_milli = 2; // Unix timestamp in milliseconds + string local_time_zone = 3; // Local timezone name (e.g., "UTC", "America/New_York") +} +``` + +This allows plugins to: + +- Get high-precision timestamps for logging and event correlation +- Perform time-based calculations using Unix timestamps +- Handle timezone-aware operations by knowing the server's local timezone + +Example usage: + +```go +// Get current time information +timeResp, err := scheduler.TimeNow(ctx, &scheduler.TimeNowRequest{}) +if err != nil { + return err +} + +// Use the different time formats +timestamp := timeResp.Rfc3339Nano // "2024-01-15T10:30:45.123456789Z" +unixMs := timeResp.UnixMilli // 1705312245123 +timezone := timeResp.LocalTimeZone // "UTC" +``` Plugins using this service must implement the `SchedulerCallback` interface: @@ -433,7 +472,7 @@ If no permissions are needed, use an empty permissions object: `"permissions": { The following permission keys correspond to host services: | Permission | Host Service | Description | Required Fields | -|---------------|--------------------|----------------------------------------------------|-------------------------------------------------------| +| ------------- | ------------------ | -------------------------------------------------- | ----------------------------------------------------- | | `http` | HttpService | Make HTTP requests (GET, POST, PUT, DELETE, etc..) | `reason`, `allowedUrls` | | `websocket` | WebSocketService | Connect to and communicate via WebSockets | `reason`, `allowedUrls` | | `cache` | CacheService | Store and retrieve cached data with TTL | `reason` | diff --git a/plugins/api/api.pb.go b/plugins/api/api.pb.go index 47359890..b570d5c6 100644 --- a/plugins/api/api.pb.go +++ b/plugins/api/api.pb.go @@ -903,7 +903,6 @@ type InitRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - // Empty for now Config map[string]string `protobuf:"bytes,1,rep,name=config,proto3" json:"config,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` // Configuration specific to this plugin } diff --git a/plugins/api/api.proto b/plugins/api/api.proto index c451a82f..7929ff9e 100644 --- a/plugins/api/api.proto +++ b/plugins/api/api.proto @@ -194,7 +194,6 @@ service LifecycleManagement { } message InitRequest { - // Empty for now map config = 1; // Configuration specific to this plugin } diff --git a/plugins/api/api_plugin_dev_named_registry.go b/plugins/api/api_plugin_dev_named_registry.go index 05421ad7..2ddb6877 100644 --- a/plugins/api/api_plugin_dev_named_registry.go +++ b/plugins/api/api_plugin_dev_named_registry.go @@ -88,3 +88,7 @@ func (n *namedSchedulerService) CancelSchedule(ctx context.Context, request *sch request.ScheduleId = key return n.svc.CancelSchedule(ctx, request) } + +func (n *namedSchedulerService) TimeNow(ctx context.Context, request *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + return n.svc.TimeNow(ctx, request) +} diff --git a/plugins/host/scheduler/scheduler.pb.go b/plugins/host/scheduler/scheduler.pb.go index 6d4c2920..07d250cc 100644 --- a/plugins/host/scheduler/scheduler.pb.go +++ b/plugins/host/scheduler/scheduler.pb.go @@ -154,6 +154,51 @@ func (x *CancelResponse) GetError() string { return "" } +type TimeNowRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *TimeNowRequest) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +type TimeNowResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Rfc3339Nano string `protobuf:"bytes,1,opt,name=rfc3339_nano,json=rfc3339Nano,proto3" json:"rfc3339_nano,omitempty"` // Current time in RFC3339Nano format + UnixMilli int64 `protobuf:"varint,2,opt,name=unix_milli,json=unixMilli,proto3" json:"unix_milli,omitempty"` // Current time as Unix milliseconds timestamp + LocalTimeZone string `protobuf:"bytes,3,opt,name=local_time_zone,json=localTimeZone,proto3" json:"local_time_zone,omitempty"` // Local timezone name (e.g., "America/New_York", "UTC") +} + +func (x *TimeNowResponse) ProtoReflect() protoreflect.Message { + panic(`not implemented`) +} + +func (x *TimeNowResponse) GetRfc3339Nano() string { + if x != nil { + return x.Rfc3339Nano + } + return "" +} + +func (x *TimeNowResponse) GetUnixMilli() int64 { + if x != nil { + return x.UnixMilli + } + return 0 +} + +func (x *TimeNowResponse) GetLocalTimeZone() string { + if x != nil { + return x.LocalTimeZone + } + return "" +} + // go:plugin type=host version=1 type SchedulerService interface { // One-time event scheduling @@ -162,4 +207,6 @@ type SchedulerService interface { ScheduleRecurring(context.Context, *ScheduleRecurringRequest) (*ScheduleResponse, error) // Cancel any scheduled job CancelSchedule(context.Context, *CancelRequest) (*CancelResponse, error) + // Get current time in multiple formats + TimeNow(context.Context, *TimeNowRequest) (*TimeNowResponse, error) } diff --git a/plugins/host/scheduler/scheduler.proto b/plugins/host/scheduler/scheduler.proto index 39fd32a5..d164b4f9 100644 --- a/plugins/host/scheduler/scheduler.proto +++ b/plugins/host/scheduler/scheduler.proto @@ -14,6 +14,9 @@ service SchedulerService { // Cancel any scheduled job rpc CancelSchedule(CancelRequest) returns (CancelResponse); + + // Get current time in multiple formats + rpc TimeNow(TimeNowRequest) returns (TimeNowResponse); } message ScheduleOneTimeRequest { @@ -39,4 +42,14 @@ message CancelRequest { message CancelResponse { bool success = 1; // Whether cancellation was successful string error = 2; // Error message if cancellation failed +} + +message TimeNowRequest { + // Empty request - no parameters needed +} + +message TimeNowResponse { + string rfc3339_nano = 1; // Current time in RFC3339Nano format + int64 unix_milli = 2; // Current time as Unix milliseconds timestamp + string local_time_zone = 3; // Local timezone name (e.g., "America/New_York", "UTC") } \ No newline at end of file diff --git a/plugins/host/scheduler/scheduler_host.pb.go b/plugins/host/scheduler/scheduler_host.pb.go index 289f3f0b..714603a3 100644 --- a/plugins/host/scheduler/scheduler_host.pb.go +++ b/plugins/host/scheduler/scheduler_host.pb.go @@ -44,6 +44,11 @@ func Instantiate(ctx context.Context, r wazero.Runtime, hostFunctions SchedulerS WithParameterNames("offset", "size"). Export("cancel_schedule") + envBuilder.NewFunctionBuilder(). + WithGoModuleFunction(api.GoModuleFunc(h._TimeNow), []api.ValueType{i32, i32}, []api.ValueType{i64}). + WithParameterNames("offset", "size"). + Export("time_now") + _, err := envBuilder.Instantiate(ctx) return err } @@ -134,3 +139,32 @@ func (h _schedulerService) _CancelSchedule(ctx context.Context, m api.Module, st ptrLen := (ptr << uint64(32)) | uint64(len(buf)) stack[0] = ptrLen } + +// Get current time in multiple formats + +func (h _schedulerService) _TimeNow(ctx context.Context, m api.Module, stack []uint64) { + offset, size := uint32(stack[0]), uint32(stack[1]) + buf, err := wasm.ReadMemory(m.Memory(), offset, size) + if err != nil { + panic(err) + } + request := new(TimeNowRequest) + err = request.UnmarshalVT(buf) + if err != nil { + panic(err) + } + resp, err := h.TimeNow(ctx, request) + if err != nil { + panic(err) + } + buf, err = resp.MarshalVT() + if err != nil { + panic(err) + } + ptr, err := wasm.WriteMemory(ctx, m, buf) + if err != nil { + panic(err) + } + ptrLen := (ptr << uint64(32)) | uint64(len(buf)) + stack[0] = ptrLen +} diff --git a/plugins/host/scheduler/scheduler_plugin.pb.go b/plugins/host/scheduler/scheduler_plugin.pb.go index afbed2bf..ab7f8cd4 100644 --- a/plugins/host/scheduler/scheduler_plugin.pb.go +++ b/plugins/host/scheduler/scheduler_plugin.pb.go @@ -88,3 +88,26 @@ func (h schedulerService) CancelSchedule(ctx context.Context, request *CancelReq } return response, nil } + +//go:wasmimport env time_now +func _time_now(ptr uint32, size uint32) uint64 + +func (h schedulerService) TimeNow(ctx context.Context, request *TimeNowRequest) (*TimeNowResponse, error) { + buf, err := request.MarshalVT() + if err != nil { + return nil, err + } + ptr, size := wasm.ByteToPtr(buf) + ptrSize := _time_now(ptr, size) + wasm.Free(ptr) + + ptr = uint32(ptrSize >> 32) + size = uint32(ptrSize) + buf = wasm.PtrToByte(ptr, size) + + response := new(TimeNowResponse) + if err = response.UnmarshalVT(buf); err != nil { + return nil, err + } + return response, nil +} diff --git a/plugins/host/scheduler/scheduler_vtproto.pb.go b/plugins/host/scheduler/scheduler_vtproto.pb.go index 1606ab7f..ee642178 100644 --- a/plugins/host/scheduler/scheduler_vtproto.pb.go +++ b/plugins/host/scheduler/scheduler_vtproto.pb.go @@ -256,6 +256,91 @@ func (m *CancelResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { return len(dAtA) - i, nil } +func (m *TimeNowRequest) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TimeNowRequest) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TimeNowRequest) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + return len(dAtA) - i, nil +} + +func (m *TimeNowResponse) MarshalVT() (dAtA []byte, err error) { + if m == nil { + return nil, nil + } + size := m.SizeVT() + dAtA = make([]byte, size) + n, err := m.MarshalToSizedBufferVT(dAtA[:size]) + if err != nil { + return nil, err + } + return dAtA[:n], nil +} + +func (m *TimeNowResponse) MarshalToVT(dAtA []byte) (int, error) { + size := m.SizeVT() + return m.MarshalToSizedBufferVT(dAtA[:size]) +} + +func (m *TimeNowResponse) MarshalToSizedBufferVT(dAtA []byte) (int, error) { + if m == nil { + return 0, nil + } + i := len(dAtA) + _ = i + var l int + _ = l + if m.unknownFields != nil { + i -= len(m.unknownFields) + copy(dAtA[i:], m.unknownFields) + } + if len(m.LocalTimeZone) > 0 { + i -= len(m.LocalTimeZone) + copy(dAtA[i:], m.LocalTimeZone) + i = encodeVarint(dAtA, i, uint64(len(m.LocalTimeZone))) + i-- + dAtA[i] = 0x1a + } + if m.UnixMilli != 0 { + i = encodeVarint(dAtA, i, uint64(m.UnixMilli)) + i-- + dAtA[i] = 0x10 + } + if len(m.Rfc3339Nano) > 0 { + i -= len(m.Rfc3339Nano) + copy(dAtA[i:], m.Rfc3339Nano) + i = encodeVarint(dAtA, i, uint64(len(m.Rfc3339Nano))) + i-- + dAtA[i] = 0xa + } + return len(dAtA) - i, nil +} + func encodeVarint(dAtA []byte, offset int, v uint64) int { offset -= sov(v) base := offset @@ -355,6 +440,37 @@ func (m *CancelResponse) SizeVT() (n int) { return n } +func (m *TimeNowRequest) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + n += len(m.unknownFields) + return n +} + +func (m *TimeNowResponse) SizeVT() (n int) { + if m == nil { + return 0 + } + var l int + _ = l + l = len(m.Rfc3339Nano) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + if m.UnixMilli != 0 { + n += 1 + sov(uint64(m.UnixMilli)) + } + l = len(m.LocalTimeZone) + if l > 0 { + n += 1 + l + sov(uint64(l)) + } + n += len(m.unknownFields) + return n +} + func sov(x uint64) (n int) { return (bits.Len64(x|1) + 6) / 7 } @@ -915,6 +1031,191 @@ func (m *CancelResponse) UnmarshalVT(dAtA []byte) error { } return nil } +func (m *TimeNowRequest) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TimeNowRequest: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TimeNowRequest: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} +func (m *TimeNowResponse) UnmarshalVT(dAtA []byte) error { + l := len(dAtA) + iNdEx := 0 + for iNdEx < l { + preIndex := iNdEx + var wire uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + wire |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + fieldNum := int32(wire >> 3) + wireType := int(wire & 0x7) + if wireType == 4 { + return fmt.Errorf("proto: TimeNowResponse: wiretype end group for non-group") + } + if fieldNum <= 0 { + return fmt.Errorf("proto: TimeNowResponse: illegal tag %d (wire type %d)", fieldNum, wire) + } + switch fieldNum { + case 1: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field Rfc3339Nano", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.Rfc3339Nano = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + case 2: + if wireType != 0 { + return fmt.Errorf("proto: wrong wireType = %d for field UnixMilli", wireType) + } + m.UnixMilli = 0 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + m.UnixMilli |= int64(b&0x7F) << shift + if b < 0x80 { + break + } + } + case 3: + if wireType != 2 { + return fmt.Errorf("proto: wrong wireType = %d for field LocalTimeZone", wireType) + } + var stringLen uint64 + for shift := uint(0); ; shift += 7 { + if shift >= 64 { + return ErrIntOverflow + } + if iNdEx >= l { + return io.ErrUnexpectedEOF + } + b := dAtA[iNdEx] + iNdEx++ + stringLen |= uint64(b&0x7F) << shift + if b < 0x80 { + break + } + } + intStringLen := int(stringLen) + if intStringLen < 0 { + return ErrInvalidLength + } + postIndex := iNdEx + intStringLen + if postIndex < 0 { + return ErrInvalidLength + } + if postIndex > l { + return io.ErrUnexpectedEOF + } + m.LocalTimeZone = string(dAtA[iNdEx:postIndex]) + iNdEx = postIndex + default: + iNdEx = preIndex + skippy, err := skip(dAtA[iNdEx:]) + if err != nil { + return err + } + if (skippy < 0) || (iNdEx+skippy) < 0 { + return ErrInvalidLength + } + if (iNdEx + skippy) > l { + return io.ErrUnexpectedEOF + } + m.unknownFields = append(m.unknownFields, dAtA[iNdEx:iNdEx+skippy]...) + iNdEx += skippy + } + } + + if iNdEx > l { + return io.ErrUnexpectedEOF + } + return nil +} func skip(dAtA []byte) (n int, err error) { l := len(dAtA) diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go index e3585990..26c5e92f 100644 --- a/plugins/host_scheduler.go +++ b/plugins/host_scheduler.go @@ -45,6 +45,10 @@ func (s SchedulerHostFunctions) CancelSchedule(ctx context.Context, req *schedul return s.ss.cancelSchedule(ctx, s.pluginID, req) } +func (s SchedulerHostFunctions) TimeNow(ctx context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + return s.ss.timeNow(ctx, req) +} + type schedulerService struct { // Map of schedule IDs to their callback info schedules map[string]*ScheduledCallback @@ -260,6 +264,17 @@ func (s *schedulerService) cancelSchedule(_ context.Context, pluginID string, re }, nil } +// timeNow returns the current time in multiple formats +func (s *schedulerService) timeNow(_ context.Context, req *scheduler.TimeNowRequest) (*scheduler.TimeNowResponse, error) { + now := time.Now() + + return &scheduler.TimeNowResponse{ + Rfc3339Nano: now.Format(time.RFC3339Nano), + UnixMilli: now.UnixMilli(), + LocalTimeZone: now.Location().String(), + }, nil +} + // runOneTimeSchedule handles the one-time schedule execution and callback func (s *schedulerService) runOneTimeSchedule(ctx context.Context, internalScheduleId string, delay time.Duration) { tmr := time.NewTimer(delay) diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index a905313b..1a3efaae 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "time" "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/plugins/host/scheduler" @@ -164,4 +165,28 @@ var _ = Describe("SchedulerService", func() { Expect(ss.scheduleCount()).To(Equal(beforeCount), "Job count should remain the same after replacement") }) }) + + Describe("TimeNow", func() { + It("returns current time in RFC3339Nano, Unix milliseconds, and local timezone", func() { + now := time.Now() + req := &scheduler.TimeNowRequest{} + resp, err := ss.timeNow(context.Background(), req) + + Expect(err).ToNot(HaveOccurred()) + Expect(resp.UnixMilli).To(BeNumerically(">=", now.UnixMilli())) + Expect(resp.LocalTimeZone).ToNot(BeEmpty()) + + // Validate RFC3339Nano format can be parsed + parsedTime, parseErr := time.Parse(time.RFC3339Nano, resp.Rfc3339Nano) + Expect(parseErr).ToNot(HaveOccurred()) + + // Validate that Unix milliseconds is reasonably close to the RFC3339Nano time + expectedMillis := parsedTime.UnixMilli() + Expect(resp.UnixMilli).To(Equal(expectedMillis)) + + // Validate local timezone matches the current system timezone + expectedTimezone := now.Location().String() + Expect(resp.LocalTimeZone).To(Equal(expectedTimezone)) + }) + }) })