From 89f208f980a2b4f73242b83e35498c5bc011d0b5 Mon Sep 17 00:00:00 2001 From: dogbertdev <118636333+dogbertdev@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:03:02 +1100 Subject: [PATCH 1/2] fix(library): harden status parsing and metadata id handling --- internal/cmd/library.go | 7 +- internal/plexclient/client.go | 88 ++++++++++++++++------ internal/plexclient/client_helpers_test.go | 81 ++++++++++++++++++++ 3 files changed, 147 insertions(+), 29 deletions(-) create mode 100644 internal/plexclient/client_helpers_test.go diff --git a/internal/cmd/library.go b/internal/cmd/library.go index c8f9467..e1f6bc8 100644 --- a/internal/cmd/library.go +++ b/internal/cmd/library.go @@ -18,7 +18,7 @@ type LibraryCmd struct { List LibrariesCmd `cmd:"" help:"List library sections"` Update LibraryUpdateCmd `cmd:"" help:"Refresh one library section or all sections"` Clean LibraryCleanCmd `cmd:"" help:"Empty trash for one library section or all sections"` - Status LibraryStatusCmd `cmd:"" help:"Show active library-related tasks and activities"` + Status LibraryStatusCmd `cmd:"" help:"Show active server tasks and activities"` } type LibraryUpdateCmd struct { @@ -187,11 +187,6 @@ func (c *LibraryStatusCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) }) } - if len(items) == 0 { - fmt.Fprintln(u.Err(), "No active library tasks") - return nil - } - return c.output(u.Out(), items) } diff --git a/internal/plexclient/client.go b/internal/plexclient/client.go index 288d5f8..5c81ed8 100644 --- a/internal/plexclient/client.go +++ b/internal/plexclient/client.go @@ -703,27 +703,27 @@ func (c *Client) StartAnalysis(ctx context.Context, sectionID string) error { } func (c *Client) AnalyzeMetadata(ctx context.Context, ids string) error { - if strings.TrimSpace(ids) == "" { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { return &PlexError{ Op: "AnalyzeMetadata", - Err: fmt.Errorf("ids are required"), + Err: err, } } - escapedIDs := url.PathEscape(ids) - return c.runLibraryGlobalAction(ctx, "AnalyzeMetadata", fmt.Sprintf("library/metadata/%s/analyze", escapedIDs), http.MethodPut) + return c.runLibraryGlobalAction(ctx, "AnalyzeMetadata", fmt.Sprintf("library/metadata/%s/analyze", encodedIDs), http.MethodPut) } func (c *Client) GenerateThumbs(ctx context.Context, ids string) error { - if strings.TrimSpace(ids) == "" { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { return &PlexError{ Op: "GenerateThumbs", - Err: fmt.Errorf("ids are required"), + Err: err, } } - escapedIDs := url.PathEscape(ids) - return c.runLibraryGlobalAction(ctx, "GenerateThumbs", fmt.Sprintf("library/metadata/%s/chapterThumbs", escapedIDs), http.MethodPut) + return c.runLibraryGlobalAction(ctx, "GenerateThumbs", fmt.Sprintf("library/metadata/%s/chapterThumbs", encodedIDs), http.MethodPut) } func (c *Client) EmptyTrash(ctx context.Context, sectionID string) error { @@ -2508,12 +2508,17 @@ func (c *Client) GetActivities(ctx context.Context) ([]ActivityTask, error) { activities = nil for _, a := range container.Activities { + progress, parseErr := parseOptionalFloat(a.ProgressRaw) + if parseErr != nil { + return fmt.Errorf("invalid activity progress %q: %w", a.ProgressRaw, parseErr) + } + activities = append(activities, ActivityTask{ UUID: a.UUID, Type: a.Type, Title: a.Title, Subtitle: a.Subtitle, - Progress: parseOptionalFloat(a.ProgressRaw), + Progress: progress, Cancellable: parseBoolAttr(a.CancelRaw), }) } @@ -2574,22 +2579,40 @@ func (c *Client) GetBackgroundTasks(ctx context.Context) ([]BackgroundTask, erro return fmt.Errorf("failed to parse response: %w", err) } - appendTask := func(rt rawTask) { + appendTask := func(rt rawTask) error { + progress, parseErr := parseOptionalFloat(rt.ProgressRaw) + if parseErr != nil { + return fmt.Errorf("invalid task progress %q: %w", rt.ProgressRaw, parseErr) + } + remaining, parseErr := parseOptionalInt64(rt.RemainingRaw) + if parseErr != nil { + return fmt.Errorf("invalid task remaining %q: %w", rt.RemainingRaw, parseErr) + } + speed, parseErr := parseOptionalFloat(rt.SpeedRaw) + if parseErr != nil { + return fmt.Errorf("invalid task speed %q: %w", rt.SpeedRaw, parseErr) + } + tasks = append(tasks, BackgroundTask{ Type: rt.Type, Title: rt.Title, - Progress: parseOptionalFloat(rt.ProgressRaw), - Remaining: parseOptionalInt64(rt.RemainingRaw), - Speed: parseOptionalFloat(rt.SpeedRaw), + Progress: progress, + Remaining: remaining, + Speed: speed, }) + return nil } tasks = nil for _, t := range container.TranscodeJobs { - appendTask(t) + if appendErr := appendTask(t); appendErr != nil { + return appendErr + } } for _, t := range container.TranscodeSession { - appendTask(t) + if appendErr := appendTask(t); appendErr != nil { + return appendErr + } } return nil @@ -2604,26 +2627,26 @@ func (c *Client) GetBackgroundTasks(ctx context.Context) ([]BackgroundTask, erro return tasks, nil } -func parseOptionalFloat(raw string) *float64 { +func parseOptionalFloat(raw string) (*float64, error) { if strings.TrimSpace(raw) == "" { - return nil + return nil, nil } v, err := strconv.ParseFloat(raw, 64) if err != nil { - return nil + return nil, err } - return &v + return &v, nil } -func parseOptionalInt64(raw string) *int64 { +func parseOptionalInt64(raw string) (*int64, error) { if strings.TrimSpace(raw) == "" { - return nil + return nil, nil } v, err := strconv.ParseInt(raw, 10, 64) if err != nil { - return nil + return nil, err } - return &v + return &v, nil } func parseBoolAttr(raw string) bool { @@ -2634,3 +2657,22 @@ func parseBoolAttr(raw string) bool { return false } } + +func encodeMetadataIDs(ids string) (string, error) { + trimmed := strings.TrimSpace(ids) + if trimmed == "" { + return "", fmt.Errorf("ids are required") + } + + parts := strings.Split(trimmed, ",") + encoded := make([]string, 0, len(parts)) + for _, part := range parts { + id := strings.TrimSpace(part) + if id == "" { + return "", fmt.Errorf("ids contain an empty segment") + } + encoded = append(encoded, url.PathEscape(id)) + } + + return strings.Join(encoded, ","), nil +} diff --git a/internal/plexclient/client_helpers_test.go b/internal/plexclient/client_helpers_test.go new file mode 100644 index 0000000..31b62e1 --- /dev/null +++ b/internal/plexclient/client_helpers_test.go @@ -0,0 +1,81 @@ +package plexclient + +import "testing" + +func TestEncodeMetadataIDs(t *testing.T) { + tests := []struct { + name string + input string + want string + wantErr bool + }{ + {name: "single id", input: "123", want: "123"}, + {name: "multiple ids", input: "1,2,3", want: "1,2,3"}, + {name: "trimmed ids", input: " 1, 2 ,3 ", want: "1,2,3"}, + {name: "escaped segment", input: "abc/def,2", want: "abc%2Fdef,2"}, + {name: "empty input", input: "", wantErr: true}, + {name: "empty segment", input: "1,,2", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := encodeMetadataIDs(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("encodeMetadataIDs(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestParseOptionalFloat(t *testing.T) { + got, err := parseOptionalFloat("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Fatalf("expected nil for empty input") + } + + got, err = parseOptionalFloat("12.5") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || *got != 12.5 { + t.Fatalf("expected 12.5, got %#v", got) + } + + if _, err = parseOptionalFloat("abc"); err == nil { + t.Fatalf("expected parse error") + } +} + +func TestParseOptionalInt64(t *testing.T) { + got, err := parseOptionalInt64("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != nil { + t.Fatalf("expected nil for empty input") + } + + got, err = parseOptionalInt64("42") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == nil || *got != 42 { + t.Fatalf("expected 42, got %#v", got) + } + + if _, err = parseOptionalInt64("abc"); err == nil { + t.Fatalf("expected parse error") + } +} From dcb1f0fb012e4d7c39b1f413134eeedee7edad80 Mon Sep 17 00:00:00 2001 From: dogbertdev <118636333+dogbertdev@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:09:50 +1100 Subject: [PATCH 2/2] fix(library): trim numeric task fields before parsing --- internal/plexclient/client.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/plexclient/client.go b/internal/plexclient/client.go index 5c81ed8..628a86d 100644 --- a/internal/plexclient/client.go +++ b/internal/plexclient/client.go @@ -2628,10 +2628,11 @@ func (c *Client) GetBackgroundTasks(ctx context.Context) ([]BackgroundTask, erro } func parseOptionalFloat(raw string) (*float64, error) { - if strings.TrimSpace(raw) == "" { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { return nil, nil } - v, err := strconv.ParseFloat(raw, 64) + v, err := strconv.ParseFloat(trimmed, 64) if err != nil { return nil, err } @@ -2639,10 +2640,11 @@ func parseOptionalFloat(raw string) (*float64, error) { } func parseOptionalInt64(raw string) (*int64, error) { - if strings.TrimSpace(raw) == "" { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { return nil, nil } - v, err := strconv.ParseInt(raw, 10, 64) + v, err := strconv.ParseInt(trimmed, 10, 64) if err != nil { return nil, err }