From 233ae954d17ecfcb91b77c0d46feae3d96249cd2 Mon Sep 17 00:00:00 2001 From: dogbertdev <118636333+dogbertdev@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:53:39 +1100 Subject: [PATCH 1/2] feat(library): add list update and clean commands --- README.md | 22 ++++ cmd/plex/main.go | 6 +- internal/cmd/library.go | 225 ++++++++++++++++++++++++++++++++++ internal/cmd/library_test.go | 58 +++++++++ internal/plexclient/client.go | 138 +++++++++++++++++++++ 5 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 internal/cmd/library.go create mode 100644 internal/cmd/library_test.go diff --git a/README.md b/README.md index 935a818..5e02b4e 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,28 @@ plex --help plex cache clear ``` +#### Manage Library Maintenance + +```bash +# List all library sections +plex library list + +# List only movie sections +plex library list --type movie + +# Refresh all sections +plex library update + +# Refresh one section +plex library update 1 + +# Clean all sections (empty trash + maintenance tasks) +plex library clean + +# Clean one section (plus global maintenance tasks) +plex library clean 1 +``` + #### Authenticate ```bash diff --git a/cmd/plex/main.go b/cmd/plex/main.go index 7e2520c..0e5d5e5 100644 --- a/cmd/plex/main.go +++ b/cmd/plex/main.go @@ -44,7 +44,7 @@ type CLI struct { Quality cmd.QualityCheckCmd `cmd:"" name:"quality-check" help:"Check video quality (resolution, HDR)"` Metadata cmd.MetadataMissingCmd `cmd:"" name:"metadata-missing" help:"Find items with incomplete metadata"` Search cmd.SearchCmd `cmd:"" help:"Search the Plex library"` - Libraries cmd.LibrariesCmd `cmd:"" help:"List library sections"` + Library cmd.LibraryCmd `cmd:"" help:"Manage library maintenance operations"` ServerInfo cmd.ServerInfoCmd `cmd:"" name:"server-info" help:"Show Plex server and library summary information"` Playlist cmd.PlaylistCmd `cmd:"" help:"Manage playlists"` Episodes cmd.EpisodesListCmd `cmd:"" help:"List episodes for a show with optional filtering"` @@ -159,7 +159,9 @@ func applyOutputFormat(cli *CLI, format string) { cli.Quality.Output = format cli.Metadata.Output = format cli.Search.Output = format - cli.Libraries.Output = format + cli.Library.List.Output = format + cli.Library.Update.Output = format + cli.Library.Clean.Output = format cli.ServerInfo.Output = format cli.Playlist.List.Output = format cli.Playlist.Create.Output = format diff --git a/internal/cmd/library.go b/internal/cmd/library.go new file mode 100644 index 0000000..dbc07af --- /dev/null +++ b/internal/cmd/library.go @@ -0,0 +1,225 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/alecthomas/kong" + + "github.com/dogbertdev/plexcli/internal/config" + "github.com/dogbertdev/plexcli/internal/outfmt" + "github.com/dogbertdev/plexcli/internal/plexclient" + "github.com/dogbertdev/plexcli/internal/ui" +) + +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"` +} + +type LibraryUpdateCmd struct { + Section string `arg:"" optional:"" name:"section" help:"Library section ID or 'all' (default)" default:"all"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryCleanCmd struct { + Section string `arg:"" optional:"" name:"section" help:"Library section ID or 'all' (default)" default:"all"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryActionResult struct { + Action string `json:"action"` + ID string `json:"id"` + Title string `json:"title"` + Outcome string `json:"outcome"` +} + +func (c *LibraryUpdateCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + selector := strings.TrimSpace(c.Section) + if selector == "" || strings.EqualFold(selector, "all") { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + if err := cc.Client.RefreshAllSections(cc.Ctx); err != nil { + return fmt.Errorf("failed to update all sections: %w", err) + } + + results := []LibraryActionResult{ + { + Action: "update", + ID: "all", + Title: "All Sections", + Outcome: "refresh requested", + }, + } + return outputLibraryActionResults(u.Out(), c.Output, results) + } + + return runLibrarySectionAction(c.Section, c.Output, u, cfg, "update", "refresh requested", func(runCtx context.Context, client *plexclient.Client, sectionID string) error { + return client.RefreshSection(runCtx, sectionID) + }) +} + +func (c *LibraryCleanCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + sections, err := cc.Client.GetSections(cc.Ctx) + if err != nil { + return fmt.Errorf("failed to get sections: %w", err) + } + + targets, err := targetLibrarySections(sections, c.Section) + if err != nil { + return err + } + if len(targets) == 0 { + fmt.Fprintln(u.Err(), "No libraries found") + return nil + } + + results := make([]LibraryActionResult, 0, len(targets)+3) + for _, section := range targets { + if err := cc.Client.EmptyTrash(cc.Ctx, section.ID); err != nil { + return fmt.Errorf("failed to clean section %s: %w", section.ID, err) + } + + results = append(results, LibraryActionResult{ + Action: "clean", + ID: section.ID, + Title: sectionTitle(section), + Outcome: "empty trash requested", + }) + } + + if err := cc.Client.CleanBundles(cc.Ctx); err != nil { + return fmt.Errorf("failed to clean bundles: %w", err) + } + results = append(results, LibraryActionResult{ + Action: "clean", + ID: "-", + Title: "Global", + Outcome: "clean bundles requested", + }) + + if err := cc.Client.DeleteCaches(cc.Ctx); err != nil { + return fmt.Errorf("failed to delete caches: %w", err) + } + results = append(results, LibraryActionResult{ + Action: "clean", + ID: "-", + Title: "Global", + Outcome: "delete caches requested", + }) + + if err := cc.Client.OptimizeDatabase(cc.Ctx); err != nil { + return fmt.Errorf("failed to optimize database: %w", err) + } + results = append(results, LibraryActionResult{ + Action: "clean", + ID: "-", + Title: "Global", + Outcome: "optimize database requested", + }) + + return outputLibraryActionResults(u.Out(), c.Output, results) +} + +func runLibrarySectionAction( + sectionSelector string, + output string, + u *ui.UI, + cfg *config.Config, + action string, + outcome string, + runAction func(ctx context.Context, client *plexclient.Client, sectionID string) error, +) error { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + sections, err := cc.Client.GetSections(cc.Ctx) + if err != nil { + return fmt.Errorf("failed to get sections: %w", err) + } + + targets, err := targetLibrarySections(sections, sectionSelector) + if err != nil { + return err + } + if len(targets) == 0 { + fmt.Fprintln(u.Err(), "No libraries found") + return nil + } + + results := make([]LibraryActionResult, 0, len(targets)) + for _, section := range targets { + if err := runAction(cc.Ctx, cc.Client, section.ID); err != nil { + return fmt.Errorf("failed to %s section %s: %w", action, section.ID, err) + } + + results = append(results, LibraryActionResult{ + Action: action, + ID: section.ID, + Title: sectionTitle(section), + Outcome: outcome, + }) + } + + return outputLibraryActionResults(u.Out(), output, results) +} + +func formatTaskProgress(progress *float64) string { + if progress == nil { + return "-" + } + if *progress < 0 { + return "indeterminate" + } + return fmt.Sprintf("%.0f%%", *progress) +} + +func targetLibrarySections(sections []plexclient.Library, sectionSelector string) ([]plexclient.Library, error) { + selector := strings.TrimSpace(sectionSelector) + if selector == "" || strings.EqualFold(selector, "all") { + return sections, nil + } + + for _, section := range sections { + if section.ID == selector { + return []plexclient.Library{section}, nil + } + } + + return nil, fmt.Errorf("section %q not found", selector) +} + +func sectionTitle(section plexclient.Library) string { + if section.Title != nil && *section.Title != "" { + return *section.Title + } + return "Unknown" +} + +func outputLibraryActionResults(w io.Writer, output string, results []LibraryActionResult) error { + formatter := outfmt.NewFormatter(outfmt.Format(output)) + + header := []string{"ACTION", "ID", "TITLE", "OUTCOME"} + rows := make([][]string, 0, len(results)) + for _, result := range results { + rows = append(rows, []string{result.Action, result.ID, result.Title, result.Outcome}) + } + + return formatter.Format(w, header, rows, results) +} diff --git a/internal/cmd/library_test.go b/internal/cmd/library_test.go new file mode 100644 index 0000000..018fd47 --- /dev/null +++ b/internal/cmd/library_test.go @@ -0,0 +1,58 @@ +package cmd + +import ( + "testing" + + "github.com/dogbertdev/plexcli/internal/plexclient" +) + +func TestTargetLibrarySections_All(t *testing.T) { + sections := []plexclient.Library{ + {ID: "1"}, + {ID: "2"}, + } + + got, err := targetLibrarySections(sections, "") + if err != nil { + t.Fatalf("targetLibrarySections() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 sections, got %d", len(got)) + } + + got, err = targetLibrarySections(sections, "all") + if err != nil { + t.Fatalf("targetLibrarySections() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 sections, got %d", len(got)) + } +} + +func TestTargetLibrarySections_SpecificID(t *testing.T) { + sections := []plexclient.Library{ + {ID: "1"}, + {ID: "2"}, + } + + got, err := targetLibrarySections(sections, "2") + if err != nil { + t.Fatalf("targetLibrarySections() error = %v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 section, got %d", len(got)) + } + if got[0].ID != "2" { + t.Fatalf("expected section 2, got %s", got[0].ID) + } +} + +func TestTargetLibrarySections_NotFound(t *testing.T) { + sections := []plexclient.Library{ + {ID: "1"}, + } + + if _, err := targetLibrarySections(sections, "9"); err == nil { + t.Fatalf("expected not found error") + } +} diff --git a/internal/plexclient/client.go b/internal/plexclient/client.go index 8d26200..62846d1 100644 --- a/internal/plexclient/client.go +++ b/internal/plexclient/client.go @@ -685,6 +685,144 @@ func (c *Client) GetSections(ctx context.Context) ([]Library, error) { return libraries, nil } +func (c *Client) RefreshSection(ctx context.Context, sectionID string) error { + return c.runLibrarySectionAction(ctx, "RefreshSection", sectionID, "refresh", http.MethodPost) +} + +func (c *Client) RefreshAllSections(ctx context.Context) error { + return c.runLibraryGlobalAction(ctx, "RefreshAllSections", "library/sections/refresh", http.MethodPost) +} + +func (c *Client) CancelRefresh(ctx context.Context, sectionID string) error { + return c.runLibrarySectionAction(ctx, "CancelRefresh", sectionID, "refresh", http.MethodDelete) +} + +func (c *Client) StartAnalysis(ctx context.Context, sectionID string) error { + return c.runLibrarySectionAction(ctx, "StartAnalysis", sectionID, "analyze", http.MethodPut) +} + +func (c *Client) AnalyzeMetadata(ctx context.Context, ids string) error { + if strings.TrimSpace(ids) == "" { + return &PlexError{ + Op: "AnalyzeMetadata", + Err: fmt.Errorf("ids are required"), + } + } + + escapedIDs := url.PathEscape(ids) + return c.runLibraryGlobalAction(ctx, "AnalyzeMetadata", fmt.Sprintf("library/metadata/%s/analyze", escapedIDs), http.MethodPut) +} + +func (c *Client) GenerateThumbs(ctx context.Context, ids string) error { + if strings.TrimSpace(ids) == "" { + return &PlexError{ + Op: "GenerateThumbs", + Err: fmt.Errorf("ids are required"), + } + } + + escapedIDs := url.PathEscape(ids) + return c.runLibraryGlobalAction(ctx, "GenerateThumbs", fmt.Sprintf("library/metadata/%s/chapterThumbs", escapedIDs), http.MethodPut) +} + +func (c *Client) EmptyTrash(ctx context.Context, sectionID string) error { + return c.runLibrarySectionAction(ctx, "EmptyTrash", sectionID, "emptyTrash", http.MethodPut) +} + +func (c *Client) CleanSection(ctx context.Context, sectionID string) error { + return c.EmptyTrash(ctx, sectionID) +} + +func (c *Client) OptimizeDatabase(ctx context.Context) error { + return c.runLibraryGlobalAction(ctx, "OptimizeDatabase", "library/optimize", http.MethodPut) +} + +func (c *Client) CleanBundles(ctx context.Context) error { + return c.runLibraryGlobalAction(ctx, "CleanBundles", "library/clean/bundles", http.MethodPut) +} + +func (c *Client) DeleteCaches(ctx context.Context) error { + return c.runLibraryGlobalAction(ctx, "DeleteCaches", "library/caches", http.MethodDelete) +} + +func (c *Client) runLibraryGlobalAction(ctx context.Context, op, path, method string) error { + err := c.executeWithRetry(ctx, op, func() error { + urlStr := fmt.Sprintf("%s/%s?X-Plex-Token=%s", c.serverURL, strings.TrimPrefix(path, "/"), c.token) + req, reqErr := http.NewRequestWithContext(ctx, method, urlStr, nil) + if reqErr != nil { + return fmt.Errorf("failed to create request: %w", reqErr) + } + + resp, doErr := c.httpClient.Do(req) + if doErr != nil { + return fmt.Errorf("failed to make request: %w", doErr) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK, http.StatusCreated, http.StatusAccepted, http.StatusNoContent: + return nil + default: + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + }) + if err != nil { + return &PlexError{ + Op: op, + Err: err, + } + } + + return nil +} + +func (c *Client) RefreshLibrarySection(ctx context.Context, sectionID string) error { + return c.RefreshSection(ctx, sectionID) +} + +func (c *Client) EmptyTrashLibrarySection(ctx context.Context, sectionID string) error { + return c.runLibrarySectionAction(ctx, "EmptyTrashLibrarySection", sectionID, "emptyTrash", http.MethodPut) +} + +func (c *Client) runLibrarySectionAction(ctx context.Context, op, sectionID, actionPath, method string) error { + if sectionID == "" { + return &PlexError{ + Op: op, + Err: fmt.Errorf("section ID is required"), + } + } + + err := c.executeWithRetry(ctx, op, func() error { + urlStr := fmt.Sprintf("%s/library/sections/%s/%s?X-Plex-Token=%s", c.serverURL, sectionID, actionPath, c.token) + req, reqErr := http.NewRequestWithContext(ctx, method, urlStr, nil) + if reqErr != nil { + return fmt.Errorf("failed to create request: %w", reqErr) + } + + resp, doErr := c.httpClient.Do(req) + if doErr != nil { + return fmt.Errorf("failed to make request: %w", doErr) + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusOK, http.StatusAccepted, http.StatusNoContent: + return nil + default: + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + }) + if err != nil { + return &PlexError{ + Op: op, + Section: sectionID, + Err: err, + } + } + + return nil +} + func (c *Client) GetServerURL() string { return c.serverURL } From 8e4212dba9117edbff17f37323faeca444195fde Mon Sep 17 00:00:00 2001 From: dogbertdev <118636333+dogbertdev@users.noreply.github.com> Date: Wed, 4 Mar 2026 22:54:11 +1100 Subject: [PATCH 2/2] feat(library): add status command for active tasks --- README.md | 3 + cmd/plex/main.go | 1 + internal/cmd/library.go | 110 ++++++++++++++++++++ internal/plexclient/client.go | 191 ++++++++++++++++++++++++++++++++++ 4 files changed, 305 insertions(+) diff --git a/README.md b/README.md index 5e02b4e..a13847c 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,9 @@ plex library clean # Clean one section (plus global maintenance tasks) plex library clean 1 + +# Show active activities/background tasks +plex library status ``` #### Authenticate diff --git a/cmd/plex/main.go b/cmd/plex/main.go index 0e5d5e5..b4819e8 100644 --- a/cmd/plex/main.go +++ b/cmd/plex/main.go @@ -162,6 +162,7 @@ func applyOutputFormat(cli *CLI, format string) { cli.Library.List.Output = format cli.Library.Update.Output = format cli.Library.Clean.Output = format + cli.Library.Status.Output = format cli.ServerInfo.Output = format cli.Playlist.List.Output = format cli.Playlist.Create.Output = format diff --git a/internal/cmd/library.go b/internal/cmd/library.go index dbc07af..c8f9467 100644 --- a/internal/cmd/library.go +++ b/internal/cmd/library.go @@ -18,6 +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"` } type LibraryUpdateCmd struct { @@ -30,6 +31,10 @@ type LibraryCleanCmd struct { Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` } +type LibraryStatusCmd struct { + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + type LibraryActionResult struct { Action string `json:"action"` ID string `json:"id"` @@ -37,6 +42,15 @@ type LibraryActionResult struct { Outcome string `json:"outcome"` } +type LibraryStatusItem struct { + Source string `json:"source"` + Type string `json:"type"` + Title string `json:"title"` + Progress string `json:"progress"` + Details string `json:"details"` + Cancellable bool `json:"cancellable"` +} + func (c *LibraryUpdateCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { selector := strings.TrimSpace(c.Section) if selector == "" || strings.EqualFold(selector, "all") { @@ -134,6 +148,72 @@ func (c *LibraryCleanCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) e return outputLibraryActionResults(u.Out(), c.Output, results) } +func (c *LibraryStatusCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + activities, err := cc.Client.GetActivities(cc.Ctx) + if err != nil { + return fmt.Errorf("failed to get activities: %w", err) + } + + backgroundTasks, err := cc.Client.GetBackgroundTasks(cc.Ctx) + if err != nil { + return fmt.Errorf("failed to get background tasks: %w", err) + } + + items := make([]LibraryStatusItem, 0, len(activities)+len(backgroundTasks)) + for _, activity := range activities { + items = append(items, LibraryStatusItem{ + Source: "activity", + Type: defaultString(activity.Type, "unknown"), + Title: firstNonEmpty(activity.Title, activity.Subtitle, "-"), + Progress: formatTaskProgress(activity.Progress), + Details: defaultString(activity.Subtitle, "-"), + Cancellable: activity.Cancellable, + }) + } + for _, task := range backgroundTasks { + items = append(items, LibraryStatusItem{ + Source: "background", + Type: defaultString(task.Type, "unknown"), + Title: defaultString(task.Title, "-"), + Progress: formatTaskProgress(task.Progress), + Details: formatBackgroundDetails(task.Remaining, task.Speed), + Cancellable: false, + }) + } + + if len(items) == 0 { + fmt.Fprintln(u.Err(), "No active library tasks") + return nil + } + + return c.output(u.Out(), items) +} + +func (c *LibraryStatusCmd) output(w io.Writer, items []LibraryStatusItem) error { + formatter := outfmt.NewFormatter(outfmt.Format(c.Output)) + + header := []string{"SOURCE", "TYPE", "TITLE", "PROGRESS", "DETAILS", "CANCELLABLE"} + rows := make([][]string, 0, len(items)) + for _, item := range items { + rows = append(rows, []string{ + item.Source, + item.Type, + item.Title, + item.Progress, + item.Details, + fmt.Sprintf("%t", item.Cancellable), + }) + } + + return formatter.Format(w, header, rows, items) +} + func runLibrarySectionAction( sectionSelector string, output string, @@ -190,6 +270,36 @@ func formatTaskProgress(progress *float64) string { return fmt.Sprintf("%.0f%%", *progress) } +func formatBackgroundDetails(remaining *int64, speed *float64) string { + parts := make([]string, 0, 2) + if remaining != nil { + parts = append(parts, fmt.Sprintf("remaining=%ds", *remaining)) + } + if speed != nil { + parts = append(parts, fmt.Sprintf("speed=%.2fx", *speed)) + } + if len(parts) == 0 { + return "-" + } + return strings.Join(parts, ", ") +} + +func defaultString(v, fallback string) string { + if strings.TrimSpace(v) == "" { + return fallback + } + return v +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + func targetLibrarySections(sections []plexclient.Library, sectionSelector string) ([]plexclient.Library, error) { selector := strings.TrimSpace(sectionSelector) if selector == "" || strings.EqualFold(selector, "all") { diff --git a/internal/plexclient/client.go b/internal/plexclient/client.go index 62846d1..288d5f8 100644 --- a/internal/plexclient/client.go +++ b/internal/plexclient/client.go @@ -11,6 +11,7 @@ import ( "math" "net/http" "net/url" + "strconv" "strings" "sync" "time" @@ -2229,6 +2230,25 @@ type Account struct { Name string `json:"name"` } +// ActivityTask represents an in-progress Plex activity. +type ActivityTask struct { + UUID string `json:"uuid"` + Type string `json:"type"` + Title string `json:"title"` + Subtitle string `json:"subtitle"` + Progress *float64 `json:"progress,omitempty"` + Cancellable bool `json:"cancellable"` +} + +// BackgroundTask represents an in-progress background task. +type BackgroundTask struct { + Type string `json:"type"` + Title string `json:"title"` + Progress *float64 `json:"progress,omitempty"` + Remaining *int64 `json:"remaining,omitempty"` + Speed *float64 `json:"speed,omitempty"` +} + // GetActiveSessions returns currently active playback sessions func (c *Client) GetActiveSessions(ctx context.Context) ([]ActiveSession, error) { var sessions []ActiveSession @@ -2443,3 +2463,174 @@ func (c *Client) GetAccounts(ctx context.Context) ([]Account, error) { return accounts, nil } + +// GetActivities returns currently running server activities. +func (c *Client) GetActivities(ctx context.Context) ([]ActivityTask, error) { + var activities []ActivityTask + + err := c.executeWithRetry(ctx, "GetActivities", func() error { + urlStr := fmt.Sprintf("%s/activities?X-Plex-Token=%s", c.serverURL, c.token) + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + var container struct { + XMLName xml.Name `xml:"MediaContainer"` + Activities []struct { + UUID string `xml:"uuid,attr"` + Type string `xml:"type,attr"` + Title string `xml:"title,attr"` + Subtitle string `xml:"subtitle,attr"` + ProgressRaw string `xml:"progress,attr"` + CancelRaw string `xml:"cancellable,attr"` + } `xml:"Activity"` + } + + if err := xml.Unmarshal(body, &container); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + activities = nil + for _, a := range container.Activities { + activities = append(activities, ActivityTask{ + UUID: a.UUID, + Type: a.Type, + Title: a.Title, + Subtitle: a.Subtitle, + Progress: parseOptionalFloat(a.ProgressRaw), + Cancellable: parseBoolAttr(a.CancelRaw), + }) + } + + return nil + }) + if err != nil { + return nil, &PlexError{ + Op: "GetActivities", + Err: err, + } + } + + return activities, nil +} + +// GetBackgroundTasks returns currently running background tasks. +func (c *Client) GetBackgroundTasks(ctx context.Context) ([]BackgroundTask, error) { + var tasks []BackgroundTask + + err := c.executeWithRetry(ctx, "GetBackgroundTasks", func() error { + urlStr := fmt.Sprintf("%s/status/sessions/background?X-Plex-Token=%s", c.serverURL, c.token) + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + type rawTask struct { + Type string `xml:"type,attr"` + Title string `xml:"title,attr"` + ProgressRaw string `xml:"progress,attr"` + RemainingRaw string `xml:"remaining,attr"` + SpeedRaw string `xml:"speed,attr"` + } + + var container struct { + XMLName xml.Name `xml:"MediaContainer"` + TranscodeJobs []rawTask `xml:"TranscodeJob"` + TranscodeSession []rawTask `xml:"TranscodeSession"` + } + + if err := xml.Unmarshal(body, &container); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + appendTask := func(rt rawTask) { + tasks = append(tasks, BackgroundTask{ + Type: rt.Type, + Title: rt.Title, + Progress: parseOptionalFloat(rt.ProgressRaw), + Remaining: parseOptionalInt64(rt.RemainingRaw), + Speed: parseOptionalFloat(rt.SpeedRaw), + }) + } + + tasks = nil + for _, t := range container.TranscodeJobs { + appendTask(t) + } + for _, t := range container.TranscodeSession { + appendTask(t) + } + + return nil + }) + if err != nil { + return nil, &PlexError{ + Op: "GetBackgroundTasks", + Err: err, + } + } + + return tasks, nil +} + +func parseOptionalFloat(raw string) *float64 { + if strings.TrimSpace(raw) == "" { + return nil + } + v, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil + } + return &v +} + +func parseOptionalInt64(raw string) *int64 { + if strings.TrimSpace(raw) == "" { + return nil + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil + } + return &v +} + +func parseBoolAttr(raw string) bool { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "true", "yes": + return true + default: + return false + } +}