diff --git a/README.md b/README.md index 96fd16e..c5066b5 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,41 @@ plex library clean 1 # Show active activities/background tasks plex library status + +# Show details for a section +plex library section show 1 + +# Create or edit a section +plex library section create "Movies" --type movie --agent tv.plex.agents.movie --location /srv/media/movies +plex library section edit 1 --language en-AU --pref languageOverride=en-AU + +# Cancel one section refresh or stop all refreshes +plex library refresh cancel 1 +plex library refresh stop-all --yes + +# Update item metadata and preferences +plex library item edit 12345 --set title="New Title" --lock title +plex library item prefs set 12345 --pref introDetection=1 +plex library section prefs defaults --type movie --agent tv.plex.agents.movie + +# Run advanced detectors +plex library detect intros 27018 --force +plex library detect status 999 + +# Use discovery helpers +plex library discover collections 1 +plex library discover autocomplete 1 --query sta + +# Download binary helpers +plex library artwork get 12345 --element poster --output /tmp/poster.jpg +plex library media file --url /library/metadata/27017/theme/1771256497 --output /tmp/theme.mp3 +plex library media part 38540 1704960287 file.mkv --output /tmp/episode.mkv +plex library media part-index 77 sd --interval 1000 --output /tmp/index.bif +plex library media stream get 77 srt --output /tmp/subtitle.srt + +# Attach subtitles from a URL or local file +plex library subtitle add 12345 --url https://example.com/subs/en.srt --language eng +plex library subtitle add 12345 --file ./subs/en.srt --language eng --format srt ``` #### Authenticate diff --git a/cmd/plex/main.go b/cmd/plex/main.go index 092da7c..af51744 100644 --- a/cmd/plex/main.go +++ b/cmd/plex/main.go @@ -165,6 +165,59 @@ func applyOutputFormat(cli *CLI, format string) { cli.Library.Update.Output = format cli.Library.Clean.Output = format cli.Library.Status.Output = format + cli.Library.Section.Show.Output = format + cli.Library.Section.Create.Output = format + cli.Library.Section.Edit.Output = format + cli.Library.Section.Delete.Output = format + cli.Library.Section.Prefs.List.Output = format + cli.Library.Section.Prefs.Set.Output = format + cli.Library.Section.Prefs.Defaults.Output = format + cli.Library.Section.Filters.Output = format + cli.Library.Section.Sorts.Output = format + cli.Library.Section.Letters.Output = format + cli.Library.Refresh.Cancel.Output = format + cli.Library.Refresh.StopAll.Output = format + cli.Library.Item.Delete.Output = format + cli.Library.Item.Edit.Output = format + cli.Library.Item.Refresh.Output = format + cli.Library.Item.Analyze.Output = format + cli.Library.Item.Unmatch.Output = format + cli.Library.Item.Split.Output = format + cli.Library.Item.Merge.Output = format + cli.Library.Item.Prefs.List.Output = format + cli.Library.Item.Prefs.Set.Output = format + cli.Library.Item.BulkUpdate.Output = format + cli.Library.Subtitle.Add.Output = format + cli.Library.Artwork.Set.Output = format + cli.Library.Artwork.Update.Output = format + cli.Library.Detect.Ads.Output = format + cli.Library.Detect.Credits.Output = format + cli.Library.Detect.Intros.Output = format + cli.Library.Detect.DeleteIntros.Output = format + cli.Library.Detect.Voice.Output = format + cli.Library.Detect.BIF.Output = format + cli.Library.Detect.Status.Output = format + cli.Library.Discover.Tags.Output = format + cli.Library.Discover.Collections.Output = format + cli.Library.Discover.Common.Output = format + cli.Library.Discover.Related.Output = format + cli.Library.Discover.Similar.Output = format + cli.Library.Discover.SonicSimilar.Output = format + cli.Library.Discover.Autocomplete.Output = format + cli.Library.Discover.Matches.Output = format + cli.Library.Discover.RandomArt.Output = format + cli.Library.Person.Show.Output = format + cli.Library.Person.Media.Output = format + cli.Library.Media.Extras.Output = format + cli.Library.Media.Tree.Output = format + cli.Library.Media.Leaves.Output = format + cli.Library.Media.Stream.Delete.Output = format + cli.Library.Media.Stream.Offset.Output = format + cli.Library.Media.Levels.Output = format + cli.Library.Media.Loudness.Output = format + cli.Library.Media.Marker.Create.Output = format + cli.Library.Media.Marker.Edit.Output = format + cli.Library.Media.Marker.Delete.Output = format cli.ServerInfo.Output = format cli.Playlist.List.Output = format cli.Playlist.Create.Output = format diff --git a/cmd/plex/main_test.go b/cmd/plex/main_test.go index 2ff9753..54739b5 100644 --- a/cmd/plex/main_test.go +++ b/cmd/plex/main_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "testing" + "github.com/alecthomas/kong" + "github.com/dogbertdev/plexcli/internal/config" ) @@ -74,3 +76,31 @@ func TestLoadConfigAppliesCacheFlagsWhenSet(t *testing.T) { t.Fatalf("expected CacheRefresh=true") } } + +func TestApplyOutputFormatDoesNotOverwritePartIndexPath(t *testing.T) { + cli := &CLI{} + cli.Library.Media.PartIndex.Output = "/tmp/index.bif" + + applyOutputFormat(cli, "json") + + if cli.Library.Media.PartIndex.Output != "/tmp/index.bif" { + t.Fatalf("expected part-index output path to be preserved, got %q", cli.Library.Media.PartIndex.Output) + } +} + +func TestLibraryMediaFileParsesDirectLibraryPathWithoutIDs(t *testing.T) { + var cli CLI + parser, err := kong.New(&cli) + if err != nil { + t.Fatalf("kong.New() error = %v", err) + } + + _, err = parser.Parse([]string{ + "library", "media", "file", + "--url", "/library/metadata/27017/theme/1771256497", + "--output", "/tmp/theme.mp3", + }) + if err != nil { + t.Fatalf("parser.Parse() error = %v", err) + } +} diff --git a/docs/plexgo-cli-coverage.md b/docs/plexgo-cli-coverage.md new file mode 100644 index 0000000..afae37b --- /dev/null +++ b/docs/plexgo-cli-coverage.md @@ -0,0 +1,321 @@ +# plexgo CLI Coverage + +This document compares the reference SDK surface in [references/plexgo](/Users/paulmansfield/projects/plex-cli/references/plexgo) with the current CLI command surface in [internal/cmd](/Users/paulmansfield/projects/plex-cli/internal/cmd). + +It is intentionally grouped by capability rather than listing every generated SDK method in one flat table. The goal is to show: + +- what the CLI already exposes +- what is only partially exposed +- what has no CLI coverage yet + +## Current CLI Surface + +The current CLI exposes commands in these files: + +- [internal/cmd/auth.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/auth.go) +- [internal/cmd/library.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/library.go) +- [internal/cmd/libraries.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/libraries.go) +- [internal/cmd/server_info.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/server_info.go) +- [internal/cmd/search.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/search.go) +- [internal/cmd/playlist.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/playlist.go) +- [internal/cmd/watch.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/watch.go) +- [internal/cmd/match.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/match.go) +- [internal/cmd/streams.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/streams.go) +- [internal/cmd/directors.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/directors.go) +- [internal/cmd/movies.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/movies.go) +- [internal/cmd/recently_added.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/recently_added.go) +- [internal/cmd/recently_watched.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/recently_watched.go) +- [internal/cmd/unwatched.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/unwatched.go) +- [internal/cmd/unmatched.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/unmatched.go) +- [internal/cmd/duplicates.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/duplicates.go) +- [internal/cmd/episodes.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/episodes.go) +- [internal/cmd/episodes_list.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/episodes_list.go) +- [internal/cmd/file_paths.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/file_paths.go) +- [internal/cmd/audio.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/audio.go) +- [internal/cmd/subtitles.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/subtitles.go) +- [internal/cmd/quality.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/quality.go) +- [internal/cmd/metadata.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/metadata.go) +- [internal/cmd/editions.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/editions.go) +- [internal/cmd/cache.go](/Users/paulmansfield/projects/plex-cli/internal/cmd/cache.go) + +## Coverage Summary + +## Operational Notes + +- `library detect intros` is exposed generically, but live Plex testing showed it behaving as a season-scoped operation on this server. In practice, season metadata IDs are the safest target. +- `library media file` supports both bundle-style URLs and direct Plex library asset paths such as `/library/metadata//theme/`. + +### Covered Or Mostly Covered + +These SDK areas have meaningful CLI coverage already, even if the CLI does not expose every SDK method. + +| SDK area | Reference file | Current CLI coverage | +| --- | --- | --- | +| Plex.tv auth and server discovery | [references/plexgo/authentication.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/authentication.go), [references/plexgo/plex.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/plex.go) | `auth login`, `auth logout`, `auth servers`, `auth token-info` | +| Basic server/library inspection | [references/plexgo/general.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/general.go), [references/plexgo/library.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/library.go) | `server-info`, `library list`, `library update`, `library clean`, `library status` | +| Search | [references/plexgo/search.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/search.go), [references/plexgo/library.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/library.go) | `search` | +| Watch/session visibility | [references/plexgo/status.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/status.go) | `watch now`, `watch history`, `watch stats`, `recently-watched` | +| Playlist basics | [references/plexgo/playlist.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/playlist.go), [references/plexgo/libraryplaylists.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/libraryplaylists.go) | `playlist list`, `playlist create`, `playlist smart`, `playlist add`, `playlist show`, `playlist delete` | +| Match and stream selection | [references/plexgo/library.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/library.go) | `match search`, `match apply`, `streams list`, `streams set` | +| Audit/report workflows built on library content | [references/plexgo/library.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/library.go), [references/plexgo/content.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/content.go) | `unwatched`, `unmatched`, `duplicates`, `episodes-missing`, `episodes-list`, `recently-added`, `file-paths`, `audio-check`, `subtitles-missing`, `quality-check`, `metadata-missing`, `editions`, `directors`, `movies` | + +### Partially Covered + +These SDK areas have some CLI coverage, but significant methods are still not exposed directly. + +| SDK area | Reference file | Covered in CLI | Missing notable methods | +| --- | --- | --- | --- | +| General | [references/plexgo/general.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/general.go) | `server-info` approximates basic server info | `GetIdentity`, `GetSourceConnectionInformation`, `GetTransientToken` | +| Status | [references/plexgo/status.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/status.go) | Session list, background tasks, playback history reads | `TerminateSession`, `DeleteHistory`, `GetHistoryItem` | +| Plex.tv | [references/plexgo/users.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/users.go) | Token inspection and resource discovery | `GetUsers` | +| Library maintenance | [references/plexgo/library.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/library.go) | `RefreshSection`, `CancelRefresh`, `EmptyTrash`, `CleanBundles`, `DeleteCaches`, `OptimizeDatabase`, `AnalyzeMetadata`, `GenerateThumbs`, `SetStreamSelection`, match/list sections helpers | Many library and metadata operations still missing | +| Playlists | [references/plexgo/libraryplaylists.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/libraryplaylists.go) | Create, add, list/show, delete | `UpdatePlaylist`, `UploadPlaylist`, `ClearPlaylistItems`, `DeletePlaylistItem`, `MovePlaylistItem`, `RefreshPlaylist`, generator methods | + +## Missing SDK Areas + +The following SDK areas currently have no corresponding CLI command family. + +### Preferences + +Reference: [references/plexgo/preferences.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/preferences.go) + +Missing methods: + +- `GetAllPreferences` +- `GetPreference` +- `SetPreferences` + +Potential CLI family: + +- `preferences list` +- `preferences get` +- `preferences set` + +### Updater + +Reference: [references/plexgo/updater.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/updater.go) + +Missing methods: + +- `CheckUpdates` +- `GetUpdatesStatus` +- `ApplyUpdates` + +Potential CLI family: + +- `updates check` +- `updates status` +- `updates apply` + +### Collections + +Reference: + +- [references/plexgo/collections.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/collections.go) +- [references/plexgo/librarycollections.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/librarycollections.go) + +Missing methods: + +- `CreateCollection` +- `AddCollectionItems` +- `DeleteCollectionItem` +- `MoveCollectionItem` +- `DeleteCollection` + +Potential CLI family: + +- `collection create` +- `collection add-items` +- `collection remove-item` +- `collection move-item` +- `collection delete` + +### Ratings And Timeline Actions + +Reference: + +- [references/plexgo/rate.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/rate.go) +- [references/plexgo/timeline.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/timeline.go) + +Missing methods: + +- `SetRating` +- `MarkPlayed` +- `Report` +- `Unscrobble` + +Potential CLI family: + +- `rate set` +- `timeline mark-played` +- `timeline unscrobble` + +### Providers And Hubs + +Reference: + +- [references/plexgo/provider.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/provider.go) +- [references/plexgo/hubs.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/hubs.go) + +Missing methods: + +- Provider management: `ListProviders`, `AddProvider`, `RefreshProviders`, `DeleteMediaProvider` +- Hub operations: `GetAllHubs`, `GetContinueWatching`, `GetHubItems`, `GetPromotedHubs`, `GetMetadataHubs`, `GetPostplayHubs`, `GetRelatedHubs`, `GetSectionHubs`, `ResetSectionDefaults`, `ListHubs`, `CreateCustomHub`, `MoveHub`, `DeleteCustomHub`, `UpdateHubVisibility` + +Potential CLI family: + +- `provider list` +- `provider add` +- `provider refresh` +- `provider delete` +- `hub list` +- `hub show` +- `hub create` +- `hub move` +- `hub delete` + +### Play Queue + +Reference: [references/plexgo/playqueue.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/playqueue.go) + +Missing methods: + +- `CreatePlayQueue` +- `GetPlayQueue` +- `AddToPlayQueue` +- `ClearPlayQueue` +- `ResetPlayQueue` +- `Shuffle` +- `Unshuffle` +- `DeletePlayQueueItem` +- `MovePlayQueueItem` + +Potential CLI family: + +- `queue create` +- `queue show` +- `queue add` +- `queue clear` +- `queue shuffle` +- `queue move-item` + +### Download Queue + +Reference: [references/plexgo/downloadqueue.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/downloadqueue.go) + +Missing methods: + +- `CreateDownloadQueue` +- `GetDownloadQueue` +- `AddDownloadQueueItems` +- `ListDownloadQueueItems` +- `GetItemDecision` +- `GetDownloadQueueMedia` +- `RemoveDownloadQueueItems` +- `GetDownloadQueueItems` +- `RestartProcessingDownloadQueueItems` + +Potential CLI family: + +- `download-queue create` +- `download-queue list` +- `download-queue add` +- `download-queue remove` +- `download-queue restart` + +### Events, Butler, Log, UltraBlur + +Reference: + +- [references/plexgo/events.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/events.go) +- [references/plexgo/butler.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/butler.go) +- [references/plexgo/log.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/log.go) +- [references/plexgo/ultrablur.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/ultrablur.go) + +Missing methods: + +- Events: `GetNotifications`, `ConnectWebSocket` +- Butler: `GetTasks`, `StartTasks`, `StopTasks`, `StartTask`, `StopTask` +- Log: `WriteLog`, `WriteMessage`, `EnablePapertrail` +- UltraBlur: `GetColors`, `GetImage` + +Potential CLI family: + +- `events listen` +- `butler list` +- `butler start` +- `butler stop` + +### Live TV, DVR, EPG, Devices, Subscriptions + +Reference: + +- [references/plexgo/dvrs.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/dvrs.go) +- [references/plexgo/epg.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/epg.go) +- [references/plexgo/devices.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/devices.go) +- [references/plexgo/livetv.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/livetv.go) +- [references/plexgo/subscriptions.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/subscriptions.go) + +Missing methods include: + +- DVR management and lineup control +- Guide/EPG browsing and lineup discovery +- Device discovery, scan, add/remove, and tuning +- Live TV session inspection +- Recording/subscription management + +Potential CLI family: + +- `dvr ...` +- `epg ...` +- `device ...` +- `livetv ...` +- `recording ...` + +## High-Volume Missing Library Methods + +The largest gap used to be the advanced library API surface in [references/plexgo/library.go](/Users/paulmansfield/projects/plex-cli/references/plexgo/library.go). The CLI now exposes nested `library` families for section/item/detect/discover/person/media workflows, including section create/edit/delete, refresh cancellation, item mutation, discovery helpers, artwork downloads, marker operations, stream helpers, and several binary media endpoints. + +The biggest remaining holes are the less-common or less-polished helpers below. + +### Section Management + +- richer custom formatting for `GetSectionsPrefs` + +### Item And Metadata Mutation + +- richer local-file subtitle upload beyond URL-based subtitle attachment +- broader artwork/body-upload variants beyond URL-driven set/update flows + +### Analysis And Detection + +- `RefreshSectionsMetadata` + +### Collections, People, Similarity, Discovery + +- section- or query-specific flag coverage is still shallow for some discovery endpoints + +### Extras, Files, Parts, Streams, Markers, Artwork + +- `AddExtras` + +### Miscellaneous Library Methods + +- `IngestTransientItem` +- `GetLibraryItems` + +## High-Value Next Candidates + +If the goal is to expand the CLI in the most useful order, these are the best next command families: + +1. Preferences: highly actionable and relatively contained. +2. Status actions: terminate sessions and delete history. +3. Collections: create and manage collection membership. +4. Ratings and timeline actions: rating, mark-played, unscrobble. +5. Library item mutation: unmatch, split, merge, refresh-item, edit metadata. +6. Updater: check/apply server updates. + +## Notes + +- This comparison is based on user-facing CLI commands, not just helper methods in [internal/plexclient/client.go](/Users/paulmansfield/projects/plex-cli/internal/plexclient/client.go). +- Some current commands are higher-level workflows that combine multiple SDK calls, so “coverage” here means practical command availability, not strict one-command-per-method parity. diff --git a/internal/cmd/library.go b/internal/cmd/library.go index e1f6bc8..8d5592c 100644 --- a/internal/cmd/library.go +++ b/internal/cmd/library.go @@ -15,10 +15,19 @@ import ( ) 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 server tasks and activities"` + 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 server tasks and activities"` + Section LibrarySectionCmd `cmd:"" help:"Manage library sections and section preferences"` + Refresh LibraryRefreshCmd `cmd:"" help:"Cancel or stop library refresh operations"` + Item LibraryItemCmd `cmd:"" help:"Mutate and inspect library metadata items"` + Subtitle LibrarySubtitleCmd `cmd:"" help:"Attach subtitles to library items"` + Artwork LibraryArtworkCmd `cmd:"" help:"Manage metadata artwork"` + Detect LibraryDetectCmd `cmd:"" help:"Run advanced detection and analysis operations"` + Discover LibraryDiscoverCmd `cmd:"" help:"Use discovery and similarity endpoints"` + Person LibraryPersonCmd `cmd:"" help:"Inspect person details and credits"` + Media LibraryMediaCmd `cmd:"" help:"Inspect and download media helpers"` } type LibraryUpdateCmd struct { diff --git a/internal/cmd/library_advanced.go b/internal/cmd/library_advanced.go new file mode 100644 index 0000000..d97fffd --- /dev/null +++ b/internal/cmd/library_advanced.go @@ -0,0 +1,876 @@ +package cmd + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/alecthomas/kong" + + "github.com/dogbertdev/plexcli/internal/config" + "github.com/dogbertdev/plexcli/internal/plexclient" + "github.com/dogbertdev/plexcli/internal/ui" +) + +type LibraryItemCmd struct { + Delete LibraryItemDeleteCmd `cmd:"" help:"Delete one or more metadata items"` + Edit LibraryItemEditCmd `cmd:"" help:"Edit metadata fields for one or more items"` + Refresh LibraryItemRefreshCmd `cmd:"" help:"Refresh metadata for one or more items"` + Analyze LibraryItemAnalyzeCmd `cmd:"" help:"Analyze one or more metadata items"` + Unmatch LibraryItemUnmatchCmd `cmd:"" help:"Unmatch one or more metadata items"` + Split LibraryItemSplitCmd `cmd:"" help:"Split a merged metadata item"` + Merge LibraryItemMergeCmd `cmd:"" help:"Merge multiple metadata items"` + Prefs LibraryItemPrefsCmd `cmd:"" help:"View or update item preferences"` + BulkUpdate LibraryItemBulkUpdateCmd `cmd:"" name:"bulk-update" help:"Bulk update items in a section using a filter expression"` +} + +type LibraryItemDeleteCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Yes bool `help:"Confirm deletion" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemEditCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Set []string `help:"Field assignment in key=value form" name:"set" required:""` + Lock []string `help:"Metadata field to lock" name:"lock"` + Unlock []string `help:"Metadata field to unlock" name:"unlock"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemRefreshCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemAnalyzeCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemUnmatchCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Yes bool `help:"Confirm unmatch" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemSplitCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Yes bool `help:"Confirm split" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemMergeCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs to merge"` + Yes bool `help:"Confirm merge" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemPrefsCmd struct { + List LibraryItemPrefsListCmd `cmd:"" help:"List item preferences"` + Set LibraryItemPrefsSetCmd `cmd:"" help:"Set item preferences"` +} + +type LibraryItemPrefsListCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemPrefsSetCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Prefs []string `help:"Preference override in key=value form" name:"pref" required:""` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryItemBulkUpdateCmd struct { + Section string `help:"Library section ID" short:"s" required:""` + Filter string `help:"Raw Plex filter expression" required:""` + Set []string `help:"Field assignment in key=value form" name:"set"` + Lock []string `help:"Metadata field to lock" name:"lock"` + AddTags []string `help:"Tag assignment in type=value form" name:"add-tag"` + RemoveTag []string `help:"Tag removal in type=value form" name:"remove-tag"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySubtitleCmd struct { + Add LibrarySubtitleAddCmd `cmd:"" help:"Attach subtitles to a metadata item from a URL or local file"` +} + +type LibrarySubtitleAddCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + URL string `help:"Subtitle URL"` + File string `help:"Local subtitle file to upload" type:"path"` + Language string `help:"Subtitle language code"` + Title string `help:"Subtitle title"` + MediaItemID int64 `help:"Specific media item ID for attachment" default:"0"` + Format string `help:"Subtitle format"` + Forced bool `help:"Mark subtitles as forced"` + HearingImpaired bool `help:"Mark subtitles as hearing impaired"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryArtworkCmd struct { + Get LibraryArtworkGetCmd `cmd:"" help:"Download artwork for a metadata item"` + Set LibraryArtworkSetCmd `cmd:"" help:"Set artwork for a metadata item from a URL"` + Update LibraryArtworkUpdateCmd `cmd:"" help:"Update artwork for a metadata item from a URL"` +} + +type LibraryArtworkGetCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Element string `help:"Artwork element" default:"poster" enum:"thumb,art,clearLogo,banner,poster,theme"` + Timestamp int64 `help:"Artwork timestamp used for cache busting" default:"0"` + Output string `help:"Destination file path" type:"path" required:""` +} + +type LibraryArtworkSetCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Element string `help:"Artwork element" default:"poster" enum:"thumb,art,clearLogo,banner,poster,theme"` + URL string `help:"Artwork URL" required:""` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryArtworkUpdateCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Element string `help:"Artwork element" default:"poster" enum:"thumb,art,clearLogo,banner,poster,theme"` + URL string `help:"Artwork URL" required:""` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryDetectCmd struct { + Ads LibraryDetectAdsCmd `cmd:"" help:"Detect ads for metadata items"` + Credits LibraryDetectCreditsCmd `cmd:"" help:"Detect credits for metadata items"` + Intros LibraryDetectIntrosCmd `cmd:"" help:"Detect intros for metadata items"` + DeleteIntros LibraryDetectDeleteIntrosCmd `cmd:"" name:"delete-intros" help:"Delete section intros"` + Voice LibraryDetectVoiceCmd `cmd:"" help:"Detect voice activity for metadata items"` + BIF LibraryDetectBIFCmd `cmd:"" name:"bif" help:"Generate BIF previews for metadata items"` + Status LibraryDetectStatusCmd `cmd:"" help:"Show augmentation status"` +} + +type LibraryDetectAdsCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDetectCreditsCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Force bool `help:"Force detection to rerun"` + Manual bool `help:"Mark detection as a manual request"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDetectIntrosCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Force bool `help:"Force detection to rerun"` + Threshold *float64 `help:"Override the intro detection threshold"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDetectDeleteIntrosCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Yes bool `help:"Confirm intro deletion" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDetectVoiceCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Force bool `help:"Force detection to rerun"` + Manual bool `help:"Mark detection as a manual request"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDetectBIFCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Force bool `help:"Force detection to rerun"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDetectStatusCmd struct { + AugmentationID string `arg:"" name:"augmentation-id" help:"Augmentation ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryDiscoverCmd struct { + Tags LibraryDiscoverTagsCmd `cmd:"" help:"List library tags"` + Collections LibraryDiscoverCollectionsCmd `cmd:"" help:"List section collections"` + Common LibraryDiscoverCommonCmd `cmd:"" help:"List common items in a section"` + Related LibraryDiscoverRelatedCmd `cmd:"" help:"List items related to metadata IDs"` + Similar LibraryDiscoverSimilarCmd `cmd:"" help:"List similar items for metadata IDs"` + SonicSimilar LibraryDiscoverSonicCmd `cmd:"" name:"sonic-similar" help:"List sonically similar items"` + Autocomplete LibraryDiscoverAutocompleteCmd `cmd:"" help:"Autocomplete within a library section"` + Matches LibraryDiscoverMatchesCmd `cmd:"" help:"List metadata matches for one or more items"` + RandomArt LibraryDiscoverRandomArtCmd `cmd:"" name:"random-artwork" help:"List random artwork candidates"` +} + +type LibraryDiscoverTagsCmd struct { + Type string `help:"Metadata type: movie, show, music, photo" default:"movie" enum:"movie,show,music,photo"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverCollectionsCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverCommonCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverRelatedCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverSimilarCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverSonicCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverAutocompleteCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Field string `help:"Field to autocomplete" default:"title"` + Query string `help:"Autocomplete query" required:""` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverMatchesCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryDiscoverRandomArtCmd struct { + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryPersonCmd struct { + Show LibraryPersonShowCmd `cmd:"" help:"Show person details"` + Media LibraryPersonMediaCmd `cmd:"" help:"List media for a person"` +} + +type LibraryPersonShowCmd struct { + Person string `arg:"" name:"person" help:"Person ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryPersonMediaCmd struct { + Person string `arg:"" name:"person" help:"Person ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryMediaCmd struct { + Extras LibraryMediaExtrasCmd `cmd:"" help:"List extras for a metadata item"` + File LibraryMediaFileCmd `cmd:"" help:"Download a bundled file for a metadata item"` + Tree LibraryMediaTreeCmd `cmd:"" help:"Show the tree for a metadata item"` + Leaves LibraryMediaLeavesCmd `cmd:"" help:"List all leaves for a metadata item"` + Part LibraryMediaPartCmd `cmd:"" help:"Download a specific media part"` + PartIndex LibraryMediaPartIndexCmd `cmd:"" name:"part-index" help:"Download a BIF index for a media part"` + Stream LibraryMediaStreamCmd `cmd:"" help:"Download or manage a stream"` + Levels LibraryMediaLevelsCmd `cmd:"" help:"Show stream levels"` + Loudness LibraryMediaLoudnessCmd `cmd:"" help:"Show stream loudness"` + Marker LibraryMediaMarkerCmd `cmd:"" help:"Create, edit, or delete markers"` + ChapterImage LibraryMediaChapterImageCmd `cmd:"" name:"chapter-image" help:"Download a chapter image"` + ItemArtwork LibraryMediaItemArtworkCmd `cmd:"" name:"item-artwork" help:"Download item artwork"` + SectionImage LibraryMediaSectionImageCmd `cmd:"" name:"section-image" help:"Download section artwork"` + BIFImage LibraryMediaBIFImageCmd `cmd:"" name:"bif-image" help:"Download an image from a BIF index"` +} + +type LibraryMediaExtrasCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaFileCmd struct { + IDs string `arg:"" optional:"" name:"ids" help:"Comma-separated metadata IDs for bundle-style file fetches"` + URL string `help:"Bundle URL to fetch"` + Output string `help:"Destination file path" type:"path" required:""` +} +type LibraryMediaTreeCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaLeavesCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaPartCmd struct { + PartID int64 `arg:"" name:"part-id" help:"Part ID"` + Changestamp int64 `arg:"" name:"changestamp" help:"Part changestamp"` + Filename string `arg:"" name:"filename" help:"Part filename"` + Download bool `help:"Request a file download"` + Output string `help:"Destination file path" type:"path" required:""` +} +type LibraryMediaPartIndexCmd struct { + PartID int64 `arg:"" name:"part-id" help:"Part ID"` + Index string `arg:"" name:"index" help:"Index type" default:"sd" enum:"sd"` + Interval int64 `help:"Interval between BIF images in milliseconds" default:"0"` + Output string `help:"Destination file path" type:"path" required:""` +} +type LibraryMediaStreamCmd struct { + Get LibraryMediaStreamGetCmd `cmd:"" help:"Download a specific stream"` + Delete LibraryMediaStreamDeleteCmd `cmd:"" help:"Delete a downloaded or sidecar stream"` + Offset LibraryMediaStreamOffsetCmd `cmd:"" help:"Set subtitle stream offset"` +} +type LibraryMediaStreamGetCmd struct { + StreamID int64 `arg:"" name:"stream-id" help:"Stream ID"` + Ext string `arg:"" name:"ext" help:"Stream extension"` + Encoding string `help:"Requested encoding"` + Format string `help:"Requested format"` + AutoAdjust bool `help:"Auto-adjust subtitle timing"` + Output string `help:"Destination file path" type:"path" required:""` +} +type LibraryMediaStreamDeleteCmd struct { + StreamID int64 `arg:"" name:"stream-id" help:"Stream ID"` + Ext string `arg:"" name:"ext" help:"Stream extension"` + Yes bool `help:"Confirm deletion" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaStreamOffsetCmd struct { + StreamID int64 `arg:"" name:"stream-id" help:"Stream ID"` + Ext string `arg:"" name:"ext" help:"Stream extension"` + Offset int64 `help:"Subtitle offset in milliseconds" required:""` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaLevelsCmd struct { + StreamID int64 `arg:"" name:"stream-id" help:"Stream ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaLoudnessCmd struct { + StreamID int64 `arg:"" name:"stream-id" help:"Stream ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaMarkerCmd struct { + Create LibraryMediaMarkerCreateCmd `cmd:"" help:"Create a marker"` + Edit LibraryMediaMarkerEditCmd `cmd:"" help:"Edit a marker"` + Delete LibraryMediaMarkerDeleteCmd `cmd:"" help:"Delete a marker"` +} +type LibraryMediaMarkerCreateCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Type string `help:"Marker type" required:"" enum:"bookmark,intro,commercial,resume,credit"` + Start int64 `help:"Start offset in milliseconds" required:""` + End int64 `help:"End offset in milliseconds"` + Attributes []string `help:"Marker attribute in key=value form" name:"attr"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaMarkerEditCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Marker string `arg:"" name:"marker" help:"Marker ID"` + Type string `help:"Marker type" required:"" enum:"bookmark,intro,commercial,resume,credit"` + Start int64 `help:"Start offset in milliseconds" required:""` + End int64 `help:"End offset in milliseconds"` + Attributes []string `help:"Marker attribute in key=value form" name:"attr"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaMarkerDeleteCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Marker string `arg:"" name:"marker" help:"Marker ID"` + Yes bool `help:"Confirm deletion" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} +type LibraryMediaChapterImageCmd struct { + MediaID int64 `arg:"" name:"media-id" help:"Media ID"` + Chapter int64 `arg:"" name:"chapter" help:"Chapter number"` + Output string `help:"Destination file path" type:"path" required:""` +} +type LibraryMediaItemArtworkCmd struct { + IDs string `arg:"" name:"ids" help:"Comma-separated metadata IDs"` + Element string `help:"Artwork element" default:"poster" enum:"thumb,art,clearLogo,banner,poster,theme"` + Timestamp int64 `help:"Artwork timestamp used for cache busting" default:"0"` + Output string `help:"Destination file path" type:"path" required:""` +} +type LibraryMediaSectionImageCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + UpdatedAt int64 `help:"Section updated-at timestamp used for cache busting" default:"0"` + Output string `help:"Destination file path" type:"path" required:""` +} +type LibraryMediaBIFImageCmd struct { + PartID int64 `arg:"" name:"part-id" help:"Part ID"` + Index string `arg:"" name:"index" help:"BIF index"` + Offset string `arg:"" name:"offset" help:"BIF offset"` + Output string `help:"Destination file path" type:"path" required:""` +} + +func (c *LibraryItemDeleteCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := requireConfirmed(c.Yes, "item delete"); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "item-delete", c.IDs, "items deleted", func(runCtx context.Context, client *plexclient.Client) error { + return client.DeleteMetadata(runCtx, c.IDs) + }) +} + +func (c *LibraryItemEditCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + setValues, err := parseKeyValueFlags(c.Set) + if err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "item-edit", c.IDs, "items updated", func(runCtx context.Context, client *plexclient.Client) error { + return client.EditMetadataDynamic(runCtx, c.IDs, plexclient.MetadataEditInput{Set: setValues, Lock: c.Lock, Unlock: c.Unlock}) + }) +} + +func (c *LibraryItemRefreshCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataPathAction(cfg, u, c.Output, c.IDs, "RefreshItemsMetadata", http.MethodPut, "/refresh", "item-refresh", "refresh requested") +} + +func (c *LibraryItemAnalyzeCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataPathAction(cfg, u, c.Output, c.IDs, "AnalyzeMetadata", http.MethodPut, "/analyze", "item-analyze", "analysis requested") +} + +func (c *LibraryItemUnmatchCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := requireConfirmed(c.Yes, "item unmatch"); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "item-unmatch", c.IDs, "items unmatched", func(runCtx context.Context, client *plexclient.Client) error { + return client.UnmatchMetadata(runCtx, c.IDs) + }) +} + +func (c *LibraryItemSplitCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := requireConfirmed(c.Yes, "item split"); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "item-split", c.IDs, "item split requested", func(runCtx context.Context, client *plexclient.Client) error { + return client.SplitMetadata(runCtx, c.IDs) + }) +} + +func (c *LibraryItemMergeCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := requireConfirmed(c.Yes, "item merge"); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "item-merge", c.IDs, "merge requested", func(runCtx context.Context, client *plexclient.Client) error { + return client.MergeMetadata(runCtx, c.IDs) + }) +} + +func (c *LibraryItemPrefsListCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if _, err := encodeCSVArg(c.IDs); err != nil { + return err + } + return fmt.Errorf("Plex does not expose a readable item preferences endpoint; use `library item prefs set` to update preferences") +} + +func (c *LibraryItemPrefsSetCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + prefs, err := parseKeyValueFlags(c.Prefs) + if err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "item-prefs-set", c.IDs, "preferences updated", func(runCtx context.Context, client *plexclient.Client) error { + return client.SetItemPreferencesDynamic(runCtx, c.IDs, prefs) + }) +} + +func (c *LibraryItemBulkUpdateCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + setValues, err := parseKeyValueFlags(c.Set) + if err != nil && len(c.Set) > 0 { + return err + } + addTags, err := parseMultiValueFlags(c.AddTags) + if err != nil && len(c.AddTags) > 0 { + return err + } + removeTags, err := parseMultiValueFlags(c.RemoveTag) + if err != nil && len(c.RemoveTag) > 0 { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "item-bulk-update", c.Section, "bulk update requested", func(runCtx context.Context, client *plexclient.Client) error { + sectionTypeID, err := sectionTypeIDForLibrary(runCtx, client, c.Section) + if err != nil { + return err + } + return client.UpdateItemsDynamic(runCtx, plexclient.BulkUpdateInput{ + SectionID: c.Section, + MediaType: sectionTypeID, + Filter: c.Filter, + Set: setValues, + Lock: c.Lock, + AddTags: addTags, + RemoveTags: removeTags, + }) + }) +} + +func (c *LibrarySubtitleAddCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "subtitle-add", c.IDs, "subtitle attached", func(runCtx context.Context, client *plexclient.Client) error { + if c.File != "" { + payload, err := os.ReadFile(c.File) + if err != nil { + return fmt.Errorf("failed to read subtitle file: %w", err) + } + title := c.Title + if title == "" { + title = filepath.Base(c.File) + } + return client.AddSubtitlesFromFile(runCtx, c.IDs, payload, c.Language, title, c.MediaItemID, c.Format, c.Forced, c.HearingImpaired) + } + return client.AddSubtitlesByURL(runCtx, c.IDs, c.URL, c.Language, c.Title, c.MediaItemID, c.Format, c.Forced, c.HearingImpaired) + }) +} + +func (c *LibraryArtworkGetCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + encodedIDs, err := encodeCSVArg(c.IDs) + if err != nil { + return err + } + return downloadLibraryFile(cfg, u, "GetItemArtwork", fmt.Sprintf("library/metadata/%s/%s/%d", encodedIDs, c.Element, c.Timestamp), nil, c.Output) +} + +func (c *LibraryArtworkSetCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "artwork-set", c.IDs, "artwork set", func(runCtx context.Context, client *plexclient.Client) error { + return client.SetItemArtworkByURL(runCtx, c.IDs, c.Element, c.URL) + }) +} + +func (c *LibraryArtworkUpdateCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "artwork-update", c.IDs, "artwork updated", func(runCtx context.Context, client *plexclient.Client) error { + return client.UpdateItemArtworkByURL(runCtx, c.IDs, c.Element, c.URL) + }) +} + +func (c *LibraryDetectAdsCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "detect-ads", c.IDs, "ad detection requested", func(runCtx context.Context, client *plexclient.Client) error { + return client.DetectMetadataAds(runCtx, c.IDs) + }) +} + +func (c *LibraryDetectCreditsCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "detect-credits", c.IDs, "credit detection requested", func(runCtx context.Context, client *plexclient.Client) error { + return client.DetectMetadataCredits(runCtx, c.IDs, c.Force, c.Manual) + }) +} + +func (c *LibraryDetectIntrosCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "detect-intros", c.IDs, "intro detection requested", func(runCtx context.Context, client *plexclient.Client) error { + return client.DetectMetadataIntros(runCtx, c.IDs, c.Force, c.Threshold) + }) +} + +func (c *LibraryDetectDeleteIntrosCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := requireConfirmed(c.Yes, "delete-intros"); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "delete-intros", c.Section, "section intros deleted", func(runCtx context.Context, client *plexclient.Client) error { + return client.DeleteSectionIntros(runCtx, c.Section) + }) +} + +func (c *LibraryDetectVoiceCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "detect-voice", c.IDs, "voice activity detection requested", func(runCtx context.Context, client *plexclient.Client) error { + return client.DetectMetadataVoiceActivity(runCtx, c.IDs, c.Force, c.Manual) + }) +} + +func (c *LibraryDetectBIFCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "detect-bif", c.IDs, "BIF generation requested", func(runCtx context.Context, client *plexclient.Client) error { + return client.GenerateBIF(runCtx, c.IDs, c.Force) + }) +} + +func (c *LibraryDetectStatusCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetAugmentationStatus", fmt.Sprintf("library/metadata/augmentations/%s", url.PathEscape(c.AugmentationID)), nil) +} + +func (c *LibraryDiscoverTagsCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + query := url.Values{} + query.Set("type", strconv.FormatInt(librarySectionTypeID(c.Type), 10)) + return runLibraryJSONCommand(u, cfg, c.Output, "GetTags", "library/tags", query) +} + +func (c *LibraryDiscoverCollectionsCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetCollections", fmt.Sprintf("library/sections/%s/collection", url.PathEscape(c.Section)), nil) +} + +func (c *LibraryDiscoverCommonCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runWithClient(cfg, func(runCtx context.Context, client *plexclient.Client) error { + sectionTypeID, err := sectionTypeIDForLibrary(runCtx, client, c.Section) + if err != nil { + return err + } + query := url.Values{} + query.Set("type", strconv.FormatInt(sectionTypeID, 10)) + + payload, err := client.LibraryJSON(runCtx, "GetCommon", http.MethodGet, fmt.Sprintf("library/sections/%s/common", url.PathEscape(c.Section)), query) + if err != nil { + return err + } + return outputGenericPayload(u.Out(), c.Output, payload) + }) +} + +func (c *LibraryDiscoverRelatedCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataJSON(u, cfg, c.Output, "GetRelatedItems", c.IDs, "/related", nil) +} + +func (c *LibraryDiscoverSimilarCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataJSON(u, cfg, c.Output, "ListSimilar", c.IDs, "/similar", nil) +} + +func (c *LibraryDiscoverSonicCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataJSON(u, cfg, c.Output, "ListSonicallySimilar", c.IDs, "/nearest", nil) +} + +func (c *LibraryDiscoverAutocompleteCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runWithClient(cfg, func(runCtx context.Context, client *plexclient.Client) error { + sectionTypeID, err := sectionTypeIDForLibrary(runCtx, client, c.Section) + if err != nil { + return err + } + query := url.Values{} + query.Set("type", strconv.FormatInt(sectionTypeID, 10)) + query.Set(fmt.Sprintf("%s.query", c.Field), c.Query) + + payload, err := client.LibraryJSON(runCtx, "Autocomplete", http.MethodGet, fmt.Sprintf("library/sections/%s/autocomplete", url.PathEscape(c.Section)), query) + if err != nil { + return err + } + return outputGenericPayload(u.Out(), c.Output, payload) + }) +} + +func (c *LibraryDiscoverMatchesCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataJSON(u, cfg, c.Output, "GetLibraryMatches", c.IDs, "/matches", nil) +} + +func (c *LibraryDiscoverRandomArtCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetRandomArtwork", "library/randomArtwork", nil) +} + +func (c *LibraryPersonShowCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetPerson", fmt.Sprintf("library/people/%s", url.PathEscape(c.Person)), nil) +} + +func (c *LibraryPersonMediaCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "ListPersonMedia", fmt.Sprintf("library/people/%s/media", url.PathEscape(c.Person)), nil) +} + +func (c *LibraryMediaExtrasCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataJSON(u, cfg, c.Output, "GetExtras", c.IDs, "/extras", nil) +} + +func (c *LibraryMediaFileCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + if strings.HasPrefix(c.URL, "/") { + return downloadLibraryFile(cfg, u, "GetFile", strings.TrimPrefix(c.URL, "/"), nil, c.Output) + } + encodedIDs, err := encodeCSVArg(c.IDs) + if err != nil { + return err + } + query := url.Values{} + if c.URL != "" { + query.Set("url", c.URL) + } + return downloadLibraryFile(cfg, u, "GetFile", fmt.Sprintf("library/metadata/%s/file", encodedIDs), query, c.Output) +} + +func (c *LibraryMediaTreeCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataJSON(u, cfg, c.Output, "GetItemTree", c.IDs, "/tree", nil) +} + +func (c *LibraryMediaLeavesCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runMetadataJSON(u, cfg, c.Output, "GetAllItemLeaves", c.IDs, "/allLeaves", nil) +} + +func (c *LibraryMediaPartCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + query := url.Values{} + if c.Download { + query.Set("download", "1") + } + return downloadLibraryFile(cfg, u, "GetMediaPart", fmt.Sprintf("library/parts/%d/%d/%s", c.PartID, c.Changestamp, url.PathEscape(c.Filename)), query, c.Output) +} + +func (c *LibraryMediaPartIndexCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + return runWithClient(cfg, func(runCtx context.Context, client *plexclient.Client) error { + var interval *int64 + if c.Interval > 0 { + interval = &c.Interval + } + result, err := client.DownloadPartIndex(runCtx, c.PartID, c.Index, interval, c.Output) + if err != nil { + return err + } + return outputBinaryResult(u.Out(), "json", result) + }) +} + +func (c *LibraryMediaStreamGetCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + query := url.Values{} + if c.Encoding != "" { + query.Set("encoding", c.Encoding) + } + if c.Format != "" { + query.Set("format", c.Format) + } + if c.AutoAdjust { + query.Set("autoAdjustSubtitle", "1") + } + return downloadLibraryFile(cfg, u, "GetStream", fmt.Sprintf("library/streams/%d.%s", c.StreamID, c.Ext), query, c.Output) +} + +func (c *LibraryMediaStreamDeleteCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := requireConfirmed(c.Yes, "stream delete"); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "stream-delete", strconv.FormatInt(c.StreamID, 10), "stream deleted", func(runCtx context.Context, client *plexclient.Client) error { + return client.DeleteStreamByID(runCtx, c.StreamID, c.Ext) + }) +} + +func (c *LibraryMediaStreamOffsetCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runSimpleClientAction(cfg, u, c.Output, "stream-offset", strconv.FormatInt(c.StreamID, 10), "stream offset updated", func(runCtx context.Context, client *plexclient.Client) error { + return client.SetStreamOffsetByID(runCtx, c.StreamID, c.Ext, c.Offset) + }) +} + +func (c *LibraryMediaLevelsCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetStreamLevels", fmt.Sprintf("library/streams/%d/levels", c.StreamID), nil) +} + +func (c *LibraryMediaLoudnessCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetStreamLoudness", fmt.Sprintf("library/streams/%d/loudness", c.StreamID), nil) +} + +func (c *LibraryMediaMarkerCreateCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + attrs, err := parseKeyValueFlags(c.Attributes) + if err != nil && len(c.Attributes) > 0 { + return err + } + var end *int64 + if c.End > 0 { + end = &c.End + } + return runSimpleClientAction(cfg, u, c.Output, "marker-create", c.IDs, "marker created", func(runCtx context.Context, client *plexclient.Client) error { + return client.CreateMarkerDynamic(runCtx, c.IDs, c.Type, c.Start, end, attrs) + }) +} + +func (c *LibraryMediaMarkerEditCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + attrs, err := parseKeyValueFlags(c.Attributes) + if err != nil && len(c.Attributes) > 0 { + return err + } + var end *int64 + if c.End > 0 { + end = &c.End + } + return runSimpleClientAction(cfg, u, c.Output, "marker-edit", c.Marker, "marker updated", func(runCtx context.Context, client *plexclient.Client) error { + return client.EditMarkerDynamic(runCtx, c.IDs, c.Marker, c.Type, c.Start, end, attrs) + }) +} + +func (c *LibraryMediaMarkerDeleteCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := requireConfirmed(c.Yes, "marker delete"); err != nil { + return err + } + return runSimpleClientAction(cfg, u, c.Output, "marker-delete", c.Marker, "marker deleted", func(runCtx context.Context, client *plexclient.Client) error { + return client.DeleteMarkerByID(runCtx, c.IDs, c.Marker) + }) +} + +func (c *LibraryMediaChapterImageCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return downloadLibraryFile(cfg, u, "GetChapterImage", fmt.Sprintf("library/media/%d/chapterImages/%d", c.MediaID, c.Chapter), nil, c.Output) +} + +func (c *LibraryMediaItemArtworkCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + encodedIDs, err := encodeCSVArg(c.IDs) + if err != nil { + return err + } + return downloadLibraryFile(cfg, u, "GetItemArtwork", fmt.Sprintf("library/metadata/%s/%s/%d", encodedIDs, c.Element, c.Timestamp), nil, c.Output) +} + +func (c *LibraryMediaSectionImageCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return downloadLibraryFile(cfg, u, "GetSectionImage", fmt.Sprintf("library/sections/%s/composite/%d", url.PathEscape(c.Section), c.UpdatedAt), nil, c.Output) +} + +func (c *LibraryMediaBIFImageCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return downloadLibraryFile(cfg, u, "GetImageFromBif", fmt.Sprintf("library/parts/%d/indexes/%s/%s", c.PartID, url.PathEscape(c.Index), url.PathEscape(c.Offset)), nil, c.Output) +} + +func runSimpleClientAction(cfg *config.Config, u *ui.UI, output, action, target, outcome string, fn func(context.Context, *plexclient.Client) error) error { + return runWithClient(cfg, func(runCtx context.Context, client *plexclient.Client) error { + if err := fn(runCtx, client); err != nil { + return err + } + return runLibraryMutationSummary(u, output, plexclient.LibraryMutationSummary{ + Action: action, + Target: target, + Outcome: outcome, + }) + }) +} + +func runMetadataJSON(u *ui.UI, cfg *config.Config, output, op, ids, suffix string, query url.Values) error { + encodedIDs, err := encodeCSVArg(ids) + if err != nil { + return err + } + return runLibraryJSONCommand(u, cfg, output, op, fmt.Sprintf("library/metadata/%s%s", encodedIDs, suffix), query) +} + +func runMetadataPathAction(cfg *config.Config, u *ui.UI, output, ids, op string, method string, suffix string, action string, outcome string) error { + encodedIDs, err := encodeCSVArg(ids) + if err != nil { + return err + } + return runSimpleClientAction(cfg, u, output, action, ids, outcome, func(runCtx context.Context, client *plexclient.Client) error { + return client.LibraryAction(runCtx, op, method, fmt.Sprintf("library/metadata/%s%s", encodedIDs, suffix), nil) + }) +} + +func downloadLibraryFile(cfg *config.Config, u *ui.UI, op, path string, query url.Values, outputPath string) error { + if err := requireOutputPath(outputPath); err != nil { + return err + } + return runWithClient(cfg, func(runCtx context.Context, client *plexclient.Client) error { + result, err := client.LibraryDownload(runCtx, op, http.MethodGet, path, query, outputPath) + if err != nil { + return err + } + return outputBinaryResult(u.Out(), "json", result) + }) +} + +func (c *LibrarySubtitleAddCmd) validate() error { + if (c.URL == "" && c.File == "") || (c.URL != "" && c.File != "") { + return fmt.Errorf("specify exactly one of --url or --file") + } + return nil +} + +func (c *LibraryArtworkGetCmd) validate() error { + return requireOutputPath(c.Output) +} + +func (c *LibraryMediaFileCmd) validate() error { + if err := requireOutputPath(c.Output); err != nil { + return err + } + if strings.TrimSpace(c.URL) == "" { + return fmt.Errorf("--url is required") + } + if strings.HasPrefix(c.URL, "/") { + return nil + } + if strings.TrimSpace(c.IDs) == "" { + return fmt.Errorf("ids are required unless --url is a direct Plex library path") + } + return nil +} + +func (c *LibraryMediaPartIndexCmd) validate() error { + return requireOutputPath(c.Output) +} + +func (c *LibraryMediaStreamGetCmd) validate() error { + return requireOutputPath(c.Output) +} diff --git a/internal/cmd/library_helpers.go b/internal/cmd/library_helpers.go new file mode 100644 index 0000000..b787e51 --- /dev/null +++ b/internal/cmd/library_helpers.go @@ -0,0 +1,280 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + + "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 BinaryDownloadItem struct { + Action string `json:"action"` + Target string `json:"target"` + OutputPath string `json:"output_path"` + ContentType string `json:"content_type"` + Bytes int64 `json:"bytes"` +} + +func parseKeyValueFlags(values []string) (map[string]string, error) { + if len(values) == 0 { + return map[string]string{}, nil + } + return plexclient.ParseKeyValuePairs(values) +} + +func parseMultiValueFlags(values []string) (map[string][]string, error) { + if len(values) == 0 { + return map[string][]string{}, nil + } + return plexclient.ParseMultiValuePairs(values) +} + +func requireConfirmed(yes bool, action string) error { + return plexclient.RequireConfirmation(yes, action) +} + +func requireOutputPath(output string) error { + return plexclient.RequireOutputPath(output) +} + +func outputBinaryResult(w io.Writer, format string, result *plexclient.LibraryBinaryResult) error { + if result == nil { + return fmt.Errorf("binary result is required") + } + + item := BinaryDownloadItem{ + Action: result.Action, + Target: result.Target, + OutputPath: result.OutputPath, + ContentType: result.ContentType, + Bytes: result.Bytes, + } + + header := []string{"ACTION", "TARGET", "OUTPUT", "CONTENT TYPE", "BYTES"} + rows := [][]string{{ + item.Action, + item.Target, + item.OutputPath, + item.ContentType, + strconv.FormatInt(item.Bytes, 10), + }} + + return outfmt.NewFormatter(outfmt.Format(format)).Format(w, header, rows, []BinaryDownloadItem{item}) +} + +func outputGenericPayload(w io.Writer, format string, data any) error { + formatter := outfmt.NewFormatter(outfmt.Format(format)) + header, rows := genericRows(data) + return formatter.Format(w, header, rows, data) +} + +func genericRows(data any) ([]string, [][]string) { + records := extractRecords(data) + if len(records) == 0 { + return []string{"VALUE"}, [][]string{} + } + + keys := orderedKeys(records) + rows := make([][]string, 0, len(records)) + for _, record := range records { + row := make([]string, 0, len(keys)) + for _, key := range keys { + row = append(row, record[key]) + } + rows = append(rows, row) + } + + header := make([]string, 0, len(keys)) + for _, key := range keys { + header = append(header, strings.ToUpper(strings.ReplaceAll(key, "_", " "))) + } + return header, rows +} + +func extractRecords(data any) []map[string]string { + switch v := data.(type) { + case map[string]any: + if container, ok := v["MediaContainer"]; ok { + return extractRecords(container) + } + for _, key := range []string{"Metadata", "Directory", "Setting", "Media", "Part", "Stream", "Marker"} { + if list, ok := v[key]; ok { + return extractRecords(list) + } + } + return []map[string]string{flattenRecord(v)} + case []any: + out := make([]map[string]string, 0, len(v)) + for _, item := range v { + switch entry := item.(type) { + case map[string]any: + out = append(out, flattenRecord(entry)) + default: + out = append(out, map[string]string{"value": compactJSON(entry)}) + } + } + return out + default: + return []map[string]string{{"value": compactJSON(v)}} + } +} + +func flattenRecord(record map[string]any) map[string]string { + if len(record) == 0 { + return map[string]string{"value": "{}"} + } + + ordered := []string{ + "ratingKey", "key", "id", "tag", "title", "type", "year", "index", + "parentTitle", "grandparentTitle", "summary", "value", "default", + "label", "group", "filter", "thumb", "art", "updatedAt", + } + flat := map[string]string{} + for _, key := range ordered { + if value, ok := record[key]; ok { + flat[normalizeKey(key)] = stringifyValue(value) + } + } + + if len(flat) == 0 { + flat["value"] = compactJSON(record) + } + return flat +} + +func orderedKeys(records []map[string]string) []string { + priority := []string{ + "rating_key", "key", "id", "tag", "title", "type", "year", "index", + "parent_title", "grandparent_title", "label", "value", "default", + "summary", "filter", "thumb", "art", "updated_at", + } + + seen := map[string]struct{}{} + keys := make([]string, 0) + for _, key := range priority { + for _, record := range records { + if _, ok := record[key]; ok { + if _, exists := seen[key]; !exists { + keys = append(keys, key) + seen[key] = struct{}{} + } + break + } + } + } + + extras := make([]string, 0) + for _, record := range records { + for key := range record { + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + extras = append(extras, key) + } + } + sort.Strings(extras) + return append(keys, extras...) +} + +func normalizeKey(key string) string { + var builder strings.Builder + for i, r := range key { + if i > 0 && r >= 'A' && r <= 'Z' { + builder.WriteByte('_') + } + builder.WriteRune(r) + } + return strings.ToLower(builder.String()) +} + +func stringifyValue(v any) string { + switch value := v.(type) { + case nil: + return "" + case string: + return value + case bool: + return strconv.FormatBool(value) + case float64: + if value == float64(int64(value)) { + return strconv.FormatInt(int64(value), 10) + } + return strconv.FormatFloat(value, 'f', -1, 64) + case []any: + parts := make([]string, 0, len(value)) + for _, item := range value { + parts = append(parts, stringifyValue(item)) + } + return strings.Join(parts, ", ") + case map[string]any: + return compactJSON(value) + default: + return fmt.Sprintf("%v", value) + } +} + +func compactJSON(v any) string { + body, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(body) +} + +func encodeCSVArg(input string) (string, error) { + return plexclient.EncodeMetadataInput(input) +} + +func runLibraryJSONCommand(u *ui.UI, cfg *config.Config, output string, op, path string, query url.Values) error { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + payload, err := cc.Client.LibraryJSON(cc.Ctx, op, http.MethodGet, path, query) + if err != nil { + return err + } + return outputGenericPayload(u.Out(), output, payload) +} + +func runLibraryMutationSummary(u *ui.UI, output string, summary plexclient.LibraryMutationSummary) error { + results := []LibraryActionResult{{ + Action: summary.Action, + ID: summary.Target, + Title: summary.Target, + Outcome: summary.Outcome, + }} + return outputLibraryActionResults(u.Out(), output, results) +} + +func sectionTypeIDForLibrary(ctx context.Context, client *plexclient.Client, sectionID string) (int64, error) { + sections, err := client.GetSections(ctx) + if err != nil { + return 0, fmt.Errorf("resolve section %s type: %w", sectionID, err) + } + + for _, section := range sections { + if section.ID == sectionID { + sectionTypeID, ok := librarySectionTypeIDValue(section.Type) + if !ok { + return 0, fmt.Errorf("resolve section %s type: unsupported section type %q", sectionID, section.Type) + } + return sectionTypeID, nil + } + } + + return 0, fmt.Errorf("resolve section %s type: section not found", sectionID) +} diff --git a/internal/cmd/library_helpers_test.go b/internal/cmd/library_helpers_test.go new file mode 100644 index 0000000..1c51213 --- /dev/null +++ b/internal/cmd/library_helpers_test.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/dogbertdev/plexcli/internal/plexclient" + "github.com/dogbertdev/plexcli/internal/ui" +) + +func TestParseKeyValueFlags(t *testing.T) { + got, err := parseKeyValueFlags([]string{"title=Movie", "summary=Hello"}) + if err != nil { + t.Fatalf("parseKeyValueFlags() error = %v", err) + } + if got["title"] != "Movie" || got["summary"] != "Hello" { + t.Fatalf("unexpected parseKeyValueFlags() result: %#v", got) + } +} + +func TestParseMultiValueFlags(t *testing.T) { + got, err := parseMultiValueFlags([]string{"genre=Action", "genre=Drama", "collection=Favorites"}) + if err != nil { + t.Fatalf("parseMultiValueFlags() error = %v", err) + } + if len(got["genre"]) != 2 || got["genre"][0] != "Action" || got["genre"][1] != "Drama" { + t.Fatalf("unexpected genre values: %#v", got["genre"]) + } + if len(got["collection"]) != 1 || got["collection"][0] != "Favorites" { + t.Fatalf("unexpected collection values: %#v", got["collection"]) + } +} + +func TestGenericRowsForMediaContainerMetadata(t *testing.T) { + header, rows := genericRows(map[string]any{ + "MediaContainer": map[string]any{ + "Metadata": []any{ + map[string]any{ + "ratingKey": "42", + "title": "Movie", + "type": "movie", + "year": float64(2024), + }, + }, + }, + }) + + if len(header) == 0 || len(rows) != 1 { + t.Fatalf("unexpected genericRows() output: header=%v rows=%v", header, rows) + } + if rows[0][0] != "42" { + t.Fatalf("expected rating key in first column, got %v", rows[0]) + } +} + +func TestOutputBinaryResultJSON(t *testing.T) { + var out bytes.Buffer + result := &plexclient.LibraryBinaryResult{ + Action: "GetItemArtwork", + Target: "library/metadata/1/poster/0", + OutputPath: "/tmp/poster.jpg", + Bytes: 128, + } + + if err := outputBinaryResult(&out, "json", result); err != nil { + t.Fatalf("outputBinaryResult() error = %v", err) + } + if !strings.Contains(out.String(), `"output_path": "/tmp/poster.jpg"`) { + t.Fatalf("unexpected outputBinaryResult() JSON: %s", out.String()) + } +} + +func TestRunLibraryMutationSummaryTable(t *testing.T) { + var out bytes.Buffer + ui := ui.New(ui.Options{Out: &out, Err: &out}) + err := runLibraryMutationSummary(ui, "table", plexclient.LibraryMutationSummary{ + Action: "item-edit", + Target: "1,2", + Outcome: "items updated", + }) + if err != nil { + t.Fatalf("runLibraryMutationSummary() error = %v", err) + } + if !strings.Contains(out.String(), "items updated") { + t.Fatalf("expected summary output, got %s", out.String()) + } +} + +func TestRequireConfirmed(t *testing.T) { + if err := requireConfirmed(false, "delete"); err == nil { + t.Fatalf("expected requireConfirmed() error") + } +} + +func TestRequireOutputPath(t *testing.T) { + if err := requireOutputPath(""); err == nil { + t.Fatalf("expected requireOutputPath() error") + } +} + +func TestSectionTypeIDForLibrary(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/library/sections" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(``)) + })) + defer server.Close() + + client, err := plexclient.NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + sectionTypeID, err := sectionTypeIDForLibrary(context.Background(), client, "10") + if err != nil { + t.Fatalf("sectionTypeIDForLibrary() error = %v", err) + } + if sectionTypeID != 2 { + t.Fatalf("expected show type ID 2, got %d", sectionTypeID) + } +} + +func TestSectionTypeIDForLibraryMapsArtistSectionsToMusicType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/library/sections" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(``)) + })) + defer server.Close() + + client, err := plexclient.NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + sectionTypeID, err := sectionTypeIDForLibrary(context.Background(), client, "7") + if err != nil { + t.Fatalf("sectionTypeIDForLibrary() error = %v", err) + } + if sectionTypeID != 8 { + t.Fatalf("expected artist section to map to music type ID 8, got %d", sectionTypeID) + } +} + +func TestSectionTypeIDForLibraryFailsForUnknownSection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/library/sections" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/xml") + _, _ = w.Write([]byte(``)) + })) + defer server.Close() + + client, err := plexclient.NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + _, err = sectionTypeIDForLibrary(context.Background(), client, "99") + if err == nil { + t.Fatalf("expected sectionTypeIDForLibrary() error") + } + if !strings.Contains(err.Error(), "section not found") { + t.Fatalf("unexpected sectionTypeIDForLibrary() error: %v", err) + } +} + +func TestSectionLettersPath(t *testing.T) { + got := sectionLettersPath("10") + want := "library/sections/10/firstCharacters" + if got != want { + t.Fatalf("sectionLettersPath() = %q, want %q", got, want) + } +} diff --git a/internal/cmd/library_sections.go b/internal/cmd/library_sections.go new file mode 100644 index 0000000..a618ea9 --- /dev/null +++ b/internal/cmd/library_sections.go @@ -0,0 +1,368 @@ +package cmd + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/alecthomas/kong" + + "github.com/dogbertdev/plexcli/internal/config" + "github.com/dogbertdev/plexcli/internal/plexclient" + "github.com/dogbertdev/plexcli/internal/ui" +) + +type LibrarySectionCmd struct { + Show LibrarySectionShowCmd `cmd:"" help:"Show details for a library section"` + Create LibrarySectionCreateCmd `cmd:"" help:"Create a new library section"` + Edit LibrarySectionEditCmd `cmd:"" help:"Edit a library section"` + Delete LibrarySectionDeleteCmd `cmd:"" help:"Delete a library section"` + Prefs LibrarySectionPrefsCmd `cmd:"" help:"View or update section preferences"` + Filters LibrarySectionFiltersCmd `cmd:"" help:"List available filters for a section"` + Sorts LibrarySectionSortsCmd `cmd:"" help:"List available sorts for a section"` + Letters LibrarySectionLettersCmd `cmd:"" help:"List first-character buckets for a section"` + Image LibrarySectionImageCmd `cmd:"" help:"Download the section composite image"` +} + +type LibrarySectionShowCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionCreateCmd struct { + Name string `arg:"" help:"Library section name"` + Type string `help:"Library type: movie, show, music, photo" required:"" enum:"movie,show,music,photo"` + Agent string `help:"Metadata agent identifier" required:""` + Scanner string `help:"Scanner identifier"` + Language string `help:"Section language (for example en-US)" default:"en-US"` + Locations []string `help:"Library location on disk" required:"" name:"location"` + Prefs []string `help:"Preference override in key=value form" name:"pref"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionEditCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Name string `help:"Updated section name"` + Agent string `help:"Updated metadata agent identifier"` + Scanner string `help:"Updated scanner identifier"` + Language string `help:"Updated section language"` + Locations []string `help:"Updated library locations" name:"location"` + Prefs []string `help:"Preference override in key=value form" name:"pref"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionDeleteCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Yes bool `help:"Confirm deletion" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionPrefsCmd struct { + List LibrarySectionPrefsListCmd `cmd:"" help:"List section preferences"` + Set LibrarySectionPrefsSetCmd `cmd:"" help:"Set section preferences"` + Defaults LibrarySectionPrefsDefaultsCmd `cmd:"" help:"List default prefs for a metadata type"` +} + +type LibrarySectionPrefsListCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionPrefsSetCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Prefs []string `help:"Preference override in key=value form" name:"pref" required:""` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionPrefsDefaultsCmd struct { + Type string `help:"Metadata type: movie, show, music, photo" required:"" enum:"movie,show,music,photo"` + Agent string `help:"Metadata agent identifier"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionFiltersCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionSortsCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionLettersCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibrarySectionImageCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + UpdatedAt int64 `help:"Section updated-at timestamp used for cache busting" default:"0"` + Output string `help:"Destination file path" type:"path" required:""` +} + +type LibraryRefreshCmd struct { + Cancel LibraryRefreshCancelCmd `cmd:"" help:"Cancel an active section refresh"` + StopAll LibraryRefreshStopAllCmd `cmd:"" name:"stop-all" help:"Stop all active refreshes"` +} + +type LibraryRefreshCancelCmd struct { + Section string `arg:"" name:"section" help:"Library section ID"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +type LibraryRefreshStopAllCmd struct { + Yes bool `help:"Confirm stopping all refreshes" name:"yes"` + Output string `help:"Output format: table, json, or tsv" default:"table" enum:"table,json,tsv"` +} + +func (c *LibrarySectionShowCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetLibraryDetails", fmt.Sprintf("library/sections/%s", url.PathEscape(c.Section)), nil) +} + +func (c *LibrarySectionCreateCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + prefs, err := parseKeyValueFlags(c.Prefs) + if err != nil { + return err + } + + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + if err := cc.Client.CreateSection(cc.Ctx, plexclient.SectionMutationInput{ + Name: c.Name, + Type: librarySectionTypeID(c.Type), + Agent: c.Agent, + Scanner: c.Scanner, + Language: c.Language, + Locations: c.Locations, + Prefs: prefs, + }); err != nil { + return err + } + + return runLibraryMutationSummary(u, c.Output, plexclient.LibraryMutationSummary{ + Action: "section-create", + Target: c.Name, + Outcome: "section created", + }) +} + +func (c *LibrarySectionEditCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + prefs, err := parseKeyValueFlags(c.Prefs) + if err != nil { + return err + } + if c.Name == "" && c.Agent == "" && c.Scanner == "" && c.Language == "" && len(c.Locations) == 0 && len(prefs) == 0 { + return fmt.Errorf("provide at least one change flag") + } + + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + if err := cc.Client.EditSection(cc.Ctx, c.Section, plexclient.SectionMutationInput{ + Name: c.Name, + Agent: c.Agent, + Scanner: c.Scanner, + Language: c.Language, + Locations: c.Locations, + Prefs: prefs, + }); err != nil { + return err + } + + return runLibraryMutationSummary(u, c.Output, plexclient.LibraryMutationSummary{ + Action: "section-edit", + Target: c.Section, + Outcome: "section updated", + }) +} + +func (c *LibrarySectionDeleteCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + if err := cc.Client.DeleteSection(cc.Ctx, c.Section); err != nil { + return err + } + + return runLibraryMutationSummary(u, c.Output, plexclient.LibraryMutationSummary{ + Action: "section-delete", + Target: c.Section, + Outcome: "section deleted", + }) +} + +func (c *LibrarySectionPrefsListCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetSectionPreferences", fmt.Sprintf("library/sections/%s/prefs", url.PathEscape(c.Section)), nil) +} + +func (c *LibrarySectionPrefsSetCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + prefs, err := parseKeyValueFlags(c.Prefs) + if err != nil { + return err + } + + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + if err := cc.Client.SetSectionPreferencesDynamic(cc.Ctx, c.Section, prefs); err != nil { + return err + } + + return runLibraryMutationSummary(u, c.Output, plexclient.LibraryMutationSummary{ + Action: "section-prefs-set", + Target: c.Section, + Outcome: "preferences updated", + }) +} + +func (c *LibrarySectionPrefsDefaultsCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + payload, err := cc.Client.GetSectionsPrefs(cc.Ctx, librarySectionTypeID(c.Type), c.Agent) + if err != nil { + return err + } + return outputGenericPayload(u.Out(), c.Output, payload) +} + +func (c *LibrarySectionFiltersCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetSectionFilters", fmt.Sprintf("library/sections/%s/filters", url.PathEscape(c.Section)), nil) +} + +func (c *LibrarySectionSortsCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetAvailableSorts", fmt.Sprintf("library/sections/%s/sorts", url.PathEscape(c.Section)), nil) +} + +func (c *LibrarySectionLettersCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + return runLibraryJSONCommand(u, cfg, c.Output, "GetFirstCharacters", sectionLettersPath(c.Section), nil) +} + +func (c *LibrarySectionImageCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + result, err := cc.Client.LibraryDownload( + cc.Ctx, + "GetSectionImage", + "GET", + fmt.Sprintf("library/sections/%s/composite/%d", url.PathEscape(c.Section), c.UpdatedAt), + nil, + c.Output, + ) + if err != nil { + return err + } + return outputBinaryResult(u.Out(), "json", result) +} + +func (c *LibraryRefreshCancelCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + if err := cc.Client.CancelRefresh(cc.Ctx, c.Section); err != nil { + return err + } + return runLibraryMutationSummary(u, c.Output, plexclient.LibraryMutationSummary{ + Action: "refresh-cancel", + Target: c.Section, + Outcome: "refresh canceled", + }) +} + +func (c *LibraryRefreshStopAllCmd) Run(ctx *kong.Context, u *ui.UI, cfg *config.Config) error { + if err := c.validate(); err != nil { + return err + } + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + + if err := cc.Client.StopAllRefreshes(cc.Ctx); err != nil { + return err + } + return runLibraryMutationSummary(u, c.Output, plexclient.LibraryMutationSummary{ + Action: "refresh-stop-all", + Target: "all", + Outcome: "all refreshes stopped", + }) +} + +func librarySectionTypeID(value string) int64 { + sectionTypeID, _ := librarySectionTypeIDValue(value) + return sectionTypeID +} + +func sectionLettersPath(section string) string { + return fmt.Sprintf("library/sections/%s/firstCharacters", url.PathEscape(section)) +} + +func librarySectionTypeIDValue(value string) (int64, bool) { + switch strings.ToLower(value) { + case "movie": + return 1, true + case "show": + return 2, true + case "music", "artist": + return 8, true + case "photo": + return 13, true + default: + return 0, false + } +} + +func (c *LibrarySectionDeleteCmd) validate() error { + return requireConfirmed(c.Yes, "section delete") +} + +func (c *LibrarySectionImageCmd) validate() error { + return requireOutputPath(c.Output) +} + +func (c *LibraryRefreshStopAllCmd) validate() error { + return requireConfirmed(c.Yes, "refresh stop-all") +} + +func runWithClient(cfg *config.Config, fn func(context.Context, *plexclient.Client) error) error { + cc, err := NewClientContext(cfg) + if err != nil { + return err + } + defer cc.Cancel() + return fn(cc.Ctx, cc.Client) +} diff --git a/internal/cmd/library_validation_test.go b/internal/cmd/library_validation_test.go new file mode 100644 index 0000000..f9ce2da --- /dev/null +++ b/internal/cmd/library_validation_test.go @@ -0,0 +1,129 @@ +package cmd + +import "testing" + +func TestLibrarySubtitleAddCmdRequiresExactlyOneSource(t *testing.T) { + tests := []struct { + name string + cmd LibrarySubtitleAddCmd + wantErr bool + }{ + { + name: "missing url and file", + cmd: LibrarySubtitleAddCmd{IDs: "1"}, + wantErr: true, + }, + { + name: "both url and file", + cmd: LibrarySubtitleAddCmd{IDs: "1", URL: "https://example.com/en.srt", File: "/tmp/en.srt"}, + wantErr: true, + }, + { + name: "url only valid", + cmd: LibrarySubtitleAddCmd{IDs: "1", URL: "https://example.com/en.srt"}, + wantErr: false, + }, + { + name: "file only valid", + cmd: LibrarySubtitleAddCmd{IDs: "1", File: "/tmp/en.srt"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cmd.validate() + if (err != nil) != tt.wantErr { + t.Fatalf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestLibrarySectionDeleteCmdRequiresYes(t *testing.T) { + cmd := LibrarySectionDeleteCmd{Section: "1", Yes: false} + if err := cmd.validate(); err == nil { + t.Fatalf("expected validate() error without --yes") + } +} + +func TestLibraryRefreshStopAllCmdRequiresYes(t *testing.T) { + cmd := LibraryRefreshStopAllCmd{Yes: false} + if err := cmd.validate(); err == nil { + t.Fatalf("expected validate() error without --yes") + } +} + +func TestLibraryMediaBinaryCommandsRequireOutput(t *testing.T) { + tests := []struct { + name string + err error + }{ + { + name: "section image", + err: (&LibrarySectionImageCmd{}).validate(), + }, + { + name: "artwork get", + err: (&LibraryArtworkGetCmd{}).validate(), + }, + { + name: "media file", + err: (&LibraryMediaFileCmd{}).validate(), + }, + { + name: "part index", + err: (&LibraryMediaPartIndexCmd{}).validate(), + }, + { + name: "stream get", + err: (&LibraryMediaStreamGetCmd{}).validate(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.err == nil { + t.Fatalf("expected validate() error for %s", tt.name) + } + }) + } +} + +func TestLibraryMediaFileCmdValidationModes(t *testing.T) { + tests := []struct { + name string + cmd LibraryMediaFileCmd + wantErr bool + }{ + { + name: "missing url", + cmd: LibraryMediaFileCmd{Output: "/tmp/file"}, + wantErr: true, + }, + { + name: "direct plex path without ids", + cmd: LibraryMediaFileCmd{URL: "/library/metadata/27017/theme/1771256497", Output: "/tmp/file"}, + wantErr: false, + }, + { + name: "bundle url without ids", + cmd: LibraryMediaFileCmd{URL: "metadata://theme", Output: "/tmp/file"}, + wantErr: true, + }, + { + name: "bundle url with ids", + cmd: LibraryMediaFileCmd{IDs: "27017", URL: "metadata://theme", Output: "/tmp/file"}, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.cmd.validate() + if (err != nil) != tt.wantErr { + t.Fatalf("validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/plexclient/client.go b/internal/plexclient/client.go index 7f0b3aa..31c184c 100644 --- a/internal/plexclient/client.go +++ b/internal/plexclient/client.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "encoding/xml" + "errors" "fmt" "io" "math" @@ -16,6 +17,7 @@ import ( "sync" "time" + "github.com/LukeHagar/plexgo" "github.com/LukeHagar/plexgo/models/components" "github.com/dogbertdev/plexcli/internal/cache" @@ -51,6 +53,7 @@ type Client struct { serverURL string token string maxRetries int + sdk *plexgo.PlexAPI libraryCache *cache.LibraryPayloadCache libraryCacheTTL time.Duration libraryCacheRefresh bool @@ -108,6 +111,15 @@ func NewClient(serverURL, token string, opts ...ClientOption) (*Client, error) { opt(client) } + sdkOpts := []plexgo.SDKOption{ + plexgo.WithSecurity(token), + plexgo.WithServerURL(serverURL), + plexgo.WithAccepts(components.AcceptsApplicationJSON), + plexgo.WithClient(client.httpClient), + } + + client.sdk = plexgo.New(sdkOpts...) + return client, nil } @@ -133,10 +145,14 @@ func isRetryableError(err error) bool { if err == nil { return false } - if _, ok := err.(interface{ Temporary() bool }); ok { + + var temporaryErr interface{ Temporary() bool } + if errors.As(err, &temporaryErr) && temporaryErr.Temporary() { return true } - if _, ok := err.(interface{ Timeout() bool }); ok { + + var timeoutErr interface{ Timeout() bool } + if errors.As(err, &timeoutErr) && timeoutErr.Timeout() { return true } return false diff --git a/internal/plexclient/client_test.go b/internal/plexclient/client_test.go new file mode 100644 index 0000000..d0a0c25 --- /dev/null +++ b/internal/plexclient/client_test.go @@ -0,0 +1,51 @@ +package plexclient + +import ( + "context" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +type retryableTransportError struct{} + +func (retryableTransportError) Error() string { return "temporary transport failure" } +func (retryableTransportError) Temporary() bool { return true } +func (retryableTransportError) Timeout() bool { return true } + +func TestSDKBackedLibraryCallsUseSharedTransportAndRetries(t *testing.T) { + client, err := NewClient("http://127.0.0.1:1", "token", WithTimeout(50*time.Millisecond), WithMaxRetries(1)) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + attempts := 0 + client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + attempts++ + if attempts == 1 { + return nil, &url.Error{ + Op: req.Method, + URL: req.URL.String(), + Err: retryableTransportError{}, + } + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: http.StatusText(http.StatusOK), + Body: io.NopCloser(strings.NewReader(`{}`)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + Request: req, + }, nil + }) + + if err := client.StopAllRefreshes(context.Background()); err != nil { + t.Fatalf("StopAllRefreshes() error = %v", err) + } + if attempts != 2 { + t.Fatalf("expected shared transport with one retry, got %d attempts", attempts) + } +} diff --git a/internal/plexclient/library_service.go b/internal/plexclient/library_service.go new file mode 100644 index 0000000..1073c26 --- /dev/null +++ b/internal/plexclient/library_service.go @@ -0,0 +1,868 @@ +package plexclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/LukeHagar/plexgo/models/components" + "github.com/LukeHagar/plexgo/models/operations" +) + +type LibraryBinaryResult struct { + Action string `json:"action"` + Target string `json:"target"` + OutputPath string `json:"output_path"` + ContentType string `json:"content_type"` + Bytes int64 `json:"bytes"` +} + +type LibraryMutationSummary struct { + Action string `json:"action"` + Target string `json:"target"` + Outcome string `json:"outcome"` +} + +type SectionMutationInput struct { + Name string + Type int64 + Agent string + Scanner string + Language string + Locations []string + Prefs map[string]string +} + +type MetadataEditInput struct { + Set map[string]string + Lock []string + Unlock []string +} + +type BulkUpdateInput struct { + SectionID string + MediaType int64 + Filter string + Set map[string]string + Lock []string + AddTags map[string][]string + RemoveTags map[string][]string +} + +func ParseCSVList(input string) ([]string, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, fmt.Errorf("at least one ID is required") + } + + parts := strings.Split(input, ",") + values := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + return nil, fmt.Errorf("empty ID in %q", input) + } + values = append(values, part) + } + return values, nil +} + +func ParseKeyValuePairs(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one key=value pair is required") + } + + parsed := make(map[string]string, len(values)) + for _, value := range values { + key, val, ok := strings.Cut(value, "=") + if !ok { + return nil, fmt.Errorf("invalid key=value pair %q", value) + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if key == "" { + return nil, fmt.Errorf("invalid key=value pair %q", value) + } + parsed[key] = val + } + return parsed, nil +} + +func ParseMultiValuePairs(values []string) (map[string][]string, error) { + if len(values) == 0 { + return nil, fmt.Errorf("at least one key=value pair is required") + } + + parsed := make(map[string][]string, len(values)) + for _, value := range values { + key, val, ok := strings.Cut(value, "=") + if !ok { + return nil, fmt.Errorf("invalid key=value pair %q", value) + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + if key == "" { + return nil, fmt.Errorf("invalid key=value pair %q", value) + } + parsed[key] = append(parsed[key], val) + } + return parsed, nil +} + +func RequireConfirmation(confirmed bool, action string) error { + if confirmed { + return nil + } + return fmt.Errorf("%s requires --yes", action) +} + +func RequireOutputPath(outputPath string) error { + if strings.TrimSpace(outputPath) == "" { + return fmt.Errorf("--output is required") + } + return nil +} + +func EncodeMetadataInput(input string) (string, error) { + return encodeMetadataIDs(input) +} + +func (c *Client) LibraryJSON(ctx context.Context, op, method, path string, query url.Values) (any, error) { + body, err := c.libraryRequest(ctx, op, method, path, query, "application/json") + if err != nil { + return nil, err + } + + if len(body) == 0 { + return map[string]any{}, nil + } + + var payload any + if err := json.Unmarshal(body, &payload); err != nil { + return nil, &PlexError{Op: op, Err: fmt.Errorf("failed to decode JSON response: %w", err)} + } + + return payload, nil +} + +func (c *Client) LibraryAction(ctx context.Context, op, method, path string, query url.Values) error { + _, err := c.libraryRequest(ctx, op, method, path, query, "*/*") + return err +} + +func (c *Client) LibraryDownload(ctx context.Context, op, method, path string, query url.Values, outputPath string) (*LibraryBinaryResult, error) { + if err := RequireOutputPath(outputPath); err != nil { + return nil, err + } + + resp, err := c.libraryHTTP(ctx, op, method, path, query, "*/*") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if mkdirErr := os.MkdirAll(filepath.Dir(outputPath), 0o755); mkdirErr != nil { + return nil, &PlexError{Op: op, Err: fmt.Errorf("failed to create output directory: %w", mkdirErr)} + } + + file, err := os.Create(outputPath) + if err != nil { + return nil, &PlexError{Op: op, Err: fmt.Errorf("failed to create output file: %w", err)} + } + defer file.Close() + + written, err := io.Copy(file, resp.Body) + if err != nil { + return nil, &PlexError{Op: op, Err: fmt.Errorf("failed to write output file: %w", err)} + } + + return &LibraryBinaryResult{ + Action: op, + Target: path, + OutputPath: outputPath, + ContentType: resp.Header.Get("Content-Type"), + Bytes: written, + }, nil +} + +func (c *Client) DownloadPartIndex(ctx context.Context, partID int64, index string, interval *int64, outputPath string) (*LibraryBinaryResult, error) { + query := url.Values{} + if interval != nil { + query.Set("interval", strconv.FormatInt(*interval, 10)) + } + return c.LibraryDownload(ctx, "GetPartIndex", http.MethodGet, fmt.Sprintf("library/parts/%d/indexes/%s", partID, url.PathEscape(index)), query, outputPath) +} + +func (c *Client) CreateSection(ctx context.Context, input SectionMutationInput) error { + query := url.Values{} + query.Set("name", input.Name) + query.Set("type", strconv.FormatInt(input.Type, 10)) + query.Set("agent", input.Agent) + if input.Scanner != "" { + query.Set("scanner", input.Scanner) + } + if input.Language != "" { + query.Set("language", input.Language) + } + for _, location := range input.Locations { + if strings.TrimSpace(location) != "" { + query.Add("location", location) + query.Add("locations", location) + } + } + addDeepObject(query, "prefs", input.Prefs) + return c.LibraryAction(ctx, "AddSection", http.MethodPost, "library/sections", query) +} + +func (c *Client) EditSection(ctx context.Context, sectionID string, input SectionMutationInput) error { + resolvedInput, err := c.resolveSectionEditInput(ctx, sectionID, input) + if err != nil { + return err + } + + query := url.Values{} + if resolvedInput.Name != "" { + query.Set("name", resolvedInput.Name) + } + if resolvedInput.Agent != "" { + query.Set("agent", resolvedInput.Agent) + } + if resolvedInput.Scanner != "" { + query.Set("scanner", resolvedInput.Scanner) + } + if resolvedInput.Language != "" { + query.Set("language", resolvedInput.Language) + } + for _, location := range resolvedInput.Locations { + if strings.TrimSpace(location) != "" { + query.Add("locations", location) + } + } + addDeepObject(query, "prefs", resolvedInput.Prefs) + return c.LibraryAction(ctx, "EditSection", http.MethodPut, fmt.Sprintf("library/sections/%s", url.PathEscape(sectionID)), query) +} + +func (c *Client) SetSectionPreferencesDynamic(ctx context.Context, sectionID string, prefs map[string]string) error { + query := url.Values{} + for key, value := range prefs { + if strings.TrimSpace(key) != "" { + query.Set(key, value) + } + } + return c.LibraryAction(ctx, "SetSectionPreferences", http.MethodPut, fmt.Sprintf("library/sections/%s/prefs", url.PathEscape(sectionID)), query) +} + +func (c *Client) EditMetadataDynamic(ctx context.Context, ids string, input MetadataEditInput) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "EditMetadataItem", Err: err} + } + + query := url.Values{} + for key, value := range input.Set { + query.Set(fmt.Sprintf("%s.value", key), value) + } + for _, key := range input.Lock { + if strings.TrimSpace(key) != "" { + query.Set(fmt.Sprintf("%s.locked", key), "1") + } + } + for _, key := range input.Unlock { + if strings.TrimSpace(key) != "" { + query.Set(fmt.Sprintf("%s.locked", key), "0") + } + } + return c.LibraryAction(ctx, "EditMetadataItem", http.MethodPut, fmt.Sprintf("library/metadata/%s", encodedIDs), query) +} + +func (c *Client) SetItemPreferencesDynamic(ctx context.Context, ids string, prefs map[string]string) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "SetItemPreferences", Err: err} + } + + query := url.Values{} + for key, value := range prefs { + if strings.TrimSpace(key) != "" { + query.Set(key, value) + } + } + return c.LibraryAction(ctx, "SetItemPreferences", http.MethodPut, fmt.Sprintf("library/metadata/%s/prefs", encodedIDs), query) +} + +func (c *Client) UpdateItemsDynamic(ctx context.Context, input BulkUpdateInput) error { + query := url.Values{} + if input.MediaType > 0 { + query.Set("type", strconv.FormatInt(input.MediaType, 10)) + } + if input.Filter != "" { + query.Set("filters", input.Filter) + } + for key, value := range input.Set { + query.Set(fmt.Sprintf("%s.value", key), value) + } + for _, key := range input.Lock { + if strings.TrimSpace(key) != "" { + query.Set(fmt.Sprintf("%s.locked", key), "1") + } + } + for tagType, values := range input.AddTags { + for i, value := range values { + query.Set(fmt.Sprintf("%s[%d].tag.tag", tagType, i), value) + } + } + for tagType, values := range input.RemoveTags { + for _, value := range values { + query.Add(fmt.Sprintf("%s[].tag.tag-", tagType), value) + } + } + return c.LibraryAction(ctx, "UpdateItems", http.MethodPut, fmt.Sprintf("library/sections/%s/all", url.PathEscape(input.SectionID)), query) +} + +func (c *Client) AddSubtitlesByURL(ctx context.Context, ids, subtitleURL, language, title string, mediaItemID int64, format string, forced, hearingImpaired bool) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "AddSubtitles", Err: err} + } + + query := url.Values{} + query.Set("url", subtitleURL) + if language != "" { + query.Set("language", language) + } + if title != "" { + query.Set("title", title) + } + if mediaItemID > 0 { + query.Set("mediaItemID", strconv.FormatInt(mediaItemID, 10)) + } + if format != "" { + query.Set("format", format) + } + if forced { + query.Set("forced", "1") + } + if hearingImpaired { + query.Set("hearingImpaired", "1") + } + return c.LibraryAction(ctx, "AddSubtitles", http.MethodGet, fmt.Sprintf("library/metadata/%s/subtitles", encodedIDs), query) +} + +func (c *Client) AddSubtitlesFromFile(ctx context.Context, ids string, payload []byte, language, title string, mediaItemID int64, format string, forced, hearingImpaired bool) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "AddSubtitles", Err: err} + } + + query := url.Values{} + if language != "" { + query.Set("language", language) + } + if title != "" { + query.Set("title", title) + } + if mediaItemID > 0 { + query.Set("mediaItemID", strconv.FormatInt(mediaItemID, 10)) + } + if format != "" { + query.Set("format", format) + } + if forced { + query.Set("forced", "1") + } + if hearingImpaired { + query.Set("hearingImpaired", "1") + } + + return c.libraryActionWithBody(ctx, "AddSubtitles", http.MethodPost, fmt.Sprintf("library/metadata/%s/subtitles", encodedIDs), query, payload, subtitleUploadContentType(format, title)) +} + +func (c *Client) SetItemArtworkByURL(ctx context.Context, ids, element, assetURL string) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "SetItemArtwork", Err: err} + } + + query := url.Values{} + query.Set("url", assetURL) + return c.LibraryAction(ctx, "SetItemArtwork", http.MethodPut, fmt.Sprintf("library/metadata/%s/%s", encodedIDs, url.PathEscape(element)), query) +} + +func (c *Client) UpdateItemArtworkByURL(ctx context.Context, ids, element, assetURL string) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "UpdateItemArtwork", Err: err} + } + + query := url.Values{} + query.Set("url", assetURL) + return c.LibraryAction(ctx, "UpdateItemArtwork", http.MethodPut, fmt.Sprintf("library/metadata/%s/%s", encodedIDs, url.PathEscape(element)), query) +} + +func (c *Client) DeleteSection(ctx context.Context, sectionID string) error { + return c.runSDKAction(ctx, "DeleteLibrarySection", sectionID, func(ctx context.Context) error { + _, err := c.sdk.Library.DeleteLibrarySection(ctx, operations.DeleteLibrarySectionRequest{SectionID: sectionID}) + return err + }) +} + +func (c *Client) StopAllRefreshes(ctx context.Context) error { + return c.runSDKAction(ctx, "StopAllRefreshes", "", func(ctx context.Context) error { + _, err := c.sdk.Library.StopAllRefreshes(ctx) + return err + }) +} + +func (c *Client) GetSectionsPrefs(ctx context.Context, metadataType int64, agent string) (any, error) { + query := url.Values{} + query.Set("type", strconv.FormatInt(metadataType, 10)) + if strings.TrimSpace(agent) != "" { + query.Set("agent", agent) + } + return c.LibraryJSON(ctx, "GetSectionsPrefs", http.MethodGet, "library/sections/prefs", query) +} + +func (c *Client) resolveSectionEditInput(ctx context.Context, sectionID string, input SectionMutationInput) (SectionMutationInput, error) { + if input.Agent != "" && input.Scanner != "" { + return input, nil + } + + payload, err := c.LibraryJSON(ctx, "GetSections", http.MethodGet, "library/sections", nil) + if err != nil { + return input, err + } + + container, ok := payload.(map[string]any) + if !ok { + return input, &PlexError{Op: "EditSection", Section: sectionID, Err: fmt.Errorf("unexpected section payload shape")} + } + mediaContainer, ok := container["MediaContainer"].(map[string]any) + if !ok { + return input, &PlexError{Op: "EditSection", Section: sectionID, Err: fmt.Errorf("missing MediaContainer in section payload")} + } + directories, ok := mediaContainer["Directory"].([]any) + if !ok { + return input, &PlexError{Op: "EditSection", Section: sectionID, Err: fmt.Errorf("missing Directory list in section payload")} + } + + for _, entry := range directories { + directory, ok := entry.(map[string]any) + if !ok { + continue + } + key, _ := directory["key"].(string) + if key != sectionID { + continue + } + + if input.Agent == "" { + if agent, _ := directory["agent"].(string); agent != "" { + input.Agent = agent + } + } + if input.Scanner == "" { + if scanner, _ := directory["scanner"].(string); scanner != "" { + input.Scanner = scanner + } + } + return input, nil + } + + return input, &PlexError{Op: "EditSection", Section: sectionID, Err: fmt.Errorf("section not found")} +} + +func (c *Client) DeleteMetadata(ctx context.Context, ids string) error { + return c.runMetadataSDKAction(ctx, "DeleteMetadataItem", ids, func(ctx context.Context, encodedIDs string) error { + _, err := c.sdk.Library.DeleteMetadataItem(ctx, operations.DeleteMetadataItemRequest{Ids: encodedIDs}) + return err + }) +} + +func (c *Client) UnmatchMetadata(ctx context.Context, ids string) error { + return c.runMetadataSDKAction(ctx, "Unmatch", ids, func(ctx context.Context, encodedIDs string) error { + _, err := c.sdk.Library.Unmatch(ctx, operations.UnmatchRequest{Ids: encodedIDs}) + return err + }) +} + +func (c *Client) SplitMetadata(ctx context.Context, ids string) error { + return c.runMetadataSDKAction(ctx, "SplitItem", ids, func(ctx context.Context, encodedIDs string) error { + _, err := c.sdk.Library.SplitItem(ctx, operations.SplitItemRequest{Ids: encodedIDs}) + return err + }) +} + +func (c *Client) MergeMetadata(ctx context.Context, ids string) error { + mergeIDs, err := ParseCSVList(ids) + if err != nil { + return &PlexError{Op: "MergeItems", Err: err} + } + return c.runMetadataSDKAction(ctx, "MergeItems", ids, func(ctx context.Context, encodedIDs string) error { + _, err := c.sdk.Library.MergeItems(ctx, operations.MergeItemsRequest{ + IdsPathParameter: encodedIDs, + IdsQueryParameter: mergeIDs, + }) + return err + }) +} + +func (c *Client) DetectMetadataAds(ctx context.Context, ids string) error { + return c.runMetadataSDKAction(ctx, "DetectAds", ids, func(ctx context.Context, encodedIDs string) error { + _, err := c.sdk.Library.DetectAds(ctx, operations.DetectAdsRequest{Ids: encodedIDs}) + return err + }) +} + +func (c *Client) DetectMetadataCredits(ctx context.Context, ids string, force bool, manual bool) error { + return c.runMetadataSDKAction(ctx, "DetectCredits", ids, func(ctx context.Context, encodedIDs string) error { + req := operations.DetectCreditsRequest{Ids: encodedIDs} + if force { + req.Force = BoolIntPointer(true) + } + if manual { + req.Manual = BoolIntPointer(true) + } + _, err := c.sdk.Library.DetectCredits(ctx, req) + return err + }) +} + +func (c *Client) DetectMetadataIntros(ctx context.Context, ids string, force bool, threshold *float64) error { + return c.runMetadataSDKAction(ctx, "DetectIntros", ids, func(ctx context.Context, encodedIDs string) error { + req := operations.DetectIntrosRequest{Ids: encodedIDs} + if force { + req.Force = BoolIntPointer(true) + } + if threshold != nil { + req.Threshold = threshold + } + _, err := c.sdk.Library.DetectIntros(ctx, req) + return err + }) +} + +func (c *Client) DetectMetadataVoiceActivity(ctx context.Context, ids string, force bool, manual bool) error { + return c.runMetadataSDKAction(ctx, "DetectVoiceActivity", ids, func(ctx context.Context, encodedIDs string) error { + req := operations.DetectVoiceActivityRequest{Ids: encodedIDs} + if force { + req.Force = BoolIntPointer(true) + } + if manual { + req.Manual = BoolIntPointer(true) + } + _, err := c.sdk.Library.DetectVoiceActivity(ctx, req) + return err + }) +} + +func (c *Client) GenerateBIF(ctx context.Context, ids string, force bool) error { + return c.runMetadataSDKAction(ctx, "StartBifGeneration", ids, func(ctx context.Context, encodedIDs string) error { + req := operations.StartBifGenerationRequest{Ids: encodedIDs} + if force { + req.Force = BoolIntPointer(true) + } + _, err := c.sdk.Library.StartBifGeneration(ctx, req) + return err + }) +} + +func (c *Client) DeleteSectionIntros(ctx context.Context, sectionID string) error { + parsedSectionID, err := strconv.ParseInt(sectionID, 10, 64) + if err != nil { + return &PlexError{Op: "DeleteIntros", Section: sectionID, Err: fmt.Errorf("section ID must be numeric: %w", err)} + } + return c.runSDKAction(ctx, "DeleteIntros", sectionID, func(ctx context.Context) error { + _, err := c.sdk.Library.DeleteIntros(ctx, operations.DeleteIntrosRequest{SectionID: parsedSectionID}) + return err + }) +} + +func (c *Client) DeleteStreamByID(ctx context.Context, streamID int64, ext string) error { + pathWithExt := fmt.Sprintf("library/streams/%d.%s", streamID, ext) + err := c.LibraryAction(ctx, "DeleteStream", http.MethodDelete, pathWithExt, nil) + if err == nil { + return nil + } + + var plexErr *PlexError + if errors.As(err, &plexErr) && strings.Contains(plexErr.Err.Error(), "unexpected status code: 400") { + return c.LibraryAction(ctx, "DeleteStream", http.MethodDelete, fmt.Sprintf("library/streams/%d", streamID), nil) + } + + return err +} + +func (c *Client) SetStreamOffsetByID(ctx context.Context, streamID int64, ext string, offset int64) error { + offsetValue := offset + err := c.executeWithRetry(ctx, "SetStreamOffset", func() error { + _, sdkErr := c.sdk.Library.SetStreamOffset(ctx, operations.SetStreamOffsetRequest{ + StreamID: streamID, + Ext: ext, + Offset: &offsetValue, + }) + if isAcceptedSDKResponse(sdkErr) { + return nil + } + return sdkErr + }) + if err == nil { + return nil + } + + if strings.Contains(err.Error(), "Status 400") { + query := url.Values{} + query.Set("offset", strconv.FormatInt(offset, 10)) + return c.LibraryAction(ctx, "SetStreamOffset", http.MethodPut, fmt.Sprintf("library/streams/%d", streamID), query) + } + + return c.wrapSDKError("SetStreamOffset", strconv.FormatInt(streamID, 10), err) +} + +func (c *Client) CreateMarkerDynamic(ctx context.Context, ids string, markerType string, start int64, end *int64, attrs map[string]string) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "CreateMarker", Err: err} + } + query := url.Values{} + query.Set("type", markerType) + query.Set("startTimeOffset", strconv.FormatInt(start, 10)) + if end != nil { + query.Set("endTimeOffset", strconv.FormatInt(*end, 10)) + } + addDeepObject(query, "attributes", attrs) + return c.LibraryAction(ctx, "CreateMarker", http.MethodPost, fmt.Sprintf("library/metadata/%s/marker", encodedIDs), query) +} + +func (c *Client) EditMarkerDynamic(ctx context.Context, ids, marker string, markerType string, start int64, end *int64, attrs map[string]string) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: "EditMarker", Err: err} + } + query := url.Values{} + query.Set("type", markerType) + query.Set("startTimeOffset", strconv.FormatInt(start, 10)) + if end != nil { + query.Set("endTimeOffset", strconv.FormatInt(*end, 10)) + } + addDeepObject(query, "attributes", attrs) + return c.LibraryAction(ctx, "EditMarker", http.MethodPut, fmt.Sprintf("library/metadata/%s/marker/%s", encodedIDs, url.PathEscape(marker)), query) +} + +func (c *Client) DeleteMarkerByID(ctx context.Context, ids, marker string) error { + return c.runMetadataSDKAction(ctx, "DeleteMarker", ids, func(ctx context.Context, encodedIDs string) error { + _, err := c.sdk.Library.DeleteMarker(ctx, operations.DeleteMarkerRequest{Ids: encodedIDs, Marker: marker}) + return err + }) +} + +func (c *Client) libraryRequest(ctx context.Context, op, method, path string, query url.Values, accept string) ([]byte, error) { + resp, err := c.libraryHTTPWithBody(ctx, op, method, path, query, accept, nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &PlexError{Op: op, Err: fmt.Errorf("failed to read response body: %w", err)} + } + + return body, nil +} + +func (c *Client) libraryHTTP(ctx context.Context, op, method, path string, query url.Values, accept string) (*http.Response, error) { + return c.libraryHTTPWithBody(ctx, op, method, path, query, accept, nil, "") +} + +func (c *Client) libraryActionWithBody(ctx context.Context, op, method, path string, query url.Values, body []byte, contentType string) error { + resp, err := c.libraryHTTPWithBody(ctx, op, method, path, query, "*/*", body, contentType) + if err != nil { + return err + } + defer resp.Body.Close() + return nil +} + +func (c *Client) libraryHTTPWithBody(ctx context.Context, op, method, path string, query url.Values, accept string, body []byte, contentType string) (*http.Response, error) { + var resp *http.Response + err := c.executeWithRetry(ctx, op, func() error { + urlStr := c.libraryURL(path, query) + var requestBody io.Reader + if len(body) > 0 { + requestBody = bytes.NewReader(body) + } + req, reqErr := http.NewRequestWithContext(ctx, method, urlStr, requestBody) + if reqErr != nil { + return fmt.Errorf("failed to create request: %w", reqErr) + } + if accept != "" { + req.Header.Set("Accept", accept) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + httpResp, doErr := c.httpClient.Do(req) + if doErr != nil { + return fmt.Errorf("failed to make request: %w", doErr) + } + + switch { + case httpResp.StatusCode >= 200 && httpResp.StatusCode < 300: + resp = httpResp + return nil + default: + defer httpResp.Body.Close() + body, _ := io.ReadAll(httpResp.Body) + return libraryHTTPStatusError(op, httpResp.StatusCode, strings.TrimSpace(string(body))) + } + }) + if err != nil { + return nil, &PlexError{Op: op, Err: err} + } + return resp, nil +} + +func (c *Client) libraryURL(path string, query url.Values) string { + base := strings.TrimSuffix(c.serverURL, "/") + path = strings.TrimPrefix(path, "/") + urlStr := fmt.Sprintf("%s/%s", base, path) + params := url.Values{} + for key, values := range query { + for _, value := range values { + params.Add(key, value) + } + } + params.Set("X-Plex-Token", c.token) + return urlStr + "?" + params.Encode() +} + +func subtitleUploadContentType(format, title string) string { + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(title), ".")) + if ext == "" { + ext = strings.ToLower(strings.TrimSpace(format)) + } + + switch ext { + case "srt", "vtt", "ssa", "ass", "sub", "txt": + return "text/plain;charset=UTF-8" + default: + return "application/octet-stream" + } +} + +func (c *Client) runMetadataSDKAction(ctx context.Context, op, ids string, fn func(context.Context, string) error) error { + encodedIDs, err := encodeMetadataIDs(ids) + if err != nil { + return &PlexError{Op: op, Err: err} + } + return c.runSDKAction(ctx, op, encodedIDs, func(ctx context.Context) error { + return fn(ctx, encodedIDs) + }) +} + +func (c *Client) runSDKAction(ctx context.Context, op, section string, fn func(context.Context) error) error { + err := c.executeWithRetry(ctx, op, func() error { + sdkErr := fn(ctx) + if isAcceptedSDKResponse(sdkErr) { + return nil + } + return sdkErr + }) + return c.wrapSDKError(op, section, err) +} + +func (c *Client) wrapSDKError(op, section string, err error) error { + if err == nil { + return nil + } + if friendly := librarySDKFriendlyError(op, err); friendly != nil { + err = friendly + } + return &PlexError{Op: op, Section: section, Err: err} +} + +func isAcceptedSDKResponse(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "Status 202") +} + +func libraryHTTPStatusError(op string, statusCode int, body string) error { + if friendly := libraryHTTPFriendlyError(op, statusCode); friendly != "" { + if body != "" { + return fmt.Errorf("%s (%s)", friendly, body) + } + return errors.New(friendly) + } + return fmt.Errorf("unexpected status code: %d: %s", statusCode, body) +} + +func libraryHTTPFriendlyError(op string, statusCode int) string { + switch { + case op == "GetStream" && statusCode == http.StatusNotImplemented: + return "stream is not a downloadable sidecar subtitle stream" + case op == "GetStreamLevels" && statusCode == http.StatusNotFound: + return "stream levels are not available for this stream" + case op == "GetStreamLoudness" && statusCode == http.StatusNotFound: + return "stream loudness is not available for this stream" + case op == "GetChapterImage" && statusCode == http.StatusNotFound: + return "chapter image is not available for this media item and chapter" + case op == "GetPartIndex" && statusCode == http.StatusNotFound: + return "BIF index is not available for this media part" + case op == "GetImageFromBif" && statusCode == http.StatusNotFound: + return "BIF image is not available for this media part, index, or offset" + case op == "GetFile" && statusCode == http.StatusBadRequest: + return "bundle file URL is invalid for this metadata item" + case op == "GetFile" && statusCode == http.StatusNotFound: + return "bundle file is not available at that path" + default: + return "" + } +} + +func librarySDKFriendlyError(op string, err error) error { + if err == nil { + return nil + } + switch { + case op == "SetStreamOffset" && strings.Contains(err.Error(), "Status 400"): + return errors.New("stream offset update was rejected by Plex") + case op == "GetStream" && strings.Contains(err.Error(), "Status 501"): + return errors.New("stream is not a downloadable sidecar subtitle stream") + default: + return nil + } +} + +func addDeepObject(values url.Values, prefix string, fields map[string]string) { + if len(fields) == 0 { + return + } + keys := make([]string, 0, len(fields)) + for key := range fields { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + values.Set(fmt.Sprintf("%s[%s]", prefix, key), fields[key]) + } +} + +func BoolIntPointer(value bool) *components.BoolInt { + if value { + return components.BoolIntTrue.ToPointer() + } + return components.BoolIntFalse.ToPointer() +} diff --git a/internal/plexclient/library_service_test.go b/internal/plexclient/library_service_test.go new file mode 100644 index 0000000..126f237 --- /dev/null +++ b/internal/plexclient/library_service_test.go @@ -0,0 +1,687 @@ +package plexclient + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +type trackingReadCloser struct { + io.Reader + closed bool +} + +func (t *trackingReadCloser) Close() error { + t.closed = true + return nil +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestParseCSVList(t *testing.T) { + got, err := ParseCSVList("1, 2,3") + if err != nil { + t.Fatalf("ParseCSVList() error = %v", err) + } + if len(got) != 3 || got[0] != "1" || got[1] != "2" || got[2] != "3" { + t.Fatalf("unexpected ParseCSVList() result: %#v", got) + } + + if _, err := ParseCSVList("1,,2"); err == nil { + t.Fatalf("expected ParseCSVList() to reject empty segments") + } +} + +func TestParseKeyValuePairs(t *testing.T) { + got, err := ParseKeyValuePairs([]string{"title=Movie", "summary=Hello"}) + if err != nil { + t.Fatalf("ParseKeyValuePairs() error = %v", err) + } + if got["title"] != "Movie" || got["summary"] != "Hello" { + t.Fatalf("unexpected ParseKeyValuePairs() result: %#v", got) + } + + if _, err := ParseKeyValuePairs([]string{"broken"}); err == nil { + t.Fatalf("expected ParseKeyValuePairs() to reject invalid input") + } +} + +func TestRequireGuards(t *testing.T) { + if err := RequireConfirmation(false, "delete"); err == nil { + t.Fatalf("expected RequireConfirmation() error") + } + if err := RequireOutputPath(""); err == nil { + t.Fatalf("expected RequireOutputPath() error") + } +} + +func TestIsAcceptedSDKResponse(t *testing.T) { + if !isAcceptedSDKResponse(errors.New("unknown status code returned: Status 202")) { + t.Fatalf("expected Status 202 to be treated as success") + } + if isAcceptedSDKResponse(errors.New("unknown status code returned: Status 400")) { + t.Fatalf("did not expect Status 400 to be treated as success") + } + if isAcceptedSDKResponse(nil) { + t.Fatalf("did not expect nil to be treated as success") + } +} + +func TestLibraryHTTPFriendlyError(t *testing.T) { + tests := []struct { + op string + status int + want string + }{ + {op: "GetStreamLevels", status: http.StatusNotFound, want: "stream levels are not available for this stream"}, + {op: "GetStreamLoudness", status: http.StatusNotFound, want: "stream loudness is not available for this stream"}, + {op: "GetChapterImage", status: http.StatusNotFound, want: "chapter image is not available for this media item and chapter"}, + {op: "GetPartIndex", status: http.StatusNotFound, want: "BIF index is not available for this media part"}, + {op: "GetImageFromBif", status: http.StatusNotFound, want: "BIF image is not available for this media part, index, or offset"}, + {op: "GetStream", status: http.StatusNotImplemented, want: "stream is not a downloadable sidecar subtitle stream"}, + } + + for _, tt := range tests { + if got := libraryHTTPFriendlyError(tt.op, tt.status); got != tt.want { + t.Fatalf("%s/%d: expected %q, got %q", tt.op, tt.status, tt.want, got) + } + } +} + +func TestLibrarySDKFriendlyError(t *testing.T) { + if got := librarySDKFriendlyError("DetectCredits", errors.New("API error occurred: Status 400")); got != nil { + t.Fatalf("did not expect friendly error for DetectCredits: %v", got) + } + if got := librarySDKFriendlyError("DetectIntros", errors.New("API error occurred: Status 400")); got != nil { + t.Fatalf("did not expect friendly error for DetectIntros: %v", got) + } +} + +func TestLibraryActionWithBodyClosesResponseBody(t *testing.T) { + client, err := NewClient("http://example.com", "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + body := &trackingReadCloser{Reader: strings.NewReader("ok")} + client.httpClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: body, + Header: make(http.Header), + }, nil + }) + + err = client.libraryActionWithBody(context.Background(), "AddSubtitles", http.MethodPost, "library/metadata/1/subtitles", nil, []byte("payload"), "text/plain") + if err != nil { + t.Fatalf("libraryActionWithBody() error = %v", err) + } + if !body.closed { + t.Fatalf("expected response body to be closed") + } +} + +func TestEditMetadataDynamicBuildsExpectedQuery(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.EditMetadataDynamic(context.Background(), "1,2", MetadataEditInput{ + Set: map[string]string{"title": "Updated"}, + Lock: []string{"title"}, + Unlock: []string{"summary"}, + }) + if err != nil { + t.Fatalf("EditMetadataDynamic() error = %v", err) + } + + if seenPath != "/library/metadata/1,2" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("title.value"); got != "Updated" { + t.Fatalf("expected title.value query, got %q", got) + } + if got := seenQuery.Get("title.locked"); got != "1" { + t.Fatalf("expected title.locked=1, got %q", got) + } + if got := seenQuery.Get("summary.locked"); got != "0" { + t.Fatalf("expected summary.locked=0, got %q", got) + } +} + +func TestCreateSectionBuildsExpectedQuery(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.CreateSection(context.Background(), SectionMutationInput{ + Name: "Movies", + Type: 1, + Agent: "com.plexapp.agents.imdb", + Language: "en-US", + Locations: []string{"/media/movies"}, + Prefs: map[string]string{"scanner": "plex"}, + }) + if err != nil { + t.Fatalf("CreateSection() error = %v", err) + } + + if seenPath != "/library/sections" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("name"); got != "Movies" { + t.Fatalf("expected name query, got %q", got) + } + if got := seenQuery.Get("locations"); got != "/media/movies" { + t.Fatalf("expected locations query, got %q", got) + } + if got := seenQuery.Get("prefs[scanner]"); got != "plex" { + t.Fatalf("expected prefs[scanner], got %q", got) + } +} + +func TestDetectCreditsIncludesForceAndManual(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.DetectMetadataCredits(context.Background(), "47648", true, true) + if err != nil { + t.Fatalf("DetectMetadataCredits() error = %v", err) + } + + if seenPath != "/library/metadata/47648/credits" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("force"); got != "1" { + t.Fatalf("expected force=1, got %q", got) + } + if got := seenQuery.Get("manual"); got != "1" { + t.Fatalf("expected manual=1, got %q", got) + } +} + +func TestDetectIntrosIncludesForceAndThreshold(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusAccepted) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + threshold := 0.42 + err = client.DetectMetadataIntros(context.Background(), "50490", true, &threshold) + if err != nil { + t.Fatalf("DetectMetadataIntros() error = %v", err) + } + + if seenPath != "/library/metadata/50490/intro" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("force"); got != "1" { + t.Fatalf("expected force=1, got %q", got) + } + if got := seenQuery.Get("threshold"); got != "0.42" { + t.Fatalf("expected threshold=0.42, got %q", got) + } +} + +func TestSetStreamOffsetFallsBackToPathWithoutExtension(t *testing.T) { + requests := make([]string, 0, 2) + var fallbackQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + switch r.URL.Path { + case "/library/streams/281565.srt": + http.Error(w, "bad request", http.StatusBadRequest) + case "/library/streams/281565": + fallbackQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.SetStreamOffsetByID(context.Background(), 281565, "srt", 0) + if err != nil { + t.Fatalf("SetStreamOffsetByID() error = %v", err) + } + + if len(requests) != 2 { + t.Fatalf("expected two requests, got %d", len(requests)) + } + if requests[0] != "/library/streams/281565.srt" { + t.Fatalf("unexpected first request: %s", requests[0]) + } + if requests[1] != "/library/streams/281565" { + t.Fatalf("unexpected fallback request: %s", requests[1]) + } + if got := fallbackQuery.Get("offset"); got != "0" { + t.Fatalf("expected offset=0 on fallback, got %q", got) + } +} + +func TestAddSubtitlesFromFileUsesPostBody(t *testing.T) { + var seenMethod string + var seenPath string + var seenBody []byte + var seenQuery url.Values + var seenContentType string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenMethod = r.Method + seenPath = r.URL.Path + seenQuery = r.URL.Query() + seenContentType = r.Header.Get("Content-Type") + seenBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.AddSubtitlesFromFile(context.Background(), "1", []byte("subtitle body"), "eng", "English", 0, "srt", true, false) + if err != nil { + t.Fatalf("AddSubtitlesFromFile() error = %v", err) + } + + if seenMethod != http.MethodPost { + t.Fatalf("expected POST, got %s", seenMethod) + } + if seenPath != "/library/metadata/1/subtitles" { + t.Fatalf("unexpected path: %s", seenPath) + } + if string(seenBody) != "subtitle body" { + t.Fatalf("unexpected body: %q", string(seenBody)) + } + if got := seenQuery.Get("language"); got != "eng" { + t.Fatalf("expected language query, got %q", got) + } + if got := seenQuery.Get("title"); got != "English" { + t.Fatalf("expected title query, got %q", got) + } + if seenContentType != "text/plain;charset=UTF-8" { + t.Fatalf("expected text subtitle content-type, got %q", seenContentType) + } +} + +func TestGetSectionsPrefsBuildsExpectedQuery(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{"Setting":[]}}`)) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if _, err := client.GetSectionsPrefs(context.Background(), 1, "agent"); err != nil { + t.Fatalf("GetSectionsPrefs() error = %v", err) + } + + if seenPath != "/library/sections/prefs" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("type"); got != "1" { + t.Fatalf("expected type=1, got %q", got) + } + if got := seenQuery.Get("agent"); got != "agent" { + t.Fatalf("expected agent query, got %q", got) + } +} + +func TestSetSectionPreferencesDynamicUsesFlatQuery(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.SetSectionPreferencesDynamic(context.Background(), "10", map[string]string{"hidden": "0"}) + if err != nil { + t.Fatalf("SetSectionPreferencesDynamic() error = %v", err) + } + + if seenPath != "/library/sections/10/prefs" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("hidden"); got != "0" { + t.Fatalf("expected hidden query, got %q", got) + } + if got := seenQuery.Get("prefs[hidden]"); got != "" { + t.Fatalf("expected no nested prefs query, got %q", got) + } +} + +func TestSetItemPreferencesDynamicUsesFlatQuery(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.SetItemPreferencesDynamic(context.Background(), "47648", map[string]string{"useOriginalTitle": "-1"}) + if err != nil { + t.Fatalf("SetItemPreferencesDynamic() error = %v", err) + } + + if seenPath != "/library/metadata/47648/prefs" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("useOriginalTitle"); got != "-1" { + t.Fatalf("expected flat item pref query, got %q", got) + } + if got := seenQuery.Get("prefs[useOriginalTitle]"); got != "" { + t.Fatalf("expected no nested prefs query, got %q", got) + } +} + +func TestUpdateItemsDynamicIncludesSectionMediaType(t *testing.T) { + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.UpdateItemsDynamic(context.Background(), BulkUpdateInput{ + SectionID: "10", + MediaType: 1, + Filter: "id=47648", + Set: map[string]string{"title": "Alien Nation"}, + }) + if err != nil { + t.Fatalf("UpdateItemsDynamic() error = %v", err) + } + + if seenPath != "/library/sections/10/all" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("type"); got != "1" { + t.Fatalf("expected type query, got %q", got) + } + if got := seenQuery.Get("filters"); got != "id=47648" { + t.Fatalf("expected filters query, got %q", got) + } + if got := seenQuery.Get("title.value"); got != "Alien Nation" { + t.Fatalf("expected title.value query, got %q", got) + } +} + +func TestUpdateItemsDynamicEncodesBulkUpdateTags(t *testing.T) { + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.UpdateItemsDynamic(context.Background(), BulkUpdateInput{ + SectionID: "10", + MediaType: 1, + AddTags: map[string][]string{ + "genre": {"Action", "Drama"}, + }, + RemoveTags: map[string][]string{ + "collection": {"Archive", "Replace"}, + }, + }) + if err != nil { + t.Fatalf("UpdateItemsDynamic() error = %v", err) + } + + if got := seenQuery.Get("genre[0].tag.tag"); got != "Action" { + t.Fatalf("expected indexed genre tag 0, got %#v", got) + } + if got := seenQuery.Get("genre[1].tag.tag"); got != "Drama" { + t.Fatalf("expected indexed genre tag 1, got %#v", got) + } + if got := seenQuery["genre[].tag.tag"]; len(got) != 0 { + t.Fatalf("expected no repeated add-tag keys, got %#v", got) + } + if got := seenQuery["collection[].tag.tag-"]; !reflect.DeepEqual(got, []string{"Archive", "Replace"}) { + t.Fatalf("expected repeated collection tag removals, got %#v", got) + } +} + +func TestSetItemArtworkByURLUsesPut(t *testing.T) { + var seenMethod string + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenMethod = r.Method + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.SetItemArtworkByURL(context.Background(), "47648", "poster", "https://example.com/poster.jpg") + if err != nil { + t.Fatalf("SetItemArtworkByURL() error = %v", err) + } + + if seenMethod != http.MethodPut { + t.Fatalf("expected PUT, got %s", seenMethod) + } + if seenPath != "/library/metadata/47648/poster" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("url"); got != "https://example.com/poster.jpg" { + t.Fatalf("expected url query, got %q", got) + } +} + +func TestCreateMarkerDynamicUsesStringType(t *testing.T) { + var seenMethod string + var seenPath string + var seenQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seenMethod = r.Method + seenPath = r.URL.Path + seenQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.CreateMarkerDynamic(context.Background(), "47648", "bookmark", 1000, nil, nil) + if err != nil { + t.Fatalf("CreateMarkerDynamic() error = %v", err) + } + + if seenMethod != http.MethodPost { + t.Fatalf("expected POST, got %s", seenMethod) + } + if seenPath != "/library/metadata/47648/marker" { + t.Fatalf("unexpected path: %s", seenPath) + } + if got := seenQuery.Get("type"); got != "bookmark" { + t.Fatalf("expected string marker type, got %q", got) + } +} + +func TestEditSectionFillsAgentAndScannerFromExistingSection(t *testing.T) { + var seenPutQuery url.Values + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/library/sections": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"MediaContainer":{"Directory":[{"key":"16","agent":"tv.plex.agents.movie","scanner":"Plex Movie"}]}}`)) + case r.Method == http.MethodPut && r.URL.Path == "/library/sections/16": + seenPutQuery = r.URL.Query() + w.WriteHeader(http.StatusOK) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + err = client.EditSection(context.Background(), "16", SectionMutationInput{Name: "Renamed"}) + if err != nil { + t.Fatalf("EditSection() error = %v", err) + } + + if got := seenPutQuery.Get("name"); got != "Renamed" { + t.Fatalf("expected name query, got %q", got) + } + if got := seenPutQuery.Get("agent"); got != "tv.plex.agents.movie" { + t.Fatalf("expected agent query, got %q", got) + } + if got := seenPutQuery.Get("scanner"); got != "Plex Movie" { + t.Fatalf("expected scanner query, got %q", got) + } +} + +func TestDeleteStreamFallsBackToPathWithoutExtension(t *testing.T) { + requests := make([]string, 0, 2) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, r.URL.Path) + switch r.URL.Path { + case "/library/streams/99.srt": + http.Error(w, "bad request", http.StatusBadRequest) + case "/library/streams/99": + w.WriteHeader(http.StatusOK) + default: + http.Error(w, "unexpected path", http.StatusNotFound) + } + })) + defer server.Close() + + client, err := NewClient(server.URL, "token") + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + if err := client.DeleteStreamByID(context.Background(), 99, "srt"); err != nil { + t.Fatalf("DeleteStreamByID() error = %v", err) + } + + want := []string{"/library/streams/99.srt", "/library/streams/99"} + if len(requests) != len(want) { + t.Fatalf("unexpected request count: got %d want %d", len(requests), len(want)) + } + for i := range want { + if requests[i] != want[i] { + t.Fatalf("unexpected request at %d: got %q want %q", i, requests[i], want[i]) + } + } +}