diff --git a/cmd/epgstationctl/main.go b/cmd/epgstationctl/main.go index 242d09c..78f9c08 100644 --- a/cmd/epgstationctl/main.go +++ b/cmd/epgstationctl/main.go @@ -5,6 +5,7 @@ import ( _ "github.com/miscord-dev/epgstationctl/internal/commands/programs" _ "github.com/miscord-dev/epgstationctl/internal/commands/recordings" "github.com/miscord-dev/epgstationctl/internal/commands/root" + _ "github.com/miscord-dev/epgstationctl/internal/commands/rules" ) func main() { diff --git a/epgstationctl b/epgstationctl new file mode 100755 index 0000000..d0f5bd9 Binary files /dev/null and b/epgstationctl differ diff --git a/internal/client/wrapper.go b/internal/client/wrapper.go index ef76542..0c66046 100644 --- a/internal/client/wrapper.go +++ b/internal/client/wrapper.go @@ -9,6 +9,20 @@ import ( "github.com/miscord-dev/epgstationctl/internal/epgstation" ) +// RuleWithID represents a complete rule with ID information +// This fixes the issue where the generated Rule type doesn't include the ID field +type RuleWithID struct { + ID int `json:"id"` + ReservesCnt *int `json:"reservesCnt,omitempty"` + epgstation.AddRuleOption +} + +// RulesWithID represents the corrected rules response +type RulesWithID struct { + Rules []RuleWithID `json:"rules"` + Total int `json:"total"` +} + type EPGStationClient struct { client *epgstation.Client config *config.Config @@ -143,3 +157,143 @@ func (c *EPGStationClient) PostSchedulesSearch(ctx interface{}, body epgstation. return &programs, nil } + +// GetRules retrieves all recording rules +func (c *EPGStationClient) GetRules(params *epgstation.GetRulesParams) (*RulesWithID, error) { + resp, err := c.client.GetRules(context.Background(), params) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(resp) + } + + var rules RulesWithID + if err := parseJSONResponse(resp, &rules); err != nil { + return nil, err + } + + return &rules, nil +} + +// GetRule retrieves a specific recording rule by ID +func (c *EPGStationClient) GetRule(ruleId int) (*RuleWithID, error) { + resp, err := c.client.GetRulesRuleId(context.Background(), ruleId) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(resp) + } + + var rule RuleWithID + if err := parseJSONResponse(resp, &rule); err != nil { + return nil, err + } + + return &rule, nil +} + +// CreateRule creates a new recording rule +func (c *EPGStationClient) CreateRule(ruleOption epgstation.AddRuleOption) (*epgstation.AddedRule, error) { + resp, err := c.client.PostRules(context.Background(), ruleOption) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return nil, handleErrorResponse(resp) + } + + var result epgstation.AddedRule + if err := parseJSONResponse(resp, &result); err != nil { + return nil, err + } + + return &result, nil +} + +// UpdateRule updates an existing recording rule +func (c *EPGStationClient) UpdateRule(ruleId int, ruleOption epgstation.AddRuleOption) error { + resp, err := c.client.PutRulesRuleId(context.Background(), ruleId, ruleOption) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp) + } + + return nil +} + +// DeleteRule deletes a recording rule +func (c *EPGStationClient) DeleteRule(ruleId int) error { + resp, err := c.client.DeleteRulesRuleId(context.Background(), ruleId) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp) + } + + return nil +} + +// EnableRule enables a recording rule +func (c *EPGStationClient) EnableRule(ruleId int) error { + resp, err := c.client.PutRulesRuleIdEnable(context.Background(), ruleId) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp) + } + + return nil +} + +// DisableRule disables a recording rule +func (c *EPGStationClient) DisableRule(ruleId int) error { + resp, err := c.client.PutRulesRuleIdDisable(context.Background(), ruleId) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return handleErrorResponse(resp) + } + + return nil +} + +// SearchRulesKeyword searches for keywords that can be used in rules +func (c *EPGStationClient) SearchRulesKeyword(params *epgstation.GetRulesKeywordParams) (*[]string, error) { + resp, err := c.client.GetRulesKeyword(context.Background(), params) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, handleErrorResponse(resp) + } + + var keywords []string + if err := parseJSONResponse(resp, &keywords); err != nil { + return nil, err + } + + return &keywords, nil +} diff --git a/internal/commands/rules/rules.go b/internal/commands/rules/rules.go new file mode 100644 index 0000000..468653a --- /dev/null +++ b/internal/commands/rules/rules.go @@ -0,0 +1,385 @@ +package rules + +import ( + "fmt" + "strconv" + + "github.com/miscord-dev/epgstationctl/internal/client" + "github.com/miscord-dev/epgstationctl/internal/commands/root" + "github.com/miscord-dev/epgstationctl/internal/epgstation" + "github.com/miscord-dev/epgstationctl/internal/output" + "github.com/spf13/cobra" +) + +const ( + outputFormatJSON = "json" +) + +var ( + offset int + limit int + halfWidth bool + // Create rule flags + keyword string + searchName bool + searchDesc bool + enableGR bool + enableBS bool + enableCS bool + allowEndLack bool + avoidDupe bool +) + +var rulesCmd = &cobra.Command{ + Use: "rules", + Short: "Manage recording rules", + Long: "Commands for managing EPGStation recording rules", +} + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List recording rules", + Long: "List all recording rules with their status", + RunE: func(cmd *cobra.Command, args []string) error { + cfg := root.GetConfig() + client, err := client.NewEPGStationClient(cfg) + if err != nil { + return fmt.Errorf("failed to create EPGStation client: %w", err) + } + + params := &epgstation.GetRulesParams{} + + if offset > 0 { + params.Offset = &offset + } + + if limit > 0 { + params.Limit = &limit + } + + rules, err := client.GetRules(params) + if err != nil { + return fmt.Errorf("failed to get rules: %w", err) + } + + var formatter output.Formatter + switch cfg.Output.Format { + case outputFormatJSON: + formatter = output.NewJSONFormatter(nil) + return formatter.Format(*rules) + default: + formatter = output.NewTableFormatter(nil, cfg.Output.NoHeader) + return formatRulesAsTable(*rules, formatter) + } + }, +} + +var showCmd = &cobra.Command{ + Use: "show ", + Short: "Show rule details", + Long: "Show detailed information about a specific recording rule", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := root.GetConfig() + client, err := client.NewEPGStationClient(cfg) + if err != nil { + return fmt.Errorf("failed to create EPGStation client: %w", err) + } + + ruleId, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + rule, err := client.GetRule(ruleId) + if err != nil { + return fmt.Errorf("failed to get rule: %w", err) + } + + var formatter output.Formatter + switch cfg.Output.Format { + case outputFormatJSON: + formatter = output.NewJSONFormatter(nil) + return formatter.Format(*rule) + default: + formatter = output.NewTableFormatter(nil, cfg.Output.NoHeader) + return formatRuleDetailsAsTable(*rule, formatter) + } + }, +} + +var deleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a recording rule", + Long: "Delete a recording rule by its ID", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := root.GetConfig() + client, err := client.NewEPGStationClient(cfg) + if err != nil { + return fmt.Errorf("failed to create EPGStation client: %w", err) + } + + ruleId, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + if err := client.DeleteRule(ruleId); err != nil { + return fmt.Errorf("failed to delete rule: %w", err) + } + + fmt.Printf("Rule %d deleted successfully\n", ruleId) + return nil + }, +} + +var enableCmd = &cobra.Command{ + Use: "enable ", + Short: "Enable a recording rule", + Long: "Enable a disabled recording rule", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := root.GetConfig() + client, err := client.NewEPGStationClient(cfg) + if err != nil { + return fmt.Errorf("failed to create EPGStation client: %w", err) + } + + ruleId, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + if err := client.EnableRule(ruleId); err != nil { + return fmt.Errorf("failed to enable rule: %w", err) + } + + fmt.Printf("Rule %d enabled successfully\n", ruleId) + return nil + }, +} + +var disableCmd = &cobra.Command{ + Use: "disable ", + Short: "Disable a recording rule", + Long: "Disable a recording rule without deleting it", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := root.GetConfig() + client, err := client.NewEPGStationClient(cfg) + if err != nil { + return fmt.Errorf("failed to create EPGStation client: %w", err) + } + + ruleId, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("invalid rule ID: %w", err) + } + + if err := client.DisableRule(ruleId); err != nil { + return fmt.Errorf("failed to disable rule: %w", err) + } + + fmt.Printf("Rule %d disabled successfully\n", ruleId) + return nil + }, +} + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create a new recording rule", + Long: "Create a new recording rule with specified options", + RunE: func(cmd *cobra.Command, args []string) error { + cfg := root.GetConfig() + client, err := client.NewEPGStationClient(cfg) + if err != nil { + return fmt.Errorf("failed to create EPGStation client: %w", err) + } + + if keyword == "" { + return fmt.Errorf("keyword is required for creating a rule") + } + + // Build the rule option + skyEnabled := false + ruleOption := epgstation.AddRuleOption{ + IsTimeSpecification: false, + SearchOption: epgstation.RuleSearchOption{ + Keyword: &keyword, + Name: &searchName, + Description: &searchDesc, + GR: &enableGR, + BS: &enableBS, + CS: &enableCS, + SKY: &skyEnabled, + }, + ReserveOption: epgstation.RuleReserveOption{ + Enable: true, + AllowEndLack: allowEndLack, + AvoidDuplicate: avoidDupe, + }, + } + + result, err := client.CreateRule(ruleOption) + if err != nil { + return fmt.Errorf("failed to create rule: %w", err) + } + + fmt.Printf("Rule created successfully with ID: %d\n", result.RuleId) + return nil + }, +} + +func init() { + // Add flags to list command + listCmd.Flags().IntVarP(&offset, "offset", "", 0, "Offset for pagination") + listCmd.Flags().IntVarP(&limit, "limit", "l", 0, "Limit number of results") + listCmd.Flags().BoolVar(&halfWidth, "half-width", true, "Use half-width characters") + + // Add flags to create command + createCmd.Flags().StringVarP(&keyword, "keyword", "k", "", "Keyword to search for (required)") + createCmd.Flags().BoolVar(&searchName, "search-name", true, "Search in program name") + createCmd.Flags().BoolVar(&searchDesc, "search-description", false, "Search in program description") + createCmd.Flags().BoolVar(&enableGR, "gr", true, "Enable for GR (terrestrial) channels") + createCmd.Flags().BoolVar(&enableBS, "bs", true, "Enable for BS channels") + createCmd.Flags().BoolVar(&enableCS, "cs", false, "Enable for CS channels") + createCmd.Flags().BoolVar(&allowEndLack, "allow-end-lack", false, "Allow recordings that end early") + createCmd.Flags().BoolVar(&avoidDupe, "avoid-duplicate", false, "Avoid duplicate recordings") + + rulesCmd.AddCommand(listCmd) + rulesCmd.AddCommand(showCmd) + rulesCmd.AddCommand(createCmd) + rulesCmd.AddCommand(deleteCmd) + rulesCmd.AddCommand(enableCmd) + rulesCmd.AddCommand(disableCmd) + root.AddCommand(rulesCmd) +} + +// formatRulesAsTable formats rules data in a user-friendly table format +func formatRulesAsTable(rules client.RulesWithID, formatter output.Formatter) error { + if len(rules.Rules) == 0 { + fmt.Println("No recording rules found") + return nil + } + + // Create a flat list of rules with essential information + type RuleInfo struct { + ID int + Status string + Keyword string + Channels string + } + + var ruleList []RuleInfo + for _, rule := range rules.Rules { + status := "Disabled" + if rule.ReserveOption.Enable { + if rule.IsTimeSpecification { + status = "Time-based" + } else { + status = "Enabled" + } + } + + keyword := "" + if rule.SearchOption.Keyword != nil { + keyword = *rule.SearchOption.Keyword + } + + channels := "All" + if rule.SearchOption.GR != nil && *rule.SearchOption.GR { + channels = "GR" + } + if rule.SearchOption.BS != nil && *rule.SearchOption.BS { + if channels != "All" { + channels += ",BS" + } else { + channels = "BS" + } + } + if rule.SearchOption.CS != nil && *rule.SearchOption.CS { + if channels != "All" { + channels += ",CS" + } else { + channels = "CS" + } + } + + ruleList = append(ruleList, RuleInfo{ + ID: rule.ID, + Status: status, + Keyword: keyword, + Channels: channels, + }) + } + + return formatter.Format(ruleList) +} + +// formatRuleDetailsAsTable formats a single rule's details in a user-friendly table format +func formatRuleDetailsAsTable(rule client.RuleWithID, formatter output.Formatter) error { + type RuleDetail struct { + Field string + Value string + } + + var details []RuleDetail + + details = append(details, RuleDetail{"ID", fmt.Sprintf("%d", rule.ID)}) + + if rule.ReservesCnt != nil { + details = append(details, RuleDetail{"ReserveCount", fmt.Sprintf("%d", *rule.ReservesCnt)}) + } else { + details = append(details, RuleDetail{"ReserveCount", "0"}) + } + + details = append(details, RuleDetail{"TimeSpecification", fmt.Sprintf("%t", rule.IsTimeSpecification)}) + + // Search options + if rule.SearchOption.Keyword != nil { + details = append(details, RuleDetail{"Keyword", *rule.SearchOption.Keyword}) + } + + channels := []string{} + if rule.SearchOption.GR != nil && *rule.SearchOption.GR { + channels = append(channels, "GR") + } + if rule.SearchOption.BS != nil && *rule.SearchOption.BS { + channels = append(channels, "BS") + } + if rule.SearchOption.CS != nil && *rule.SearchOption.CS { + channels = append(channels, "CS") + } + if rule.SearchOption.SKY != nil && *rule.SearchOption.SKY { + channels = append(channels, "SKY") + } + if len(channels) > 0 { + details = append(details, RuleDetail{"Channels", fmt.Sprintf("%v", channels)}) + } + + searchName := "false" + if rule.SearchOption.Name != nil { + searchName = fmt.Sprintf("%t", *rule.SearchOption.Name) + } + details = append(details, RuleDetail{"SearchName", searchName}) + + searchDescription := "false" + if rule.SearchOption.Description != nil { + searchDescription = fmt.Sprintf("%t", *rule.SearchOption.Description) + } + details = append(details, RuleDetail{"SearchDescription", searchDescription}) + + searchExtended := "false" + if rule.SearchOption.Extended != nil { + searchExtended = fmt.Sprintf("%t", *rule.SearchOption.Extended) + } + details = append(details, RuleDetail{"SearchExtended", searchExtended}) + + // Reserve options + details = append(details, RuleDetail{"RuleEnabled", fmt.Sprintf("%t", rule.ReserveOption.Enable)}) + details = append(details, RuleDetail{"AllowEndLack", fmt.Sprintf("%t", rule.ReserveOption.AllowEndLack)}) + details = append(details, RuleDetail{"AvoidDuplicate", fmt.Sprintf("%t", rule.ReserveOption.AvoidDuplicate)}) + + return formatter.Format(details) +}